Skip to content

Migrate Avada Core V5 (Expiring Offline Tokens)

Guide migrate một app Shopify của Avada lên @avada/core v5, để hỗ trợ Shopify expiring offline access tokens.

Tham khảo chi tiết API trong repo core: @avada/coredocs/expiring-offline-tokens.md.

Shopify bắt buộc expiring offline access tokens cho public apps:

  • App tạo từ 2026-04-01 trở đi: bắt buộc ngay.
  • Tất cả public apps: phải migrate trước 2027-01-01. Sau ngày này, gọi Admin API bằng non-expiring token sẽ bị reject.

Khác biệt: token cũ (non-expiring) không bao giờ hết hạn. Token mới (expiring) sống ~1 giờ (expires_in: 3600), được xoay vòng bằng refresh_token sống 90 ngày (refresh_token_expires_in: 7776000). Custom apps / merchant-created apps không bị ảnh hưởng.

  • Bỏ shopify-api-node khỏi @avada/core. Core không còn bundle package này; mọi Admin API call trong core dùng raw fetch và throw error kèm status + body thật của Shopify. App của bạn vẫn giữ shopify-api-node riêng — không cần bỏ.
  • getShopifyApi() / getShopifyApiWithValidToken() giờ trả về object nhẹ {options: {shopName, accessToken}} (không phải instance shopify-api-node). Nếu code nào gọi method shopify-api-node trực tiếp lên object trả về từ getShopifyApi thì phải đổi sang gọi API trực tiếp.
  • Các token helper nhận options object thay vì positional params: getValidAccessToken(shopDomain, {accessTokenKey, apiKey, secret}), refreshAccessToken(shop, refreshToken, {apiKey, secret}), v.v.

Đa số app Avada (joy, app-base-template, …) dùng shopify-api-node riêng + prepareShopData, không dùng getShopifyApi của core, nên breaking change này không ảnh hưởng call sites của app. Chỉ là bump version thuần về mặt API.

Mô hình “hai công tắc” — cần CẢ HAI

Section titled “Mô hình “hai công tắc” — cần CẢ HAI”
Công tắcLà gìThiếu nó
expiringOfflineToken: true trên auth optionsacquire — Shopify cấp expiring token + refresh_tokentoken vẫn non-expiring, không có gì để refresh
getValidShopToken trong initShopifyconsume — refresh token trước khi hết hạntoken expiring sẽ chết sau ~1h → 401

Bật một cái mà thiếu cái kia là lỗi phổ biến nhất: chỉ flag → 401 sau 1h; chỉ getValidShopToken → no-op (không có refresh token để xoay).

packages/functions/package.json"@avada/core": "5.0.0-alpha.7" (hoặc mới hơn), rồi yarn install.

2. initShopify → async dùng getValidShopToken

Section titled “2. initShopify → async dùng getValidShopToken”

getValidShopToken(shop, shopifyConfig) trả {shopifyDomain, accessToken} (cùng shape với prepareShopData), tự refresh qua offline session. shopifyConfig đã có sẵn {apiKey, secret, accessTokenKey}.

// trước
export function initShopify(shop, apiVersion = API_VERSION) {
const {shopifyDomain, accessToken} = prepareShopData(shop.id, shop, shopifyConfig.accessTokenKey);
return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});
}
// sau
import {getValidShopToken} from '@avada/core';
export async function initShopify(shop, apiVersion = API_VERSION) {
const {shopifyDomain, accessToken} = await getValidShopToken(shop, shopifyConfig);
return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});
}

getValidShopToken đọc/refresh từ offline session (nơi lưu refresh token — shop record KHÔNG lưu refresh token). Backward-safe: shop chưa có expiry metadata thì trả token cũ nguyên vẹn.

3. Thêm await cho TẤT CẢ call sites của initShopify

Section titled “3. Thêm await cho TẤT CẢ call sites của initShopify”

