Day 23:middleware、locals 與 island 的狀態邊界已經把完整資料流拆開:每個 request 都由 middleware 辨認使用者,再把結果放進該次 request 的 locals.user。接下來要處理那份 user/session 怎麼產生、cookie 誰簽、資料存哪裡,再把「收藏文章」限制為只有登入者能執行的動作。
動手串接之前,得先決定要把驗證交給誰。登入幾乎一定會採用現成方案,而選哪一個,是這個系列裡保存期限最短的決定。
我照著別人的教學做實作,已經遇過停更或棄用的套件。更常見的情況沒那麼戲劇化:套件只要改版,幾個操作步驟就可能和教學對不上。舊套件會如此,熱門的新套件也會。最近很紅的 hermes agent 就是一例,光是串 Google Chat,幾個步驟的實際做法已經和教學不同。這類版本落差本來就是開發日常。
這個系列也遇到了同樣的事。最初的策略文件把會員登入鎖定為 Auth.js,並列出 Firebase、自建 Session、Lucia 當備選。不到兩年後重新查證,結果如下(查證日期皆為 2026-07-21):
auth-astro 最後一次發佈是 2024-12-03,停更超過一年半。README 寫著「只支援到 Astro 5、等官方 PR 合併後就棄用」,而且它已經從 Astro 官方的 auth 指南裡消失。四個當年看起來合理的選項,過兩年已有兩個不能照原樣使用。問題不在當初選錯,而是這類知識的保存期限很短。動手當天得重新判斷該把驗證交給誰。
順序別反,由外而內:
用這三題檢查,auth-astro 不符合第 1、2 題;Lucia 更早就停在「還能不能裝」。
依前面三題,選 Better Auth 的理由有三個:
user.id,不必為了 auth 再另外維護一套儲存。把幾個選項放在一起比較,先看「現在還能不能照抄」:
| 方案 | 現在還能照抄嗎 | 為什麼 |
|---|---|---|
| Better Auth | ✅ 選它 | 官方首選、可跑 Workers、原生 Drizzle + SQLite |
| Auth.js | ⚠️ 不建議 | 核心仍在維護,但 Astro 橋接 auth-astro 停更、只到 Astro 5,已不在官方指南內 |
| Lucia | ❌ 不能 | 2025-03 棄用,已改成教學資源 |
| 自己刻 | 🟡 特殊情況才值得 | 後文細講 |
第一次接觸 Better Auth,可以先把它理解成處理登入細節的套件:cookie 簽章、與 GitHub 交換登入資訊、CSRF 防護和 session 簽發,都不必自己寫。你只要提供資料庫位置,並設定要開啟哪些登入方式。
後面會反覆用到四個詞:
登入之後那份「你是誰」的狀態要放哪,有兩種做法:
這裡採用 Better Auth 預設的 DB session,並把 session 存進現有的 Turso。原因有三個:
user.id 當外鍵,DB session 讓使用者資料和收藏留在同一個資料庫。第一步:安裝套件(版本基準 2026-07-21:better-auth 1.6.23):
npm install better-auth
第二步:用官方 CLI 產 schema,不要手抄。 Better Auth 對四張核心表(user / session / account / verification)的欄位名稱與型別有固定規則;只要抄錯一個,套件就讀不到。先用 CLI 產生,再併進既有的 src/db/schema.ts:
npx @better-auth/cli generate --config scripts/auth-schema.config.ts --output ./tmp.ts
產生後,把四張表併進 schema,再沿用上一篇的 drizzle-kit generate → migrate 流程。Better Auth 內建的 migrate 只支援 Kysely;這裡使用 Drizzle,因此 migration 仍由既有的 drizzle-kit 套用,不必另學一套。
同一份 migration 再加上「收藏」表,外鍵接 Better Auth 的 user.id:
CREATE TABLE `favorites` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`slug` text NOT NULL,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `favorites_user_slug_unq` ON `favorites` (`user_id`,`slug`);
(user_id, slug) 設成唯一,所以同一個人對同一篇文章只會有一筆收藏。
第三步:建立 auth 實例。 資料庫連線仍遵守上一篇的條件:只在收到請求後建立,不放在模組頂層。
// src/lib/auth.ts
export function createAuth(baseURL: string) {
const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN); // 收藏查詢跟 auth 共用同一個
return betterAuth({
baseURL,
secret: BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: 'sqlite' }), // libSQL / Turso 用 sqlite provider
emailAndPassword: { enabled: true },
// GitHub 的 secret 齊了才掛(正式站主推 GitHub 登入)
});
}
兩個 import 路徑容易寫錯:adapter 的 import 路徑是
better-auth/adapters/drizzle,不是@better-auth/drizzle-adapter;Vue island 使用的 client 在better-auth/vue。
這和上一篇的資料庫連線限制相同:Workers 在 build 與模組載入階段拿不到 secret,因此 auth 和 db 都要等請求進入 handler 後再建立。所有 /api/auth/* 的請求交給一個 catch-all endpoint 處理:
// src/pages/api/auth/[...all].ts
export const prerender = false;
export const ALL: APIRoute = (ctx) => createAuth(ctx.url.origin).handler(ctx.request);
接上這個 catch-all endpoint 後,登入、登出、OAuth callback 與 session 讀取路徑就能運作。
middleware 需要讀取 session,再把使用者放進 locals。若用靜態 import 載入 createAuth,寫法會像這樣:
import { createAuth } from './lib/auth'; // ← 靜態 import,就是這行害的
接著執行 npm run build 會失敗:
[ERROR] EnvInvalidVariables: TURSO_DATABASE_URL is missing
原因在 astro:env secret 的讀取時機:它會在「模組載入時」讀取,不會等到實際使用。middleware 會經過每個請求,也包含 build 預先產生靜態頁時的請求。只要 middleware 靜態 import 會讀 secret 的模組,build 每產生一頁就會嘗試讀取 secret;建置環境沒有設定 secret,便會拋出上面的錯誤。
Day 22 的資料層沒有遇到這個問題,因為當時只有 action 這種「有請求才會載入」的模組會碰 secret;middleware 則會經過每個頁面。
解法是改用動態 import,只在需要查 session 時載入該模組:
// src/middleware.ts
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = null;
context.locals.session = null;
// 沒有 cookie 就一定沒登入(build 時的靜態頁請求也沒有 cookie):直接跳過,不碰 DB 和 secret
if (context.isPrerendered || !context.request.headers.get('cookie')) return next();
const { createAuth } = await import('./lib/auth'); // 用到才載入
const auth = createAuth(context.url.origin);
const data = await auth.api.getSession({ headers: context.request.headers });
context.locals.user = data?.user ?? null;
context.locals.session = data?.session ?? null;
return next();
});
這也會省下一次查詢:沒有 cookie 的匿名請求不會進資料庫查 session,能減少 edge 上不必要的查詢。
Day 24 的實作有一條邊界:個人化的登入狀態不能放進預先產生的靜態頁。內容站的文章頁在 build 時就產生固定的 HTML,不可能「build 時就知道現在這個訪客登入了沒」。因此分工如下:
export const prerender = false,改成收到請求時才算的動態頁。/demos/auth 是 on-demand 頁,由 server 算好登入狀態後再傳 props;這套做法不能直接套在靜態文章頁。收藏 action 只允許登入者執行,因此先在伺服器端用 locals.user 檢查;未登入就直接回錯:
// src/actions/index.ts
toggleFavorite: defineAction({
input: z.object({ slug: z.string().min(1) }),
handler: async ({ slug }, ctx) => {
const user = ctx.locals.user;
if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: '請先登入才能收藏文章' });
// 已登入:對 favorites 做 toggle(有就刪、沒有就加),回傳最新狀態
},
}),
on-demand 的 /demos/auth 會由伺服器把 isLoggedIn 和 initialFavorited(登入才查)當 props 傳給 FavoriteButton.vue,因此 island 不必為初始畫面再查一次 session。使用者點下按鈕呼叫 action 時會產生新的 request,middleware 仍要重新辨認使用者,這次檢查不能省。互動採樂觀更新、失敗回滾;未登入時,按鈕會顯示「登入以收藏」,點下後走 GitHub 登入。
登入前後的收藏 UX 如下:
auth 屬於易變的高風險主題,以下用 build 與本機資料寫入來驗證。
build 過不過? npm run build 在真實的 @astrojs/cloudflare adapter 下通過:21 個靜態頁順利產出,建置過程不會載入 auth;登入示範頁則被編成動態 function。這個結果確認「Better Auth + Cloudflare adapter + Vue island + Drizzle」的組合能通過 adapter 建置。
Better Auth 真的把資料寫進 libSQL 了嗎? 在本機跑一次註冊加登入,直接查資料庫:
→ signUpEmail:user + token ✓
→ signInEmail(同一個帳號):user + token ✓
=== Better Auth 寫進 libSQL 的資料 ===
users: 1 | sessions: 2 | accounts: 1(provider = credential)
一次註冊和一次登入各寫入一筆 session,account 表也有一筆資料。
目前的驗證缺口:上面的測試使用 Node 連本機檔案,已驗證「Better Auth ↔ Drizzle ↔ libSQL」這條 SQL 鏈。app 正式跑在 Workers 上,改用只走 HTTP 的 @libsql/client/web;目前還沒有在真實 workerd 中完成整套登入測試。要補上這層驗證,需先把本機 libSQL 服務成 HTTP,再用 wrangler 執行一次。差異位於傳輸層,不在 SQL;正式上線前仍要補測。
nodejs_compat 是必要設定:Better Auth 用到 AsyncLocalStorage,wrangler.jsonc 要有 nodejs_compat,compatibility_date 則要在 2024-09-23 之後。缺少這項設定時,Better Auth 無法在 Workers 上執行。astro:env:BETTER_AUTH_SECRET(至少 32 個字元的高熵字串)、GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET,跟上一篇的 Turso secret 用同一套機制;本機放 .dev.vars,上線用 wrangler secret put。{你的網域}/api/auth/callback/github。有些課程會教你自己寫登入,但教學通常只示範最簡單的判斷條件,正式上線還有更多使用情境要處理。登入也是多數產品的固定流程,已有成熟方案可用。freeCodeCamp 一開始就提到 DRY(Don't Repeat Yourself),登入正是適合套用這個原則的地方。
自己實作完整登入系統,光這一項就可能吃掉整個開發時程一半以上。cookie 簽章、OAuth code 交換、CSRF、session 輪替都是安全細節,任何一項出錯都可能形成漏洞。採用官方指南推薦且持續維護的套件,可以把開發時間留給產品真正獨特的功能。因此,登入不建議自己刻。例外是把「親手實作一次 cookie 簽章和 OAuth 流程」當作學習主題;這時目標是理解原理,不是直接上線。
account 表一張表兩用:email/password 登入時,它的 password 欄位存的是雜湊過的密碼;OAuth 登入則存 provider 的 token。locals.user 現在有了驗證來源,收藏 action 也能拒絕未登入的請求。Better Auth 的安裝方式會過期,三個選型問題比較耐用:官方現在推薦誰、套件是否仍在發版、是否支援你的 runtime 與資料層。今天選出的套件,明年不保證仍然適用。
這個內容站的會員功能停在「登入 + 收藏 + feedback」:不做完整會員中心、角色權限或後台,因為這些功能不是目前內容站的需求。功能邊界由網站需求決定;系列最後還會再檢查一次「這個站真正需要多少」,而這裡的會員能力到此已經足夠。