FR-167 · 技術規格 · 2026-11-01
update-center Worker:部署形態、三類呼叫者的身分、每個端點的輸入輸出與錯誤、跨版路徑演算法、R2 物件 key、後台頁、設定與部署
更新站是一支 Cloudflare Worker,沒有任何私鑰:它只判斷「這台客戶主機是不是合法安裝、該看到哪幾版」,把決策者在自己電腦簽好的發布清單原樣轉交,再發 15 分鐘有效的 R2 下載網址。客戶端不信任它回的任何東西,清單另用本機印樣驗章。本頁以 update-center repo 的程式碼為準,欄位與數字都附來源檔與行號。
| 項目 | 現況 | 來源 |
|---|---|---|
| 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 |
更新站只做四件事:
signed_manifest)原樣放進回應,不解析、不改一個位元組。清單驗章、版號遞增、主版號、sha256 全在客戶端做(見 發布簽章規格)。更新站被攻破時能做與不能做的事,見該頁「威脅對照」。
| 元件 | 綁定名 | 實體 | 用途 |
|---|---|---|---|
| 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)。
| 呼叫者 | 打哪些端點 | 身分證明 | 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)。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。payload_type 必須等於呼叫的那一種(缺鍵視為 license),不符回 wrong_payload_type(src/licenses.ts:184)。product_major 缺欄視為 1;不是 ≥1 的整數就算格式錯(src/licenses.ts:161-167)。expires_at(授權過期仍可升同主版號最新版)、時鐘回撥、時間窗(客戶端沒有私鑰簽不出新時間戳;重放風險與送原文相同,都綁指紋)。MAX_LICENSE_TEXT + 4KB=68KB(src/routes/client.ts:17);payload 字串 64KB、解壓後 64KB,解壓邊讀邊累計,超過立刻丟(src/licenses.ts:23-24、:97-121)。Access 只擋經過它的流量;Worker 自己再驗一次,只看「有沒有帶 header」等於誰都能偽造(src/access.ts:3-5)。
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)。alg = RS256 且有 kid(:96-103)。aud 含 ACCESS_AUD、iss = team domain、exp/nbf 容許 60 秒誤差、email 必須存在(service token 沒有 email,一律不收)且在放行清單(:82-93)。<team>/cdn-cgi/access/certs 抓,快取 10 分鐘;未知 kid 觸發重抓,但兩次重抓至少隔 30 秒(:13-16、:52-62)。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)的判定順序固定:
identify),失敗 → 403 forbidden。沒身分的人連「這條路存不存在」都不該知道,所以身分在路由比對之前。not_found;路徑存在但方法不對 → 405。ci 不為 true 的路由 → 403 forbidden。crossSiteWrite,:310-317):非 GET/HEAD 時,帶 Origin 就必須同源(否則 403);非 DELETE 時 content-type 必須是 application/json(否則 415 unsupported_media_type)。CLI 與 CI 不帶 Origin,不受影響。cache-control: no-store(src/http.ts:26-31)。/v1/report 成功回 204 無 body。{"error": "<代碼>"},部分後台錯誤多帶 reason、status、pending。只回代碼,不回原因——偽造者不該知道是 kid 錯還是簽章錯。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 金鑰,算不出預簽網址 |
三支都是 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)。拒絕名單排在主版號判定之前:被拒的授權連「有沒有新版」都不該知道。
| 項目 | 內容 |
|---|---|
| 誰打 | 客戶主系統:啟動時一次、之後每 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 讀。
| 項目 | 內容 |
|---|---|
| 誰打 | 客戶主系統,按下載後 |
| 另帶 | 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),更新站被冒用時不能把主機導去內網下載。
| 項目 | 內容 |
|---|---|
| 誰打 | 客戶主系統:主機升級寫回終態時(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)。
路由表在 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) |
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)。
body:kid(^[0-9a-f]{16}$)、signed_manifest(string,非空,≤ 64KB,必須能 parse 成 JSON 物件;信封上若有 kid 必須等於 body 的 kid)(src/routes/admin.ts:148-173)。
驗法依序:
kid 在 signing_keys 是 revoked → 409 kid_revoked。印樣表沒登記這個 kid 不擋。withdrawn → 409 bad_status。draft 第一次發布:internal_fingerprints 每一台都要 installed,否則 409 {reason:"internal_not_installed", pending:[…]}。清單為空時更新站放行(CLI 比這裡嚴,空清單直接拒絕)。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 |
客戶從現行版走到最新版,中間可能有「必經版」:某版的 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
規則細節:
src/versions.ts):格式 X.Y.Z 或 X.Y.Z-後綴,與 BE release_json.sh 鎖同一格式。數字逐段比(不能字串比,"1.10.0" < "1.9.0");核心相同時有後綴的預覽版排在正式版之前。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。
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 設。
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 天效期的提醒。客戶端已不檢查清單有效期(見 發布簽章規格 的客戶端驗證段),這個標目前只是提示,不代表客戶會拒收。
只列名稱;值一律見 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 已在檔頭設好。
兩套 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( 數。)
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)。admin.spec.ts「CI token:只能打 register-draft」把路由表每一支都用 CI token 打一次(ADMIN_ROUTE_TABLE),新增路由忘了填 ci 會預設 403。client.spec.ts「D1 schema 守衛」確認 instance_version_reports 沒有客戶名稱類欄位、六張主表都在。.worktrees/<母卡號>/(工作坊小弟的工作區,已 gitignore),裡面也有一整套 test/。在 repo 根目錄手動跑 vitest 要排除它:npx vitest run --exclude '.worktrees/**',否則會連工作區的測試一起跑、結果混在一起。npm run typecheck(Worker、test、cli 三個 tsconfig)。部署與對真庫套 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[env.dev] 的 routes = [] 不可省,env 會繼承頂層 routes,--env dev 部署會把正式網域搶走(wrangler.toml 註解)。npx wrangler d1 execute guidant-update-center --remote --json --command "SELECT …"。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)還沒啟用。
guidantai upgrade