iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

Bento Day 18 environment boundary

有一類 bug 很容易把 Debug 帶錯方向:

localhost 正常
tests 正常
Worker local 正常
遠端瀏覽器卻失敗

第一個直覺通常是「是不是 Code 還有 bug」。

便當系統遇到這種情況後,訂單邏輯沒有改,修正落在 Environment Boundary。

同一份 React、同一份 Worker route,在 localhost、正式前端與 temporary tunnel 發出的 request,至少有一個明確差異:Origin 不一樣。

本機 PASS 只能證明本機那組 Environment Identity;Code 相同,不代表遠端執行條件相同。

Day 17 談的是 Production Data:修錯歷史,可能改變我們對過去的理解。

Day 18 換另一種 Production 風險:Code 根本不用變,只要 request 從不同環境進來,結果就可能不同。

這次不是 business logic 壞掉,而是 Origin 變了

便當系統的正式前端、localhost 與遠端測試網址不是同一個 Origin。

Worker 的 CORS policy 明確區分幾類來源:

Production frontend
固定的 Netlify production origin

Local development
http://localhost:5173
http://127.0.0.1:5173

Temporary remote test
https://<validated-label>.run.pinggy-free.link
以及受限制的 Pinggy hostname forms

Unknown origin
拒絕

這個差異在 local unit test 裡很容易被忽略。

因為 route 本身可能完全正確:

GET /api/me
→ request 進入 Worker
→ auth 判定未登入
→ 回 401 AUTH_REQUIRED

如果瀏覽器 Origin 不在 CORS boundary 裡,使用者看到的卻不一定是 AUTH_REQUIRED。

瀏覽器可能先把 response 擋掉,最後只剩一個看起來像「API 壞了」的 CORS error。

同一個 401 可能出現兩種結果:

有正確 ACAO
→ Browser 可以讀到 AUTH_REQUIRED
→ CORS 通,auth 沒通

沒有 ACAO
→ Browser 不讓前端讀 response
→ 看起來像 remote API 故障

這兩種畫面很像,修法完全不同。

一開始的假設:remote-test mode 設好就夠了

早期做法把 temporary Pinggy origin 放在 remote-test CORS mode 裡。

這個設計表面合理:

CORS_MODE=remote-test
→ 才允許 remote test origin

但它偷偷增加了一個部署前提:

Code 正確之外,遠端 Worker 還必須帶著正確 runtime configuration。

於是 Environment Identity 不只包含 source code,還包含 deploy-time variables。

Repo 後來連續修過這條邊界。最後在一個名為 fix(worker): make pinggy cors policy permanent 的變更裡,把兩件事固定下來:

  1. stable localhost origins 不再依賴 CORS_MODE;
  2. 合法的動態 Pinggy HTTPS origin 改由嚴格 hostname validator 判定,而不是要求每次部署都先補一個 runtime flag。

這不是單純「把 CORS 放寬」。

validator 仍然要求:

  • HTTPS;
  • 沒有額外 port;
  • request 必須是完整 origin,不能帶 path;
  • hostname 必須符合限定的 Pinggy pattern;
  • wildcard、任意 domain 與 malformed origin 仍然拒絕。

被移除的是「不必要的環境偶然性」;security boundary 仍然保留。

Temporary tunnel 不能因此變成 Domain Rule

Pinggy 對手機測試、LIFF 或需要公開 HTTPS endpoint 的情境很方便。

但 tunnel 的角色只是 transport。

如果為了方便測試直接寫:

Access-Control-Allow-Origin: *

確實能讓很多問題暫時消失,代價是把「哪些 frontend 可以讀 API response」這條邊界一起拿掉。

便當系統最後保留的是明確 allowlist / validator:

stable production origin
+ stable localhost origins
+ validated temporary Pinggy origin class
+ optional exact DEV_ALLOWED_ORIGINS

其中額外的 DEV_ALLOWED_ORIGINS 只有在明確的 local / remote-test mode 才會被讀取,而且每一筆仍必須是完整 HTTP(S) origin。

Environment-aware 不等於 Environment-permissive。

遠端測試需要額外能力,但那個能力仍然要有 boundary。

Remote smoke 不只測「Worker 有沒有回應」

CORS 最容易出現一種假 PASS:

curl API 有 response
→ 看起來 Worker 正常
→ Browser 還是不能用

因為一般 HTTP client 不會替瀏覽器執行 CORS enforcement。

所以 repo 裡另外有一支 read-only remote CORS smoke。

它會對幾種 Origin 做接近 Browser contract 的檢查。

對 localhost、127.0.0.1 與指定 Pinggy origin,各跑:

OPTIONS /api/me
預期 204
預期 ACAO = request Origin
預期 Allow-Methods 包含 GET
預期 Allow-Headers 包含 Authorization

GET /api/me(未登入)
預期 401 AUTH_REQUIRED
預期 ACAO 仍存在

最後再送一個未知 origin:

GET /api/me
預期仍是 401
但不能出現 Access-Control-Allow-Origin

