FR-167 · 技術規格 · 2026-11-01

更新站規格

update-center Worker:部署形態、三類呼叫者的身分、每個端點的輸入輸出與錯誤、跨版路徑演算法、R2 物件 key、後台頁、設定與部署

更新站是一支 Cloudflare Worker,沒有任何私鑰:它只判斷「這台客戶主機是不是合法安裝、該看到哪幾版」,把決策者在自己電腦簽好的發布清單原樣轉交,再發 15 分鐘有效的 R2 下載網址。客戶端不信任它回的任何東西,清單另用本機印樣驗章。本頁以 update-center repo 的程式碼為準,欄位與數字都附來源檔與行號。

§1

定位

項目 現況 來源
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 全在客戶端做(見 發布簽章規格)。更新站被攻破時能做與不能做的事,見該頁「威脅對照」。

§2

部署形態與架構

更新站架構:客戶機房、GitLab CI、決策者電腦三類呼叫者,經 Worker 進 D1 與 R2

圖 1 — 更新站架構:三類呼叫者、Worker 兩組路由、D1 與 R2(點圖看大圖)

元件 綁定名 實體 用途
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 見 更新站資料表
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)。

§3

三類呼叫者與身分

呼叫者 打哪些端點 身分證明 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

客戶身分:身分票與授權檔

  • 兩種格式都收: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 只擋經過它的流量;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)。

後台的權限順序

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。
§4

共用回應規則

  • 成功: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 金鑰,算不出預簽網址
§5

客戶端端點

三支都是 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

項目 內容
誰打 客戶主系統:啟動時一次、之後每 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

項目 內容
誰打 客戶主系統,按下載後
另帶 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

項目 內容
誰打 客戶主系統:主機升級寫回終態時(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)。

§6

後台端點

路由表在 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}

詳情物件

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

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

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)。更新站不驗清單簽章,它沒有、也不該有那把鑰;它只擋「一看就錯」的形狀。

其他寫入端點

端點 驗法 錯誤
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
§7

跨版路徑 planPath

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

%%{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
圖 2 — planPath:每一跳挑現在能直升的最高版

規則細節:

  • 版號比較(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。

§8

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 設。

§9

後台頁

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 天效期的提醒。客戶端已不檢查清單有效期(見 發布簽章規格 的客戶端驗證段),這個標目前只是提示,不代表客戶會拒收。

§10

設定與祕密

只列名稱;值一律見 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 已在檔頭設好。

§11

測試

兩套 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)。
§12

部署

部署與對真庫套 migration 由首腦在測試期做,runner 不做(update-center CLAUDE.md)。

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)還沒啟用。

§13

相關文件