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.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ướ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.

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:

Terminal window
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:

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).

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ở:

Terminal window
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ể”.

Sau khi deploy: initShopify async đổi gì ở runtime

Section titled “Sau khi deploy: initShopify async đổi gì ở runtime”

Việc convert thì máy móc, nhưng nó đổi hai tính chất của một hàm trước đây vừa miễn phí vừa không bao giờ fail. Mọi call site đều thừa hưởng cả hai. Audit mấy thứ này trước lần deploy production đầu tiên — không cái nào lộ ra qua test, qua build, hay qua smoke test trên staging.

1. Mỗi lần gọi giờ tốn 1 Firestore read

Section titled “1. Mỗi lần gọi giờ tốn 1 Firestore read”

getValidShopTokengetValidAccessToken đọc session document ở mỗi lần gọi. Không cache, không memo — chỉ có in-flight map cho refresh đồng thời, là chuyện khác:

const session = await sessionRepository.findOne(sessionId, accessTokenKey); // mỗi lần gọi

Trước migration initShopify chỉ là một constructor, không tốn gì. Sau migration, app có 150+ call site tức là đã thêm 1 Firestore read vào từng chỗ đó. Đây là vấn đề chi phí và latency, không phải correctness — nhưng nó nhân lên ở các path fan-out.

Cách sửa là thêm parameter, không phải thêm cache. Hàm nào có thể bị gọi nhiều lần cho cùng một shop thì nên nhận client tuỳ chọn:

export async function updateMetafields({shopId, shopify}) {
if (!shopify) {
const shop = await getShopById(shopId);
shopify = await initShopify(shop); // một lần, không phải mỗi item
}
}

Caller trong vòng lặp build client một lần rồi truyền xuống. Grep những chỗ rebuild client của cùng một shop bên trong vòng lặp và kéo ra ngoài. Đừng nhét cache vào trong initShopify — token cache bị stale còn tệ hơn nhiều so với tốn thêm một read.

2. initShopify giờ có thể throw — và catch nuốt lỗi biến nó thành mất dữ liệu âm thầm

Section titled “2. initShopify giờ có thể throw — và catch nuốt lỗi biến nó thành mất dữ liệu âm thầm”

Đây là cái thực sự cắn. Hai loại lỗi mới tới được mọi call site:

Throw từMessageNghĩa là
getValidShopTokenNo access token available for {shop}. Merchant must re-authorize.không có session token shop record cũng không có token dùng được
performRefreshFailed to refresh access token for {shop}. Merchant may need to re-authorize.refresh grant fail; error.isRefreshTokenRevoked phân biệt authorization chết hẳn với việc retry transient đã cạn

Trước migration initShopify không thể fail — nó build client từ token trên shop record, token hỏng thì mãi sau mới lộ ra thành 401 của chính lời gọi API. Sau migration, điểm fail dịch lên sớm hơn, vào ngay lúc tạo client.

Điều đó quan trọng vì chỗ nó rơi vào. Một webhook handler dạng này trông vô hại:

try {
const shopify = await initShopify(shop); // ← giờ có thể throw
const customer = await getShopifyCustomer(shopify, id);
return (ctx.body = {success: true});
} catch (e) {
console.error(`Error handling the order webhook ${e?.message}`);
return (ctx.body = {success: false, error: e.message}); // ← HTTP 200
}

Koa trả 200 cho body đó. Shopify coi 200 là đã giao thành công và không retry. Nên một lần refresh fail thoáng qua sẽ âm thầm đánh rơi đơn đó: không cộng điểm, không retry, chỉ còn đúng một dòng console.error để giải thích với merchant ba tuần sau.

Lưu ý blast radius mở rộng ra chứ không phải sinh từ hư không — token chết hẳn thì trước đây cũng đã rơi vào catch này qua 401. Cái mới là một shop hoàn toàn khoẻ mạnh cũng có thể rơi vào đó khi refresh fail thoáng qua.

Audit theo hình dạng này: mọi catch bọc quanh call site đã convert mà trả về 2xx. Với từng chỗ, quyết định có chủ đích:

  • Lỗi nhóm token nên retry được. Trả non-2xx cho nhóm đó để Shopify gửi lại — lần gửi lại thường thành công, vì cái refresh vừa fail lúc đó đã xong.
  • Tối thiểu thì phải kêu to. Log lỗi token ở mức có alert thật. Một cái 200 im lặng thì không thể phân biệt với thành công trên mọi dashboard bạn đang có.

