From 59d524fbd27e85c8e2743f9d84f918730101ede4 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 12 Apr 2026 21:23:01 +0800 Subject: [PATCH] docs: add auth and user profile implementation plan Made-with: Cursor --- docs/superpowers/plans/2026-04-12-auth-user.md | 1233 ++++++++++++++++++++++++ 1 file changed, 1233 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-auth-user.md diff --git a/docs/superpowers/plans/2026-04-12-auth-user.md b/docs/superpowers/plans/2026-04-12-auth-user.md new file mode 100644 index 0000000..ccb8b80 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-auth-user.md @@ -0,0 +1,1233 @@ +# 登录、注册与用户信息 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` 的 `