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

17 KiB
Raw Blame History

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,完整内容如下:

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运行测试确认它先失败

运行:

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.tsEnv interface 中,放在 API_KEY_DEFER_UPDATES?: string; 后追加:

  AUTUMN_SECRET_KEY?: string;
  AUTUMN_API_URL?: string;
  AUTUMN_FREE_PLAN_ID?: string;
  AUTUMN_REGISTER_SYNC_ENABLED?: string;
  • 步骤 2编写最小实现

创建 src/autumn.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 单测,确认通过

运行:

pnpm vitest run tests/autumn.test.ts

预期PASS。

  • 步骤 4提交

运行:

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.tsdescribe("Autumn registration sync", () => { 内追加这个测试:

  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运行类型检查确认通过

运行:

pnpm typecheck

预期PASS。

  • 步骤 3更新 TODO.md 的生产变量说明

TODO.mdConfigure Only If Enabling The Feature 表格末尾追加这四行:

| `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运行测试和类型检查

运行:

pnpm typecheck
pnpm vitest run tests/autumn.test.ts

预期:两个命令都 PASS。

  • 步骤 5提交

运行:

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 改成:

import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";

在文件中 describe("production auth config", () => { 之前追加:

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运行测试确认它先失败

运行:

pnpm vitest run tests/auth-config.test.ts --testNamePattern "Autumn registration hook"

预期FAIL错误显示 databaseHooksuser.create.afterundefined

  • 步骤 3修改 src/auth.ts

在 import 区追加:

import { syncAutumnCustomerOnRegistration } from "./autumn";

trustedOrigins: trustedOrigins(env, runtime.requestOrigin), 后追加:

    databaseHooks: {
      user: {
        create: {
          after: async (user) => {
            const sync = syncAutumnCustomerOnRegistration(env, user);
            if (runtime.waitUntil) {
              runtime.waitUntil(sync);
              return;
            }

            await sync;
          },
        },
      },
    },
  • 步骤 4运行 hook 测试,确认通过

运行:

pnpm vitest run tests/auth-config.test.ts --testNamePattern "Autumn registration hook"

预期PASS。

  • 步骤 5运行相关测试和类型检查

运行:

pnpm typecheck
pnpm vitest run tests/auth-config.test.ts tests/autumn.test.ts

预期:两个命令都 PASS。

  • 步骤 5提交

运行:

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.tsdescribe("cfw-auth worker", () => { 内、does not expose a custom session wrapper 测试后追加:

  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 测试

运行:

pnpm vitest run tests/auth-worker.test.ts

预期PASS。

  • 步骤 3提交

运行:

git add tests/auth-worker.test.ts
git commit -m "test: preserve auth worker boundary"

预期:提交成功。

任务 6全量验证

文件:

  • 无新增代码,验证全仓库状态。

  • 步骤 1运行类型检查

运行:

pnpm typecheck

预期PASS。

  • 步骤 2运行测试

运行:

pnpm test

预期PASS。

  • 步骤 3运行 ready gate

运行:

pnpm ready

预期PASS。该命令应包含 db:checktypechecktest

  • 步骤 4检查工作树

运行:

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 覆盖。