3. Refresh nằm trong ngân sách 5 giây của webhook

Section titled “3. Refresh nằm trong ngân sách 5 giây của webhook”

Shopify cho webhook ~5 giây. Nếu call site đã convert nằm ở phần đồng bộ của webhook handler thì refresh giờ diễn ra bên trong ngân sách đó, mà path refresh thì không bị chặn trên:

  • performRefresh retry lỗi transient 2 lần, backoff 500 ms và 1000 ms (TRANSIENT_REFRESH_RETRIES = 2, TRANSIENT_REFRESH_BACKOFF_MS = 500) — 1,5 s chỉ để ngủ, chưa tính 3 lần gọi HTTP.
  • fetch bên dưới không có AbortController, không có timeout. Token endpoint của Shopify chậm thì không có gì trong core chặn lại.

Case thường: 1 Firestore read (vài chục ms). Case xấu nhất: 3 lần HTTP tới endpoint đang yếu + 1,5 s backoff — vượt 5 s thoải mái.

Hai tính chất khiến nó đáng thiết kế né chứ không nên bỏ qua:

  • Hiếm với từng shop — refresh khoảng 1 lần/giờ, chỉ những lời gọi rơi vào cửa sổ 5 phút trước hạn mới kích hoạt.
  • Nhưng tương quan giữa các shop — nguyên nhân thường là token endpoint của Shopify đang có vấn đề, tức là mọi shop refresh trong cùng cửa sổ đó đều dính cùng lúc.

Ưu tiên resolve client ở background consumer thay vì ở phần đồng bộ của webhook. Chỗ nào handler thật sự cần một lời gọi API sống trước khi trả lời (ví dụ check eligibility) thì chấp nhận rủi ro một cách có ý thức, và đảm bảo failure mode ở §2 là retry chứ không phải 200 im lặng.

4. Concurrency: core lo được tới đâu, và cái gap chính core tự chỉ ra

Section titled “4. Concurrency: core lo được tới đâu, và cái gap chính core tự chỉ ra”

Refresh token là one-time-use trên toàn fleet, nên refresh song song cho cùng một shop là mối nguy thật. Core lo nhiều hơn bạn tưởng — đừng tự viết lock trước khi đọc cái đã có sẵn.

Trong một process: các caller đồng thời gộp vào cùng một in-flight promise, key theo shop.

Giữa nhiều instance: khi một refresh thua cuộc đua, core phục hồi thay vì fail:

// Another instance may have rotated this token first… Re-read the session: if a
// different instance has since persisted a fresh, still-valid token, use it instead
// of forcing the merchant through re-auth.
const latest = await sessionRepository.findOne(sessionId, accessTokenKey);
if (latest && latest.refreshToken !== refreshToken && /* còn hạn */) {
return latest.accessToken; // "Refresh lost the race … using token refreshed by another instance"
}

Nên một đám worker fan-out vào cùng một shop lúc token hết hạn không cần lock ở tầng app.

Gap mà chính core nêu tên: “Scope is per-process. Multi-instance deployments can still race; the retry-on-401 path is the safety net for that case.” Cái safety net đó dành cho token bị revoke hoặc xoay ngoài luồng, khi accessTokenExpiresAt cache vẫn bảo “còn hạn” nên không có gì kích hoạt refresh — lời gọi chỉ đơn giản là 401. Core export sẵn forceRefreshAccessToken đúng cho việc này: bọc lời gọi Admin API sao cho gặp 401 thì ép refresh một lần rồi retry một lần. App chạy multi-instance nên có, không phải thứ trang trí.

Checklist trước khi deploy cho nhóm rủi ro này

Section titled “Checklist trước khi deploy cho nhóm rủi ro này”
  • Call site rebuild client theo từng item trong vòng lặp — kéo ra ngoài, hoặc truyền shopify tuỳ chọn xuống.
  • Mọi catch quanh call site đã convert mà trả 2xx — lỗi token phải retry được, hoặc tối thiểu là log có alert.
  • Call site đã convert nằm trong path đồng bộ của webhook — chuyển xuống background consumer, hoặc chấp nhận có ý thức.
  • Retry-on-401 bằng forceRefreshAccessToken nếu app chạy multi-instance.
  • Sau deploy, theo dõi lượng Firestore read: thêm đúng 1 read cho mỗi lần call site chạy là hình dạng mong đợi.

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:

