---
title: "更新站 Worker 規格與架構 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "更新站規格"
subtitle: "update-center Worker：部署形態、三類呼叫者的身分、每個端點的輸入輸出與錯誤、跨版路徑演算法、R2 物件 key、後台頁、設定與部署"
lede: "更新站是一支 Cloudflare Worker，**沒有任何私鑰**：它只判斷「這台客戶主機是不是合法安裝、該看到哪幾版」，把決策者在自己電腦簽好的發布清單原樣轉交，再發 15 分鐘有效的 R2 下載網址。客戶端不信任它回的任何東西，清單另用本機印樣驗章。本頁以 update-center repo 的程式碼為準，欄位與數字都附來源檔與行號。"
---

## 定位 {#role nav="定位"}

| 項目 | 現況 | 來源 |
|---|---|---|
| repo | `~/Projects/Billows/Audit-Manager/update-center/`（TypeScript，wrangler 4，Node 22） | `package.json` |
| Worker 名稱 | `guidant-update-center`（本機開發 `--env dev` 為 `guidant-update-center-dev`） | `wrangler.toml:4`、`:33` |
| 對外網址 | `https://guidantai-update.jedicotech.com`（custom domain；`workers_dev = false`，`*.workers.dev` 網址關閉） | `wrangler.toml:8-10` |
| 入口 | `src/index.ts`：`/v1/*` 客戶端、`/admin/*` 後台、`/healthz` 回 `{ok:true}`，其他一律 404 `not_found` | `src/index.ts:8-31` |
| 私鑰 | **零**。手上只有兩種公鑰：授權中心（License Center，LC）公鑰表（驗客戶身分）、發布印樣登記表（只給後台顯示，不拿來驗任何東西） | `src/licenses.ts:30-39`、`src/keys.ts:1-3` |

更新站只做四件事：

1. **認客戶**：驗客戶出示的身分票或授權檔是 LC 簽的、綁的指紋就是來問的這台、不在拒絕名單。
2. **選版**：在同主版號的已發布版（加上指定給這台先裝的草稿）裡，算出從現行版走到最新版的必經路徑。
3. **轉交**：把決策者簽好的清單字串（`signed_manifest`）原樣放進回應，不解析、不改一個位元組。
4. **發網址**：用 R2 S3 相容金鑰在 Worker 內算 15 分鐘預簽網址；金鑰本身從不離開 Worker。

清單驗章、版號遞增、主版號、sha256 全在客戶端做（見 [發布簽章規格](release-signing-spec.md)）。更新站被攻破時能做與不能做的事，見該頁「威脅對照」。

## 部署形態與架構 {#arch nav="架構"}

::: diagram
[![更新站架構：客戶機房、GitLab CI、決策者電腦三類呼叫者，經 Worker 進 D1 與 R2](diagrams/update-site.svg)](diagrams/update-site.svg)

[圖 1 — 更新站架構：三類呼叫者、Worker 兩組路由、D1 與 R2（點圖看大圖）]{.diagram-caption}
:::

| 元件 | 綁定名 | 實體 | 用途 |
|---|---|---|---|
| Worker | — | `guidant-update-center`，`compatibility_date = 2026-10-01`、`nodejs_compat` | 全部邏輯；`[observability] enabled = true` 開 Workers Logs |
| D1 | `UPDATE_DB` | `guidant-update-center`（id 見 update-center `wrangler.toml`／`.env`，不抄進文件） | 發行版、回報、拒絕名單、印樣表、CI token；schema 見 [更新站資料表](update-site-schema-spec.md) |
| R2 | `RELEASES` | bucket `guidant-ai-release` | 出貨包、清單、`.sha256`、維運指令小檔。Worker 只用綁定做 `head()`（判斷 ops 小檔在不在），**不讀物件內容** |
| R2 S3 端點 | 變數 `R2_S3_ENDPOINT` | 帳號專屬端點（值見 `wrangler.toml`） | 只拿來拼預簽網址 |
| Cloudflare Access | — | 一個 Access 應用程式守 `/admin/*` | 決策者登入；Worker 自己再驗一次 JWT（見下節） |

**刻意不做的**：沒有 KV、沒有 Durable Object、沒有排程（cron）、沒有對外發信。Worker 是無狀態的，唯一的模組層快取是 Access JWKS（10 分鐘，`src/access.ts:14`）。

## 三類呼叫者與身分 {#identity nav="身分"}

