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/core → docs/expiring-offline-tokens.md.
Tại sao phải migrate
Section titled “Tại sao phải migrate”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.
Breaking changes của V5
Section titled “Breaking changes của V5”- Bỏ
shopify-api-nodekhỏi@avada/core. Core không còn bundle package này; mọi Admin API call trong core dùng rawfetchvà throw error kèm status + body thật của Shopify. App của bạn vẫn giữshopify-api-noderiêng — không cần bỏ. getShopifyApi()/getShopifyApiWithValidToken()giờ trả về object nhẹ{options: {shopName, accessToken}}(không phải instanceshopify-api-node). Nếu code nào gọi methodshopify-api-nodetrực tiếp lên object trả về từgetShopifyApithì 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-noderiêng +prepareShopData, không dùnggetShopifyApicủ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ắc | Là gì | Thiếu nó |
|---|---|---|
expiringOfflineToken: true trên auth options | acquire — Shopify cấp expiring token + refresh_token | token vẫn non-expiring, không có gì để refresh |
getValidShopToken trong initShopify | consume — refresh token trước khi hết hạn | token 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).
Các bước migrate
Section titled “Các bước migrate”1. Bump @avada/core
Section titled “1. Bump @avada/core”packages/functions/package.json → "@avada/core": "5.0.1" (bản stable v5 đầu tiê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ướcexport function initShopify(shop, apiVersion = API_VERSION) { const {shopifyDomain, accessToken} = prepareShopData(shop.id, shop, shopifyConfig.accessTokenKey); return new Shopify({shopName: shopifyDomain, accessToken, apiVersion, autoLimit: true});}
// sauimport {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):
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 = 0grep -rn "initShopify(" packages/functions/src --include="*.js" \ | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" | wc -l4. 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)”cd packages/functions && node esbuild.config.js --productionesbuild 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.
6. Bật expiringOfflineToken
Section titled “6. Bật expiringOfflineToken”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.
shopifyCharge cũng không cần. Kiểm chứng trên build 5.0.1: flag chỉ được đọc ở đúng 3 file — controllers/authController.js, services/shopifyAuthService.js, helpers/verifyEmbedRequest/verifyToken.js — và không file charge nào (charge.js, shopifyCharge.js, chargeRepository.js) đụng tới token hay exchange. Set ở đó là no-op vô hại, không cần đi tìm. Tự verify cho version của bạn:
grep -rln "expiringOfflineToken" node_modules/@avada/core/build | grep -v '\.d\.ts'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:
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 quagetValidShopToken. - False positive:
prepareShopDatariê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 trongmakeGraphQlApi(đã được cover).
Branch sống lâu: audit lại sau MỖI lần merge master
Section titled “Branch sống lâu: audit lại sau MỖI lần merge master”Đây là thay đổi contract xuyên suốt codebase, nên nó va chạm với công việc đang chạy theo cách
khó chịu nhất: thêm await là một phép chèn thuần tuý, nên một call site initShopify(
mới viết trên master sẽ merge vào branch của bạn không hề có conflict marker — thiếu
await và vô hình. Merge sạch về mặt text ≠ đúng về mặt ngữ nghĩa.
Cộng với việc build không bắt được (xem cảnh báo ở bước 5), sẽ không có công cụ nào báo cho
bạn. Chạy lại grep ở bước 3 sau mỗi lần merge, và quét cả cây packages/ — package mới vẫn được
thêm vào trong lúc branch của bạn còn mở:
grep -rn "initShopify(" packages --include="*.js" --include="*.mjs" --include="*.cjs" --include="*.ts" \ | grep -v node_modules | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" \ | grep -vE "^[^:]+:[0-9]+: *(\*|//)" # bỏ JSDoc; phải không in ra gìSố liệu thật từ Joy: 5 call site thiếu await lọt vào theo đường này qua 3 lần merge — 3 cái
trong cùng một ngày khi catch-up ~900 commit. Coi đây là chắc chắn xảy ra, không phải “có thể”.
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. Shopify nhận 2 loại subject khác nhau, và chính lựa chọn này quyết định có cần merchant mở app hay không:
| Subject | subject_token_type | Cần embedded request? | Có trong @avada/core? |
|---|---|---|---|
| App Bridge session token | ...oauth:token-type:id_token | Có | Có — migrateToExpiringToken / autoMigrateOfflineToken |
| Chính token non-expiring cũ | urn:shopify:params:oauth:token-type:offline-access-token | Không — server-to-server | Không — phải tự implement |
Core chỉ implement cách 1, nên 2 cách dưới đây đều cần embedded request (cần @avada/core ≥ 5.0.0-alpha.7):
- Tự động (config): set
autoMigrateOfflineToken: truevàexpiringOfflineToken: truetrên auth options.verifyTokensẽ 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ì. Re-exchange chỉ chạy sau khicheckIfActiveAccessTokenxác nhận token hiện tại còn sống. Chính thứ tự đó — không phảiisInstalled— là thứ giữ cho nó không ăn mất một lần reinstall thật: session giữ token non-expiring đã bị revoke cũng có access token và không có refresh token, giống y hệt signature của shop chưa migrate. Check token trước thì phân biệt được; check signature trước thì một lần reinstall thật bị hiểu thành “migration” và bỏ quainitialPlan/webhooks/afterInstall.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 (vdafterLogin). No-op nếu đã expiring.
Shop không bao giờ mở admin (background-only) không migrate được bằng 2 cách trên — không có session token. Dùng cách headless bên dưới.
Migrate headless / hàng loạt (không cần merchant)
Section titled “Migrate headless / hàng loạt (không cần merchant)”Shopify nói rõ: “The migration can be done via a background job or during the next app launch.” Dùng chính token cũ làm subject:
curl -X POST https://{shop}/admin/oauth/access_token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Accept: application/json' \ -d 'client_id={client_id}' \ -d 'client_secret={client_secret}' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ -d 'subject_token={non_expiring_offline_token}' \ -d 'subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'expiring=1'exchangeOfflineToken của core hardcode subject_token_type: id_token nên không dùng được cho cách này — fleet runner phải tự gọi request và tự ghi refreshTokenHash / accessTokenExpiresAt / refreshTokenExpiresAt vào shopifySession/offline_{shop} (mã hoá AES bằng accessTokenKey, giống sessionRepository của core).
Chỉ shop nào token cũ còn dùng được mới migrate kiểu này. Shop đã mất token thì vẫn 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.
90-day refresh token & recovery
Section titled “90-day refresh token & recovery”- 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).
forceRefreshAccessTokensẽ 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.
Verify sau khi deploy
Section titled “Verify sau khi deploy”- 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
getValidShopTokenrefresh).
Checklist
Section titled “Checklist”- Bump
@avada/corelên 5.0.1 (stable; alpha.7+ là mức tối thiểu). -
initShopifyasync +getValidShopToken. -
awaittất cả call sites (grep = 0 bare calls); không cóawaittrong default param. - Bật
expiringOfflineTokentrênverifyEmbedRequest+shopifyAuth(shopifyChargeKHÔNG cần). - Route các bypass
new Shopify({accessToken})quagetValidShopToken. - Bật
autoMigrateOfflineToken(hoặc gọimigrateToExpiringToken) để migrate shop cũ. - Chạy lại grep bước 3 sau lần merge master CUỐI CÙNG — build xanh không chứng minh được gì.
- Test stub nào mock
@avada/corethì phải export thêmgetValidShopToken. - 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.
Claude Skill (download)
Section titled “Claude Skill (download)”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.
Nội dung skill (copy nhanh bằng nút copy ở góc phải):
---name: expiring-offline-tokens-migrationdescription: 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 (**5.0.1** is the first stable v5 — prefer it); 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/coreEdit `packages/functions/package.json` → `"@avada/core": "5.0.1"` (first stable v5), 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}`.
```jsimport {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):```bashgrep -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 leftgrep -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 blocksSet `expiringOfflineToken: true` on **`verifyEmbedRequest`** (token-exchange, embedded apps) and **`shopifyAuth`** (OAuth install). `verifyRequest()` does no token exchange — skip it.
**`shopifyCharge` needs nothing either.** Verified against 5.0.1's build: the flag is read in exactly three files — `controllers/authController.js`, `services/shopifyAuthService.js`, `helpers/verifyEmbedRequest/verifyToken.js` — and none of `charge.js` / `shopifyCharge.js` / `chargeRepository.js` touches a token or an exchange. Setting it there is a harmless no-op; don't go hunting for it. Confirm for your own version with:```bashgrep -rln "expiringOfflineToken" node_modules/@avada/core/build | grep -v '\.d\.ts'```
### 6. Build-verify (catches the await-in-non-async trap)```bashcd 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:```jsexport 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.
> ⚠️ **The build does NOT catch a _missing_ `await`.** It only rejects `await` in a position the> parser forbids (a default param, a non-async function). `const shopify = initShopify(shop)` is> valid JavaScript, so esbuild bundles it happily — it does no type analysis at all. Verified by> deleting an `await` from a converted call site: the full production build still reported> "Build completed!" across all 13 bundles.>> **The grep in step 3 is the only detector.** Never treat a green build as proof the conversion> is complete.
### 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:```bashgrep -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 verifyPush 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. Shopify accepts **two subjects**, and that choice decides whether a merchant must be in the browser:
| Subject | `subject_token_type` | Embedded request? | In core? ||---|---|---|---|| App Bridge session token | `...oauth:token-type:id_token` | **yes** | yes — `migrateToExpiringToken` || The old non-expiring offline token | `urn:shopify:params:oauth:token-type:offline-access-token` | **no** — server-to-server | **no** — implement yourself |
Core only does the first, so these three triggers all need an embedded request (all 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.
The re-exchange runs only **after** `checkIfActiveAccessToken` confirms the current token is still live. That ordering — **not** `isInstalled` — is what keeps it from swallowing a genuine reinstall: a session holding a *revoked* non-expiring token has an access token and no refresh token, byte for byte the migration signature. Check the token first and the two are distinguishable; check the signature first and a reinstall silently becomes a "migration", skipping `initialPlan`/webhooks/`afterInstall`.- **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 the three above — no session token reaches them. Use the headless path.
## Headless / bulk migration (no merchant needed)
Shopify: *"The migration can be done via a background job or during the next app launch."* Exchange the **old offline token itself**:
```bashcurl -X POST https://{shop}/admin/oauth/access_token \ -H 'Content-Type: application/x-www-form-urlencoded' -H 'Accept: application/json' \ -d 'client_id={client_id}' -d 'client_secret={client_secret}' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ -d 'subject_token={non_expiring_offline_token}' \ -d 'subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \ -d 'expiring=1'```
Core's `exchangeOfflineToken` hardcodes `subject_token_type: id_token`, so it can't do this. A fleet runner issues the request itself and writes `refreshTokenHash` / `accessTokenExpiresAt` / `refreshTokenExpiresAt` to `shopifySession/offline_{shop}`, AES-encrypted with `accessTokenKey` exactly as core's `sessionRepository` does.
**IRREVERSIBLE, one shot per shop.** Shopify revokes the old token the instant the exchange succeeds; the replacement lives 1h. A failed persist after a successful exchange locks that shop out permanently. Log the raw response BEFORE writing, make the runner resumable, and prove a small batch survives past the 1h expiry before widening. Only shops whose old token still works can migrate this way.
## Long-lived branch: re-audit after EVERY merge from the main branch
The conversion is a cross-cutting contract change, so it collides with ongoing work in thenastiest possible way: **adding `await` is a pure insertion**, so a new `initShopify(` callsite written on `master` merges into your branch with **no conflict marker**, un-awaited andinvisible. Textual merge success ≠ semantic correctness.
Combined with the build blindness above, nothing in the toolchain will tell you. Re-run thestep 3 grep after every merge, and scope it to the whole `packages/` tree — new packages getadded while your branch is open:
```bashgrep -rn "initShopify(" packages --include="*.js" --include="*.mjs" --include="*.cjs" --include="*.ts" \ | grep -v node_modules | grep -vE "/commands/|/scripts/" \ | grep -vE "function initShopify|import |from '|await initShopify\(" \ | grep -vE "^[^:]+:[0-9]+: *(\*|//)" # drops JSDoc mentions; must print nothing```
Real numbers from Joy: **five** un-awaited sites arrived this way across three merges — three ofthem in a single day's catch-up of ~900 commits. Treat this as certain, not possible.
**The worst shape is silent.** One of them sat inside a bare `try { … } catch { }`:
```jstry { const shopify = initShopify(shop); // Promise, not a client const info = await shopify.shop.get({fields: 'domain'}); …} catch { // Admin API unreachable — fall back to what we already have}```
The `TypeError` on `shopify.shop` was swallowed whole. No log, no 500 — the function justquietly degraded to its fallback and rejected valid input. **Never assume a missed `await`shows up as an error in logs.**
## Test doubles that stub `@avada/core`
Any suite that replaces `@avada/core` with a hand-written stub will break the moment`initShopify` starts calling `getValidShopToken`, usually with`(0, import_core.getValidShopToken) is not a function`.
Add it to the stub — but **mirror core's real no-session branch** rather than returning adummy token. With no offline session, core falls back to the shop record's own token via`prepareShopData`, and throws when there isn't one:
```jsconst prepareShopData = (id, shop) => ({...shop, id});module.exports = { prepareShopData, getValidShopToken: async shop => { const {accessToken} = prepareShopData(shop.id, shop); if (!accessToken) throw new Error('No access token available for ' + shop.shopifyDomain); return {shopifyDomain: shop.shopifyDomain, accessToken}; }};```
A stub that just returns a token would green-light calls the real implementation rejects.
## 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).- [ ] Re-ran the step 3 grep after the LAST merge from master — a green build proves nothing.- [ ] Any test stub of `@avada/core` also exports `getValidShopToken`.- [ ] 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`.- [ ] Shops that never open the admin need the **headless** exchange (old token as subject) — core can't do it; don't assume they need re-auth.- [ ] Any per-shop "is it migrated?" indicator reads `migrated` the moment you look, because `autoMigrateOfflineToken` fires on the same request that renders it. Use it as a health check, not a rollout tracker.