一句話:給租戶自己(與其 agent)用的 OAuth device-flow CLI,經版本化
/api/v1操作後台「處理流程 / 取資訊 / 產報表」;前端不變,CLI 與 server action 共用同一 core operation。
@toko-cloud/core operation(商業邏輯 + audit 早已在 core,見 system-design)。scripts/tenant.ts 是平台上級管理租戶(建租戶 / 停權 / 設網域,直連 control_db);本 CLI 是租戶自用,經 OAuth 打自己租戶的 API。兩者不同受眾、不同信任邊界,不合併。三個元件(一個 feature、分階段交付):
buildAuth()(tenant-architecture 的 per-tenant better-auth)加官方 deviceAuthorization + bearer plugin;新增 /device 核准頁。/api/v1/* JSON route handlers:每端點 bearer 還原 session(per-host → 該租戶)→ requirePermission('...')(與後台同一把閘)→ 呼叫 canonical core operation → 回 JSON envelope。packages/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-db → getAuthForTenant(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)
buildAuth(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 強制)再回核准頁。login:POST 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。定案先前 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 → 失效)。apikey 表 —— key 欄只存 hash、start 存頭幾碼(清單顯示「前綴…」的依據)、prefix、expiresAt、enabled、lastRequest(最後使用)、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/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 必須不通。permissions 欄 —— 避免出現第二套授權面;未來「唯讀 key」scope 分級再議,見 §11)。auth.api.createApiKey(server-only、userId 恆為目前 session user)。api_key.manage(進 permissions-ac.ts statement catalog,admin wildcard 天然涵蓋);自己的 key 自撤免新權限(plugin owner check)。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 供清單「最後使用」。api_key.create / api_key.revoke 進 audit_log(wrapper action 記,含 key id / name,絕不含 secret);此外經 key 認證的寫入操作,audit metadata 帶 authType: 'api_key' + key id / name —— 事後才能回答「這筆 mutation 是哪把 key 做的」。rateLimitTimeWindow / rateLimitMax 作 app 層基本防線;Cloudflare 層 quota 仍留開放議題(§11)。/admin/settings 新「開發者」tab(wireframe:apps/wireframes/app/admin/settings/page.tsx),兩面板:
listSessions / revokeSession;撤銷即該裝置 401、需重新 toko login)。辨識問題:session 表無 client 型別欄,device-flow session 與瀏覽器 session 混在一起 —— 落地時需為 device-flow 核發的 session 加標記(session metadata 或欄位),否則清單只能猜 userAgent。/api/v1、到期 / 撤銷 / 停用連坐 401、後台 action 路徑帶 key 必須不通、跨租戶 key 失效;e2e —— 後台建 key → 帶 key 打 read 端點 → 撤銷 → 401。@better-auth/api-key、option 名 enableSessionForAPIKeys / customAPIKeyGetter / apiKeyHeaders / keyExpiration(defaultExpiresIn / minExpiresIn / maxExpiresIn)/ requireName / defaultKeyLength(預設 64,官方:longer is better)/ hash 預設開啟(官方強烈建議勿關)、mock session 不落 DB、mock 路徑無 banned 檢查、過期 key 自動清除(每次 endpoint 呼叫、10s 冷卻)。每個 operation 收斂成單一 canonical core 函式 op(db, actor, input): Result,actor = { userId, role, scope },business logic + audit 全在此。兩個 transport 各自只做邊緣膠水:
| 關注點 | server action(web) | /api/v1 route(CLI) |
|---|---|---|
| 認證 | cookie session | Authorization: Bearer |
| 授權 | requirePermission(perm) | requirePermission(perm)(同一支) |
| 取 DB | getCurrentTenantDb() | getCurrentTenantDb()(同一支) |
| 輸入 | FormData / object → 共用 zod schema | JSON → 共用 zod schema |
| 邏輯 + audit | coreOp(共用) | coreOp(共用) |
| 輸出 | { ok, error } + revalidatePath / redirect | JSON envelope(無 revalidate) |
FormData → object 解析留在 action(web-only),驗證與下游共用。writeAuditLog、error→message map、owner-scope(deriveOrderScope)。每個 op 的抽取需求見 tenant-cli-inventory 的 extraction 標記。cliRoute({ permission, input, handler }):收斂 bearer 認證 + requirePermission + zod 驗證 + envelope + 錯誤映射,讓每個資源端點只剩「呼叫哪個 coreOp」。redirect() / revalidatePath() / cookie / FormData 檔案上傳不進 API route(route 改回傳 id / JSON)。共用的只有 coreOp + schema。/api/v1,資源化(/api/v1/orders、/api/v1/orders/{id}/mark-paid、/api/v1/reports/pnl…)。{ ok: true, data };error { ok: false, error: { code, message, issues? } },HTTP status 對齊(401 / 403 / 404 / 422 / 409 / 500)。error.code:unauthorized / forbidden / not_found / validation / invariant / conflict / internal。領域錯誤(OrderInvariantError / RefundStateError / ManualChargeError / zod)統一映射。{ rows, total, page, pageSize };報表 format=json|csv(預設 json)。markOrderManuallyPaid 冪等)。packages/cli/src/{core,commands,types}.ts + entry packages/cli/src/index.ts;package.json 加 "toko": "bun run packages/cli/src/index.ts"。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。toko help、toko <resource> help、toko whoami。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)。permissions-ac.ts statement catalog(dynamic-rbac),action 與 route 同步使用。scope 可作 token 級額外限制層(如唯讀 token),但最終授權仍以 role × catalog 為準。rateLimit。session 表天然失效。deriveOrderScope(role, userId)),絕不信任 client 傳入的 scope(sales 經 CLI 仍只能碰自己的單,與 UI 全等)。--confirm(client 守衛)+ server 端權限閘(真守衛)。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 冒泡、其餘解密失敗不洩漏、成功才稽核)。buildMemberDataExport 會解密證號/護照,後台無對應 admin RBAC 端點(唯一出口是 customer self-service,限本人 session)。CLI//api/v1 刻意不鏡像——開 admin 端點等於新增一個繞過 per-field reveal audit/IDOR 邊界的批次 PII 解密面,違反上一條 PII 紀律。會員資料只到 member.read 的非 PII rollup(getMemberDetail,明確排除旅客 PII)。createManualOrder)的單元測試。parseArgs / config / requestJson(對 mock server)。bun run test:e2e 的種子 reset)。/device 頁、CLI login/whoami/logout/target(端到端打通,最高風險先做)。cliRoute 共用 helper + 各資源 read 端點 + --json。每階段以 ultracode dynamic workflow 平行開發 + 驗證。
bun run toko …),未來 npm / standalone binary(OAuth + API 地基不需重做)。receiving_account.manage,sales / op 進不了 settings 頁)一併設計。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 4 | 2026-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 開放議題同步。 | — |
| 5 | 2026-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 檢查升為硬性要求)。核實清單改列「已核實」與「剩餘」。 | — |
| 3 | 2026-06-30 | 平台↔CLI 寫入面 parity 全掃補完(20 op):三層稽核(server action ↔ /api/v1 handler ↔ CLI)後補齊所有「平台做得到、CLI 做不到」的寫入缺口 —— 訂單派工/審核/旅客 CRUD(抽 core orders/assign 指派+通知、assertOrderInScope IDOR 閘)、財務審批(payment-requests approve、reconciliation create-transfer 抽 accounts/record-transfer 原子 tx、pnl expense-create/update)、廠商主檔、員工邀請(一次性密碼)、行程住宿包(抽 trip-scope IDOR 檢查)、auto-pair 預覽、費用類別更新。每條 route+CLI 共用同一 core op/權限/module 閘/audit。 | — |
| 2 | 2026-06-30 | 先前刻意跳過的 SUBSTANTIAL/安全/binary/報表 ops 補完(抽 core fn:lottery 分配/備案、交易性 auto-pair、期限解析、代客 K 單/代訂含 in-tx 現金腳、PII reveal、行程 base64 上傳、匯出逐頁引擎+CSV;web action 一併改呼叫同一 core fn)。新增「不開放批次 PII 匯出」設計不變式。 | — |
| 1 | 2026-06-30 | 初版:租戶 CLI + 版本化 /api/v1 設計(OAuth device flow + bearer、雙 transport 共用 core operation、/device 核准頁)。 | — |