前言

Shopify App 的方案頁設定完成後,商家可以選擇方案、確認訂閱,再回到 App。這只能證明付款入口存在,不能自動阻止未訂閱帳號使用功能。

如果 App 只在首頁顯示「請先訂閱」,使用者仍可能直接開啟編輯網址,甚至對 action 發送 POST。真正的收費存取控制必須放在 server-side loader 與 action 執行之前。

這次實作從 Partner API 查詢 active subscription,加入正負快取、有限 stale access、redirect refresh 與 fail-closed 狀態;後來又把 guard 從最外層 App route 下放到每個受保護 route,才補上直接 mutation 的漏洞。

上一篇談的是 POS runtime eligibility 與 retailLocation,這篇處理商家能否進入整個 App 的權限。

原本的做法與問題症狀

第一階段只完成 Shopify 主持的 pricing page 與導向流程。商家未訂閱時,App 首頁可以 redirect 到方案頁;完成方案選擇後,Shopify 再帶 plan_handle 回來。

這時很容易把「UI 會導向付款」誤當成「功能已受保護」。

但嵌入式 App 的 route 不只有頁面:

  • loader 可能讀取 Promotion、Customer Segment 或 Campaign。
  • action 可能建立 discount、修改設定或批量改價。
  • 使用者可以直接提交 nested route 的 POST。
  • Partner API 或資料庫可能暫時不可用。

最外層 layout loader 不一定會在 child action 前替它完成權限判斷。若 action 只呼叫一般 Admin authentication,知道「這是合法登入的商店」仍不代表「這間商店有有效訂閱」。

問題是怎麼推敲出來的?

目前 Shopify App Pricing 由 Shopify 主持方案選擇與常見 billing 流程。商家完成選擇後,App 應查詢 Partner API 的 activeSubscription(appId:, shopId:),確認目前有效合約;沒有 active subscription 時回傳 null

這個查詢需要兩種 Shopify identity:

  1. Admin GraphQL session 能取得目前 shop GID。
  2. Partner API credentials 與 app ID 能查這個 shop 的 active subscription。

接著要決定外部查詢失敗時怎麼辦。

每個 loader/action 都即時呼叫 Partner API,權限最即時,卻增加 latency、rate limit 與故障耦合。完全相信長期快取則可能讓已取消訂閱的商店繼續使用。

因此這次把結果分成三種,而不是只回傳 boolean:

active       已確認可用
inactive     已確認沒有有效訂閱
unavailable  現在無法可靠確認

unavailable 不能被當成 active,否則 Partner API 故障就會讓所有人免費通過;也不能一律當成 inactive,否則短暫故障會把所有付費商家鎖在門外。

最後採用的架構

受保護 request 的處理順序是:

Shopify Admin authentication
        ↓
允許的 shop 檢查
        ↓
Billing route guard
        ├─ fresh cache
        └─ Admin shop GID → Partner API activeSubscription
                ↓
        active / inactive / unavailable

結果策略:

  • active:繼續執行 loader 或 action。
  • inactive:使用 Shopify top-level redirect 前往 pricing page。
  • unavailable:loader 顯示可重試錯誤;mutation 直接回應 503。

本地 cache 使用 shop domain 作唯一鍵,保存 shop GID、active 狀態、plan handle、最後成功驗證時間與最近錯誤時間。

正向 active cache 使用五分鐘 TTL,降低每次 navigation 都查 Partner API 的成本;inactive cache 只保留三十秒,讓剛完成訂閱的商家較快重新驗證。

Shopify redirect 帶回 plan_handle 時強制略過 cache 查詢,再把網址清回乾淨的 /app,避免重複 refresh。

關鍵實作

共用 authentication helper 把授權順序固定下來:

async function authenticateAppRequest(request) {
  const authenticated = await authenticate.admin(request);
  requireAuthorizedShop(authenticated.session.shop);
  await requireAppBilling(request, authenticated);
  return authenticated;
}

每個受保護的 loader 與 action 都改用這個 helper。知識圖譜追蹤到 Promotion、Display Settings 與 Sale Price Campaign 等 13 個 route handlers,而不是只保護 /app 首頁。

Billing guard 的決策則保持集中:

if (access.status === 'active') return;

if (access.status === 'inactive') {
  throw redirect(pricingUrl, {target: '_top'});
}

throw new Response('Unable to verify subscription', {status: 503});

Partner API client 設定五秒 timeout;第一次遇到 429 或 5xx 時短暫等待後重試一次。401/403 會分類為 authentication failure,不使用 stale access。

只有「先前確定 active」且最後驗證不超過 24 小時的 cache,才可在一般 transient failure 時暫時通過。Configuration error、authentication error、inactive cache 或超過時間上限,一律回到 unavailable。

實際驗證

這次測試包含權限決策與 route 行為:

  • 五分鐘內的 active cache 不重查 Partner API。
  • 三十秒內的 inactive cache 不重查。
  • 剛好到 TTL 邊界時必須 refresh。
  • URL 出現 plan_handle 時強制 refresh。
  • Active Subscription API 回傳 null 時保存 inactive。
  • Partner API 429/5xx 只重試有限次數。
  • 401/403 不允許使用 stale active cache。
  • 先前 active 的結果只在限定時間內提供 stale access。
  • 無法取得 shop GID、回應格式錯誤或 shop identity 不合法時 fail closed。
  • 未訂閱商店直接 POST 建立 Promotion 時,在 mutation 執行前被 redirect。
  • Billing 無法確認時,直接 POST 回應 503。
  • Promotion、Display Settings 與批量特價的 loaders/actions 都使用共同 guard。

這些測試證明的不是「有一個 pricing button」,而是受保護的讀取與寫入路徑都經過同一套 server-side entitlement 判斷。

仍然存在的限制

五分鐘 active TTL 代表取消或失效後可能有短暫存取延遲;24 小時 stale active 更是明確的產品取捨。它適合避免暫時性 Partner API 故障影響已付費商家,不適合高風險或按次計費操作。

目前 guard 只判斷「是否有 active subscription」,沒有依 plan items 做功能分級。若未來有不同方案權限,必須讓 Partner API 回傳的 subscription items 成為權威,不能只相信 redirect URL 裡的 plan_handle

Cache 也需要生命週期管理。App uninstall、shop redact、長期未使用商店與方案變更,都應有清理或重新驗證策略。

Shopify App Pricing 是目前推薦方式,但既有 App 可能仍使用 legacy Billing API。導入前要先確認哪套系統是 billing authority,不能同時維護兩份互不一致的訂閱真相。

最後,這篇只記錄 App route access。Theme Extension 與已建立的 Automatic App Discounts 是否應在訂閱失效後停用,是另一個 store-state lifecycle 問題,不能假設封鎖後台後它們會自動消失。

可以延伸到哪些情境?

Route-level billing guard 的模式可以延伸到任何需要 server-side entitlement 的功能:

  • 不同方案的功能旗標。
  • 用量上限與 usage event。
  • 付費 API endpoint。
  • Background job 建立與重跑。
  • Extension 設定修改。

實作順序應是先完成 authentication,再完成 authorization,最後才執行業務 mutation。UI 上是否顯示按鈕,只是體驗層的輔助。

下一批文章會進入 Shopify App 搬到 Serverless hosting 與資料庫後,四個部署面的差異。

參考資料