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