平台佈建
dispatcher Worker 只能經由 edge/department-workers/scripts/deploy-platform.mjs 佈建(刻意排除在開發者 npm 套件之外)。dispatcher 綁定三個 dispatch namespace 位置(見〈環境契約〉),每一個都沒有綁定 Outbound Worker — 部門 Worker 的對外網路存取不受限制 — 而腳本會拒絕任何試圖加上 outbound binding 的設定:
node scripts/deploy-platform.mjs --environment staging|production [--check|--dry-run|--drift] [--allow-api-overwrite]模式最多只能給一個(--check、--dry-run、--drift;都不給就是真正的部署)。--allow-api-overwrite 與 --check 或 --drift 一起使用會被拒絕;任何其他參數都會以 unsupported argument: <arg> 失敗。
自後端 v5.10.10 起,staging 部署與 production release 會在 dispatcher 原始碼漂移時自動執行它,並排在後端之前(見下方〈CI:dispatcher 先於後端部署〉);漂移蓋章看不到的變更,仍要靠手動執行出貨。
必要環境(Infisical 範圍)
EDGE_WORKERS_CLOUDFLARE_API_TOKEN(機密)、…_ACCOUNT_ID(32 hex)、…_DISPATCH_NAMESPACE(共用位置的 namespace,或該環境自己的舊版 namespace — 其他值一律以 EDGE_WORKERS_CLOUDFLARE_DISPATCH_NAMESPACE does not match the selected environment 失敗)、…_WORKERS_SUBDOMAIN(DNS 標籤)、…_DISPATCH_KEY_ID、…_DISPATCH_ISSUER、…_DISPATCH_AUDIENCE、…_DISPATCH_PUBLIC_JWK(Ed25519、僅供 verify、kid 必須等於 key id)、…_DISPATCH_PRIVATE_KEY(機密、PKCS8 PEM)、…_DISPATCH_ORIGIN(https://<dispatcher>.<subdomain>.workers.dev,恰好四個標籤),以及 EDGE_WORKERS_R2_BUCKET,即後端已在使用的 edge 產物 bucket(缺少或為佔位符:EDGE_WORKERS_R2_BUCKET is not configured safely;不是合法的 bucket 名稱:EDGE_WORKERS_R2_BUCKET must name the hosted edge artifacts bucket)。另有一個選填輸入:EDGE_WORKERS_SERVING_ROUTES,即啟用對照表(見下)。
硬性預檢失敗:任何佔位符值(REPLACE_WITH_ENV_*、CHANGE_ME、DEPLOY_VIA_SCRIPT_ONLY);私鑰推導出的公鑰 ≠ 公開 JWK(以一次實際測試簽章證明);套件目錄裡只要存在任何 .env / .dev.vars* 檔案(憑證只能來自當前行程環境);注入後設定檔仍含 JWK 哨兵值。
環境契約
| 環境 | Dispatcher Worker | 舊版 outbound Worker(2026-09-03 已在兩個環境退役;此步驟現在只是一次唯讀 GET) |
|---|---|---|
| staging | teamsync-department-worker-dispatcher-staging | teamsync-department-worker-outbound-deny-staging |
| production | teamsync-department-worker-dispatcher-production | teamsync-department-worker-outbound-deny-production |
兩個環境的 dispatcher 綁定同樣三個 dispatch namespace 位置,各用自己的 binding 名稱:
| Binding | 位置 |
|---|---|
DEPARTMENT_WORKERS | 共用位置 — 部署上傳 canary 的位置 |
DEPARTMENT_WORKERS_LEGACY_STAGING | 舊版 staging 位置 |
DEPARTMENT_WORKERS_LEGACY_PRODUCTION | 舊版 production 位置 |
dispatcher 設定在選定環境中必須恰好列出這三個 binding,各出現一次且 namespace 符合腳本契約(否則 Wrangler <env> topology does not match the TeamSync platform contract),而且任何 binding 上都不得帶 outbound 鍵(頂層、env.staging、env.production 皆然)。綁了 outbound Worker 的設定會以 Wrangler <env> topology binds an outbound Worker; department Workers must keep unrestricted egress 驗證失敗,所以帶閘門的位置不可能出貨。舊版 Worker 欄位只為了讓部署能退役早期拓撲部署過的全拒 Worker;退役後不再有任何東西參照它。
腳本不會建立 dispatch namespace:請在這個 dispatcher 第一次部署之前先建立共用位置。某個部門 Worker 由哪個位置服務,不是由這裡決定,而是 dispatcher 在請求當下決定:有啟用(啟用對照表條目,或後端搬移 Worker 時記錄的啟用)就只問被釘定的位置,斷言中指名了 Worker 位置的簽章呼叫則受該位置約束(調度協定);兩者皆無則依序嘗試舊版 staging、舊版 production、共用位置(調度協定)。
子網域釘定(任何變更之前)
一次線上 GET /accounts/{id}/workers/subdomain:帳號的 workers.dev 子網域必須等於 EDGE_WORKERS_CLOUDFLARE_WORKERS_SUBDOMAIN,且 EDGE_WORKERS_DISPATCH_ORIGIN 必須恰為該帳號上該環境的 dispatcher。之後後端閘道在簽任何租戶請求前也要求同一個被認證的標籤 — 兩側各自獨立釘住同一個 origin。(後端依自己的 API 環境挑選預期的 dispatcher,而不是依 namespace 變數。)
啟用對照表(EDGE_WORKERS_SERVING_ROUTES)
選填輸入 EDGE_WORKERS_SERVING_ROUTES 會被寫入 dispatcher 變數 EDGE_WORKER_SERVING_ROUTES(注意是單數 WORKER)。它是由伺服端擁有的啟用對照表,對公開 URL 與簽章請求都適用:一個 JSON 物件,從 script 名稱對應到唯一負責服務它的位置。這裡的條目優先於後端在搬移 Worker 時記錄的啟用。自後端 5.22.0 起,後端會在簽章呼叫中指名該 Worker 已記錄的位置,所以為尚未被搬移的 Worker 所設的條目(它的紀錄是 epoch 0,而對照表條目的 epoch 至少要是 1),或其他不等於後端紀錄的條目,都會讓該 script 的這些呼叫回 409 serving_target_mismatch。
{ "dw-<48 hex>": { "dispatch_namespace": "<bound namespace>", "routing_epoch": 1 } }| 規則 | 值 |
|---|---|
| 大小 | ≤ 32 768 字元(32 KiB) |
| 條目 | ≤ 128 |
| 鍵 | script 名稱:dw- 加 48 個小寫 hex 字元 |
| 值 | 鍵恰好是 dispatch_namespace 與 routing_epoch |
dispatch_namespace | 三個已綁定 namespace 之一 |
routing_epoch | 安全整數 ≥ 1 |
違反任何一條,腳本會以 serving routes configuration is invalid 失敗(不是 JSON 物件的文字:serving routes is not valid JSON)。腳本會把對照表重新序列化為緊湊格式並依鍵排序後再安裝;之後的讀回比對的就是這份正規化文字。dispatcher 在每個請求都重新檢查同樣規則並 fail-closed:對照表格式錯誤時,每個需要查找的請求都回 503 dispatcher_unavailable。
- 沒有明確值的部署會保留線上對照表。
--check、--dry-run與部署會先讀線上 dispatcher 的 settings,並把它目前的EDGE_WORKER_SERVING_ROUTES帶過去(不存在視為{};這類 binding 超過一個,或不是plain_text,則以serving routes readback is invalid失敗),所以例行的原始碼部署不會重設任何啟用。這次讀取不重試:HTTP 429 或 5xx 會以Cloudflare serving routes readback returned a retryable Cloudflare response失敗。 - 明確的值會整份取代線上對照表(不合併)。啟用是營運者的明確動作;
wrangler.jsonc內附帶的{}只是預設值,永遠不會抹掉線上的對照表。腳本判斷的是變數是否不存在,所以空字串算「已設定」,並會因 JSON 無效而失敗。 - 全新的 dispatcher 首次部署需要設定這個變數。 保留線上值的那次讀取不容許 dispatcher 不存在:在該環境的 dispatcher 從未部署過的帳號上,除
--drift外的每個模式都會以Cloudflare serving routes readback returned HTTP 404失敗,直到設定EDGE_WORKERS_SERVING_ROUTES(首次部署設為{})。 - 對照表不是 digest 的輸入。 變更它永遠不會顯示為漂移(見〈原始碼 digest 蓋章與
--drift〉)。 - 診斷。 在共用拓撲中,dispatcher 會在 Worker(或退役墓碑)產生的每個回應上蓋
x-teamsync-serving-namespace與x-teamsync-routing-epoch,並覆蓋 Worker 自己回傳的同名標頭;它們只是平台診斷用途 — 不要據此建立邏輯。
兩個 Wrangler 階段 + 退役(deploy 模式)
- Dispatcher — 來自暫存設定檔(
wx、0600):哨兵值換成真正的公開 JWK、啟用對照表與原始碼 digest 分別蓋成EDGE_WORKER_SERVING_ROUTES與EDGE_DISPATCHER_SOURCE_DIGEST、env縮到選定環境,然後過圍欄 — 序列化文字不得包含私鑰、Cloudflare token 或任何佔位符(outbound鍵早在寫出暫存檔之前就已被validatePlatformConfigs拒絕)。 - Dispatcher settings 讀回(Cloudflare API;僅 deploy 模式)—
GET /accounts/{id}/workers/scripts/{dispatcher}/settings必須恰好有環境契約規定的三個dispatch_namespacebinding(否則deployed dispatcher does not bind the exact shared and legacy namespaces)、其中任何一個都沒有outbound,並且恰好有一個plain_text的EDGE_WORKER_SERVING_ROUTES,內容等於設定的或保留的對照表(否則deployed dispatcher serving routes do not match the activation configuration)。任何失敗都會在動到舊版 Worker 之前讓部署失敗(殘留 outbound Worker 時:deployed dispatcher still binds an outbound Worker; refusing to retire the legacy deny-all Worker),舊拓撲原封不動。這道閘門的由來:設定檔沒有outbound時,Wrangler 的資源佈建會把已部署的 dispatch namespace binding 以type: "inherit"上傳,等於保留遠端的 outbound Worker;所以部署改以--no-experimental-provision執行 Wrangler,並以這次讀回證明 binding 真的變了。(2026-09-03 沒有這道閘門的順序在 dispatcher 仍參照舊 Worker 時就把 staging 的全拒 Worker 刪掉,所有部門 Worker 呼叫都回 502,直到 binding 被修復為止。) - 退役舊版全拒 Outbound Worker(Cloudflare API,不是 Wrangler;僅 deploy 模式)—
GET /accounts/{id}/workers/scripts/{legacy_name}/settings;不存在(404/錯誤碼10007)→ 跳過;存在 →DELETE /accounts/{id}/workers/scripts/{legacy_name}?force=true,再以有界重試讀回同一個 settings GET 直到回報不存在。失敗會在 canary 執行前中止部署,所以 canary 永遠不會量到退役到一半的拓撲。Worker 消失之後,這一步在往後每次部署都只是一次唯讀 GET。 - Canary — 一個可拋棄式 Worker,以推導的 script 名稱(隨機部門 UUID;公司 UUID 在 production 是隨機的,在 staging 是固定的測試公司)上傳進共用位置:先證明不存在、上傳、以有界重試讀回 settings,然後帶真正的 30 秒簽章斷言走簽章 dispatcher 通道呼叫(最多六次嘗試,等待 0、1、2、4、8、12 秒,每次嘗試上限 5 秒,因為新 Worker 可能在控制平面已可見時,edge 路由尚未收斂)。成功要求:HTTP 200、精確的隨機標記,以及 canary 自己對
https://www.cloudflare.com/cdn-cgi/trace的外部fetch(不跟隨轉址、body 上限 4 KiB)回 200 且含h=www.cloudflare.com一行的證明 — 回報為outbound_status: 200、outbound_target: "www.cloudflare.com"— 加上合法的x-teamsync-request-id。一個請求同時證明了簽章驗證、script 名稱推導、namespace 路由與不受限制的對外連線;而且 canary 沒有啟用(對照表中沒有條目,後端也沒有記錄)、只存在於共用位置,所以要在兩個舊版位置都回報找不到之後才會被查到,通過也就同時證明了這段往下一個位置的查找。若收到 403outbound_fetch_denied(殘留的 Outbound Worker)、任何非 200 或非預期的 body,canary 會以 502 回outbound_denied/outbound_unreachable/outbound_unexpected,部署則以signed dispatcher canary did not prove assertion, namespace routing, and unrestricted outbound失敗。清理(刪除 + 讀回不存在)在finally執行,即使 Wrangler 曖昧失敗也一樣。
Wrangler 以 --strict --no-experimental-provision、shell: false、凍結的六變數允許清單環境執行(CI、CLOUDFLARE_ACCOUNT_ID、CLOUDFLARE_API_TOKEN、NODE_ENV、PATH、WRANGLER_SEND_METRICS=false)— 沒有 HOME、proxy、自訂 CA、Infisical 或簽章金鑰;輸出上限 2 MiB 且經遮罩(任何 ≥8 字元的機密 → [REDACTED]);10 分鐘逾時,SIGTERM 後 SIGKILL。
--check vs --dry-run vs --drift vs deploy
| 模式 | 環境+設定驗證 | 線上唯讀 GET | Wrangler | 舊版 deny Worker 退役 | Canary |
|---|---|---|---|---|---|
--check | ✔ | 子網域釘定;另加一次保留線上對照表的 dispatcher settings GET(除非已設定 EDGE_WORKERS_SERVING_ROUTES) | — | — | — |
--drift | 設定拓樸 + 只要 …_API_TOKEN/…_ACCOUNT_ID(永遠不需要簽章金鑰) | 一次 dispatcher settings GET(不讀啟用對照表) | — | — | — |
--dry-run | ✔ | 同 --check | 2 × wrangler deploy --dry-run(dispatcher、canary) | —(不做任何 API 變更) | 不上傳、不呼叫 |
| deploy | ✔ | 同 --check | 2 次真部署 | 先做 dispatcher settings 讀回(三個 binding、無 outbound、對照表完全一致);再 GET,若存在則 DELETE ?force=true + 讀回不存在 | 完整簽章證明 + 清理 |
--allow-api-overwrite 只對 dispatcher 階段解除 --strict。用於手動以 Cloudflare API 修復 dispatcher 之後的那一次部署(否則 Wrangler 會以 「last updated via the script API」 中止);settings 讀回仍必須通過,canary 階段維持 strict。與 --check 或 --drift 一起使用會被拒絕。
--dry-run 不是真部署證明。通過的 --check、--dry-run 或部署會印出一行並以 exit 0 結束(Platform deployment preflight passed for <env>; no changes were made.、Platform Wrangler dry-run passed for <env>; no changes were made.、Platform deployment and signed dispatcher canary passed for <env>.)。失敗印出 edge_worker_platform_deploy_failed: <message>,不含堆疊或機密內容,任何模式都以 exit 1 結束。
原始碼 digest 蓋章與 --drift
每次 --dry-run 與部署都會計算 EDGE_DISPATCHER_SOURCE_DIGEST — 依序對 src/dispatcher.ts、wrangler.jsonc、package-lock.json 取 sha256,每個檔名與內容都以 8 位元組長度框住,domain 為 teamsync-edge-dispatcher-source-v1\0 — 並以純文字變數蓋進 dispatcher。--drift 讀一次線上 dispatcher 的 settings,只印一行:
dispatcher drift: in_sync|drift|unstamped live=<8 hex|none> source=<8 hex>| Exit | 意義 |
|---|---|
0 | in_sync — 線上蓋章等於 checkout |
10 | drift,或 unstamped(蓋章機制出現之前部署的 dispatcher) |
1 | 無法證明線上狀態(讀回失敗 — 包括 dispatcher 尚不存在的 HTTP 404 — 或蓋章不是單一純文字 sha256 變數) |
digest 不涵蓋 Infisical 的值(因此也不含 EDGE_WORKERS_SERVING_ROUTES),也不涵蓋 deploy-platform.mjs 本身:金鑰輪替、EDGE_WORKERS_* 變更、啟用對照表變更,或只改部署腳本之後,要從乾淨的 master checkout 手動部署。
CI:dispatcher 先於後端部署
dispatcher 部署工作流程(僅 workflow_call)由 staging 部署(environment=staging:authorize → edge-dispatcher → build-and-deploy)與 production release(authorize → edge-dispatcher → release)呼叫。它先跑 --drift;exit 0 就結束,exit 10 則依序執行 --check、含簽章 canary 的完整部署,再跑一次 --drift 且必須為 0;其他結果一律失敗。dispatcher job 失敗會擋住後端 job — 新的 dispatcher 接受舊後端,但新後端遇上舊 dispatcher 可能讓所有 edge 呼叫都回 401。每個環境同時只跑一個,執行中絕不取消。憑證經由 GitHub OIDC 從 Infisical 取得(每個環境一個唯讀身分,在 npm ci 之後才取得);GitHub 內不存放任何長期機密。這個 job 會匯出該環境的 EDGE_WORKERS_* 值,所以由 CI 觸發的部署會安裝那裡的 EDGE_WORKERS_SERVING_ROUTES(沒有設定時則保留線上對照表)— 但光是這個值變更,永遠不會觸發部署。
機密處理規則
Cloudflare token 只經由子行程環境允許清單抵達 Wrangler — 絕不進 argv 或設定檔。私鑰永不落盤;在行程內載入、用來推導/驗證公開 JWK 並簽 canary 斷言。暫存檔皆 0600 且在 finally 移除。出貨的 wrangler.jsonc 刻意不可部署(每個區段都是哨兵 JWK,頂層名稱與 namespace 為 deploy-via-script-only),手動 wrangler deploy 產出的 dispatcher 會對一切回 503。
Dispatcher 執行期變數
EDGE_WORKER_ASSERTION_PUBLIC_JWK / _ISSUER(teamsync-backend)/ _AUDIENCE(teamsync-department-workers)、EDGE_WORKER_CPU_MS(50)、EDGE_WORKER_SUBREQUESTS(500)、EDGE_WORKER_SERVING_ROUTES(出貨設定的兩個環境區段中皆為 {};不存在時視為 {};無效 → 503 dispatcher_unavailable),以及部署時蓋上的 EDGE_DISPATCHER_SOURCE_DIGEST。兩個上限變數套用於公開通道,以及沒帶 limits claim 的簽章請求 — 後端會把自己的 EDGE_WORKERS_LIMIT_* 值簽進每個斷言,並以它為準。解析 fail-closed(無效 → 需要它們的每個請求都回 503 dispatcher_unavailable)。這些上限是部門 Worker 網路流量的唯一界限:沒有 Outbound Worker、egress 允許清單或政策變數。EDGE_WORKER_SUBREQUESTS 在 2026-09-07(#1205)於 wrangler.jsonc 從 10 改為 500,但直到兩個 dispatcher 於 2026-09-19 重新部署才生效 — 在 CI job 出現之前,合併的設定變更要等到有人執行這支腳本才會生效。可觀測性:logs 全量 head sampling、traces 5%;超過上限會記錄為 edge_worker_dispatch_limit_exceeded,附 limitsSource(assertion 或 env)。