Day4 把 art-tracking 掛上 art.kiwi-walk.com、用 Cloudflare Access 擋在登入前面。Day5 到 Day7 講的 coffee-review 則還是另一套:GitHub Pages 發靜態檔、Supabase 當資料庫和 API、Google 登入由 Supabase Auth 處理。
兩套並存一陣子之後,我把 coffee-review 也整個搬到 Cloudflare。理由很單純:兩個 side project 用同一套技術棧,我不用在腦袋裡切換。
先看結果——畫面一模一樣:

但底下換掉了:
| 之前 | 之後 | |
|---|---|---|
| 靜態檔 | GitHub Pages | Worker(Static Assets) |
| API | PostgREST | 同一支 Worker 的 /api/* |
| 資料庫 | Supabase Postgres | D1 |
| 登入 | Supabase Auth(瀏覽器裡跑 OAuth) | Cloudflare Access |
搬家過程中花最多心思的不是資料庫,是最後那一列。原本登入是 app 的一部分——app.js 要載 supabase-js、要開 OAuth、要處理回跳、要監聽 session 變化。搬完之後,app.js 裡一行 OAuth 程式碼都沒有。
第一個決定是登入怎麼接。三個選項:
自己在 Worker 裡實作 Google OAuth——處理 code flow、簽 session cookie、維護 users 表。好處是完全掌控、任何 Google 帳號都能註冊。壞處是要自己寫也要自己維護一套 OAuth,而這個 app 的使用者就只有我。
Cloudflare Access,完全照 art-tracking 的做法——Access 擋在網域前面,Google 登入整段由它處理,Worker 完全不碰 OAuth。art-tracking 那邊連 user_id 欄位都沒有,allow-list 就是唯一的授權模型。
Access,但保留 user_id——登入交給 Access,但資料仍然每列隔離,user_id 由 Access 給的 email 推導出來。
選了第三個。選 Access 而不是自己寫,理由是「不用維護一套 OAuth」;但不跟著 art-tracking 把 user_id 拿掉,是因為 coffee-review 的資料庫裡本來就有兩個帳號(我自己,加一個測試帳號),而且既有資料的 user_id 全部是 Supabase 的 UUID——拿掉這一欄等於要在搬家時改寫每一列。
留著它有代價:隔離從「資料庫自己保證」變成「每個查詢都要記得寫」。Supabase 的 RLS 沒有了,漏一個 and user_id = ? 就是跨使用者外洩,而且不會有任何錯誤訊息。這件事後面用測試補。
第二個決定是要不要趁這次把前端也整理一下。app.js 是 4900 行的 vanilla JS、classic script、沒有 build step,art-tracking 那邊是 React + Vite。統一成同一套很誘人。
最後選的是原樣保留,只改 api 這一層。理由不是技術上的,是除錯上的:搬家本身要動 65 個檔案、8266 行新增,如果同時把前端拆成 ES module、導入 Vite,出事的時候分不出來是「後端搬錯了」還是「前端重構壞了」。
代價很明確:repo 裡現在有一個 TypeScript 的 Worker 和一個 vanilla 的前端,兩種風格。我接受,因為它可以之後單獨處理,而混在一起的除錯成本是當下就要付的。
搬家前,登入是這樣的:
async function signInWithGoogle() {
try {
const sb = await ensureSupabase();
if (!sb) return;
// 回跳到 app 根(origin+pathname);PKCE 的 ?code= 落在 query,不干擾 hash router。
const redirectTo = window.location.origin + window.location.pathname;
const { error } = await sb.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo },
});
if (error) showErrorToast('登入失敗:' + (error.message || error));
} catch (e) {
showErrorToast('登入失敗:' + (e.message || e));
}
}
那句 PKCE 的註解是之前踩過的坑留下的。supabase-js 預設走 implicit flow,token 落在 URL 的 hash 裡,而這個 app 是 hash router——登入回跳之後會停在「找不到頁面」。所以要強制 PKCE,讓回跳帶的是 query 上的 ?code=。
搬家後,同一個功能變成:
// 登入頁不在這支 app 裡。重新載入會讓邊緣的 Access 接手,把使用者送去 Google。
function retrySignIn() {
window.location.reload();
}
function signOutUser() {
window.location.assign('/cdn-cgi/access/logout');
}
async function initAuth() {
try {
const me = await apiFetch('/api/me');
clearAuthReloadMark();
placesEnabled = !!me?.placesEnabled;
setSessionUser(me?.user_id ? { id: me.user_id, email: me.email } : null);
} catch (e) {
console.error('initAuth 失敗:', e);
}
}
沒有 OAuth、沒有 PKCE、沒有回跳處理、沒有 onAuthStateChange。原因是未認證的請求在碰到 Worker 之前就被 Access 擋下並導去 Google 了,app.js 根本不會執行到。它現在只做一件事:打一次 /api/me 問「我是誰」。
hash router 跟 token 打架的問題也一起消失了——回跳是 Access 自己處理的,網址上不會多出任何東西。
Cloudflare 的 Google IdP 不是按一下就好,它需要一組自己的 OAuth 憑證。但不用重建:Google 的 OAuth client 本來就支援多個 redirect URI,同一組 Client ID / secret 可以同時服務 Supabase 和 Cloudflare Access。
在原本那個 client 上加一條:
https://<team>.cloudflareaccess.com/cdn-cgi/access/callback
就這樣。OAuth consent screen、test users 那些設定是專案層級的,不是 client 層級的——既然 Supabase 版登得進去,就代表已經設好了,不用重填。
Zero Trust 那邊的設定是:Integrations → Identity providers 加一個 Google(不是 Google Workspace,那個是給企業網域用的),填進 Client ID 與 secret;Access controls → Applications 開一個 self-hosted,網域填 coffee.kiwi-walk.com,policy 只有一條 Allow ← 我的 email。
一條 Bypass policy 都沒建。有些設定教學會叫你為 API 路徑開 bypass,但這個 app 沒有任何機器會來寫入資料,開了就是直接在 Access 上鑿一個洞。
Access 已經在邊緣擋掉未認證的請求了,Worker 還是再驗一次它轉發的 JWT。理由是萬一 Access application 被誤刪或設錯,要 fail closed 而不是 fail open(節錄,註解有簡化):
export const requireAccess: MiddlewareHandler<AppEnv> = async (c, next) => {
const aud = c.env.ACCESS_AUD;
const team = c.env.ACCESS_TEAM_DOMAIN;
if (!aud && !team) {
// 本機開發:身分改由 .dev.vars 的 DEV_USER_EMAIL 提供,沒設就 503。
const dev = c.env.DEV_USER_EMAIL;
if (!dev) throw new HTTPException(503, { message: 'no identity: ...' });
c.set('auth', 'open');
c.set('email', dev);
return next();
}
// 半套設定是部署疏失,fail closed 而不是 fail open。
if (!aud || !team) throw new HTTPException(503, { message: 'Access misconfigured: ...' });
const jwt = readAccessToken(c.req.header('cf-access-jwt-assertion'), c.req.header('cookie'));
if (!jwt) throw new HTTPException(401, { message: 'access token required' });
if (!(await verifyAccessJwt(jwt, team, aud))) throw new HTTPException(401, { message: 'invalid access token' });
// ...
};
兩個 ACCESS_* 都空 = 本機開發,身分改由 .dev.vars 提供;只設一個 = 部署疏失,直接 503。deploy workflow 裡還有第三道:兩個值任一為空就 exit 1,部署根本不會跑。
這三道都指向同一件事:絕對不能出現「Worker 以為自己有防護、其實沒有」的狀態。
email 拿到之後還要一層解析,因為資料是用 user_id 隔離的,而 Access 只給 email:
export async function resolveUserId(db: D1Database, email: string): Promise<string> {
const key = email.toLowerCase();
const hit = cache.get(key);
if (hit) return hit;
const found = await db.prepare('select id from users where email = ?1').bind(email).first<{ id: string }>();
if (found) {
cache.set(key, found.id);
return found.id;
}
// 第一次登入才會走到這裡,產一個新的 UUID
// ...
}
新建的 users 表取代 Supabase 的 auth.users,而且沿用原本的 UUID,所以既有資料的 user_id 一列都不用改。
Access 的 application token 只帶 email,沒有姓名也沒有頭像——那些要另外打 get-identity 端點才拿得到。Supabase 的 session user 則有完整的 user_metadata。
所以個人頁從「頭像 + 姓名 + email」變成只剩 email。為了一張頭像多一次 subrequest 不划算,就這樣了。連帶 safeHttpUrl(本來用來擋掉非 http/https 的頭像網址)也整個退場。
這是那種在計畫階段不會想到、實作到一半才發現的東西。
資料庫那邊也有幾個 Postgres 功能在 SQLite 上沒有對應物,各自要換一個做法:
| Postgres | D1 的替代 |
|---|---|
| Row Level Security | Worker 每個查詢寫 and user_id = ?,加一道寫入白名單 |
unique(...) deferrable |
存檔改成整批刪光再重插(一個 D1 batch) |
text[] / jsonb |
存成 JSON 文字,編解碼只在 Worker 做 |
第三項的驗證方式是店家頁,上面幾乎每一塊都來自那些欄位:

「環境感受」的三軸原本是 jsonb,餐點和飲料是 text[]。這一頁渲染正確,就代表整條路都對。因為編解碼放在 Worker 而不是前端,wire 上的形狀和 PostgREST 完全一樣,app.js 讀這些欄位的地方一行都沒改。
隔離不再由資料庫保證之後,測試就得補上:test/worker/ 對每一張有擁有者的表都寫一個「看不到別人的資料」案例,跑在真的 workerd 加 miniflare 的 D1 上。Day7 那時是 318 條測試,現在 372 條。
所有東西都寫完、CI 全綠、合併進 main,第一次正式部署倒在最後一步:
✘ [ERROR] A request to the Cloudflare API
(/accounts/***/workers/scripts/coffee-review) failed.
CPU limits are not supported for the Free plan. [code: 100328]
原因是我在 wrangler.jsonc 加了一段「失控用量的保險」:
"limits": { "cpu_ms": 10000, "subrequests": 50 },
這兩個值只有付費方案能設。免費方案本來就是固定的 10 ms CPU / 50 個外部 subrequest,由平台自己擋,所以直接移除。
比較值得記的是為什麼 CI 沒抓到。CI 跑的是 wrangler deploy --dry-run,它只驗本地設定並打包 Worker,不會把設定送到 Cloudflare API。這一類「設定本身被 API 拒絕」的錯誤只有真的部署才會浮現。這是 dry-run 的固有邊界,不是我漏設了什麼——但也代表 --dry-run 通過不等於部署會過。
順帶一提,這次失敗是倒在最後一步,前面的 D1 migration 都已經成功套用了,所以資料庫是完整的,只是 Worker script 沒建立起來。
Access 的設定裡有一個 AUD tag(Application Audience),Worker 驗 JWT 時會比對它。設錯的後果很難看:Access 在邊緣放行(使用者確實登入了),但 Worker 驗不過、一路回 401。
問題是我沒辦法在部署前驗證它——wrangler 的 OAuth token 沒有 Access 的讀取權限,打 API 拿到 Authentication error。只知道 GitHub 那個變數是一串 64 位 hex,格式對。
部署完之後,驗收是打一個沒有 cookie 的請求:
curl -sI https://coffee.kiwi-walk.com/api/me
沒帶 cookie 的請求會在邊緣被 Access 攔下,根本到不了 Worker,所以這裡必須是 302,絕對不能是 200——200 代表 Access 沒有罩住這個網域,整個 API 是公開的。(反過來,就算 ACCESS_* 沒吃到,正式環境也沒有 DEV_USER_EMAIL,Worker 會回 503 而不是 200。)
302 只證明 Access 有在前面擋,AUD 對不對要看別的地方。好在答案就藏在 Location 裡:
https://<team>.cloudflareaccess.com/cdn-cgi/access/login/coffee.kiwi-walk.com
?kid=aa322d9c10272812c53c7456fb7ea60d...
那個 kid 和 GitHub 上的 ACCESS_AUD 變數完全一致,夾帶的 meta JWT 裡也寫著 hostname: coffee.kiwi-walk.com。設定是對的。
另外一個容易誤解的地方是 ACCESS_TEAM_DOMAIN 不能加 https://。Worker 裡是 `https://${teamDomain}/cdn-cgi/access/certs`,加了會變成 https://https://…,結果是每個請求都 401,而且錯誤訊息完全看不出原因。Cloudflare 官方文件的某些範例是帶 scheme 的,照抄就中。
搬完之後跑了一次 code review,抓到一個我自己寫下去時沒想到的失敗模式。
Access session 過期時,/api/* 會被 302 導去 team domain 的登入頁。fetch 用 redirect: 'manual',拿到的是一個 opaqueredirect,前端看到它(或 401 / 403)就重新載入整頁,讓瀏覽器走完 Access 的登入流程——這取代了原本的 token refresh。我原本以為正常的過期一定會收斂:頂層導航去 Google 再回來。(下一個坑會看到,這個「一定」不成立。)
但如果是「Access 放行、Worker 拒絕」呢?也就是上面那個 AUD 設錯的情境,或者 JWKS 抓取逾時。那時候 Worker 一直回 401,而無條件重載就變成打不完的迴圈:載入 → /api/me → 401 → 重載 → 載入 → …… 分頁一直轉,使用者看不到任何錯誤訊息,Worker 還被自己的前端打。
修法是讓自動重載只做一次:
const AUTH_RELOAD_KEY = 'coffee-review:auth-reloaded';
function shouldReloadForAuth() {
try {
if (sessionStorage.getItem(AUTH_RELOAD_KEY) === '1') return false;
sessionStorage.setItem(AUTH_RELOAD_KEY, '1');
return true;
} catch {
// 無痕模式等情況下 sessionStorage 可能不可用。寧可不自動重載
//(使用者還有擋板上的「重新載入」按鈕),也不要冒迴圈的風險。
return false;
}
}
第二次起就讓錯誤浮上來、由擋板顯示。成功拿到身分之後把記號清掉,下一次真的過期還是會自動重登。
這個迴圈在本機模擬不出來——本機沒有 Access,走的是另一個分支。所以只能靠測試釘住。
上面那個修法有個前提:重新載入時,請求真的會到 Cloudflare 的邊緣,Access 才有機會把人導去 Google。合併之後再 review 一次,才發現 PWA 的 service worker 讓這個前提不成立。
sw.js 對 app shell 用的是 stale-while-revalidate:有快取就先回快取,背景再去網路更新。問題是它對整頁導航也一視同仁。於是 Access session 過期(預設 24 小時)之後:
/api/me 被 302,前端觸發一次重新載入。index.html,這個請求沒到邊緣,Access 根本沒機會出手。opaqueredirect,res.ok 是 false,不寫快取,也沒人用它。/api/me 又是 302。這次 sessionStorage 已經有記號,不再自動重載,停在擋板上。location.reload(),又回到第 2 步。使用者只能按 Ctrl+Shift+R 跳過 SW 才出得來,而手機上的 PWA 幾乎沒辦法這樣做。上一個坑的「只重載一次」剛好讓症狀從無限轉圈變成安靜地卡住,更難看出是什麼壞了。Supabase 時代登入是 app 內的事,SW 攔不攔導航都無所謂,所以這是換成邊緣登入才出現的新問題。
修法是讓導航請求走 network-first,離線才退回快取:
async function handleNavigate(request) {
const cache = await caches.open(CACHE);
try {
const res = await fetch(request);
if (res.ok && res.type === 'basic') cache.put(request, res.clone()).catch(() => {});
return res;
} catch {
const cached = await cache.match(request) || await cache.match('./index.html');
return cached || new Response('離線且無快取', { status: 504 });
}
}
self.addEventListener('fetch', event => {
// ...
if (event.request.mode === 'navigate') {
event.respondWith(handleNavigate(event.request));
return;
}
// 其餘照舊 stale-while-revalidate
});
導航請求的 redirect mode 是 manual,所以拿到的 opaqueredirect 原樣交回給瀏覽器,由瀏覽器自己跟去 Access。VERSION 也要一起升,已安裝的 PWA 才會換上新的 SW。
這個坑的教訓是:登入從 app 內搬到邊緣之後,任何會在瀏覽器和邊緣之間「代答」的東西都要重新檢查。service worker 就是最典型的一個,它本來的工作就是讓請求不要出門。
coffee-review 從 GitHub Pages + Supabase 搬到 Cloudflare 完成了:一支 Worker 同時當 API 和靜態站、D1 當資料庫、Access 當登入、coffee.kiwi-walk.com 上線。14 個 commit、65 個檔案、372 條測試,外加一個 service worker 的修正。
登入那條線的變化最大:原本是 app 的一部分(載 SDK、開 OAuth、處理 PKCE 回跳、監聽 session),現在被推到 app 之外,app.js 只剩一次 /api/me。既有的 Google OAuth client 不用重建,加一條 redirect URI 就接上了。
但「登入不在 app 裡」也有另一面:app 以為重新載入就等於重新登入,而中間的 service worker 並不知道這件事。SW 修正部署之後,還要在手機的 PWA 上真的等一次 session 過期(或到 Access 後台撤銷 session),確認會被導去 Google,這條線才算驗完。
明天還沒想好要先動哪一塊。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day12-supabase-to-d1/