線稿索引所有 spec最後更新 2026-07-07
specs/tenant-cli.md

租戶 CLI(toko)與 /api/v1 對外 API

一句話:給租戶自己(與其 agent)用的 OAuth device-flow CLI,經版本化 /api/v1 操作後台「處理流程 / 取資訊 / 產報表」;前端不變,CLI 與 server action 共用同一 core operation

1. 目標 / 問題

  • 租戶要能在終端(人或 agent)處理流程、取資訊、產報表,而非只能點後台 UI。
  • 未來對外 public(npm / binary 發行),故 API 一開始就做版本化、public 品質
  • 不重寫前端:server action 與 API route 是兩個薄 transport,共用同一個 @toko-cloud/core operation(商業邏輯 + audit 早已在 core,見 system-design)。
  • 與既有工具的分工:scripts/tenant.ts平台上級管理租戶(建租戶 / 停權 / 設網域,直連 control_db);本 CLI 是租戶自用,經 OAuth 打自己租戶的 API。兩者不同受眾、不同信任邊界,不合併。

2. 架構總覽

三個元件(一個 feature、分階段交付):

  • (A) 認證地基buildAuth()tenant-architecture 的 per-tenant better-auth)加官方 deviceAuthorization + bearer plugin;新增 /device 核准頁。
  • (B) /api/v1/* JSON route handlers:每端點 bearer 還原 session(per-host → 該租戶)→ requirePermission('...')(與後台同一把閘)→ 呼叫 canonical core operation → 回 JSON envelope。
  • (C) CLI clientpackages/cli/src/*純 fetch client,不 import 任何 @toko-cloud/core(故不需 --conditions react-server、不碰 DB)。結構抄 ~/zawa.archive/scripts/zawa(已驗證可行的同型 CLI)。

多租戶:CLI 的「目標」= 租戶 host。apps/web/src/proxy.ts 依 host 反查 → x-tenant-dbgetAuthForTenant(dbName),device flow 與 bearer token 天然 scope 在發起的那個租戶(A 租戶 token 不存在於 B 租戶的 session 表 → 失效)。

CLI (packages/cli) ──OAuth device flow──▶ /api/auth/device/{code,token}  (better-auth, per-tenant)
   │  Authorization: Bearer <token>
   ▼
/api/v1/<resource>/... (route handler) ──▶ requirePermission(perm) ──▶ coreOp(db, actor, input) ──▶ { ok, data }
                                              (bearer→session→role)        (= 同一個 server action 呼叫的 core fn)

3. 認證(OAuth 2.0 Device Authorization Grant)

  • ServerbuildAuth(dbName)plugins 加:
    • deviceAuthorization({ verificationUri: '/device' }) → 經既有 apps/web/src/app/api/auth/[...all]/route.ts catch-all 自動露出 /api/auth/device/code/api/auth/device/token(grant urn:ietf:params:oauth:grant-type:device_code)。零新路由接線
    • bearer()auth.api.getSession({ headers }) 能從 Authorization: Bearer <token> 還原 session,使 getCurrentSession() / requirePermission()/api/v1 route handler 內如常運作。
  • /device 核准頁(新 UI):已登入的租戶使用者輸入 / 確認 user_code,呼叫 better-auth 的 device approve / deny。未登入 → 導去登入(完整登入含 2FA 強制)再回核准頁。
  • CLI loginPOST device/code → 印 user_code + 開瀏覽器到 verification_uri_complete → 輪詢 POST device/token(處理 authorization_pending / slow_down / expired_token / access_denied)→ 取得 access_token~/.toko/config.json(mode 0600),記 expires_in
  • 2FA 不再是 CLI 的問題:token 是「完整登入(含 TOTP)後」才核發的 session token,bearer 帶的是已通過 2FA 的 session;CLI 端零認證邏輯。

3.1 長期 API 金鑰(後台自助申請)

定案先前 app-topology §6 的「bearer token 機制選型」開放議題:PAT 式長期 API key,綁建立者使用者。不採 OAuth client-credentials —— 機器身分需要另立 RBAC 歸屬與 owner-scope 語意,成本高且違反「token → user → role」既有鏈。device-flow session token(§3)與 API key 並存:前者是互動登入的短生命 session(CLI 自用),後者是給無人值守程式(排程 / 串接 / agent)的長期憑證。

  • 實作載體:官方獨立套件 @better-auth/api-key(better-auth 1.6.x 起 apiKey plugin 拆出 core、須另安裝,版本對齊 better-auth 版本線;repo 現況未安裝 —— 落地第一步即安裝 + 依實際 types 核對本節所有 option / schema 名)。加進 buildAuth(dbName)plugins(與 deviceAuthorization / bearer 同模式)。per-tenant auth instance → plugin 的 apikey 表落在各租戶 DB,跨租戶天然隔離(A 租戶 key 不存在於 B 租戶 DB → 失效)。
  • 資料模型:plugin 自帶 apikey 表 —— key只存 hashstart 存頭幾碼(清單顯示「前綴…」的依據)、prefixexpiresAtenabledlastRequest(最後使用)、per-key rate limit 欄;表併入 drizzle schema 走 better-auth schema generate → packages/core/src/schema/business.ts 慣例。完整金鑰只在建立回應出現一次,遺失只能撤銷重建。key 規格:toko_sk_ + ≥256-bit 隨機值;hash 演算法 / 常數時間比對 / start 長度(8 碼)依 plugin 預設並於落地時核實;hash 欄唯一索引。
  • 認證路徑(只對版本化對外 API 面 /api/v* 生效,現行 v1、未來 v2 / v3 沿用):對外單一 Authorization: Bearer header,session token 與 API key 共用、以 key 格式決定性分派(GitHub 模式:ghp_ / gho_ 同一 header 的作法)。分派權在我們自己的 customAPIKeyGetter:值帶 toko_sk_ 前綴 → api-key plugin 驗證(enableSessionForAPIKeys 還原記憶體 mock session → requirePermission() / owner-scope 沿用);其餘值 getter 回 null、留給 bearer plugin 當 session token —— 單一 owner 分派,不依賴兩個 plugin 的 hook 順序。安全依據(已讀 better-auth 1.6.11 與 @better-auth/api-key 原始碼證實):(1) key 由 generateRandomString(a-z, A-Z) 產生永不含 .,簽章 session token 必含 . —— 兩種格式 disjoint;(2) bearer plugin 對無 . 值會自簽成 request cookie,但下游仍須 session 表命中才有 session,key 永不寫入 session 表 → 恆 miss、fail-closed,key 無法經 bearer 路徑兌換成有效 session;(3) mock session 純記憶體(ctx.context.session)、不落 DB、不落 session 表。生效面限定:只有版本化對外 API(/api/v*)的 cliRoute 認證路徑接受 key;不含 /api/auth/*(better-auth 端點)、/api/webhooks/*(走各自簽驗)、/api/test/* 等非版本化面;後台 page / server action 的 getCurrentSession() 不得被 API key 還原(長期 PAT 不能變成後台瀏覽 session)。回歸測試釘三條:key 經 bearer 路徑不得還原 session、session token 不被 key getter 接受、後台 action 帶 key 必須不通。
  • 授權面
    • key 權限 = 該使用者角色(不用 plugin 的 per-key permissions 欄 —— 避免出現第二套授權面;未來「唯讀 key」scope 分級再議,見 §11)。
    • 不直曝 plugin 的 client 端點:建立 / 撤銷走後台 server action 包 auth.api.createApiKey(server-only、userId 恆為目前 session user)。
    • MVP 建立限 admin(settings 既有進站閘不動;sales / op / accountant 開放自建 key 連同其 UI 入口列 §11 開放議題);customer / guide 永不可建。
    • 檢視 / 撤銷全租戶 key 需新權限 api_key.manage(進 permissions-ac.ts statement catalog,admin wildcard 天然涵蓋);自己的 key 自撤免新權限(plugin owner check)。
    • 建立 / 一次性顯示需 fresh auth:既有 2FA 閘只驗「已綁定」,不驗近期驗證 —— 建立 key 時要求重新驗證(TOTP 或密碼),防被劫持的舊 session 鑄長期憑證。
    • 使用者停用連坐(硬性要求,已讀原始碼證實 mock 路徑無 banned 檢查):plugin 的 session mock 只 findUserById不驗 user.banned —— key 認證路徑每次 live 檢查 user.banned / 角色(fail-closed),且 banStaff / 移除員工 / 改角色時同 tx disable 名下全部 key(比照現行刪 session 的語意)。
  • 生命週期:到期 expiresAt(UI 預設 90 天,選項 90 / 180 / 365 天 / 永不);撤銷 = soft disable(enabled=false、立即失效、列保留供清單顯示「已撤銷」與稽核追溯(不 hard delete);lastRequest 供清單「最後使用」。
  • Auditapi_key.create / api_key.revokeaudit_log(wrapper action 記,含 key id / name,絕不含 secret);此外經 key 認證的寫入操作,audit metadata 帶 authType: 'api_key' + key id / name —— 事後才能回答「這筆 mutation 是哪把 key 做的」。
  • Rate limit:plugin 內建 per-key rateLimitTimeWindow / rateLimitMax 作 app 層基本防線;Cloudflare 層 quota 仍留開放議題(§11)。
  • UI/admin/settings 新「開發者」tab(wireframe:apps/wireframes/app/admin/settings/page.tsx),兩面板:
    • API 金鑰:清單(名稱 / 前綴 / 綁定身分 / 建立 / 最後使用 / 到期 / 狀態)+ 兩步建立 dialog(表單 → 一次性顯示完整金鑰)+ 撤銷。
    • CLI 登入:device-flow 核發 session 的檢視 / 撤銷(better-auth listSessions / revokeSession;撤銷即該裝置 401、需重新 toko login)。辨識問題:session 表無 client 型別欄,device-flow session 與瀏覽器 session 混在一起 —— 落地時需為 device-flow 核發的 session 加標記(session metadata 或欄位),否則清單只能猜 userAgent。
  • 測試:vitest —— wrapper action 權限 / fresh-auth / audit、key 認證打 /api/v1、到期 / 撤銷 / 停用連坐 401、後台 action 路徑帶 key 必須不通、跨租戶 key 失效;e2e —— 後台建 key → 帶 key 打 read 端點 → 撤銷 → 401。
  • 已核實(2026-07-07 讀官方 docs + 原始碼):套件名 @better-auth/api-key、option 名 enableSessionForAPIKeys / customAPIKeyGetter / apiKeyHeaders / keyExpirationdefaultExpiresIn / minExpiresIn / maxExpiresIn)/ requireName / defaultKeyLength(預設 64,官方:longer is better)/ hash 預設開啟(官方強烈建議勿關)、mock session 不落 DB、mock 路徑無 banned 檢查、過期 key 自動清除(每次 endpoint 呼叫、10s 冷卻)。
  • 實作時核實清單(剩餘):hash 演算法與常數時間比對細節、schema generate 產出欄位如何併入 drizzle、device-flow session 標記方式、api-key hook 與 bearer hook 在同一請求併存時的實際互動(以上述三條回歸測試釘住)。

4. Transport pattern:雙 transport 共用 core operation

每個 operation 收斂成單一 canonical core 函式 op(db, actor, input): Resultactor = { userId, role, scope },business logic + audit 全在此。兩個 transport 各自只做邊緣膠水:

關注點server action(web)/api/v1 route(CLI)
認證cookie sessionAuthorization: Bearer
授權requirePermission(perm)requirePermission(perm)同一支
取 DBgetCurrentTenantDb()getCurrentTenantDb()(同一支)
輸入FormData / object → 共用 zod schemaJSON → 共用 zod schema
邏輯 + auditcoreOp(共用)coreOp(共用)
輸出{ ok, error } + revalidatePath / redirectJSON envelope(無 revalidate)
  • 共用 transport-neutral zod schema(plain object,非 FormData):web 端把 FormData → object 解析留在 action(web-only),驗證與下游共用。
  • 殼裡殘留邏輯逐 op 推進 core / shared:form-parse split、inline writeAuditLog、error→message map、owner-scope(deriveOrderScope)。每個 op 的抽取需求見 tenant-cli-inventoryextraction 標記。
  • API route 共用 helper cliRoute({ permission, input, handler }):收斂 bearer 認證 + requirePermission + zod 驗證 + envelope + 錯誤映射,讓每個資源端點只剩「呼叫哪個 coreOp」。
  • web-only 邊緣留在 actionredirect() / revalidatePath() / cookie / FormData 檔案上傳不進 API route(route 改回傳 id / JSON)。共用的只有 coreOp + schema。

5. API 形狀(public 品質)

  • base path /api/v1,資源化(/api/v1/orders/api/v1/orders/{id}/mark-paid/api/v1/reports/pnl…)。
  • envelope:success { ok: true, data };error { ok: false, error: { code, message, issues? } },HTTP status 對齊(401 / 403 / 404 / 422 / 409 / 500)。
  • 穩定 error.codeunauthorized / forbidden / not_found / validation / invariant / conflict / internal。領域錯誤(OrderInvariantError / RefundStateError / ManualChargeError / zod)統一映射。
  • 讀取分頁 { rows, total, page, pageSize };報表 format=json|csv(預設 json)。
  • 寫入冪等性沿用 core 既有語意(如 markOrderManuallyPaid 冪等)。

6. CLI client

  • packages/cli/src/{core,commands,types}.ts + entry packages/cli/src/index.tspackage.json"toko": "bun run packages/cli/src/index.ts"
  • 抄 zawa:parseArgs--flag value / --flag=value / boolean)、target/config 層(resolveTarget / requireToken / ~/.toko/config.json 0600)、requestJson(Bearer + ApiError)、openBrowser--json 機器輸出、prod 寫入 --confirm、寫入 --dry-run | --apply
  • 指令命名:toko <resource> <verb> [positional] [--flags],對齊 inventory 的 cliCommand
  • 自我描述(agent 友善):toko helptoko <resource> helptoko whoami

7. 權限

  • 重用既有 packages/core/src/permissions-ac.ts catalog/api/v1 route 的 requirePermission(perm) 字串 = 對應 server action 的字串。既有 /api/v1 resource 不新增 permission(純多一個 transport,授權面不變);例外:developer key 管理本身新增 api_key.manage(§3.1)。
  • 新增 operation 若需新 permission,先進 permissions-ac.ts statement catalog(dynamic-rbac),action 與 route 同步使用。
  • (未來,非 MVP)device flow 的 scope 可作 token 級額外限制層(如唯讀 token),但最終授權仍以 role × catalog 為準。

8. 安全

  • token 檔 0600 + expiry 檢查;device 端點吃 better-auth rateLimit
  • 跨租戶 token replay 因 per-tenant session 表天然失效。
  • owner-scope 一律 server 端重算deriveOrderScope(role, userId)),絕不信任 client 傳入的 scope(sales 經 CLI 仍只能碰自己的單,與 UI 全等)。
  • prod 寫入需 --confirm(client 守衛)+ server 端權限閘(真守衛)。
  • PII(travelers.id_number / passport_no)維持 order-scoped gated reveal + traveler.decrypt audit;list/get 預設回遮罩,reveal 是獨立 gated op(core fn travelers/reveal.ts:owner-scope 在 core 重推、pii_crypto_misconfigured 冒泡、其餘解密失敗不洩漏、成功才稽核)。
  • 不開放批次 PII 匯出buildMemberDataExport 會解密證號/護照,後台對應 admin RBAC 端點(唯一出口是 customer self-service,限本人 session)。CLI//api/v1 刻意不鏡像——開 admin 端點等於新增一個繞過 per-field reveal audit/IDOR 邊界的批次 PII 解密面,違反上一條 PII 紀律。會員資料只到 member.read 的非 PII rollup(getMemberDetail,明確排除旅客 PII)。

9. 測試(對應目標「補滿測試確定流程無誤」)

  • core operation 單元:既有多已覆蓋;補「抽取後新 canonical fn」(如 createManualOrder)的單元測試。
  • API route handler(vitest):bearer 認證、權限放行 / 拒絕、envelope 形狀、error code 映射、分頁。
  • CLI client 單元parseArgs / config / requestJson(對 mock server)。
  • e2e(Playwright):device flow 核准 → 帶 token 打一個 read + 一個 flow(沿用 bun run test:e2e 的種子 reset)。
  • 流程回歸 smoke:以 CLI 對本地租戶實跑關鍵鏈(建單 → 收款 → 改狀態 → 退款;建梯次 → 報名 → 分房;登記成本 → 核簽)驗證原設計流程無誤。

10. 分階段

  • P0 認證地基 + CLI 骨架:device + bearer plugin、/device 頁、CLI login/whoami/logout/target(端到端打通,最高風險先做)。
  • P1 取資訊(read)cliRoute 共用 helper + 各資源 read 端點 + --json
  • P2 產報表(report):reports 端點 + CSV。
  • P3 處理流程(flow):write 端點(含重抽取 op)+ prod-confirm。

每階段以 ultracode dynamic workflow 平行開發 + 驗證。

11. 開放議題

  • 發行:先 repo-run(bun run toko …),未來 npm / standalone binary(OAuth + API 地基不需重做)。
  • 報表 CSV 欄位契約逐報表敲定。
  • scope 分級(唯讀 token / 細粒度 scope)是否導入 —— 適用 device flow session 與 API key(§3.1 刻意不用 per-key permissions,導入時一併設計,避免兩套授權面)。
  • API key 的 Cloudflare 層 rate limit / quota(app 層 per-key 防線已由 plugin 內建)。
  • API key 自建開放到 sales / op / accountant(§3.1 MVP 限 admin):連同 UI 入口(settings 進站閘現為 receiving_account.manage,sales / op 進不了 settings 頁)一併設計。
  • 完整 31 resource × 300 op 目錄tenant-cli-inventory(一次性實作藍圖,實作完即刪,code 為真相)。

Changelog

版本日期變更Commit
42026-07-07新增 §3.1 長期 API 金鑰:定案 PAT 式 key 綁建立者使用者(收掉 app-topology §6 選型議題);官方獨立套件 @better-auth/api-key per-tenant、hash 儲存一次性顯示、x-api-key header(棄 Bearer 雙語意)、生效面限 /api/v1、MVP 建立限 admin + fresh auth、新權限 api_key.manage、停用連坐 live 檢查 + 同 tx disable、撤銷 = soft disable、audit 含 key attribution、settings 開發者 tab(wireframe 先行)。經 Codex 對抗式覆核後修訂(Bearer 混淆面 / banned 非既有行為 / 全域 session 面 / fresh 2FA / 撤銷語意)。§7 例外註記、§11 開放議題同步。
52026-07-07§3.1 header 決策反轉:x-api-key單一 Authorization: Bearer(使用者偏好標準 header)。安全前提改為源碼級證實:key 格式無 . 與簽章 token disjoint、customAPIKeyGetter 前綴分派(單一 owner,GitHub 模式)、bearer 路徑對 key 恆 fail-closed(session 表永無 key)、mock session 不落 DB、mock 路徑無 banned 檢查(live 檢查升為硬性要求)。核實清單改列「已核實」與「剩餘」。
32026-06-30平台↔CLI 寫入面 parity 全掃補完(20 op):三層稽核(server action ↔ /api/v1 handler ↔ CLI)後補齊所有「平台做得到、CLI 做不到」的寫入缺口 —— 訂單派工/審核/旅客 CRUD(抽 core orders/assign 指派+通知、assertOrderInScope IDOR 閘)、財務審批(payment-requests approvereconciliation create-transferaccounts/record-transfer 原子 tx、pnl expense-create/update)、廠商主檔、員工邀請(一次性密碼)、行程住宿包(抽 trip-scope IDOR 檢查)、auto-pair 預覽、費用類別更新。每條 route+CLI 共用同一 core op/權限/module 閘/audit。
22026-06-30先前刻意跳過的 SUBSTANTIAL/安全/binary/報表 ops 補完(抽 core fn:lottery 分配/備案、交易性 auto-pair、期限解析、代客 K 單/代訂含 in-tx 現金腳、PII reveal、行程 base64 上傳、匯出逐頁引擎+CSV;web action 一併改呼叫同一 core fn)。新增「不開放批次 PII 匯出」設計不變式。
12026-06-30初版:租戶 CLI + 版本化 /api/v1 設計(OAuth device flow + bearer、雙 transport 共用 core operation、/device 核准頁)。