
有一類 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 從不同環境進來,結果就可能不同。
便當系統的正式前端、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 故障
這兩種畫面很像,修法完全不同。
早期做法把 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 的變更裡,把兩件事固定下來:
CORS_MODE;這不是單純「把 CORS 放寬」。
validator 仍然要求:
被移除的是「不必要的環境偶然性」;security boundary 仍然保留。
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。
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 問題更難切割。
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,資訊增益高很多。

這次之後,我不太會再接受只有這種驗證描述:
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 彼此不能互相替代。
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 回答的是不同問題。
遇到「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。
這次 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