diff --git a/docs/superpowers/plans/2026-07-02-cfw-auth-autumn-registration-sync.md b/docs/superpowers/plans/2026-07-02-cfw-auth-autumn-registration-sync.md new file mode 100644 index 0000000..37e11c9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-cfw-auth-autumn-registration-sync.md @@ -0,0 +1,657 @@ +# cfw-auth 注册同步 Autumn 实现计划 + +> **给 agent 执行者:** 必选子技能:使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 按任务逐项实现本计划。步骤统一使用 checkbox(`- [ ]`)语法跟踪。 + +**目标:** 在 `cfw-auth` 用户注册成功后,异步创建或获取 Autumn customer,并自动启用配置的免费套餐。 + +**方案概览:** 新增 `src/autumn.ts` 作为唯一 Autumn 同步边界,用最小 `fetch` 调用 Autumn `POST /v1/customers.get_or_create`,避免为单个接口先引入 `autumn-js`。在 `src/auth.ts` 的 Better Auth `databaseHooks.user.create.after` 中触发同步;Worker 场景使用 `waitUntil`,测试或非 Worker 场景直接等待。同步失败 fail-open,只记录脱敏结构化日志。 + +**技术栈:** Cloudflare Workers、Hono、Better Auth、Vitest、TypeScript、Autumn HTTP API + +--- + +## 文件结构 + +- 新建:`src/autumn.ts` + - Autumn 注册同步边界模块。负责 env 解析、payload 构造、HTTP 调用和脱敏错误日志。 +- 修改:`src/env.ts` + - 增加 Autumn 相关 Worker env 类型。 +- 修改:`src/auth.ts` + - 在 Better Auth 配置中挂载 `databaseHooks.user.create.after`,调用 Autumn 同步模块。 +- 新建:`tests/autumn.test.ts` + - 覆盖 env、payload、fetch、fail-open、日志脱敏和关闭开关。 +- 修改:`tests/auth-config.test.ts` + - 覆盖 `createAuth` 注册 hook 的 waitUntil 调度行为。 +- 修改:`TODO.md` + - 增加生产环境 Autumn 变量说明。 + +## 任务 1:新增 Autumn 同步模块的失败测试 + +**文件:** +- 新建:`tests/autumn.test.ts` +- 后续实现:`src/autumn.ts` + +- [ ] **步骤 1:写失败测试** + +创建 `tests/autumn.test.ts`,完整内容如下: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AUTUMN_DEFAULT_API_URL, + buildAutumnCustomerPayload, + syncAutumnCustomerOnRegistration, +} from "../src/autumn"; +import type { Env } from "../src/env"; + +const baseEnv: Env = { + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + AUTUMN_SECRET_KEY: "autumn-secret", + AUTUMN_FREE_PLAN_ID: "free", + AUTUMN_API_URL: "https://autumn.example.com", +}; + +const user = { + id: "user_123", + email: "user@example.com", + name: "Example User", +}; + +let fetchMock: ReturnType; +let errorLog: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ id: "user_123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("Autumn registration sync", () => { + it("builds a stable user-scoped customer payload", () => { + expect(buildAutumnCustomerPayload(baseEnv, user)).toEqual({ + customer_id: "user_123", + email: "user@example.com", + name: "Example User", + auto_enable_plan_id: "free", + metadata: { + source: "cfw-auth", + auth_provider: "better-auth", + }, + }); + }); + + it("uses the Autumn default API URL when AUTUMN_API_URL is not configured", async () => { + await syncAutumnCustomerOnRegistration( + { + ...baseEnv, + AUTUMN_API_URL: undefined, + }, + user, + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${AUTUMN_DEFAULT_API_URL}/v1/customers.get_or_create`, + expect.any(Object), + ); + }); + + it("posts get_or_create with the configured secret and free plan", async () => { + await syncAutumnCustomerOnRegistration(baseEnv, user); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://autumn.example.com/v1/customers.get_or_create"); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer autumn-secret", + "Content-Type": "application/json", + }); + expect(JSON.parse(String(init.body))).toEqual({ + customer_id: "user_123", + email: "user@example.com", + name: "Example User", + auto_enable_plan_id: "free", + metadata: { + source: "cfw-auth", + auth_provider: "better-auth", + }, + }); + }); + + it("does not call Autumn when registration sync is disabled", async () => { + await syncAutumnCustomerOnRegistration( + { + ...baseEnv, + AUTUMN_REGISTER_SYNC_ENABLED: "false", + }, + user, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(errorLog).not.toHaveBeenCalled(); + }); + + it("fails open and logs a redacted config error when required env is missing", async () => { + await expect( + syncAutumnCustomerOnRegistration( + { + ...baseEnv, + AUTUMN_SECRET_KEY: undefined, + }, + user, + ), + ).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledTimes(1); + const message = String(errorLog.mock.calls[0]?.[0]); + expect(message).toContain("autumn_registration_sync_failed"); + expect(message).toContain("AUTUMN_SECRET_KEY"); + expect(message).not.toContain("autumn-secret"); + expect(message).not.toContain("Authorization"); + }); + + it("fails open and logs a redacted Autumn API error", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "upstream exploded" }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ); + + await expect(syncAutumnCustomerOnRegistration(baseEnv, user)).resolves.toBeUndefined(); + + expect(errorLog).toHaveBeenCalledTimes(1); + const message = String(errorLog.mock.calls[0]?.[0]); + expect(message).toContain("autumn_registration_sync_failed"); + expect(message).toContain("user_123"); + expect(message).toContain("500"); + expect(message).not.toContain("autumn-secret"); + expect(message).not.toContain("Bearer"); + }); +}); +``` + +- [ ] **步骤 2:运行测试,确认它先失败** + +运行: + +```bash +pnpm vitest run tests/autumn.test.ts +``` + +预期:FAIL,错误包含 `Cannot find module '../src/autumn'` 或 `Failed to resolve import "../src/autumn"`。 + +## 任务 2:实现 `src/autumn.ts` + +**文件:** +- 新建:`src/autumn.ts` +- 修改:`src/env.ts` +- 测试:`tests/autumn.test.ts` + +- [ ] **步骤 1:先补充 `Env` 类型** + +在 `src/env.ts` 的 `Env` interface 中,放在 `API_KEY_DEFER_UPDATES?: string;` 后追加: + +```ts + AUTUMN_SECRET_KEY?: string; + AUTUMN_API_URL?: string; + AUTUMN_FREE_PLAN_ID?: string; + AUTUMN_REGISTER_SYNC_ENABLED?: string; +``` + +- [ ] **步骤 2:编写最小实现** + +创建 `src/autumn.ts`,完整内容如下: + +```ts +import type { Env } from "./env"; + +export const AUTUMN_DEFAULT_API_URL = "https://api.useautumn.com"; + +export interface AutumnRegistrationUser { + id: string; + email?: string | null; + name?: string | null; +} + +export interface AutumnCustomerPayload { + customer_id: string; + email?: string; + name?: string; + auto_enable_plan_id: string; + metadata: { + source: "cfw-auth"; + auth_provider: "better-auth"; + }; +} + +export function buildAutumnCustomerPayload( + env: Env, + user: AutumnRegistrationUser, +): AutumnCustomerPayload { + const autoEnablePlanId = requireAutumnEnv(env.AUTUMN_FREE_PLAN_ID, "AUTUMN_FREE_PLAN_ID"); + const payload: AutumnCustomerPayload = { + customer_id: user.id, + auto_enable_plan_id: autoEnablePlanId, + metadata: { + source: "cfw-auth", + auth_provider: "better-auth", + }, + }; + + if (user.email) { + payload.email = user.email; + } + + if (user.name) { + payload.name = user.name; + } + + return payload; +} + +export async function syncAutumnCustomerOnRegistration( + env: Env, + user: AutumnRegistrationUser, +): Promise { + if (!isAutumnRegistrationSyncEnabled(env)) { + return; + } + + try { + const secretKey = requireAutumnEnv(env.AUTUMN_SECRET_KEY, "AUTUMN_SECRET_KEY"); + const payload = buildAutumnCustomerPayload(env, user); + const response = await fetch(`${autumnApiUrl(env)}/v1/customers.get_or_create`, { + method: "POST", + headers: { + Authorization: `Bearer ${secretKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new AutumnRegistrationSyncError("Autumn customer sync failed", response.status); + } + } catch (error) { + logAutumnRegistrationSyncError(env, user, error); + } +} + +export function isAutumnRegistrationSyncEnabled(env: Env): boolean { + return env.AUTUMN_REGISTER_SYNC_ENABLED !== "false"; +} + +function autumnApiUrl(env: Env): string { + return (env.AUTUMN_API_URL || AUTUMN_DEFAULT_API_URL).replace(/\/+$/, ""); +} + +function requireAutumnEnv(value: string | undefined, key: string): string { + if (!value) { + throw new AutumnRegistrationSyncError(`Missing required env: ${key}`, 0, "config_error"); + } + + return value; +} + +class AutumnRegistrationSyncError extends Error { + constructor( + message: string, + readonly status: number, + readonly code = "autumn_request_failed", + ) { + super(message); + } +} + +function logAutumnRegistrationSyncError( + env: Env, + user: AutumnRegistrationUser, + error: unknown, +): void { + try { + const knownError = error instanceof AutumnRegistrationSyncError ? error : undefined; + console.error( + JSON.stringify({ + event: "autumn_registration_sync_failed", + userId: user.id, + status: knownError?.status ?? 0, + code: knownError?.code ?? "autumn_request_failed", + message: error instanceof Error ? error.message : "Autumn customer sync failed", + autumnApiUrlConfigured: Boolean(env.AUTUMN_API_URL), + }), + ); + } catch { + // Billing sync observability must not affect auth registration. + } +} +``` + +- [ ] **步骤 3:运行 Autumn 单测,确认通过** + +运行: + +```bash +pnpm vitest run tests/autumn.test.ts +``` + +预期:PASS。 + +- [ ] **步骤 4:提交** + +运行: + +```bash +git add src/autumn.ts src/env.ts tests/autumn.test.ts +git commit -m "feat: add autumn registration sync client" +``` + +预期:提交成功。 + +## 任务 3:补充 Autumn 配置文档 + +**文件:** +- 修改:`TODO.md` +- 测试:`tests/autumn.test.ts` + +- [ ] **步骤 1:补一条 Env 形状测试** + +在 `tests/autumn.test.ts` 的 `describe("Autumn registration sync", () => {` 内追加这个测试: + +```ts + it("accepts the Autumn registration sync env shape", () => { + const env: Env = { + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + AUTUMN_SECRET_KEY: "secret", + AUTUMN_FREE_PLAN_ID: "free", + AUTUMN_API_URL: "https://autumn.example.com", + AUTUMN_REGISTER_SYNC_ENABLED: "true", + }; + + expect(env.AUTUMN_FREE_PLAN_ID).toBe("free"); + }); +``` + +- [ ] **步骤 2:运行类型检查,确认通过** + +运行: + +```bash +pnpm typecheck +``` + +预期:PASS。 + +- [ ] **步骤 3:更新 `TODO.md` 的生产变量说明** + +在 `TODO.md` 的 `Configure Only If Enabling The Feature` 表格末尾追加这四行: + +```md +| `AUTUMN_SECRET_KEY` | Enable registration-time Autumn customer sync. | Required when `AUTUMN_REGISTER_SYNC_ENABLED` is not `false`; store as a Worker secret. | +| `AUTUMN_FREE_PLAN_ID` | Auto-enable the free Autumn plan during registration. | Required when Autumn registration sync is enabled; no code default. | +| `AUTUMN_API_URL` | Override Autumn API URL. | Optional; omit to use `https://api.useautumn.com`. | +| `AUTUMN_REGISTER_SYNC_ENABLED` | Disable registration sync when set to `false`. | Optional; defaults to enabled so missing required Autumn env is visible in logs. | +``` + +- [ ] **步骤 4:运行测试和类型检查** + +运行: + +```bash +pnpm typecheck +pnpm vitest run tests/autumn.test.ts +``` + +预期:两个命令都 PASS。 + +- [ ] **步骤 5:提交** + +运行: + +```bash +git add TODO.md tests/autumn.test.ts +git commit -m "chore: document autumn registration sync env" +``` + +预期:提交成功。 + +## 任务 4:把 Autumn 同步接入 Better Auth 注册 hook + +**文件:** +- 修改:`src/auth.ts` +- 修改:`tests/auth-config.test.ts` + +- [ ] **步骤 1:写失败测试,确认注册 hook 会调度 waitUntil** + +在 `tests/auth-config.test.ts` 的 import 区,把 `describe, expect, it` 改成: + +```ts +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +``` + +在文件中 `describe("production auth config", () => {` 之前追加: + +```ts +describe("Autumn registration hook", () => { + it("schedules Autumn sync after Better Auth creates a user", async () => { + const waitUntil = vi.fn(); + const auth = createAuth( + { + ...env, + AUTUMN_SECRET_KEY: "autumn-secret", + AUTUMN_FREE_PLAN_ID: "free", + }, + { + waitUntil, + }, + ); + + const hook = ( + auth as unknown as { + options: { + databaseHooks?: { + user?: { + create?: { + after?: ( + user: { id: string; email?: string | null; name?: string | null }, + context?: unknown, + ) => Promise; + }; + }; + }; + }; + } + ).options.databaseHooks?.user?.create?.after; + expect(hook).toEqual(expect.any(Function)); + + await hook?.( + { + id: "user_123", + email: "user@example.com", + name: "Example User", + }, + undefined, + ); + + expect(waitUntil).toHaveBeenCalledTimes(1); + expect(waitUntil.mock.calls[0]?.[0]).toBeInstanceOf(Promise); + }); +}); +``` + +- [ ] **步骤 2:运行测试,确认它先失败** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts --testNamePattern "Autumn registration hook" +``` + +预期:FAIL,错误显示 `databaseHooks` 或 `user.create.after` 是 `undefined`。 + +- [ ] **步骤 3:修改 `src/auth.ts`** + +在 import 区追加: + +```ts +import { syncAutumnCustomerOnRegistration } from "./autumn"; +``` + +在 `trustedOrigins: trustedOrigins(env, runtime.requestOrigin),` 后追加: + +```ts + databaseHooks: { + user: { + create: { + after: async (user) => { + const sync = syncAutumnCustomerOnRegistration(env, user); + if (runtime.waitUntil) { + runtime.waitUntil(sync); + return; + } + + await sync; + }, + }, + }, + }, +``` + +- [ ] **步骤 4:运行 hook 测试,确认通过** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts --testNamePattern "Autumn registration hook" +``` + +预期:PASS。 + +- [ ] **步骤 5:运行相关测试和类型检查** + +运行: + +```bash +pnpm typecheck +pnpm vitest run tests/auth-config.test.ts tests/autumn.test.ts +``` + +预期:两个命令都 PASS。 + +- [ ] **步骤 5:提交** + +运行: + +```bash +git add src/auth.ts tests/auth-config.test.ts +git commit -m "feat: sync autumn customer after signup" +``` + +预期:提交成功。 + +## 任务 5:补 Worker 边界回归测试 + +**文件:** +- 修改:`tests/auth-worker.test.ts` + +- [ ] **步骤 1:写测试,确认公开 HTTP 边界没有扩大** + +在 `tests/auth-worker.test.ts` 的 `describe("cfw-auth worker", () => {` 内、`does not expose a custom session wrapper` 测试后追加: + +```ts + it("does not expose a custom Autumn sync endpoint", async () => { + const response = await worker.fetch(new Request("http://auth.local/internal/autumn/sync"), env); + expect(response.status).toBe(404); + }); +``` + +- [ ] **步骤 2:运行 Worker 测试** + +运行: + +```bash +pnpm vitest run tests/auth-worker.test.ts +``` + +预期:PASS。 + +- [ ] **步骤 3:提交** + +运行: + +```bash +git add tests/auth-worker.test.ts +git commit -m "test: preserve auth worker boundary" +``` + +预期:提交成功。 + +## 任务 6:全量验证 + +**文件:** +- 无新增代码,验证全仓库状态。 + +- [ ] **步骤 1:运行类型检查** + +运行: + +```bash +pnpm typecheck +``` + +预期:PASS。 + +- [ ] **步骤 2:运行测试** + +运行: + +```bash +pnpm test +``` + +预期:PASS。 + +- [ ] **步骤 3:运行 ready gate** + +运行: + +```bash +pnpm ready +``` + +预期:PASS。该命令应包含 `db:check`、`typecheck` 和 `test`。 + +- [ ] **步骤 4:检查工作树** + +运行: + +```bash +git status --short +``` + +预期:没有未提交文件。 + +## 自检映射 + +- 自动创建 Autumn customer:任务 1、2、4 覆盖。 +- 自动分配免费套餐:任务 1、2 通过 `auto_enable_plan_id` 覆盖。 +- 自动开通试用:由 Autumn plan/product 配置承担,任务 2 保持 `cfw-auth` 不硬编码试用。 +- 自动同步用户资料:任务 1、2 覆盖 `id/email/name` 映射。 +- fail-open:任务 1、2 覆盖缺 env、Autumn 500 和不抛出。 +- 不扩大公开 API:任务 5 覆盖。 +- 显式 env 和无假默认 plan/secret:任务 2、3 覆盖。 +- 验收命令:任务 6 覆盖。