iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Claude AI

利用 Claude 建置自己各種興趣的 Side Project系列 第 12 篇

Day12 [coffee-review] 從 GitHub Pages + Supabase 搬到 Cloudflare:登入整個搬出 app 之外

  • 分享至 

  • xImage
  •  

今天要解的問題

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 用同一套技術棧,我不用在腦袋裡切換。

先看結果——畫面一模一樣:

遷移後的記錄列表,資料從 D1 讀出來

但底下換掉了:

之前 之後
靜態檔 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 的前端,兩種風格。我接受,因為它可以之後單獨處理,而混在一起的除錯成本是當下就要付的。

實作

登入從 app 裡消失

搬家前,登入是這樣的:

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 自己處理的,網址上不會多出任何東西。

既有的 Google OAuth client 直接接上 Zero Trust

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 上鑿一個洞。

Worker 自己再驗一次

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 做

第三項的驗證方式是店家頁,上面幾乎每一塊都來自那些欄位:

店家頁:環境感受、服務、餐點、飲料都是從 JSON 文字解回來的

「環境感受」的三軸原本是 jsonb,餐點和飲料是 text[]。這一頁渲染正確,就代表整條路都對。因為編解碼放在 Worker 而不是前端,wire 上的形狀和 PostgREST 完全一樣,app.js 讀這些欄位的地方一行都沒改。

隔離不再由資料庫保證之後,測試就得補上:test/worker/ 對每一張有擁有者的表都寫一個「看不到別人的資料」案例,跑在真的 workerd 加 miniflare 的 D1 上。Day7 那時是 318 條測試,現在 372 條。

踩到的坑

首次部署失敗,而 CI 抓不到

所有東西都寫完、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 沒建立起來。

AUD 設對了沒有,只有一個 302 能證明

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 抓到的無限重載

搬完之後跑了一次 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,走的是另一個分支。所以只能靠測試釘住。

Service worker 把「重新載入」攔下來了

上面那個修法有個前提:重新載入時,請求真的會到 Cloudflare 的邊緣,Access 才有機會把人導去 Google。合併之後再 review 一次,才發現 PWA 的 service worker 讓這個前提不成立。

sw.js 對 app shell 用的是 stale-while-revalidate:有快取就先回快取,背景再去網路更新。問題是它對整頁導航也一視同仁。於是 Access session 過期(預設 24 小時)之後:

  1. /api/me 被 302,前端觸發一次重新載入。
  2. SW 直接從快取回 index.html,這個請求沒到邊緣,Access 根本沒機會出手。
  3. 背景更新拿到的是 opaqueredirect,res.ok 是 false,不寫快取,也沒人用它。
  4. 再打一次 /api/me 又是 302。這次 sessionStorage 已經有記號,不再自動重載,停在擋板上。
  5. 擋板的「重新載入」按鈕是 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/


上一篇
Day11 [llm-wiki] 如果你要自己蓋一個:五步起手式、12 條習慣,和我繞過的遠路
系列文
利用 Claude 建置自己各種興趣的 Side Project 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言