賽道二打通:0G Storage 真的寫進去了(Node 代理繞開 Cloudflare 的裸 IP 與埠限制) - #12
Conversation
Chrome console 講得很直白: Mixed Content: The page at 'https://conssswars-web.pages.dev/' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint 'http://34.19.125.196:5678/'. This request has been blocked. indexer 回傳的 storage node 是裸 IP + 明文 http + 非標準埠。上一版的代理只 放行 https://*.0g.ai,這些位址就被原樣留著,瀏覽器連送都不送就擋掉,SDK 只 看得到一句沒有內容的 "Network Error"。所以真正的死因不是 CORS,是 mixed content —— 但兩者的解法一樣:不要讓瀏覽器直接碰那些節點。 改成節點網址一律改寫走代理。沒有網域可以白名單(都是裸 IP),所以改用 HMAC 簽章綁住:只有代理自己從 indexer 回應裡吐出來的網址才轉發得動,外人塞任意 網址進來會拿到 403,不會變成人人可用的開放代理。另外擋掉內網位址,萬一金鑰 外流也打不到內部服務或雲端 metadata 端點。 路由從 /node/<b64> 變成 /node/<簽章>/<b64>,舊格式落到 404。 前端錯誤訊息跟著更新:多一條 mixed content 的判讀,網路類的診斷改成先探測 自家代理再下結論,代理通就直說是斷在節點或 Flow 合約那步。 測過(scratchpad/test.mjs,mock fetch 跑完整 onRequest): 改寫後節點 1: /api/og/zg/node/49e32595…/aHR0cDovLzM0LjE5… 節點轉發目標 : http://34.19.125.196:5678/ ← POST zgs_uploadSegment 保留節點路徑 : https://node2.0g.ai/rpc/file 內網節點 : http://10.0.0.5:5678(原樣留著,不轉) 偽造簽章 : 403,且完全沒發出任何 fetch 借用當代理 : 403 另確認 cookie / authorization 不會被轉給第三方主機。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
_redirects 最後一行是 `/* /index.html 200`。這個遊戲從頭到尾沒有任何前端 路由(grep pushState / replaceState / popstate / hashchange 全部沒有), 所以這條規則沒有任何用途,卻有一個很貴的副作用:任何不存在的路徑都回 index.html 加狀態 200。 代價是我們今天真的付了。線上檢查 /images/heroes.jpeg 拿到 200,據此判斷 「圖片在、是程式的問題」,於是往 art.js 的方向查。實際上那張圖根本沒被部署, 200 是後備規則給的假象 —— 正式網址停在 PR #4 前後的舊版本。查了好幾輪才靠 「按鈕文案是舊的」這個線索翻案。 改成:刪掉 _redirects,加一個 public/404.html。Pages 對找不到的路徑會原生 回這一頁並帶狀態 404,靜態檔案照樣正常服務。以後 curl 或 fetch 拿到 404 就是真的 404,不會再有「看起來一切正常」的假象。 404.html 裡直接寫明「/js/ /css/ /images/ /vendor/ 底下的檔案變成這一頁, 代表那個檔案沒部署上來」,讓下一個人不用再推理一次。 本地 wrangler pages dev 不套用 _redirects(原版的 SPA 後備在 dev 也是 404), 所以這一項只能靠 Pages 的靜態資源優先規則判斷,無法在容器內實測。已確認的是 實體檔案本身都正常:art.js 20617、main.js 31043、style.css 21808、 heroes.jpeg 915933、ethers.min.js 501359 bytes,全部 200。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
console 上原本會多出三筆紅色 404:heroes.png、heroes.webp、heroes.jpg。 那是四個候選副檔名平行載造成的 —— 命中的是 .jpeg,另外三個必然 404。 平行載當初是為了繞開 SPA fallback:找不到的檔案不會快速 404,而是回一整份 index.html 才失敗,依序試會拖慢開場。那條 fallback 已經在上一個 commit 拿掉, 不存在的檔案現在直接 404,依序試的代價回到正常。 所以改回依序,並把 repo 裡實際存在的 /images/heroes.jpeg 排第一個。正常情況 只發一個請求就命中,console 乾淨。評審會打開 console 看,那三筆紅字會讓人 以為東西壞了。 實測(wrangler pages dev + headless Chromium 監看 network): 形象照請求: 200 /images/heroes.jpeg ← 只有這一筆 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
代理修掉 mixed content 之後,下一關是 403: POST /api/og/zg/node/835eaa0…/aHR0cDovLzM0LjE5… 403 內容: error code: 1003 1003 是 Cloudflare 的 Direct IP Access Not Allowed —— Worker 不能對裸 IP 發出站請求,連線根本沒送出去。而 0G 的 storage node 清一色是裸 IP (實測 http://34.19.125.196:5678)。這不是簽章或白名單的問題:console 上的 簽章 49e3259… 跟本地算出來的 49e325954b41… 一致,34.19.125.196 也不在擋掉的 內網範圍內,兩道檢查都過了,403 是上游回的被原樣轉回來。 改法:轉發前把裸 IPv4 換成 a.b.c.d.sslip.io。sslip.io 是公開 DNS 服務, 永遠把這種名稱解析回 a.b.c.d,所以連到的是同一台機器同一個埠,只是讓 Cloudflare 有 hostname 可打。只對裸 IPv4 動手 —— indexer 之後若改成回傳 網域名稱,這段就自動不生效。 代價是多一個第三方 DNS 依賴。要拿掉的話得改用自己的主機當代理, Cloudflare Workers 這條路做不到。 前端錯誤訊息補一條 403/502/1003 的判讀,之後再撞到才看得懂是哪一關。 測試(mock fetch 跑完整 onRequest): 裸 IP 節點 : http://34.19.125.196.sslip.io:5678/ ← POST zgs_uploadSegment 網域節點 : https://node2.0g.ai/rpc/file (不加後綴) 內網節點 : 原樣留著,不轉 偽造簽章 / 借用當代理 : 403,且完全沒發出任何 fetch Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
Cloudflare Pages Functions 這條路走不通,兩道平台牆而 0G 的 storage node
兩道都佔:
error 1003 Direct IP Access Not Allowed
Worker 不能對裸 IP 發出站請求,節點清一色是裸 IP
(實測 http://34.19.125.196:5678)。這道還能繞 ——
改用 a.b.c.d.sslip.io 這種解析回同一個 IP 的名稱,實測 1003 消失。
error 521 Web Server Is Down
節點跑在 5678,Workers 出站只支援固定幾個埠。這道繞不掉。
SDK 也沒有第二條路:Indexer.selectNodes 之後一律 new StorageNode(node.url)
直接打節點,indexer 本身不收上傳。
所以把轉發那一段搬到 Node(proxy/,部署到 Vercel),主站其他部分照舊留在
Cloudflare。邏輯跟原本那支一樣 —— HMAC 簽章綁住只轉發自己發出的節點位址、
擋掉內網與 metadata 位址、不轉 cookie/authorization —— 只是多了 CORS
(現在是跨網域)與原樣讀 body(segment 一個 byte 都不能差)。
前端不寫死代理網址:/api/og/status 新增 storage.zgProxy,值來自
OG_ZG_PROXY_BASE,沒設就退回同源的 /api/og/zg。換代理只要改一個環境變數。
Cloudflare 那支 /api/og/zg 保留:indexer 之後若改成回傳網域名稱它就能用,
而且它是這段歷史的現場。
端對端實測(假 storage node 架在 127.0.0.1:5678 —— 正是 Cloudflare 打不到
的那種埠,走真的 HTTP,不是 mock):
真實轉發 : 127.0.0.1:5678 ← POST / zgs_uploadSegment ✅
大 segment : 340076 bytes 原封不動送達 ✅
偽造簽章 / 借用當代理 : 403
改寫後節點 : https://…/api/node/49e32595…/aHR0cDovLzM0LjE5…
內網節點 : 原樣留著,不轉
CORS(pages) : 放行 / CORS(陌生來源) : 不給標頭 / OPTIONS 預檢 : 204
另確認 cookie / authorization 不外流,config.shardId 等欄位不受改寫影響。
順手更正 .env.example 裡寫錯的鏈 ID(16601 → 16602)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
vercel.json 裡寫了 functions.maxDuration = 60。免費方案的上限比這個低, 設定值超過上限時 Vercel 是直接讓部署失敗,不是默默降級 —— 為了一個 用不到的設定卡住部署不划算。 Vercel 本來就會自動把 api/ 底下的檔案當成 Node 函式,沒有設定檔也能跑。 README 補上第一次執行 npx vercel 時那幾個互動問題該怎麼答。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
線上實測 /api/health 直接落到 404:
$ curl -s https://…vercel.app/api/health
{"error":"unknown proxy route"}
回的是我們自己的 JSON,所以函式有跑起來、只是 req.query.path 拿不到東西。
那個參數的形狀綁在檔名慣例與執行環境上;req.url 是 HTTP 本身的東西,
換平台也不會變。改成一律從 req.url 解析路徑,req.query.path 留作備援。
404 分支順便帶上實際看到的 segments 與 url,下次再撞到不用猜。
測試也拿掉了假的 req.query —— 現在的 harness 只給 req.url,證明路由不依賴它:
/api/health : 200 ✅(不靠 req.query)
/api/nope : 404 {"error":"unknown proxy route","saw":["nope"],"url":"/api/nope"}
其餘照舊全過(真實轉發到 127.0.0.1:5678、340076 bytes segment、簽章 403、
CORS 放行與拒絕、OPTIONS 預檢)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
上一版改成自己解析 req.url 之後,/api/indexer 通了(節點網址確實被改寫成 vercel.app),但 /api/node/<簽章>/<網址> 仍然失敗: Access to XMLHttpRequest at 'https://…/api/node/49e3259…/aHR0cDovLzM0LjE5…' from origin 'https://123360f7.conssswars-web.pages.dev' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present 同一支函式、同一段 CORS 程式碼,一層深的路徑通、三層深的不通 —— 差別不在 CORS,是深層路徑根本沒被路由到函式,瀏覽器只是把「沒有回應」講成 CORS 失敗。 所以不再依賴 catch-all 檔名慣例:api/[...path].js 改名成 api/index.js, vercel.json 明寫一條 rewrite 把 /api/* 全部導過去,原始路徑放在 zgpath 參數裡。 rewrite 之後 req.url 會不會保留原路徑是平台細節,不值得賭,所以路徑改成三個 來源依序試(zgpath → req.url → req.query.path),任一個成立就能正確路由。 轉發時會把 zgpath 從查詢字串拿掉,不讓自己的路由參數漏到 storage node 去。 測試補上這兩條(其餘照舊全過): 原始路徑 : 200 → 節點看到 / ✅ rewrite+zgpath: 200 → 節點看到 / ✅ 深層預檢 : 204 https://123360f7.conssswars-web.pages.dev ✅ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
api/[...path].js 改名成 api/index.js 時漏掉這個連結,點進去會是 404。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
1. 鏈之英雄傳 → 鏈州英雄傳
index.html(標題、描述、og:title、h1)、manifest.webmanifest、rules.js、
story.js、README、以及兩支 agent 的 system prompt,共 10 處。簡報那份原本
就寫鏈州,現在全站一致。
2. HUD 改成玩家在左、遺忘者在右
跟棋盤方向一致(劇情裡就寫「你在左、遺忘者在右」),原本 HUD 是反的。
.hud-side--hero / --foe 兩塊在 index.html 裡對調;CSS 的 row-reverse 與
text-align:right 是給「靠外側那一方」用的,所以從 hero 改掛到 foe。
實測(headless Chromium 量座標):
左側: 零界守望者 (x 18–165) 右側: 遺忘者 (x 225–372) ✅
3. README 加上實測狀態表
三條賽道都標成通,並附這一場的實際證據:0G Storage 檔案 root
0x31dbf573…d7cb25、Flow submit tx 0x9c7377b8…58b2218、儲存費
92200934886 neuron;0G Chain 區塊 #54068232、5 個確認、四項全部相符。
下面補一段說明賽道二為什麼繞這麼久(裸 IP + 5678 埠 vs mixed content
與 Cloudflare 的 1003/521)。
4. 簡報第 5 頁:0G Storage 從「◐ 部分驗證」改成「● 線上實測通過」
內容換成檔案 root、Flow submit、節點回報 Single file upload completed。
標題「交件時三條賽道的真實狀態」的「交件時」拿掉,結語改成三條都通。
順手更正第 3 頁失效的行號(storage.js#L53-L85 → #L65-L108)並補上
proxy/api/index.js —— 舊行號點下去會指到不相干的程式碼。
PDF 用原始 deck 重新算版渲染,五頁都確認沒有溢出(每頁內容 720px = 版面高度)。
依先前的要求 slides/ 仍然只留 PDF。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3
There was a problem hiding this comment.
🟡 Changes recommended
There are correctness and documentation mismatches in the new proxy/diagnostic path (notably probe-based diagnostics and proxy docs) that should be fixed to avoid mis-deploys and misleading runtime troubleshooting.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR aims to make 0G Storage uploads work reliably in the browser by ensuring storage-node URLs are always routed through a proxy (avoiding mixed content / platform restrictions), and to restore correct 404 behavior by removing the SPA fallback so missing static files fail loudly.
Changes:
- Update the 0G Storage proxying flow (signed
/node/<sig>/<b64>URLs) and switch the frontend to use a configurablezgProxybase from/api/og/status. - Add a Node/Vercel proxy service under
proxy/to bypass Cloudflare Workers’ outbound restrictions for bare IPs / non-standard ports. - Remove
public/_redirectsSPA fallback and addpublic/404.htmlso missing assets return real 404s.
File summaries
| File | Description |
|---|---|
| README.md | Updates project narrative and links (incl. storage upload evidence). |
| public/manifest.webmanifest | Updates app display name text. |
| public/js/story.js | Updates displayed title text. |
| public/js/storage.js | Switches upload to proxyBase/zgProxy and improves client-side diagnostics wording/flow. |
| public/js/rules.js | Updates header/title text. |
| public/js/main.js | Passes zgProxy as proxyBase to storage upload + error explainer. |
| public/js/art.js | Reorders hero sheet candidates and changes probing to sequential. |
| public/index.html | Updates title/meta text and swaps HUD sides/labels. |
| public/css/style.css | Adjusts HUD side modifier to match the swapped layout. |
| public/404.html | Adds a custom 404 page clarifying missing-deploy vs code issues. |
| public/_redirects | Removes SPA fallback that masked missing static assets as 200. |
| proxy/vercel.json | Adds rewrite so /api/* reliably routes to the single handler. |
| proxy/README.md | Documents the new Node/Vercel proxy and deployment wiring back to Pages. |
| proxy/package.json | Defines Node runtime requirements for the proxy service. |
| proxy/api/index.js | Implements signed node forwarding + CORS + internal-address filtering in Node. |
| functions/api/og/zg/[[path]].js | Updates Pages Function proxy to signed node URLs + internal-address filtering + DNS workaround for direct-IP restriction. |
| functions/api/og/status.js | Exposes storage.zgProxy derived from OG_ZG_PROXY_BASE (or default). |
| functions/api/narrate.js | Updates system prompt title text. |
| functions/api/agent.js | Updates system prompt title text. |
| .env.example | Adds OG_ZG_PROXY_BASE and updates chain testnet comment. |
Review details
- Files reviewed: 20/21 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| * 內網位址一律拒絕。 | ||
| * | ||
| * 節點網址是 indexer 給的,正常情況都是公網 IP;但簽章金鑰萬一外流, | ||
| * 這道防線可以讓這支代理仍然打不到任何內部服務(含雲端 metadata 端點)。 | ||
| */ |
| /** | ||
| * 內網位址一律拒絕。 | ||
| * | ||
| * 節點網址是 indexer 給的,正常都是公網 IP;但簽章金鑰萬一外流,這道防線 | ||
| * 讓這支服務仍然打不到任何內部服務(含雲端 metadata 端點)。 | ||
| * | ||
| * ZG_ALLOW_LOOPBACK 只給本機測試用 —— 有了它才能把假的 storage node 架在 | ||
| * 127.0.0.1 上跑完整條鏈路。正式環境不要設。 | ||
| */ |
| export async function probeIndexerFromBrowser(proxyBase) { | ||
| try { | ||
| const res = await fetch(indexer, { | ||
| const res = await fetch(indexerUrl(proxyBase), { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'indexer_getShardedNodes', params: [] }), |
| const probe = await probeIndexerFromBrowser(proxyBase); | ||
| if (!probe.reachable) { | ||
| return `連不上 0G 代理(${indexerUrl(proxyBase)})—— 代理沒部署,或它沒放行這個網域的 CORS。原始錯誤:${msg.slice(0, 100)}`; | ||
| } |
| 沒有 `vercel.json` 是刻意的 —— Vercel 會自動把 `api/` 底下的檔案當成 Node 函式, | ||
| 不需要設定檔;寫死 `maxDuration` 之類的值反而可能在免費方案上讓部署直接失敗。 |
兩個修正,第二個是查第一個時挖出來的。
1. storage node 網址一併改寫走代理,修掉 mixed content 封鎖
不是 CORS,是 mixed content:
indexer 回傳的 storage node 是裸 IP + 明文 http + 非標準埠。#11 的代理只放行
https://*.0g.ai,這些位址就被原樣留著,瀏覽器連送都不送就擋掉,SDK 只看得到一句沒有內容的Network Error。0g.ai。OG_PROXY_SECRET,沒設用內建常數。/node/<b64>→/node/<簽章>/<b64>,舊格式落到 404。onRequest用 mockfetch跑完整流程:另確認
cookie/authorization不外流。對照 vendored SDK:trusted.map(node => new StorageNode(node.url))+axios({url: this.url, method:'post'})—— 網址原封不動使用,改寫這個欄位就夠。2. 拿掉 SPA 後備,讓「檔案沒部署」直接現形成 404
_redirects最後一行/* /index.html 200。這個遊戲沒有任何前端路由(pushState/replaceState/popstate/hashchange全部 grep 不到),這條規則沒有用途,卻讓任何不存在的路徑都回 index.html 加狀態 200。代價今天真的付了:線上檢查
/images/heroes.jpeg拿到 200,據此判斷「圖片在、是程式的問題」。實際上那張圖根本沒被部署。查了好幾輪才靠「按鈕文案是舊的」翻案。改成刪掉
_redirects、加public/404.html。Pages 對找不到的路徑原生回這一頁並帶 404,靜態檔案照常服務。以後 404 就是真的 404。404.html直接寫明「/js//css//images//vendor/底下的檔案變成這一頁 = 那個檔案沒部署上來」。這一項的驗證限度
本地
wrangler pages dev不套用_redirects(原版的 SPA 後備在 dev 也回 404),所以刪除的效果無法在容器內實測,只能依 Pages「靜態資源優先」的規則判斷。實體檔案本身確認正常:部署前提
這兩項都要部署到正式網址才看得到效果。線上的
conssswars-web.pages.dev長期停在很舊的版本 —— 原因是本地 checkout 落後 26 個 commit,每次wrangler pages deploy上傳的都是舊檔案(部署輸出裡的Uploading _redirects與14 already uploaded是證據,現在的public/有 21 個檔案)。先前我把這個現象判斷成「部署落在 Preview、要改 production branch」,那是錯的:
wrangler對正式部署也一樣只印 hash 網址,hash 網址不是 Preview 的證據。production branch 不用動,git pull之後重新部署即可。🤖 Generated with Claude Code
https://claude.ai/code/session_015NBxaWtzqEX3i8NvoZEeq3