Subjectsubject_token_typeCần embedded request?Có trong @avada/core?
App Bridge session token...oauth:token-type:id_tokenCó — migrateToExpiringToken / autoMigrateOfflineToken
Chính token non-expiring cũurn:shopify:params:oauth:token-type:offline-access-tokenKhông — server-to-serverKhô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: 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ì. Re-exchange chỉ chạy sau khi checkIfActiveAccessToken xác nhận token hiện tại còn sống. Chính thứ tự đó là guard: 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ỏ qua initialPlan/registerWebhooks/afterInstall. Comment trong core ghi rõ đây là bug đã từng dính: “Letting needsMigration short-circuit it meant a session holding a REVOKED non-expiring token took the migration branch.”

isInstalled không được đọc ở nhánh migration — nhưng không phải là vô can trong verifyToken. Biết chính xác nó áp dụng ở đâu là thứ trả lời được câu “tại sao install hooks chạy / không chạy”:

checkIfActiveAccessToken(session)
├─ còn sống → needsMigration ? re-exchange (migration) → hooks KHÔNG BAO GIỜ chạy, không đọc isInstalled
└─ đã chết → recoverInvalidToken(session) → 1 trong 4 outcome:
├─ transient → 503, không quyết gì, để client retry
├─ refreshed → hết hạn thường lệ; mang token vừa xoay đi tiếp
├─ revoked → (re)install thật → hooks CHẠY, không đọc isInstalled
└─ refresh-expired → shop dormant quá 90 ngày → hooks chỉ chạy NẾU shop record
không tồn tại hoặc isInstalled === false

Hai hệ quả đáng nhớ:

  • revoked cố tình không phụ thuộc webhook. isInstalled: false chỉ do một thứ ghi — webhook APP_UNINSTALLED — mà webhook đó không thể fire nếu chưa từng được register. Nên reinstall trên shop có webhook không tới vẫn phát hiện được. Đó là lý do nhánh này cố tình không đọc isInstalled.
  • refresh-expired thì cố tình có đọc. Shop nằm im quá mốc refresh 90 ngày cần token mới nhưng chưa hề uninstall; coi đó là install thì sẽ tạo lại initial plan cho một merchant đang sống. Ở đây webhook im lặng mới là câu trả lời đúng.

Shop legacy non-expiring mà token đã chết thì phân loại là revoked, không phải refresh-expired — lý do của core: “A non-expiring offline token never expires, so one the API rejected can only have been revoked.”

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 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:

