Files
cfw-auth/docs/superpowers/plans/2026-07-02-cfw-auth-autumn-registration-sync.md

658 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<typeof vi.fn>;
let errorLog: ReturnType<typeof vi.spyOn>;
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<void> {
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<void>;
};
};
};
};
}
).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 覆盖。