這裡最容易誤會的是:

401 反而可以是 remote smoke 的 PASS。

因為這支 smoke 不負責證明登入成功。

它要證明的是:

request 到得了 deployed Worker
+
CORS preflight contract 正確
+
實際 error response 也保留 CORS header
+
未知 origin 沒有被放行

如果期待一定要 200,反而會把 auth、test account、token 等更多變數一起塞進 smoke,讓 Environment 問題更難切割。

Health endpoint 再切掉另一層

CORS smoke 回答的是:

Browser origin 能不能正確穿過 CORS boundary?

但如果 deployed Worker 本身或 D1 binding 就有問題,還需要更前面一個切點。

正式 Worker 的 GET /api/health 會嘗試對 D1 執行一個唯讀查詢:

SELECT COUNT(*) AS users FROM users

成功時回:

{
  "ok": true,
  "database": true,
  "users": ...
}

如果 DB binding / query 不可用,則回 503 與:

{
  "ok": false,
  "database": false,
  "users": 0,
  "error": "DATABASE_UNAVAILABLE"
}

這讓 remote Debug 可以先切成:

health 503
→ 先查 Worker / binding / D1

health 200,但 CORS smoke fail
→ 查 Origin / response decoration / preflight

health 200,CORS smoke pass,但登入失敗
→ 再往 auth / identity 查

前面都 pass,特定 mutation 才失敗
→ 才進 business logic / data

這比看到遠端錯誤就直接叫 AI 改 route,資訊增益高很多。

Bento Day 18 remote debug narrowing

Environment Evidence 要和 Code Evidence 放在一起

這次之後,我不太會再接受只有這種驗證描述:

tests passed
ready to deploy

至少要問一句:

在哪個 environment passed?

對便當系統,遇到「本機正常、遠端錯」時,有判別力的 Evidence 通常包含:

request Origin
target Worker URL
deployment / runtime state
CORS mode 或相關 env
binding / database target
request path
response status
response CORS headers

不是每次都要建立完整報表。

重點是不能把 Environment 當作透明背景。

同一個 commit 可以同時存在:

local unit test PASS
remote health PASS
remote CORS FAIL
production auth UNKNOWN

這些 Evidence 彼此不能互相替代。

AI 最容易犯的錯,是把「剛剛測過」外推太遠

Vibe Coding 很容易形成一條順手的推論:

Code 改完
→ local tests PASS
→ build PASS
→ ready to deploy

在單一 runtime 的小工具裡,這條線有時夠用。

到了 React + remote Worker + D1 + CORS + temporary tunnel,PASS 必須帶著範圍。

本機測試能證明:

在本機模擬出的輸入與環境下,這段行為符合預期。

它不能自動證明:

遠端 Worker 的 deployment、binding、Origin policy 與 Browser contract 也相同。

這也是為什麼 Day 16 的 bounded fast path 即使允許某些 Worker-only 變更直接部署,部署後仍保留 smoke。

這不是對 test 的不信任;test 與 remote runtime 回答的是不同問題。

一個比較實用的 Remote Debug 順序

遇到「localhost 好好的,遠端壞掉」,我現在會先照這個順序縮小:

1. Health
   Worker + D1 最基本的 runtime 是否可用?

2. Origin / CORS
   Browser request 是否被正確允許?
   OPTIONS 與實際 response 是否都保留 contract?

3. Auth / Identity
   request 已經能被 Browser 讀到後,身份是否正確?

4. Route / Domain
   特定 API path 與 business rule 是否失敗?

5. Mutation / Data
   最後才碰 production write 與資料問題。

這個順序不一定適用所有系統;在這次 incident 裡,它先驗證差異最大的 Environment Boundary,再進入更昂貴、風險更高的 Production mutation。

如果第一步就叫 AI 改 business logic,很可能是在拿 Code 修 Environment。

同一份 Code,只是驗證的起點

這次 CORS 演進留下的工程判斷很簡單:

Same code
≠ Same environment

Local PASS
≠ Remote PASS

HTTP response exists
≠ Browser can read it

Temporary test access
≠ Allow everything

AI 可以很快產生 Code,也可以很快跑 local tests。

但當系統進到 Production,人的工作會慢慢從「功能寫出來了嗎」轉成:

這個 PASS 到底發生在哪裡?又能證明到哪裡?

下一篇會看另一種更難察覺的 Production Boundary:Cron。

它不是今天部署時立即寫資料,而是今天放進 Production 後,未來某個時間自己醒來執行 mutation。

那會把 Authority 從「哪個環境」再延伸到「哪個時間」。

本系列實作專案

這是一套持續開發中的便當訂購系統。文章著重「為什麼」,GitHub 保留實際程式碼、文件與演進紀錄。

GitHub:https://github.com/henryfir456/bento-order-app


上一篇
Day 17|Schema 可以回滾,歷史語意不一定能回來
系列文
從 GAS 到 Cloudflare D1:AI Agent 如何打造、接手並重構真實便當系統 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言