| 呼叫者 | 打哪些端點 | 身分證明 | Worker 怎麼驗 | 來源 |
|---|---|---|---|---|
| **客戶主系統**（BE `update_site_client.py`） | `/v1/check`、`/v1/download-url`、`/v1/report` | body 帶 `ticket`（LC 簽的更新站身分票）或 `license`（授權檔原文，舊路相容一版）＋`fingerprint` | LC 公鑰表 kid 查表、WebCrypto Ed25519 驗章 → `payload_type` 必須對 → 照綁的指紋＝`fingerprint` → 不在拒絕名單 | `src/routes/client.ts:25-39`、`src/licenses.ts:173-197` |
| **GitLab CI**（BE `scripts/build/publish_draft.sh`） | 只有 `POST /admin/releases/register-draft` | `Authorization: Bearer guc_<64 hex>` | sha256 查 `ci_tokens`（`is_active=1` 且 `scope='register-draft'`）；路由表 `ci: true` 只有一列 | `src/access.ts:129-140`、`src/routes/admin.ts:278` |
| **決策者 CLI**（`guidant-release`） | `GET /admin/releases/<v>`、`POST …/publish`、`POST …/withdraw`、`POST /admin/keys` | `cf-access-token`（`cloudflared access token` 取得），Access 換成 `Cf-Access-Jwt-Assertion` 轉給 Worker | 見下方 Access JWT 驗法 | `cli/site.ts:69-105` |
| **決策者瀏覽器**（後台頁） | `/admin` 與全部 `/admin/*` | Access 登入 cookie → `Cf-Access-Jwt-Assertion` | 同上 | `src/routes/admin.ts:330-346` |

### 客戶身分：身分票與授權檔 {#client-identity nav="客戶身分"}

- **兩種格式都收**：`ticket`（`payload_type = update_ticket`，LC 另簽的小 payload，只有 `license_id`、`machine_fingerprint`、`product_major`、`kid`，沒有客戶名稱）與 `license`（授權檔原文，含 `issued_to`）。兩者都帶時走 `license`（`src/routes/client.ts:32`）。BE 有票就只送票（`update_site_client.py:99-104`）。
- **信封格式**：LC 的 v1 明文、v2 信封（`payload` = base64(zlib(canonical))）、v3 armor（`-----BEGIN GUIDANT LICENSE-----` 包 v2）三種都收（`src/licenses.ts:7-9`）。
- **公鑰表**：`PROD_KEYS` 只有 LC 正式簽發站一把（`src/licenses.ts:30-32`）；`DEV_KEYS` 只有 `LICENSE_KEYRING = "prod+dev"` 才併入，這個值只寫在 `wrangler.toml` 的 `[env.dev.vars]`（`:41-43`）。漏設時預設只認 PROD。
- **借簽防護**：同一把 LC 鑰也簽授權檔、身分票、完整性 manifest、解鎖碼，所以 `payload_type` 必須等於呼叫的那一種（缺鍵視為 `license`），不符回 `wrong_payload_type`（`src/licenses.ts:184`）。
- **主版號**：`product_major` 缺欄視為 1；不是 ≥1 的整數就算格式錯（`src/licenses.ts:161-167`）。
- **刻意不看**：`expires_at`（授權過期仍可升同主版號最新版）、時鐘回撥、時間窗（客戶端沒有私鑰簽不出新時間戳；重放風險與送原文相同，都綁指紋）。
- **成本上限**：body 上限 `MAX_LICENSE_TEXT + 4KB`＝68KB（`src/routes/client.ts:17`）；`payload` 字串 64KB、解壓後 64KB，解壓邊讀邊累計，超過立刻丟（`src/licenses.ts:23-24`、`:97-121`）。

### Access JWT 驗法 {#access-jwt nav="Access JWT"}

Access 只擋經過它的流量；Worker 自己再驗一次，只看「有沒有帶 header」等於誰都能偽造（`src/access.ts:3-5`）。