Terminal window
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.

  • 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.1 (stable; alpha.7+ là mức tối thiểu).
  • 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 KHÔNG cần).
  • Route các bypass new Shopify({accessToken}) qua getValidShopToken.
  • Bật autoMigrateOfflineToken (hoặc gọi migrateToExpiringToken) để 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/core thì phải export thêm getValidShopToken.
  • 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 (**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/core
Edit `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}`.
```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.
**`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:
```bash
grep -rln "expiringOfflineToken" node_modules/@avada/core/build | grep -v '\.d\.ts'
```
### 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.
> ⚠️ **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:
```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. 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 is the guard: 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`/`registerWebhooks`/`afterInstall`. Core's own comment calls this out as a bug it already hit: *"Letting needsMigration short-circuit it meant a session holding a REVOKED non-expiring token took the migration branch."*
### Which branch runs the install hooks
`isInstalled` is **not** consulted on the migration path — but it is not irrelevant to `verifyToken` either, and knowing exactly where it applies is what answers "why did/didn't my install hooks fire":
```
checkIfActiveAccessToken(session)
├─ live → needsMigration ? re-exchange (migration) → hooks NEVER run, isInstalled not read
└─ dead → recoverInvalidToken(session) → one of four outcomes:
├─ transient → 503, decide nothing and let the client retry
├─ refreshed → routine expiry; rotated tokens carried forward
├─ revoked → genuine (re)install → hooks RUN, isInstalled not read
└─ refresh-expired → 90-day dormant shop → hooks run ONLY IF the shop record
is absent or isInstalled === false
```
Two consequences worth knowing:
- **`revoked` is webhook-independent, by design.** `isInstalled: false` is written by exactly one thing — the `APP_UNINSTALLED` webhook — which cannot fire if it was never registered. So a reinstall on a shop whose uninstall webhook never arrived is still detected. That is why this branch deliberately does *not* consult `isInstalled`.
- **`refresh-expired` consults it deliberately.** A shop dormant past the 90-day refresh cliff needs a new token but never uninstalled; treating that as an install would re-create the initial plan for a live merchant. Here the webhook's silence is the correct answer.
A legacy non-expiring shop whose token is dead classifies as **`revoked`**, not `refresh-expired` — core's reasoning: *"A non-expiring offline token never expires, so one the API rejected can only have been revoked."*
- **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**:
```bash
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'
```
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 the
nastiest possible way: **adding `await` is a pure insertion**, so a new `initShopify(` call
site written on `master` merges into your branch with **no conflict marker**, un-awaited and
invisible. Textual merge success ≠ semantic correctness.
Combined with the build blindness above, nothing in the toolchain will tell you. Re-run the
step 3 grep after every merge, and scope it to the whole `packages/` tree — new packages get
added while your branch is open:
```bash
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]+: *(\*|//)" # drops JSDoc mentions; must print nothing
```
Real numbers from Joy: **five** un-awaited sites arrived this way across three merges — three of
them 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 { }`:
```js
try {
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 just
quietly 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 a
dummy token. With no offline session, core falls back to the shop record's own token via
`prepareShopData`, and throws when there isn't one:
```js
const 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.
## What changes at runtime once `initShopify` is async
The conversion is mechanical, but it changes two properties of a function that used to be
free and infallible. Every call site inherits both. Audit for these *before* the first
production deploy — none of them show up in tests, a build, or a staging smoke test.
### 1. Every call now costs a Firestore read
`getValidShopToken``getValidAccessToken` reads the offline session document on **every
invocation**. There is no cache and no memoisation — only an in-flight map for concurrent
*refreshes*, which is a different thing:
```ts
const sessionId = getOfflineSessionId(shopDomain);
const session = await sessionRepository.findOne(sessionId, accessTokenKey); // every call
```
Before the migration `initShopify` was a constructor call costing nothing. After it, an app
with 150+ call sites has added one Firestore read to every one of them. This is a cost and
latency change, not a correctness one, but it compounds in fan-out paths.
**The fix is a parameter, not a cache.** Functions that may be called repeatedly for one shop
should accept an optional client and only build one when they weren't given one:
```js
export async function updateMetafields({shopId, shopify}) {
if (!shopify) {
const shop = await getShopById(shopId);
shopify = await initShopify(shop); // once, not once per item
}
}
```
Callers in a loop then build the client once and pass it down. Grep for call sites where the
same shop's client is rebuilt inside an iteration and hoist those; don't try to add a cache
inside `initShopify`, because a stale cached token is far worse than an extra read.
### 2. `initShopify` can now throw, and a swallowing `catch` turns that into silent data loss
This is the one that actually bites. Two new error shapes reach every call site:
| Thrown by | Message | Means |
|---|---|---|
| `getValidShopToken` | `No access token available for {shop}. Merchant must re-authorize.` | no session token *and* no usable shop-record token |
| `performRefresh` | `Failed to refresh access token for {shop}. Merchant may need to re-authorize.` | refresh grant failed; `error.isRefreshTokenRevoked` distinguishes a dead authorization from an exhausted transient retry |
Before the migration, `initShopify` could not fail — it built a client from whatever token the
shop record held, and a bad token only surfaced later as a `401` from the actual API call.
Afterwards the failure moves *earlier*, into the client construction itself.
That matters because of where it lands. A webhook handler shaped like this looks harmless:
```js
try {
const shopify = await initShopify(shop); // ← can now throw
const customer = await getShopifyCustomer(shopify, id);
return (ctx.body = {success: true});
} catch (e) {
console.error(`Error handling the order webhook ${e?.message}`);
return (ctx.body = {success: false, error: e.message}); // ← HTTP 200
}
```
Koa sends **200** for that body. Shopify treats 200 as delivered and **never retries**. So a
transient token-refresh failure silently drops that order: no points, no retry, nothing but one
`console.error` line to explain it to the merchant three weeks later.
Note the blast radius *widened* rather than appearing from nothing — a genuinely dead token
already reached this catch via a `401`. What is new is that a perfectly healthy shop can land
there during a momentary refresh failure.
**Audit for this shape:** any `catch` around a converted call site that returns a 2xx. For each,
decide deliberately:
- **Token-class errors should be retryable.** Return a non-2xx for them so Shopify redelivers —
the retry will usually succeed, because the refresh that failed will have completed by then.
- **At minimum, make it loud.** Log token failures at a level you actually alert on. A silent
200 is indistinguishable from success in every dashboard you have.
### 3. Refresh inside a webhook's response budget
Shopify gives a webhook ~5 seconds. If a converted call site sits in the synchronous part of a
webhook handler, the refresh now happens inside that budget, and the refresh path is not bounded:
- `performRefresh` retries transient failures **twice**, with 500 ms and 1000 ms backoff
(`TRANSIENT_REFRESH_RETRIES = 2`, `TRANSIENT_REFRESH_BACKOFF_MS = 500`) — 1.5 s of sleeping
before the attempts are even counted.
- The underlying `fetch` has **no `AbortController` and no timeout**. A slow Shopify token
endpoint is not bounded by anything in core.
Normal case is one Firestore read (tens of ms). Worst case is three HTTP attempts to a degraded
endpoint plus 1.5 s of backoff — comfortably past 5 s.
Two properties make this worth designing around rather than ignoring:
- It is **rare per shop** — a refresh happens roughly hourly, and only calls landing in the
5-minute pre-expiry window can trigger one.
- It is **correlated across shops** — the trigger is usually Shopify's token endpoint being
unwell, which affects every shop refreshing in that window simultaneously.
Prefer resolving the client in the background consumer rather than in the webhook's synchronous
path. Where the handler genuinely needs a live API call before it can answer (an eligibility
check, say), accept the risk knowingly and make sure §2's failure mode is a retry rather than a
silent 200.
### 4. Concurrency: what core handles, and the one gap it names
Refresh tokens are one-time-use **across the whole fleet**, so parallel refreshes for one shop
are a real hazard. Core handles more of this than you would expect — don't build your own lock
before reading what is already there.
**Per-process:** concurrent callers collapse onto one in-flight promise, keyed by shop.
**Cross-instance:** when a refresh loses the race, core recovers instead of failing:
```ts
// Another instance may have rotated this token first… Re-read the session: if a
// different instance has since persisted a fresh, still-valid token, use it instead
// of forcing the merchant through re-auth. This collapses a swarm of expired-token
// callers into one real refresh + N cheap re-reads.
const latest = await sessionRepository.findOne(sessionId, accessTokenKey);
if (latest && latest.refreshToken !== refreshToken && /* still valid */) {
return latest.accessToken; // "Refresh lost the race … using token refreshed by another instance"
}
```
So a fan-out of workers hitting one shop at expiry does **not** need an app-level lock.
**The gap core names itself:** *"Scope is per-process. Multi-instance deployments can still race;
the retry-on-401 path is the safety net for that case."* That safety net covers a token revoked
or rotated **out of band**, where the cached `accessTokenExpiresAt` still says "valid" so nothing
triggers a refresh — the call just 401s. Core exports `forceRefreshAccessToken` for exactly this;
wrap Admin API calls so a 401 forces one refresh and retries once. Multi-instance apps should
have it; it is not optional dressing.
### Pre-deploy checklist for this class of risk
- [ ] Call sites that rebuild a client per item in a loop — hoist, or thread an optional `shopify` param.
- [ ] Every `catch` around a converted call site that returns 2xx — token errors made retryable, or at minimum alert-loud.
- [ ] Converted call sites in a webhook's synchronous path — moved to the background consumer, or knowingly accepted.
- [ ] Retry-on-401 via `forceRefreshAccessToken` for multi-instance deployments.
- [ ] After deploy, watch Firestore read volume: one extra read per call site invocation is the expected shape.
## 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`.
- [ ] No `catch` around a converted call site silently returns 2xx on a token error.
- [ ] Converted call sites are out of webhook synchronous paths (refresh is unbounded).
- [ ] Retry-on-401 via `forceRefreshAccessToken` if the app runs multi-instance.
- [ ] 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.