initShopify giờ là async nên mọi nơi gọi phải await. Có 3 dạng call cần đổi: = initShopify(, shopify: initShopify( (object prop), fn(initShopify( (arg).

Regex an toàn theo từng file (loại trừ commands//scripts/, bảo vệ dòng định nghĩa function initShopify, tránh double-await):

Terminal window
grep -rl "initShopify(" packages/functions/src --include="*.js" \
| grep -vE "/commands/|/scripts/" \
| while read -r f; do
perl -i -pe 's/(?<!function )(?<!await )\binitShopify\(/await initShopify(/g' "$f"
done
# verify: phải = 0
grep -rn "initShopify(" packages/functions/src --include="*.js" \
| grep -vE "/commands/|/scripts/" \
| grep -vE "function initShopify|import |from '|await initShopify\(" | wc -l

4. Client factory build qua initShopify (vd makeGraphQlApi)

Section titled “4. Client factory build qua initShopify (vd makeGraphQlApi)”

Nếu helper như makeGraphQlApi build client bằng initShopify(shop) bên trong và đã là async, thì bước 3 đã sửa luôn call nội bộ → hàng trăm callers của nó không cần đổi. Chỉ cần đảm bảo factory đó là async.

5. Build verify (bắt lỗi await trong default param)

Section titled “5. Build verify (bắt lỗi await trong default param)”
Terminal window
cd packages/functions && node esbuild.config.js --production

esbuild sẽ báo lỗi nếu có await trong default parameter, vd function f(shop, shopify = await initShopify(shop)). Sửa bằng cách đưa vào body:

export async function f(shop, shopify) {
if (!shopify) shopify = await initShopify(shop);
...
}

Lỗi Failed to write to output file ... lib/*: permission denied là do thư mục output bị root sở hữu trên máy local — KHÔNG phải lỗi code (parse/bundle đã pass), CI build sạch. App functions là JS (không có tsc), nên build này là cách duy nhất bắt lỗi await sai context ở local.

Set expiringOfflineToken: true trên các option block acquire token: verifyEmbedRequest (token exchange — embedded apps) và shopifyAuth (OAuth install). verifyRequest() không exchange → bỏ qua. Nếu app mount shopifyCharge thì set luôn.

7. Audit các chỗ đọc token BỎ QUA initShopify

Section titled “7. Audit các chỗ đọc token BỎ QUA initShopify”

Chỗ nào build client mà không qua initShopify sẽ 401 sau ~1h khi shop đã migrate:

Terminal window
grep -rn "new Shopify(" packages/functions/src --include="*.js" | grep -vE "/commands/|/scripts/"
grep -rn "X-Shopify-Access-Token" packages/functions/src --include="*.js"
grep -rn "prepareShopData" packages/functions/src --include="*.js"

Phân loại:

  • Thật: new Shopify({accessToken}) với token lấy từ shop record / payload cũ → route qua getValidShopToken.
  • False positive: prepareShopData riêng của app (vd build profile cho customer.io); X-Shopify-Access-Token: partnerKey (Partner API key, không phải token của shop); dòng trong makeGraphQlApi (đã được cover).

Migrate các shop CŨ (đang dùng non-expiring token)

Section titled “Migrate các shop CŨ (đang dùng non-expiring token)”

Đây là phần dễ hiểu nhầm nhất. Bật expiringOfflineToken: true chỉ đổi token cho install/re-auth mới. Shop đã cài rồi không tự migrate khi login — vì token non-expiring luôn hợp lệ → checkIfActiveAccessToken luôn true → verifyToken không re-exchange. Kiểm tra: session doc có accessTokenHash nhưng thiếu refreshTokenHash/accessTokenExpiresAt = chưa migrate.

Migrate cần token exchange → cần App Bridge session token → chỉ chạy được trong embedded request (cron/script không migrate được). Hai cách (cần @avada/core ≥ 5.0.0-alpha.7):

  • Tự động (config): set autoMigrateOfflineToken: true expiringOfflineToken: true trên auth options. verifyToken sẽ re-exchange shop có token nhưng chưa có refresh token ở request embedded kế tiếp — một lần duy nhất mỗi shop, không cần code app, không cần merchant làm gì. Có gate isInstalled nên không chạy lại install hooks.
    verifyEmbedRequest({
    apiKey, secret, accessTokenKey, scopes,
    expiringOfflineToken: true,
    autoMigrateOfflineToken: true // ← migrate shop cũ khi mở app
    });
  • Thủ công (function): gọi migrateToExpiringToken(ctx, {apiKey, secret, accessTokenKey}) trong embedded handler (vd afterLogin). No-op nếu đã expiring.

Shop không bao giờ mở admin (background-only) không migrate được bằng các cách trên — không có session token. Phải re-auth thật trước 2027-01-01.

Test nhanh 1 shop: xoá session doc shopifySession/offline_{shop} (hoặc field accessTokenHash) trên Firestore → mở lại embedded app → re-exchange với expiring:1 → doc xuất hiện lại kèm refreshTokenHash + accessTokenExpiresAt.

  • Mỗi lần refresh, Shopify trả refresh token mới với hạn 90 ngày mới (sliding window) và vô hiệu hoá refresh token cũ ngay. Shop nào còn được app gọi thường xuyên thì refresh token không bao giờ hết hạn.
  • Nếu refresh token hết hạn (sau 90 ngày không hoạt động): không refresh được nữa → merchant mở lại app để token exchange cấp cặp token mới (không cần reinstall, không cần duyệt scope lại). forceRefreshAccessToken sẽ throw “Merchant must re-authorize”.
  • Code chạy nền (cron/pubsub/webhook) phải dùng getValidShopToken / getShopifyApiWithValidToken, nếu không sẽ 401 ~1h sau khi shop migrate.
  • Session doc của shop đã migrate có refreshTokenHash + accessTokenExpiresAt.
  • Embedded request: hoạt động bình thường, token tự refresh.
  • Background job gọi sau mốc 1h vẫn chạy (do getValidShopToken refresh).
  • Bump @avada/core lên 5.0.0-alpha.7+.
  • initShopify async + getValidShopToken.
  • await tất cả call sites (grep = 0 bare calls); không có await trong default param.
  • Bật expiringOfflineToken trên verifyEmbedRequest + shopifyAuth (+ shopifyCharge nếu có).
  • Route các bypass new Shopify({accessToken}) qua getValidShopToken.
  • Bật autoMigrateOfflineToken (hoặc gọi migrateToExpiringToken) để migrate shop cũ.
  • Build/CI sạch; deploy staging; kiểm tra session có refreshTokenHash.
  • commands//scripts/ và dev/mock tooling (build client từ token truyền vào) — làm sau, rủi ro thấp.

Có sẵn bản Claude/agent skill cho migration này. Tải về và đặt vào repo của bạn tại .claude/skills/expiring-offline-tokens-migration/SKILL.md (và .agent/skills/... nếu dùng), rồi agent sẽ tự dùng làm runbook khi bạn migrate.

⬇️ Download SKILL.md

Nội dung skill (copy nhanh bằng nút copy ở góc phải):

---
name: expiring-offline-tokens-migration
description: Migrate an Avada Shopify app to @avada/core v5 Shopify expiring offline access tokens. Use when bumping @avada/core to 5.x, adopting getValidShopToken, enabling expiringOfflineToken, or when background jobs 401 ~1h after the last admin visit. Covers the initShopify async conversion, the flag, the audit, build verification, and the existing-shop migration gap.
---
# Migrate an app to expiring offline access tokens (@avada/core v5)
Shopify requires expiring offline access tokens for public apps (new apps since 2026-04-01; all public apps by 2027-01-01). Access tokens live ~1h (`expires_in: 3600`), rotated by a 90-day `refresh_token`. `@avada/core``5.0.0-alpha.6` provides the machinery; this skill is the per-app adoption runbook.
## The two-switch mental model (BOTH are required)
| Switch | What | Without it |
|---|---|---|
| `expiringOfflineToken: true` on auth options | **acquire** — Shopify mints an expiring token + refresh_token | tokens stay non-expiring; nothing to refresh |
| `getValidShopToken` in `initShopify` | **consume** — refresh before expiry | expiring tokens lapse after ~1h → `401` |
Shipping one without the other is the #1 mistake: flag-only → 401s after 1h; getValidShopToken-only → silent no-op.
## Source of truth: session vs shop record
- **Session** (`shopifySession/offline_{shop}`) = credentials. It holds `accessTokenHash`, `refreshTokenHash`, `accessTokenExpiresAt`. `getValidShopToken`/`getValidAccessToken` refresh here. Deleted on uninstall.
- **Shop record** (`shops`) = lifecycle/install state (`isInstalled`). It does NOT store the refresh token. Never read the refresh token from it.
## Step-by-step
### 1. Bump @avada/core
Edit `packages/functions/package.json``"@avada/core": "5.0.0-alpha.6"` (or later), then `yarn install`. The sandbox link step may EACCES on root-owned `node_modules`; the **lockfile still updates correctly** and CI installs clean.
### 2. Make `initShopify` async via `getValidShopToken`
`getValidShopToken(shop, shopifyConfig)` returns `{shopifyDomain, accessToken}` (same shape as `prepareShopData`), refreshing via the session. `shopifyConfig` already has `{apiKey, secret, accessTokenKey}`.
```js
import {getValidShopToken} from '@avada/core';
export async function initShopify(shop, apiVersion = API_VERSION) {
const {shopifyDomain, accessToken} = await getValidShopToken(shop, shopifyConfig);
return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});
}
```
Keep the app's own `shopify-api-node` — core no longer bundles it (v5). `getValidShopToken` falls back to the shop-record token when no session exists (legacy/non-expiring), so it's backward-safe.
### 3. Convert ALL `initShopify(` call sites to `await`
`initShopify` is now async, so every caller must `await`. Three call forms exist — convert all:
`= initShopify(` , `shopify: initShopify(` (object prop) , `fn(initShopify(` (arg).
Safe per-file regex (excludes `commands/`/`scripts/`, protects the `function initShopify` def, avoids double-await):
```bash
grep -rl "initShopify(" packages/functions/src --include="*.js" \
| grep -vE "/commands/|/scripts/" \
| while read -r f; do
perl -i -pe 's/(?<!function )(?<!await )\binitShopify\(/await initShopify(/g' "$f"
done
# verify: 0 bare calls left
grep -rn "initShopify(" packages/functions/src --include="*.js" \
| grep -vE "/commands/|/scripts/" \
| grep -vE "function initShopify|import |from '|await initShopify\(" | wc -l # → 0
```
### 4. Client factories that build via initShopify (e.g. makeGraphQlApi)
If a helper like `makeGraphQlApi` does `shopify = initShopify(shop)` internally and is already `async`, the regex in step 3 already fixed its internal call → its own (many) callers need no change. Just confirm such factories are `async`.
### 5. Enable the flag on token-acquisition option blocks
Set `expiringOfflineToken: true` on **`verifyEmbedRequest`** (token-exchange, embedded apps) and **`shopifyAuth`** (OAuth install). `verifyRequest()` does no token exchange — skip it. If the app mounts `shopifyCharge`, set it there too.
### 6. Build-verify (catches the await-in-non-async trap)
```bash
cd packages/functions && node esbuild.config.js --production
```
esbuild fails on `await` in a **default parameter** — e.g. `function f(shop, shopify = await initShopify(shop))`. Fix by moving it into the body:
```js
export async function f(shop, shopify) {
if (!shopify) shopify = await initShopify(shop);
...
}
```
A "Failed to write to output file … permission denied" on `lib/*` is the root-owned-output sandbox issue, NOT a code error — it means parsing/bundling already succeeded. CI builds clean. (App functions are JS — no `tsc` — so this build is your only local check for missed `await` context.)
### 7. Audit token-read BYPASSES (the part the regex can't catch)
Anything that builds a client WITHOUT `initShopify` will 401 ~1h after a shop migrates:
```bash
grep -rn "new Shopify(" packages/functions/src --include="*.js" | grep -vE "/commands/|/scripts/"
grep -rn "X-Shopify-Access-Token" packages/functions/src --include="*.js"
grep -rn "prepareShopData" packages/functions/src --include="*.js"
```
Triage (real vs false positive):
- **Real:** `new Shopify({accessToken})` where `accessToken` comes from the shop record / a stale payload → route through `getValidShopToken`.
- **False positives:** the app's OWN local `prepareShopData` (e.g. a customer.io profile builder); `X-Shopify-Access-Token: partnerKey` (Partner API key, not a shop token); the line inside `makeGraphQlApi` (already covered).
### 8. Deploy to staging and verify
Push the branch to the staging branch/slot the app's `.gitlab-ci.yml` deploys from (repoint the slot's `only:` to your branch if needed). Then confirm a migrated session shows **`refreshTokenHash` + `accessTokenExpiresAt`** in Firestore.
## CRITICAL: existing shops do NOT migrate just by logging in
`verifyToken` only re-acquires a token when the current one is invalid (`checkIfActiveAccessToken` → false). A **non-expiring** token is always valid → the re-exchange never runs → the session never upgrades. So with `expiringOfflineToken` alone, already-installed shops keep their non-expiring token forever (only *new* installs/re-auths get expiring tokens). Confirm by inspecting a session doc: if it has `accessTokenHash` but no `refreshTokenHash`/`accessTokenExpiresAt`, it hasn't migrated.
Migration needs a token exchange, which needs the App Bridge **session token** → it can only happen **inside an embedded request** (no headless/cron path). Triggers (both require `@avada/core` ≥ 5.0.0-alpha.7):
- **Automatic (config):** set `autoMigrateOfflineToken: true` **and** `expiringOfflineToken: true` on the auth option blocks (`verifyEmbedRequest`, `shopifyAuth`). `verifyToken` re-exchanges a shop with a token but no refresh token on its next embedded request — one-time per shop, no app code, no merchant action. `isInstalled`-gated so it doesn't re-fire install hooks.
- **Explicit (function):** `await migrateToExpiringToken(ctx, {apiKey, secret, accessTokenKey})` from an embedded handler (e.g. `afterLogin`) for app-controlled timing. No-op if already expiring.
- **Force one shop (to test):** delete its `shopifySession/offline_{shop}` doc (or its `accessTokenHash`) in Firestore, then reload the embedded app → it re-exchanges with `expiring:1` → the doc reappears with `refreshTokenHash` + `accessTokenExpiresAt`.
**Background-only shops** (never opened in admin) can't be migrated by any of these — no session token reaches them. They need a real re-auth before 2027-01-01.
## Out of scope by default
`commands/` and `scripts/` (one-off/manual), and dev/mock tooling that builds its own client from a passed-in token (e.g. mock-order generators) — convert later; low production risk.
## Gotchas checklist
- [ ] BOTH switches set (flag + getValidShopToken) — not one.
- [ ] All three call forms awaited; `grep` shows 0 bare calls.
- [ ] No `await` in default params (esbuild catches; move to body).
- [ ] Bypass `new Shopify({accessToken})` sites routed through `getValidShopToken`.
- [ ] Local same-named `initShopify` (e.g. in a command) NOT swept up.
- [ ] Don't commit the repo's pre-existing dirty files — stage only your paths.
- [ ] Existing shops won't migrate on the flag alone — also set `autoMigrateOfflineToken: true` (or call `migrateToExpiringToken(ctx)`); verify a session gains `refreshTokenHash`.