1. 設定三項 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`、`ACCESS_ALLOWED_EMAILS` 缺一 → 所有 Access 身分 403（`accessConfig`，`src/access.ts:69-80`）。team domain 必須 https；只有 `127.0.0.1`／`localhost` 准用 http（本機假 JWKS）。
2. token 長度 ≤ 8KB、三段、header `alg = RS256` 且有 `kid`（`:96-103`）。
3. 先驗 claims 再抓鑰：`aud` 含 `ACCESS_AUD`、`iss` = team domain、`exp`／`nbf` 容許 60 秒誤差、`email` 必須存在（service token 沒有 email，一律不收）且在放行清單（`:82-93`）。
4. JWKS 從 `<team>/cdn-cgi/access/certs` 抓，快取 10 分鐘；未知 kid 觸發重抓，但兩次重抓至少隔 30 秒（`:13-16`、`:52-62`）。
5. `RSASSA-PKCS1-v1_5` + SHA-256 驗簽章。

**Bearer 優先**：請求只要帶了 `Authorization`，就只當 CI token 看，**不退回 Access**（`src/access.ts:146-157`）。CLI 因此絕不能帶 `Authorization`，否則 publish 一律 403（`cli/site.ts:4`）。

### 後台的權限順序 {#admin-order nav="後台順序"}

`handleAdmin`（`src/routes/admin.ts:330-346`）的判定順序固定：

1. 認身分（`identify`），失敗 → 403 `forbidden`。**沒身分的人連「這條路存不存在」都不該知道**，所以身分在路由比對之前。
2. 比對路由：路徑不存在 → 404 `not_found`；路徑存在但方法不對 → 405。
3. CI 身分打 `ci` 不為 true 的路由 → 403 `forbidden`。
4. 跨站寫入擋（`crossSiteWrite`，`:310-317`）：非 GET／HEAD 時，帶 `Origin` 就必須同源（否則 403）；非 DELETE 時 `content-type` 必須是 `application/json`（否則 415 `unsupported_media_type`）。CLI 與 CI 不帶 `Origin`，不受影響。
5. 執行 handler。

## 共用回應規則 {#conventions nav="回應規則"}

- 成功：JSON，`cache-control: no-store`（`src/http.ts:26-31`）。`/v1/report` 成功回 **204 無 body**。
- 失敗：`{"error": "<代碼>"}`，部分後台錯誤多帶 `reason`、`status`、`pending`。**只回代碼，不回原因**——偽造者不該知道是 kid 錯還是簽章錯。
- 未預期例外：500 `internal_error`；log 只記路徑與錯誤種類，**不記 body**（客戶端 body 是整張授權檔，後台 body 可能是清單原文或 token 明文）（`src/index.ts:12-27`）。

| 錯誤代碼 | HTTP | 什麼時候 |
|---|---|---|
| `invalid_request` | 400 | body 不是 JSON 物件、欄位缺或格式錯、版號格式錯、`artifact` 不認得 |
| `invalid_license` | 401 | 驗章不過、kid 不在公鑰表、`payload_type` 不對、照沒綁指紋、照綁的不是這台 |
| `forbidden` | 403 | 後台無身分、CI 打非 CI 路由、跨站 Origin |
| `license_denied` | 403 | 授權在拒絕名單 |
| `not_found` | 404 | 版號看不到（別的主版號、撤回、別台的草稿、不存在）、ops 小檔不在 R2、後台查無 |
| — | 405 | 客戶端用 POST 以外的方法（帶 `allow: POST`）；後台方法不符 |
| `conflict` | 409 | 狀態不允許（`reason` 見各端點） |
| `payload_too_large` | 413 | body 超過上限（先看宣告長度、讀完再量一次） |
| `unsupported_media_type` | 415 | 後台寫入不是 `application/json` |
| `download_unavailable` | 503 | Worker 沒設 R2 金鑰，算不出預簽網址 |

## 客戶端端點 {#client nav="客戶端端點"}

三支都是 `POST`、body 為 JSON。共用的身分欄位（`authenticate`，`src/routes/client.ts:25-39`）：

| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `ticket` | string（armor／JSON 字串）或物件 | 與 `license` 擇一 | LC 簽的更新站身分票 |
| `license` | string 或物件 | 與 `ticket` 擇一 | 授權檔原文（舊路，相容一版）。兩者都帶走這個 |
| `fingerprint` | string，`^[0-9a-f]{64}$` | 是 | 本機機器指紋 |
| `current_version` | string，`X.Y.Z` 或 `X.Y.Z-後綴`，≤64 字 | 是 | 主系統現行版號 |

**身分判定順序**（三端點相同）：欄位格式（400）→ 驗章與 `payload_type`（401）→ 照綁的指紋＝`fingerprint`（401）→ 拒絕名單（403）。拒絕名單排在主版號判定之前：**被拒的授權連「有沒有新版」都不該知道**。

### POST /v1/check {#v1-check nav="check"}

| 項目 | 內容 |
|---|---|
| 誰打 | 客戶主系統：啟動時一次、之後每 6 小時（`core/scheduler_update.py:10`），以及管理員按「檢查更新」 |
| 另帶 | 無 |
| 驗什麼 | 身分（上述四步） |
| 副作用 | upsert `instance_version_reports` 的 `current_version`、`last_seen`（`touchInstance`，`src/instances.ts:8-17`）。**不動** `last_upgrade_*` |
| 判定 | 取「同主版號（授權的 `product_major`）的 `published`」＋「`draft` 且 `internal_fingerprints` 含這台」，撤回的永遠不取（`visibleReleases`，`src/releases.ts:62-75`）；再跑 `planPath` |
| 回（沒新版） | `200 {"status":"none"}`。路徑走不通（中間必經版被撤回）也回 `none`，不回半條路 |
| 回（有新版） | `200 {"status":"available","latest":"<最後一跳>","path":["<v1>","<v2>"…],"releases":[ReleaseView…]}` |
| 錯誤 | 400、401、403、413、405 |

`releases[]` 每一筆（`ReleaseView`，`src/releases.ts:103-136`）：

| 欄位 | 型別 | 說明 |
|---|---|---|
| `version` | string | 版號 |
| `major` | number | 主版號 |
| `min_base_version` | string | 可直升的最低起始版 |
| `sha256` | string，64 hex | 整包雜湊（客戶端不採用，以驗過章的清單為準） |
| `size` | number | 整包位元組數 |
| `has_security_fix` | boolean | |
| `status` | `"published"` 或 `"draft"` | 草稿只會出現在被指定先裝的機器 |
| `signed_manifest` | string 或 null | 決策者簽好的 v2 信封原文，原樣轉交 |
| `kid` | string 或 null | 主簽章的印章 kid |
| `notes` | string[] | 後台「這一版更新了什麼」，沒填為 `[]` |
| `impact` | string | 影響範圍，沒填為 `""` |
| `action` | string | 建議動作，沒填為 `""` |

`notes`／`impact`／`action` **不在簽章內**，發布後可在後台修字不必重簽。`min_agent_version` 不在 `ReleaseView`：客戶端從驗過章的清單 payload 讀。

### POST /v1/download-url {#v1-download nav="download-url"}

| 項目 | 內容 |
|---|---|
| 誰打 | 客戶主系統，按下載後 |
| 另帶 | `version`（string，必填，目標版號）；`artifact`（選填，只認 `"ops"`） |
| 驗什麼 | 身分 → `version` 格式 → `artifact` 只能缺或 `"ops"`（否則 400）→ 該版必須是這張授權 `check` 看得到的版（同一個 `visibleReleases`，`src/routes/client.ts:57`）→ 要 ops 時 R2 `head()` 確認小檔存在 |
| 回（整包） | `200 {"version","sha256","size","url","expires_in":900}`，並寫一列 `release_download_log` |
| 回（ops 小檔） | `200 {"version","artifact":"ops","url","expires_in":900}`；**不寫** `release_download_log`（整包才算一次下載） |
| 錯誤 | 400；401；403；404（看不到的版，或 ops 小檔不在 R2——客戶端據此退回從整包抽）；503 `download_unavailable`（Worker 沒設 R2 金鑰） |

客戶端對 ops 回應的檢查：回應沒明標 `artifact: "ops"` 一律不採用，因為不認得 `artifact` 的舊更新站會照回整包網址（`update_site_client.py:121-139`）。客戶端也只接受下載主機為 R2 帳號端點的網址（`DOWNLOAD_HOSTS`，`update_site_client.py:28`），更新站被冒用時不能把主機導去內網下載。

### POST /v1/report {#v1-report nav="report"}

| 項目 | 內容 |
|---|---|
| 誰打 | 客戶主系統：主機升級寫回終態時（`upgrade_order.py` 的 `report`），以及代 Agent 回報（`agent_rollout_service.report_finished`） |
| 另帶 | `result`（`succeeded`／`rolled_back`／`failed`／`skipped`）、`from_version`、`to_version`（皆必填、版號格式）；`target`（`"host"`／`"agent"`，缺＝`host`）；`target = agent` 時必填 `agent_fingerprint`（64 hex） |
| 驗什麼 | 身分 → `target` 白名單 → `result` 白名單、兩個版號格式 → agent 時 `agent_fingerprint` 格式 |
| 副作用（host） | upsert `instance_version_reports`：`current_version` 取 body 的 `current_version`，`last_upgrade_*` 四欄寫入（`recordUpgrade`，`src/instances.ts:21-41`） |
| 副作用（agent） | upsert `agent_version_reports`（主鍵 `license_id, agent_fingerprint`）：`current_version` **只有 `succeeded` 才寫 `to_version`，其餘寫 `from_version`**（`recordAgentUpgrade`，`src/instances.ts:47-68`） |
| 回 | `204` 無 body |
| 錯誤 | 400、401、403、413 |

Agent 沒有自己的授權，身分仍是主機的票，`agent_fingerprint` 只用來分列。BE 的欄位對應：`agent_rollout_service._unreported` 送 `agent_fingerprint`、`result`（＝rollout 列的 `status`）、`from_version`、`to_version`（＝`target_version`）；送在交易外，送達才寫 `reported_at`（`agent_rollout_service.py:415-443`）。

**`instance_version_reports` 寫入者只有兩支**：`/v1/check`（只動版號與 last_seen）與 `/v1/report`（target=host）。發布前的「內部先裝全成功」只認 report 寫的結果，`check` 不算（`internalTargetStatus`，`src/releases.ts:276-298`）。

## 後台端點 {#admin nav="後台端點"}

路由表在 `src/routes/admin.ts:275-292`。`VERSION_SEG` = `[0-9A-Za-z.+-]{1,64}`，進 handler 後再以版號格式驗一次（不符 400）。body 上限 `MAX_SIGNED_MANIFEST + 8KB`＝72KB（`:44`）。每次寫入都打一行稽核 log：`{by, action, target, at}`，`by` 是 Access 信箱或 `ci:<token 名>`，**不記 body**（`:67-70`）。

| 方法 路徑 | 誰 | body | 回 |
|---|---|---|---|
| `GET /admin`、`/admin/` | 決策者 | — | 後台頁 HTML（見「後台頁」節） |
| `GET /admin/releases` | 決策者 | — | `{releases:[…]}`，新到舊；每筆＝詳情欄位扣掉 `signed_manifest`，多 `adoption` |
| `POST /admin/releases/register-draft` | **CI**（也接受決策者） | 見下 | 201 |
| `GET /admin/releases/<v>` | 決策者 | — | 詳情（含 `signed_manifest`、`internal_targets`） |
| `PATCH /admin/releases/<v>` | 決策者 | `{release_notes?, impact?, action?}` | 詳情 |
| `POST /admin/releases/<v>/publish` | 決策者（CLI） | `{signed_manifest, kid}` | 詳情 |
| `POST /admin/releases/<v>/withdraw` | 決策者 | `{}` | 詳情 |
| `PUT /admin/releases/<v>/internal-targets` | 決策者 | `{fingerprints:[…]}` | 詳情 |
| `GET /admin/denylist` | 決策者 | — | `{denylist:[…]}` |
| `POST /admin/denylist` | 決策者 | `{license_id, reason}` | 201 `{denylist}` |
| `DELETE /admin/denylist/<license_id>` | 決策者 | — | `{denylist}` |
| `GET /admin/keys` | 決策者 | — | `{keys:[…]}` |
| `POST /admin/keys` | 決策者（CLI `sync-keys`、後台頁） | `{kid, pubkey, status}` | `{keys}` |
| `GET /admin/ci-tokens` | 決策者 | — | `{ci_tokens:[…]}` |
| `POST /admin/ci-tokens` | 決策者 | `{name}` | 201 `{id, name, token, scope}` |
| `POST /admin/ci-tokens/<id>/revoke` | 決策者 | — | `{ci_tokens}` |

### 詳情物件 {#detail nav="詳情物件"}

`adminView`＋`internal_targets`（`src/routes/admin.ts:72-99`）：

| 欄位 | 型別 | 說明 |
|---|---|---|
| `version`、`major`、`min_base_version`、`sha256`、`size`、`r2_key`、`status` | | D1 原值；`status` 含 `withdrawn` |
| `internal_fingerprints` | string[] | 指定先裝的機器指紋 |
| `has_security_fix` | boolean | |
| `min_agent_version` | string 或 null | |
| `release_notes` | string[] | |
| `impact`、`action` | string | |
| `kid`、`signed_manifest` | string 或 null | 列表端點不回 `signed_manifest` |
| `retain_until`、`created_at`、`published_at`、`withdrawn_at` | ISO 字串或 null | |
| `internal_targets` | `[{fingerprint, current_version, result, installed}]` | `installed` = 該指紋有任一列 `last_upgrade_to = 這版 ∧ last_upgrade_result = succeeded`（同一台換過授權會有多列，任一列成功就算） |
| `adoption`（只在列表） | 物件或 null | `on_version`、`rolled_back`、`failed`、`major_total`、`agent_on_version`、`agent_rolled_back`、`agent_failed`（`releaseAdoption`，`src/releases.ts:358-385`） |

### register-draft {#register-draft nav="register-draft"}

body（`parseDraftInput`，`src/releases.ts:196-214`）：

| 欄位 | 型別 | 必填 | 驗法 |
|---|---|---|---|
| `version` | string | 是 | 版號格式 |
| `min_base_version` | string | 是 | 版號格式、≤ `version`、主版號與 `version` 相同 |
| `sha256` | string | 是 | `^[0-9a-f]{64}$` |
| `size` | number | 是 | 安全整數、> 0 |
| `has_security_fix` | boolean | 否 | 缺＝false；給了非 boolean 就 400 |
| `min_agent_version` | string 或 null | 否 | 有值就要是版號格式 |

主版號由 `version` 推，**不收 CI 自報的 major**。CI 實際送的是未簽的 `release.json` 整份（`publish_draft.sh` `--data-binary @"$MANIFEST"`），多出來的欄位（例如 `ops_sha256`）被忽略。

行為（`registerDraft`，`src/releases.ts:226-250`）：

- 版號不存在 → 新增一列 `draft`，`r2_key` 由 Worker 拼，`retain_until` = 現在 + 10 年。
- 版號已是 `draft` → 覆蓋 `min_base_version`、`sha256`、`size`、`has_security_fix`、`created_at`、`min_agent_version`；**保留** `internal_fingerprints`、版本說明。若 `sha256` 變了（包換了），清掉那幾台內部先裝機器對這版的 `last_upgrade_result`，要重裝重報才算。
- 版號已 `published` 或 `withdrawn` → 409 `{reason:"not_draft", status}`。不然一支 CI token 就能把客戶正在裝的包換掉。

回 201：`{version, major, status:"draft", upload:{bundle, manifest, sha256}}`，`upload` 是三個 R2 目標 key，CI 照著傳、不自己拼（`publish_draft.sh` 先傳大包、再 `.sha256`、最後 `release.json`）。

### publish {#publish nav="publish"}

body：`kid`（`^[0-9a-f]{16}$`）、`signed_manifest`（string，非空，≤ 64KB，必須能 parse 成 JSON 物件；信封上若有 `kid` 必須等於 body 的 `kid`）（`src/routes/admin.ts:148-173`）。

驗法依序：

1. `kid` 在 `signing_keys` 是 `revoked` → 409 `kid_revoked`。印樣表沒登記這個 kid **不擋**。
2. 版號不存在 → 404；`withdrawn` → 409 `bad_status`。
3. `draft` 第一次發布：`internal_fingerprints` 每一台都要 `installed`，否則 409 `{reason:"internal_not_installed", pending:[…]}`。清單為空時更新站**放行**（CLI 比這裡嚴，空清單直接拒絕）。
4. `published` 再送一次＝同版重簽：直接覆蓋 `signed_manifest`、`kid`、`published_at`，不再核內部先裝。

寫入：`status = published`、`signed_manifest`、`kid`、`published_at`（`publishRelease`，`src/releases.ts:331-347`）。**更新站不驗清單簽章**，它沒有、也不該有那把鑰；它只擋「一看就錯」的形狀。

### 其他寫入端點 {#admin-writes nav="其他寫入"}

| 端點 | 驗法 | 錯誤 |
|---|---|---|
| `withdraw` | 只有 `draft`／`published` 能撤；撤回是終點，`check`／`download-url` 永遠不再回這版，同版號也不能再登記 | 404；409 `bad_status` |
| `PATCH` 版本說明 | `release_notes` ≤ 20 條、每條 ≤ 500 字，存前 trim 並濾空行；`impact`／`action` ≤ 2000 字；只寫有給的欄位；三欄都沒給 400；`withdrawn` 不改 | 400；404；409 |
| `internal-targets` | `fingerprints` 陣列 ≤ 20、每個 64 hex，去重後整份取代；只有 `draft` 能改 | 400；404；409 `bad_status` |
| `POST /admin/denylist` | `license_id` `^[A-Za-z0-9_-]{1,64}$`；`reason` 非空、≤ 500 字；同 license 重加＝更新理由與操作者 | 400 |
| `DELETE /admin/denylist/<id>` | URL decode 後同上格式 | 400；404 |
| `POST /admin/keys` | `pubkey` 是 base64 的 32 bytes、`kid` = 它 sha256 前 16 hex（`kidMatches`）；`status` ∈ primary／backup／revoked；已 `revoked` 的 kid 不能改回 | 400 `{reason:"kid_mismatch"}`；409 `kid_revoked` |
| `POST /admin/ci-tokens` | `name` `^[A-Za-z0-9._-]{1,64}$`、不可重名；產 `guc_` + 64 hex，D1 只存 sha256；**明文只在這次回應出現** | 400；409 `name_taken` |
| `POST /admin/ci-tokens/<id>/revoke` | `is_active` 1 → 0，不刪列 | 404 |

## 跨版路徑 planPath {#plan-path nav="跨版路徑"}

客戶從現行版走到最新版，中間可能有「必經版」：某版的 `min_base_version` 高於客戶現行版時，客戶必須先升到一個 ≥ 該起始版的版本。`planPath`（`src/releases.ts:82-101`）：

```{.mermaid cap="圖 2 — planPath：每一跳挑現在能直升的最高版"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
  A["候選＝看得到的版中，版號 ＞ 現行者<br/>（版號或 min_base 格式壞的丟掉）"] --> B{候選是空的？}
  B -- 是 --> N1["回 []（→ check 回 none）"]
  B -- 否 --> C["依 semver 排序，最高版＝latest<br/>at ＝ 現行版"]
  C --> D{at ＜ latest？}
  D -- 否 --> OK["回 path"]
  D -- 是 --> E["在『版號 ＞ at 且 at 的 X.Y.Z ≥ 該版 min_base』中<br/>挑最高的一版"]
  E --> F{挑得到？}
  F -- 否 --> N2["回 null（→ check 回 none，不回半條路）"]
  F -- 是 --> G["加進 path，at ＝ 那一版"] --> D
```

規則細節：

- **版號比較**（`src/versions.ts`）：格式 `X.Y.Z` 或 `X.Y.Z-後綴`，與 BE `release_json.sh` 鎖同一格式。數字逐段比（不能字串比，`"1.10.0" < "1.9.0"`）；核心相同時有後綴的預覽版排在正式版之前。
- **必經判定只比 X.Y.Z**（`compareCore`）：客戶在 `1.27.0-beta.1` 時，`min_base_version = 1.27.0` 的版也算可直升。
- **每一跳挑最高**：跳數最少。走不到最新版（例如中間的必經版被撤回）就回 `null`，`check` 回 `none`——客戶端照半條路升完會停在一個沒人告訴它下一步的版本。
- 測試案例在 `test/releases.spec.ts:65-92`：無必經直升、單一必經 `1.24.1 → 1.26.0 → 1.27.0`、兩道必經、已最新回空、必經版缺回 null、預覽版看得到同核心正式版。

現況：D1 十個已發布版（1.27.1～1.27.10）的 `min_base_version` 都是 `1.0.0`，所以任何 1.x 客戶都是一跳直升 1.27.10。

## R2 物件與預簽網址 {#r2 nav="R2"}

**key 只在 `src/releases.ts` 拼**（`:3-5`）。主版號資料夾是授權邊界（D7），CI、客戶端、後台各拼一份遲早有人拼錯。

| 物件 | key | 誰寫 | 來源 |
|---|---|---|---|
| 整包 | `v<major>/<version>/guidant-ai-<version>.tar.gz` | CI（照 `register-draft` 回的 `upload.bundle`） | `releaseObjectKeys`，`:30-39` |
| 未簽清單 | `v<major>/<version>/release.json` | CI | 同上 |
| 雜湊 | `v<major>/<version>/guidant-ai-<version>.tar.gz.sha256` | CI | 同上 |
| 維運指令小檔 | `v<major>/<version>/guidant-ai-<version>.ops.tar.gz` | 出包流程（選配；舊版沒有） | `opsObjectKey`：整包 key 把 `.tar.gz` 換成 `.ops.tar.gz`，D1 不另存欄位，`:25-27` |

`r2Key` 的防呆（`:16-22`）：版號格式必須合法、`major` 必須是 ≥1 的整數且等於版號第一段、檔名 `^[A-Za-z0-9][A-Za-z0-9._-]*$` 且不含 `..`，任一不符丟 `ReleaseKeyError`（後台轉成 400）。

R2 上的 `release.json` 是 **CI 產的未簽版**；簽好的信封只存在 D1 `product_releases.signed_manifest`，客戶從 `check` 回應拿。

**預簽網址**（`presignDownload`，`:160-172`）：用 `aws4fetch` 以 `R2_ACCESS_KEY_ID`／`R2_SECRET_ACCESS_KEY` 簽 S3 GET（`region = auto`、`signQuery`），`X-Amz-Expires = 900`（`DOWNLOAD_URL_TTL_SECONDS = 15 * 60`，`:10`）。網址本身只帶簽章、不帶金鑰。兩把金鑰任一沒設 → 503 `download_unavailable`。

**保留**：`retain_until` 在登記草稿時寫死為 10 年後（D29，`RETAIN_YEARS = 10`，`:177`、`:216-220`）。Worker 不刪任何 R2 物件；保留規則照這欄在 R2 設。

## 後台頁 {#admin-page nav="後台頁"}

`GET /admin` 回 `public/admin.html`（452 行，打包進 Worker）。每次請求產一個 nonce，回應標頭（`src/routes/admin.ts:258-273`）：

- `content-security-policy`：`default-src 'none'; script-src 'nonce-…'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
- `cache-control: no-store`、`x-content-type-options: nosniff`、`referrer-policy: no-referrer`

頁面所有 API 呼叫用 `credentials: "same-origin"` 打 `/admin/*`，靠 Access cookie。四個分頁：

| 分頁 | 看得到 | 能做 |
|---|---|---|
| 發行版 | 版本（含「含安全修正」標）、主版號、可直升起始版、大小、狀態、內部先裝、已更新客戶、Agent（在版／退回／失敗）、最近一次簽發（距今天數，已發布且 ≥ 80 天加「快過期，請重簽」標）、保留到 | 版本說明（非撤回）；指定內部先裝（只有草稿，textarea 一行一個指紋，下方列每台狀態）；撤回（非撤回） |
| 拒絕名單 | license_id、理由、操作者、時間 | 加入、移除 |
| 印章 | kid、公鑰、狀態、建立／作廢時間 | 登記印樣（kid＋base64 公鑰＋狀態）；作廢 |
| CI token | 名稱、啟用、建立、最後使用 | 產生（明文只顯示一次，頁面提示存到 GitLab CI 變數 `UPDATE_SITE_CI_TOKEN`）、撤銷 |

**後台不能發布**：publish 只收 Access 身分送回的簽好清單，而更新站沒有私鑰，簽不出任何東西。發布只能在決策者電腦跑 CLI。

「快過期，請重簽」標是發布清單 90 天效期的提醒。客戶端已不檢查清單有效期（見 [發布簽章規格](release-signing-spec.md) 的客戶端驗證段），這個標目前只是提示，不代表客戶會拒收。

## 設定與祕密 {#config nav="設定"}

只列名稱；值一律見 update-center `.env`、`wrangler.toml`、`wrangler secret`。

| 名稱 | 種類 | 誰用 | 說明 |
|---|---|---|---|
| `UPDATE_DB` | D1 綁定 | Worker | |
| `RELEASES` | R2 綁定 | Worker | |
| `LICENSE_KEYRING` | `[vars]` | Worker | `prod`（正式）或 `prod+dev`（只在 `env.dev`）；漏設＝只認 PROD |
| `R2_BUCKET` | `[vars]` | Worker | 拼預簽網址 |
| `R2_S3_ENDPOINT` | `[vars]` | Worker | 拼預簽網址 |
| `R2_ACCESS_KEY_ID` | **secret** | Worker | 只用來算預簽網址 |
| `R2_SECRET_ACCESS_KEY` | **secret** | Worker | 同上 |
| `ACCESS_TEAM_DOMAIN` | secret（非祕密，但目前以 secret 設定） | Worker | `https://<team>.cloudflareaccess.com` |
| `ACCESS_AUD` | secret | Worker | Access 應用程式的 AUD |
| `ACCESS_ALLOWED_EMAILS` | secret | Worker | 放行信箱，逗號分隔 |
| `CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID` | `.env` | wrangler（部署者） | 不進 Worker |
| `UPDATE_SITE_URL`、`UPDATE_SITE_CI_TOKEN` | GitLab CI 變數 | `publish_draft.sh` | token 走 header 檔，不進 argv |
| `GUIDANT_RELEASE_SITE` | 決策者 shell 環境 | CLI | 只是網址 |
| `UPDATE_SITE_URL` | 主系統 env（`config/config.py:207`） | BE | 預設空字串 |

正式 Worker 現有 secret 五個：`ACCESS_ALLOWED_EMAILS`、`ACCESS_AUD`、`ACCESS_TEAM_DOMAIN`、`R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`（`wrangler secret list` 實查）。

**本機陷阱**：沒有 `.dev.vars` 時 wrangler 會把 `.env` 整包（含 Cloudflare 權杖）當 Worker 變數載入。手測加 `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false`；`vitest.config.ts` 已在檔頭設好。

## 測試 {#tests nav="測試"}

兩套 vitest，`npm test` 依序跑兩套：

| 套 | 設定 | 跑在 | 內容 |
|---|---|---|---|
| Worker | `vitest.config.ts`（`@cloudflare/vitest-plugin` 的 `cloudflareTest`，`wrangler.toml` 的 `env.dev`） | workerd | `test/admin.spec.ts`（35）、`client.spec.ts`（31）、`license-guard.spec.ts`（19）、`releases.spec.ts`（12）；排除 `test/cli/**` |
| CLI | `vitest.cli.config.ts` | Node | `test/cli/keys.spec.ts`（8）、`publish.spec.ts`（15）、`rotate.spec.ts`（7）、`site.spec.ts`（5） |

（括號內為 `it(` 數。）

- 測試 D1 每次由 `test/apply-migrations.ts` 套 `migrations/`；R2 金鑰用假值 `test-access-key-id`。
- **真授權檔守衛**：`test/fixtures/*.license`、`*.ticket`、`*.json` 是 LC 用 DEV 鑰真簽的檔（客戶欄位都是 `DEV-FIXTURE`），`license-guard.spec.ts` 用它們驗 WebCrypto 驗章與 LC 的 Python 實作逐位元組一致。紅了代表驗章實作和 LC 對不上，**不要手改 fixture**；重簽用 `test/fixtures/sign_dev_fixtures.py`（見 README）。
- **CI 權限守衛**：`admin.spec.ts`「CI token：只能打 register-draft」把路由表每一支都用 CI token 打一次（`ADMIN_ROUTE_TABLE`），新增路由忘了填 `ci` 會預設 403。
- **schema 守衛**：`client.spec.ts`「D1 schema 守衛」確認 `instance_version_reports` 沒有客戶名稱類欄位、六張主表都在。
- **工作坊工作區陷阱**：repo 下有 `.worktrees/<母卡號>/`（工作坊小弟的工作區，已 gitignore），裡面也有一整套 `test/`。在 repo 根目錄手動跑 vitest 要排除它：`npx vitest run --exclude '.worktrees/**'`，否則會連工作區的測試一起跑、結果混在一起。
- 型別檢查 `npm run typecheck`（Worker、test、cli 三個 tsconfig）。

## 部署 {#deploy nav="部署"}

部署與對真庫套 migration 由首腦在測試期做，runner 不做（update-center `CLAUDE.md`）。

```bash
cd ~/Projects/Billows/Audit-Manager/update-center
npm ci
# 1. schema：照 migrations/ 順序套到正式 D1（已套過的會跳過，紀錄在 d1_migrations 表）
npx wrangler d1 migrations apply guidant-update-center --remote
# 2. Worker：predeploy 會先跑 typecheck 與兩套測試（含真授權檔守衛），紅了就不部署
npm run deploy          # = wrangler deploy
# 3. 第一次或換值時設 secret（互動輸入，不留在 shell history）
npx wrangler secret put R2_ACCESS_KEY_ID
```

- **順序**：新欄位、新表先套 migration 再 deploy；Worker 新版讀不到欄位會 500。
- **env.dev 不可部署成正式**：`[env.dev]` 的 `routes = []` 不可省，env 會繼承頂層 `routes`，`--env dev` 部署會把正式網域搶走（`wrangler.toml` 註解）。
- **唯讀查真庫**：`npx wrangler d1 execute guidant-update-center --remote --json --command "SELECT …"`。
- **LC 換鑰**：LC 換正式簽發鑰或加站時要同步改 `src/licenses.ts` 的 `PROD_KEYS` 並重新部署，否則新照全回 401（`:28-29`）。

現況（2026-11-01 實查）：`d1_migrations` 已套 0001～0004；`product_releases` 10 列全部 `published`；`instance_version_reports` 3 列、`agent_version_reports` 2 列、`release_download_log` 15 列；`signing_keys`、`ci_tokens`、`license_denylist` 皆 0 列。也就是說，測試期的十個版本都是用測試章簽好後直接寫進 D1，沒有走 CI `register-draft`、CLI `publish` 與內部先裝（十列的 `internal_fingerprints` 都是 `[]`）；正式出版流程（CI token、正式章、`sync-keys`）還沒啟用。

## 相關文件 {#refs nav="相關"}

- [更新站資料表](update-site-schema-spec.md)：七張 D1 表的欄位、索引、誰寫誰讀
- [發布簽章規格](release-signing-spec.md)：信封格式、印章、CLI、客戶端驗證、威脅對照
- [更新中心 API 規格](update-center-api-spec.md)
- [升級指令規格](upgrade-runner-spec.md)：主機端 `guidantai upgrade`
- [Agent 跟進規格](agent-rollout-spec.md)：Agent 升級與代回報
- [時序圖](sequence-diagrams-spec.md)
- [發版操作手冊](release-runbook.md)
- [設計文件](design.md) §5.4、§5.8
