# 登录、注册与用户信息 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 Nuxt 4 / Nitro 上实现邮箱密码注册登录、Redis Session(HttpOnly Cookie)、可配置「已验证邮箱」门禁、`auth_challenges` 驱动的验证与重置密码闭环、NoopMailer、最小页面(`/login`、`/register`、`/me`),并落地 `linked_accounts` 空表与 `hello` 泄露修复。 **Architecture:** 业务逻辑集中在 `server/services/auth.ts`(及拆出的 `session.ts`、`password.ts`、`challenge.ts`),API handler 薄封装;会话仅存 Redis,Cookie 仅存 opaque id;用 `users_table.session_version` 在密码重置时 **O(1)** 吊销所有旧会话(无需 SCAN)。密码哈希选用纯 JS **`bcryptjs`**(Bun 友好、无原生编译);挑战 token 明文仅响应给调试日志,库内存 **SHA-256 hex**。 **Tech Stack:** Nuxt 4.4、Nitro、Bun、Drizzle ORM 0.45、PostgreSQL、`pg`、`ioredis`、`bcryptjs`、项目已有 `log4js` / `logger`。 **规格依据:** `docs/superpowers/specs/2026-04-12-auth-user-design.md` --- ## 文件结构总览(创建 / 修改) | 路径 | 职责 | |------|------| | `package.json` | 新增依赖 `bcryptjs`、`ioredis`;新增 `test` 脚本 | | `.env.example` | 增加 `REDIS_URL`、`AUTH_DEBUG_LOG_TOKENS`、`SESSION_TTL_SECONDS`、`COOKIE_DOMAIN` | | `packages/drizzle-pkg/lib/schema/schema.ts` | 扩展 `users_table`;新增 `auth_challenge_type` 枚举表字段;`auth_challenges`、`linked_accounts` | | `packages/drizzle-pkg/migrations/*.sql` | `bun run db:generate --name auth_user` 生成后人工检查 | | `packages/drizzle-pkg/seed.ts` | 为 seed 用户写入合法 `password_hash` 或暂时跳过 users 种子 | | `packages/drizzle-pkg/lib/db.ts` | 如需导出类型可微调(尽量不动) | | `server/utils/redis.ts` | `getRedis()` 单例(`ioredis`) | | `server/utils/password.ts` | `hashPassword` / `verifyPassword`(bcryptjs) | | `server/utils/challenge-token.ts` | `randomUrlToken` + `hashChallengeToken`(`node:crypto` sha256) | | `server/utils/session-cookie.ts` | Cookie 名、`serializeSessionCookie`、`clearSessionCookie` | | `server/utils/session-ttl.ts` | `sessionTtlSeconds()`(无 Redis 依赖,便于单测) | | `server/utils/session-redis.ts` | `createSession`、`readSession`、`deleteSession`;value 含 `userId`、`sessionVersion` | | `server/utils/errors.ts` | `jsonError(status, code, message)` 基于 `createError` | | `server/utils/rate-limit.ts` | Redis INCR + EX 窗口限流 | | `server/utils/verification-policy.ts` | `needsEmailVerified(handlerId: string): boolean` | | `server/utils/mailer.ts` | `Mailer` 接口 + `NoopMailer` | | `server/services/auth.ts` | 注册、登录、登出、me、patch、forgot、reset、verify;组合 DB+Redis | | `server/plugins/04.redis.ts`(序号可按现有 plugins 顺延) | Nitro 启动时 `getRedis()`;关闭时 `quit` | | `server/api/auth/register.post.ts` 等 | 薄 handler | | `server/api/me.get.ts`、`server/api/me.patch.ts` | 当前用户 | | `server/api/hello.ts` | **删除文件**(或改为 `401` 无数据;计划采用删除,避免误暴露) | | `app/pages/login.vue`、`register.vue`、`me.vue` | 最小 UI | | `app/middleware/auth.ts`、`guest.ts` | 页面守卫 | | `nuxt.config.ts` | 如需要 `runtimeConfig` 暴露公共配置(通常不必) | | `test/unit/session-ttl.test.ts` | TTL 默认值 | | `test/unit/password.test.ts` | bcrypt 封装单测 | | `test/unit/challenge-token.test.ts` | token 长度与哈希确定性 | | `test/unit/verification-policy.test.ts` | 策略默认值 | | `test/integration/auth.http.test.ts` | 可选:`TEST_INTEGRATION=1` 且已 `bun run dev` 时跑 HTTP 流程 | --- ### Task 1: 依赖与脚本 **Files:** - Modify: `package.json` - Modify: `.env.example` - [ ] **Step 1: 安装依赖** Run: ```bash cd /home/dash/projects/person-panel && bun add bcryptjs ioredis && bun add -d @types/bcryptjs ``` Expected: `package.json` 出现 `bcryptjs`、`ioredis` 与 `@types/bcryptjs`。 - [ ] **Step 2: 增加 `test` 脚本** 在根 `package.json` 的 `scripts` 中增加: ```json "test": "bun test test/unit", "test:integration": "bun test test/integration" ``` (路径可按最终目录微调,但需与 Step 3 文件一致。) - [ ] **Step 3: 更新 `.env.example`** 在 `DATABASE_URL=` 下追加(整文件示例): ```env DATABASE_URL=postgresql://postgres:123456qaz@localhost:6666/postgres REDIS_URL=redis://127.0.0.1:6379 SESSION_TTL_SECONDS=604800 AUTH_DEBUG_LOG_TOKENS=false COOKIE_DOMAIN= ``` - [ ] **Step 4: Commit** ```bash git add package.json bun.lock .env.example && git commit -m "chore: add auth-related deps and env example" ``` --- ### Task 2: Drizzle schema(用户、挑战、OAuth 预留) **Files:** - Modify: `packages/drizzle-pkg/lib/schema/schema.ts` - [ ] **Step 1: 写失败类型检查(可选)** — 跳过纯类型任务;直接进入 Step 2。 - [ ] **Step 2: 用以下内容替换 / 合并进 `schema.ts`(保留导出名称与项目风格一致)** ```typescript import { integer, pgTable, varchar, timestamp, text, pgEnum, uniqueIndex, index, } from "drizzle-orm/pg-core"; export const authChallengeTypeEnum = pgEnum("auth_challenge_type", [ "email_verify", "password_reset", ]); export const usersTable = pgTable("users_table", { id: integer().primaryKey().generatedAlwaysAsIdentity(), name: varchar({ length: 255 }).notNull(), age: integer().notNull(), email: varchar({ length: 320 }).notNull().unique(), passwordHash: text("password_hash").notNull(), emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true, }), sessionVersion: integer("session_version").notNull().default(0), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }); export const authChallengesTable = pgTable( "auth_challenges", { id: integer().primaryKey().generatedAlwaysAsIdentity(), userId: integer("user_id") .notNull() .references(() => usersTable.id, { onDelete: "cascade" }), type: authChallengeTypeEnum("type").notNull(), tokenHash: varchar("token_hash", { length: 64 }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), consumedAt: timestamp("consumed_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ tokenHashIdx: index("auth_challenges_token_hash_idx").on(t.tokenHash), }), ); export const linkedAccountsTable = pgTable( "linked_accounts", { id: integer().primaryKey().generatedAlwaysAsIdentity(), userId: integer("user_id") .notNull() .references(() => usersTable.id, { onDelete: "cascade" }), provider: varchar({ length: 64 }).notNull(), providerUserId: varchar("provider_user_id", { length: 255 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ providerUserUnique: uniqueIndex("linked_accounts_provider_uid").on( t.provider, t.providerUserId, ), }), ); ``` 注意:`password_hash` **NOT NULL** 后,旧迁移里的行若存在,需在 Task 3 用 SQL 处理或清空表(开发环境)。 - [ ] **Step 3: 生成迁移** Run: ```bash cd /home/dash/projects/person-panel && DATABASE_URL="$DATABASE_URL" bun run db:generate --name auth_user ``` Expected: `packages/drizzle-pkg/migrations/` 出现新 SQL;打开文件确认:创建 enum、`auth_challenges`、`linked_accounts`;`ALTER users_table` 增加列。若 Drizzle 对 `NOT NULL password_hash` 与已有数据冲突,在 SQL 中改为:先 `ADD COLUMN password_hash text`,`UPDATE` 占位后再 `SET NOT NULL`,或 **开发环境 `TRUNCATE users_table`** 后加 `NOT NULL`。 - [ ] **Step 4: 应用迁移** ```bash bun run db:migrate ``` Expected: 无错误。 - [ ] **Step 5: Commit** ```bash git add packages/drizzle-pkg/lib/schema/schema.ts packages/drizzle-pkg/migrations packages/drizzle-pkg/migrations/meta && git commit -m "feat(db): auth schema and migrations" ``` --- ### Task 3: 修复 `seed.ts` 以匹配新 `users_table` **Files:** - Modify: `packages/drizzle-pkg/seed.ts` - [ ] **Step 1: 将 seed 改为显式插入带密码用户(避免 drizzle-seed 无法填 passwordHash)** 用下面实现 **替换** `main` 逻辑(保留 `import './env'` 等顶层): ```typescript import './env'; import { dbGlobal } from "./lib/db"; import { usersTable } from "./lib/schema/schema"; import bcrypt from "bcryptjs"; async function main() { const passwordHash = bcrypt.hashSync("seed-password-123", 10); await dbGlobal.insert(usersTable).values( Array.from({ length: 5 }).map((_, i) => ({ name: `Seed User ${i}`, age: 20 + i, email: `seed${i}@example.com`, passwordHash, })), ); console.log('Seed complete!'); process.exit(0); } main().catch((e) => { console.error(e); process.exit(1); }); ``` 删除对 `drizzle-seed` 的依赖引用;若根 `package.json` 不再使用 `drizzle-seed`,可在后续单独 PR 移除(本任务可保留依赖以免范围膨胀)。 - [ ] **Step 2: 运行 seed(空库或已 truncate)** ```bash bun run db:seed ``` Expected: `Seed complete!` - [ ] **Step 3: Commit** ```bash git add packages/drizzle-pkg/seed.ts && git commit -m "fix(seed): insert users with password hash for new schema" ``` --- ### Task 4: Redis 单例与 Cookie 常量 **Files:** - Create: `server/utils/redis.ts` - Create: `server/utils/session-cookie.ts` - Create: `server/plugins/04.redis.ts`(若与现有编号冲突,改为下一个空闲编号) - [ ] **Step 1: `server/utils/redis.ts`** ```typescript import Redis from "ioredis"; let client: Redis | null = null; export function getRedis(): Redis { if (client) return client; const url = process.env.REDIS_URL; if (!url) { throw new Error("REDIS_URL is required"); } client = new Redis(url, { maxRetriesPerRequest: 2 }); return client; } export async function closeRedis(): Promise { if (!client) return; await client.quit(); client = null; } ``` - [ ] **Step 2: `server/utils/session-cookie.ts`** ```typescript import type { H3Event } from "h3"; import { getCookie, setCookie, deleteCookie } from "h3"; export const SESSION_COOKIE_NAME = "pp_session"; export function readSessionIdFromCookie(event: H3Event): string | undefined { return getCookie(event, SESSION_COOKIE_NAME); } export function writeSessionCookie( event: H3Event, sessionId: string, maxAgeSeconds: number, ): void { const secure = process.env.NODE_ENV === "production"; const domain = process.env.COOKIE_DOMAIN?.trim() || undefined; setCookie(event, SESSION_COOKIE_NAME, sessionId, { httpOnly: true, sameSite: "lax", secure, path: "/", maxAge: maxAgeSeconds, domain, }); } export function clearSessionCookie(event: H3Event): void { const secure = process.env.NODE_ENV === "production"; const domain = process.env.COOKIE_DOMAIN?.trim() || undefined; deleteCookie(event, SESSION_COOKIE_NAME, { path: "/", httpOnly: true, sameSite: "lax", secure, domain, }); } ``` - [ ] **Step 3: `server/plugins/04.redis.ts`** ```typescript export default defineNitroPlugin(() => { import("~/server/utils/redis").then(({ getRedis }) => { getRedis(); }); }); ``` 若路径别名 `~` 在 Nitro 插件中不可用,改为相对路径 `../utils/redis`。 - [ ] **Step 4: Commit** ```bash git add server/utils/redis.ts server/utils/session-cookie.ts server/plugins/04.redis.ts && git commit -m "feat(server): redis singleton and session cookie helpers" ``` --- ### Task 5: 会话 Redis 与版本吊销 **Files:** - Create: `server/utils/session-ttl.ts` - Create: `server/utils/session-redis.ts` - Create: `test/unit/session-ttl.test.ts` - [ ] **Step 1: `server/utils/session-ttl.ts`** ```typescript export function sessionTtlSeconds(): number { const raw = process.env.SESSION_TTL_SECONDS; const n = raw ? Number(raw) : NaN; return Number.isFinite(n) && n > 0 ? n : 604800; } ``` - [ ] **Step 2: `server/utils/session-redis.ts`** ```typescript import { getRedis } from "./redis"; import { sessionTtlSeconds } from "./session-ttl"; export type SessionPayload = { userId: number; sessionVersion: number; createdAt: string; }; const sessionKey = (id: string) => `sess:${id}`; export async function createSession( sessionId: string, payload: SessionPayload, ): Promise { const redis = getRedis(); const ttl = sessionTtlSeconds(); await redis.set(sessionKey(sessionId), JSON.stringify(payload), "EX", ttl); } export async function readSession( sessionId: string, ): Promise { const redis = getRedis(); const raw = await redis.get(sessionKey(sessionId)); if (!raw) return null; try { return JSON.parse(raw) as SessionPayload; } catch { return null; } } export async function deleteSession(sessionId: string): Promise { const redis = getRedis(); await redis.del(sessionKey(sessionId)); } ``` 登录时从 DB 读取 `usersTable.sessionVersion` 写入 payload;`requireUser` 在读到 Redis payload 后 **再查 DB** 比对 `sessionVersion`,不一致则删 Redis 会话并 401。 - [ ] **Step 3: `test/unit/session-ttl.test.ts`** ```typescript import { describe, expect, it, beforeEach } from "bun:test"; import { sessionTtlSeconds } from "../../server/utils/session-ttl"; describe("sessionTtlSeconds", () => { beforeEach(() => { delete process.env.SESSION_TTL_SECONDS; }); it("defaults to 7d", () => { expect(sessionTtlSeconds()).toBe(604800); }); it("respects env", () => { process.env.SESSION_TTL_SECONDS = "120"; expect(sessionTtlSeconds()).toBe(120); }); }); ``` - [ ] **Step 4: Run** ```bash bun test test/unit/session-ttl.test.ts ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add server/utils/session-ttl.ts server/utils/session-redis.ts test/unit/session-ttl.test.ts && git commit -m "feat(server): redis session storage and ttl helper" ``` --- ### Task 6: 密码与挑战 token 工具 **Files:** - Create: `server/utils/password.ts` - Create: `server/utils/challenge-token.ts` - Create: `test/unit/password.test.ts` - Create: `test/unit/challenge-token.test.ts` - [ ] **Step 1: `server/utils/password.ts`** ```typescript import bcrypt from "bcryptjs"; const ROUNDS = 10; export async function hashPassword(plain: string): Promise { const salt = await bcrypt.genSalt(ROUNDS); return bcrypt.hash(plain, salt); } export async function verifyPassword( plain: string, hash: string, ): Promise { return bcrypt.compare(plain, hash); } ``` - [ ] **Step 2: `server/utils/challenge-token.ts`** ```typescript import { createHash, randomBytes } from "node:crypto"; export function randomUrlToken(): string { return randomBytes(32).toString("base64url"); } export function hashChallengeToken(token: string): string { return createHash("sha256").update(token, "utf8").digest("hex"); } ``` - [ ] **Step 3: 测试 `test/unit/password.test.ts`** ```typescript import { describe, expect, it } from "bun:test"; import { hashPassword, verifyPassword } from "../../server/utils/password"; describe("password", () => { it("hashes and verifies", async () => { const h = await hashPassword("hunter2"); expect(await verifyPassword("hunter2", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); }); ``` - [ ] **Step 4: 测试 `test/unit/challenge-token.test.ts`** ```typescript import { describe, expect, it } from "bun:test"; import { hashChallengeToken, randomUrlToken, } from "../../server/utils/challenge-token"; describe("challenge-token", () => { it("hash is stable", () => { expect(hashChallengeToken("abc")).toBe(hashChallengeToken("abc")); }); it("random has reasonable length", () => { expect(randomUrlToken().length).toBeGreaterThan(20); }); }); ``` - [ ] **Step 5: Run** ```bash bun test test/unit/password.test.ts test/unit/challenge-token.test.ts ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add server/utils/password.ts server/utils/challenge-token.ts test/unit/password.test.ts test/unit/challenge-token.test.ts && git commit -m "feat(server): password and challenge token utilities" ``` --- ### Task 7: 错误 JSON、限流、验证策略 **Files:** - Create: `server/utils/errors.ts` - Create: `server/utils/rate-limit.ts` - Create: `server/utils/verification-policy.ts` - Create: `server/utils/mailer.ts` - Create: `test/unit/verification-policy.test.ts` - [ ] **Step 1: `server/utils/errors.ts`** ```typescript import { createError } from "h3"; /** 与规格一致的错误体;依赖 Nitro 将 `createError` 的 `data` 序列化进 JSON。 */ export function jsonError(status: number, code: string, message: string): never { throw createError({ statusCode: status, data: { error: { code, message } }, }); } ``` - [ ] **Step 2: `server/utils/rate-limit.ts`** ```typescript import { createError } from "h3"; import { getRedis } from "./redis"; export async function rateLimitOrThrow( key: string, limit: number, windowSeconds: number, ): Promise { const redis = getRedis(); const n = await redis.incr(key); if (n === 1) { await redis.expire(key, windowSeconds); } if (n > limit) { throw createError({ statusCode: 429, data: { error: { code: "RATE_LIMITED", message: "请求过于频繁,请稍后再试" }, }, }); } } ``` - [ ] **Step 3: `server/utils/verification-policy.ts`** ```typescript const NEEDS_VERIFIED = new Set(["patch-me"]); export function needsEmailVerified(handlerId: string): boolean { return NEEDS_VERIFIED.has(handlerId); } ``` - [ ] **Step 4: 测试 `test/unit/verification-policy.test.ts`** ```typescript import { describe, expect, it } from "bun:test"; import { needsEmailVerified } from "../../server/utils/verification-policy"; describe("verification-policy", () => { it("patch-me requires verified", () => { expect(needsEmailVerified("patch-me")).toBe(true); }); it("unknown defaults false", () => { expect(needsEmailVerified("other")).toBe(false); }); }); ``` - [ ] **Step 5: `server/utils/mailer.ts`** ```typescript export type Mailer = { sendVerificationEmail(input: { to: string; token: string }): Promise; sendPasswordResetEmail(input: { to: string; token: string }): Promise; }; export const noopMailer: Mailer = { async sendVerificationEmail() {}, async sendPasswordResetEmail() {}, }; ``` - [ ] **Step 6: Run单测** ```bash bun test test/unit/verification-policy.test.ts ``` - [ ] **Step 7: Commit** ```bash git add server/utils/errors.ts server/utils/rate-limit.ts server/utils/verification-policy.ts server/utils/mailer.ts test/unit/verification-policy.test.ts && git commit -m "feat(server): errors, rate limit stub, verification policy, noop mailer" ``` --- ### Task 8: `server/services/auth.ts`(核心逻辑) **Files:** - Create: `server/services/auth.ts` 将 **附录 A** 全文写入 `server/services/auth.ts`,再按本仓库路径别名做最小调整(确保 `drizzle-pkg`、`logger` 与 `../utils/*` 解析正确)。行为摘要: - `registerWithSession`:限流 → 校验 → 插入用户 + `email_verify` challenge → `noopMailer` → 调试日志 → 轮换旧会话 → 新 Redis session + Cookie;`NODE_ENV=test` 时响应含 `_testTokens.verify`。 - `loginWithSession`:限流 → 校验密码 → 轮换旧会话 → 新 session;错误统一 **401**(不区分用户不存在与密码错误,可选;附录为防枚举使用统一文案)。 - `logoutSession`:删 Redis + `clearSessionCookie`。 - `getCurrentUser` / `patchMe`:`requireSessionUser` 比对 `sessionVersion`;`patch-me` 需 `emailVerifiedAt`。 - `forgotPassword`:限流 → **始终** `{ ok: true }`;存在用户则写 `password_reset`;test 环境附加 `_testTokens.reset`。 - `resetPassword`:`session_version` 用 Drizzle `sql` 表达式 **+1** 吊销旧会话;消费 challenge。 - `verifyEmail`:写 `emailVerifiedAt` + 消费 challenge。 - [ ] **Step 1: 复制附录 A 到 `server/services/auth.ts` 并修正 import** - [ ] **Step 2: Commit** ```bash git add server/services/auth.ts && git commit -m "feat(server): auth service (register login session profile challenges)" ``` --- ### Task 9: Nitro API handlers **Files:** - Create: `server/api/auth/register.post.ts` - Create: `server/api/auth/login.post.ts` - Create: `server/api/auth/logout.post.ts` - Create: `server/api/auth/forgot-password.post.ts` - Create: `server/api/auth/reset-password.post.ts` - Create: `server/api/auth/verify-email.post.ts` - Create: `server/api/me.get.ts` - Create: `server/api/me.patch.ts` - Modify: `server/plugins/03.error-logger.ts`(若存在)确保 `createError` 的 `data` 原样输出 JSON **示例 `server/api/auth/login.post.ts`:** ```typescript import { readBody } from "h3"; import { loginWithSession } from "../../services/auth"; export default defineEventHandler(async (event) => { const body = await readBody<{ email?: string; password?: string }>(event); return loginWithSession(event, { email: body.email ?? "", password: body.password ?? "", }); }); ``` 其余路由同理调用 service。**`PATCH /api/me`** 的 handlerId 内部传 `patch-me`。 - [ ] **Step 1: 创建全部 handler 文件** - [ ] **Step 2: 删除 `server/api/hello.ts`** ```bash git rm server/api/hello.ts ``` - [ ] **Step 3: 手动 smoke** ```bash bun run dev ``` 另开终端: ```bash curl -i -X POST http://localhost:3000/api/auth/register \ -H 'content-type: application/json' \ -d '{"email":"a@b.com","password":"password123","name":"A","age":20}' ``` Expected: `Set-Cookie: pp_session=...` - [ ] **Step 4: Commit** ```bash git add server/api && git commit -m "feat(api): auth and profile endpoints, remove public hello" ``` --- ### Task 10: 集成测试 `test/integration/auth.http.test.ts` **前提:** 终端 A 已 `NODE_ENV=test REDIS_URL=... DATABASE_URL=... bun run dev` 监听 `http://127.0.0.1:3000`;终端 B 跑测试。`NODE_ENV=test` 时注册/忘记密码响应含 `_testTokens`(见附录 A),仅用于自动化。 - [ ] **Step 1: 创建 `test/integration/auth.http.test.ts`** ~~~typescript import { describe, it, expect } from "bun:test"; const BASE = process.env.TEST_BASE_URL ?? "http://127.0.0.1:3000"; function parseCookie(res: Response): string { const raw = res.headers.get("set-cookie"); if (!raw) return ""; return raw.split(";")[0] ?? ""; } (process.env.TEST_INTEGRATION ? describe : describe.skip)("auth over HTTP", () => { it("register → me → verify → patch", async () => { const email = `u${Date.now()}@example.com`; const reg = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password: "password123", name: "T", age: 30, }), }); expect(reg.status).toBe(200); const regJson = (await reg.json()) as { user: { emailVerified: boolean }; _testTokens?: { verify?: string }; }; expect(regJson.user.emailVerified).toBe(false); const cookie = parseCookie(reg); expect(cookie.length).toBeGreaterThan(5); const me1 = await fetch(`${BASE}/api/me`, { headers: { cookie } }); expect(me1.status).toBe(200); const patch1 = await fetch(`${BASE}/api/me`, { method: "PATCH", headers: { cookie, "content-type": "application/json" }, body: JSON.stringify({ name: "T2" }), }); expect(patch1.status).toBe(403); const verify = await fetch(`${BASE}/api/auth/verify-email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: regJson._testTokens?.verify }), }); expect(verify.status).toBe(200); const patch2 = await fetch(`${BASE}/api/me`, { method: "PATCH", headers: { cookie, "content-type": "application/json" }, body: JSON.stringify({ name: "T2" }), }); expect(patch2.status).toBe(200); }); }); ~~~ - [ ] **Step 2: Run(双终端)** 终端 A: ```bash export NODE_ENV=test REDIS_URL=redis://127.0.0.1:6379 DATABASE_URL=postgresql://... bun run dev ``` 终端 B: ```bash export TEST_INTEGRATION=1 NODE_ENV=test bun test test/integration/auth.http.test.ts ``` Expected: `pass` 1 test。 - [ ] **Step 3: Commit** ```bash git add test/integration/auth.http.test.ts && git commit -m "test: optional HTTP integration for auth flow" ``` --- ### Task 11: 最小前端页面 **Files:** - Create: `app/pages/login.vue` - Create: `app/pages/register.vue` - Create: `app/pages/me.vue` - Create: `app/middleware/auth.ts` - Create: `app/middleware/guest.ts` - [ ] **Step 1: `app/middleware/auth.ts`**(HttpOnly Cookie 不可用 `useCookie` 读取,必须用 `$fetch` 探测会话) ```typescript export default defineNuxtRouteMiddleware(async (to) => { try { await $fetch("/api/me", { credentials: "include" }); } catch { return navigateTo({ path: "/login", query: { redirect: to.fullPath } }); } }); ``` - [ ] **Step 2: `app/middleware/guest.ts`** ```typescript export default defineNuxtRouteMiddleware(async () => { try { await $fetch("/api/me", { credentials: "include" }); return navigateTo("/me"); } catch { return; } }); ``` 在 `app/pages/login.vue`、`register.vue` 的 `