diff --git a/.env.example b/.env.example index ee26cb55..3d5ec160 100644 --- a/.env.example +++ b/.env.example @@ -86,11 +86,21 @@ DIRECT_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:54 BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:4040 +# === Private Beta 准入(默认关闭,显式开启后仅有有效权益的账号可调用产品 API) === +BETA_ACCESS_ENFORCED=false +# 逗号分隔的精确 modelId;Beta 账号只能使用这里已完成渠道和价格验收的模型。 +BETA_MODEL_IDS= +# 邀请邮件 outbox 的 AES-256-GCM 密钥:`openssl rand -base64 32`。 +# 仅服务端使用;轮换前必须先处理仍在 queued/failed 的旧 payload。 +BETA_INVITE_ENCRYPTION_KEY= + # === 邮件服务(Resend):邮箱验证 / 找回密码 === # 配了 RESEND_API_KEY 才会强制邮箱验证(并把初始额度改到「验证后」发放,防白嫖); # 未配置则降级为「注册即用」(仅开发用)。EMAIL_FROM 需用 Resend 已验证的域名。 RESEND_API_KEY= EMAIL_FROM=Thread Chat +# Resend webhook signing secret,用于 /api/webhooks/resend 验签与投递状态去重。 +RESEND_WEBHOOK_SECRET= # 可选:覆盖 Resend 端点(自建/测试)。 # RESEND_BASE_URL= diff --git a/.github/workflows/beta-access.yml b/.github/workflows/beta-access.yml new file mode 100644 index 00000000..7be3d177 --- /dev/null +++ b/.github/workflows/beta-access.yml @@ -0,0 +1,55 @@ +name: Beta access + +on: + pull_request: + paths: + - "app/api/admin/beta/**" + - "app/api/beta/**" + - "app/api/chat/**" + - "app/api/webhooks/resend/**" + - "constants/beta-access.ts" + - "lib/auth/**" + - "lib/beta/**" + - "lib/db/beta-access-schema.ts" + - "lib/email/**" + - "lib/thread-chat/**" + - "e2e/beta-access/**" + - ".github/workflows/beta-access.yml" + +jobs: + access: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: beta_access_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d beta_access_test" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/beta_access_test + DIRECT_URL: postgres://postgres:postgres@localhost:5432/beta_access_test + BETTER_AUTH_URL: http://localhost:4040 + BETA_INVITE_ENCRYPTION_KEY: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Enable pgvector + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "create extension if not exists vector" + - run: pnpm db:push + - run: pnpm test:beta-access:foundation + - run: pnpm typecheck diff --git a/app/admin/beta/page.tsx b/app/admin/beta/page.tsx new file mode 100644 index 00000000..bab177c7 --- /dev/null +++ b/app/admin/beta/page.tsx @@ -0,0 +1,74 @@ +import { asc, inArray } from "drizzle-orm" +import { BetaWaitlistActions } from "@/components/admin/beta-waitlist-actions" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { db } from "@/lib/db" +import { betaWaitlistEntries } from "@/lib/db/schema" + +export const dynamic = "force-dynamic" + +export default async function AdminBetaPage() { + const entries = await db + .select() + .from(betaWaitlistEntries) + .where(inArray(betaWaitlistEntries.status, ["pending", "approved"])) + .orderBy(asc(betaWaitlistEntries.createdAt)) + .limit(200) + + return ( + <> +
+

Private Beta 审核

+

+ 批准会签发新 token、加密写入邮件 outbox,并在事务提交后发送。重发会先撤销旧邀请。 +

+
+ + + 待处理与已批准申请 + + + + + + 邮箱 + 语言 + 状态 + 申请时间 + 操作 + + + + {entries.map((entry) => ( + + {entry.emailNormalized} + {entry.locale} + {entry.status} + {entry.createdAt.toISOString()} + + + + + ))} + {entries.length === 0 && ( + + + 暂无待处理申请 + + + )} + +
+
+
+ + ) +} diff --git a/app/api/admin/beta/waitlist/[id]/approve/route.ts b/app/api/admin/beta/waitlist/[id]/approve/route.ts new file mode 100644 index 00000000..6ba8818a --- /dev/null +++ b/app/api/admin/beta/waitlist/[id]/approve/route.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto" +import { z } from "zod" +import { AdminAccessError, requireAdmin } from "@/lib/admin/auth" +import { assertSameOrigin, betaErrorResponse, BetaHttpError } from "@/lib/beta/http" +import { approveBetaWaitlist, deliverBetaInvite } from "@/lib/beta/invites" +import { isEmailConfigured } from "@/lib/email/client" + +const inputSchema = z.object({ reason: z.string().trim().min(1).max(500) }).strict() + +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + assertSameOrigin(request) + const actor = await requireAdmin() + if (!isEmailConfigured()) + throw new BetaHttpError("EMAIL_NOT_CONFIGURED", "邮件服务未配置", 503) + const { reason } = inputSchema.parse(await request.json()) + const { id } = await context.params + const result = await approveBetaWaitlist({ + waitlistId: z.uuid().parse(id), + actorId: actor.id, + reason, + requestId: request.headers.get("x-request-id") ?? randomUUID(), + }) + await deliverBetaInvite(result.outboxId) + return Response.json({ ok: true, inviteId: result.inviteId }) + } catch (error) { + if (error instanceof AdminAccessError) + return betaErrorResponse( + new BetaHttpError("ADMIN_ACCESS_REQUIRED", error.message, error.status) + ) + if (error instanceof z.ZodError) + return betaErrorResponse( + new BetaHttpError("VALIDATION_ERROR", "请求参数不合法", 400) + ) + return betaErrorResponse(error) + } +} diff --git a/app/api/beta/invites/redeem/route.ts b/app/api/beta/invites/redeem/route.ts new file mode 100644 index 00000000..2289974c --- /dev/null +++ b/app/api/beta/invites/redeem/route.ts @@ -0,0 +1,31 @@ +import { randomUUID } from "node:crypto" +import { z } from "zod" +import { getSession } from "@/lib/auth/server" +import { assertSameOrigin, betaErrorResponse, BetaHttpError } from "@/lib/beta/http" +import { redeemBetaInvite } from "@/lib/beta/invites" + +const inputSchema = z.object({ token: z.string().trim().min(40).max(128) }).strict() + +export async function POST(request: Request) { + try { + assertSameOrigin(request) + const session = await getSession(request.headers) + if (!session) throw new BetaHttpError("UNAUTHORIZED", "请先登录", 401) + const input = inputSchema.parse(await request.json()) + const result = await redeemBetaInvite({ + token: input.token, + userId: session.user.id, + email: session.user.email, + emailVerified: session.user.emailVerified, + requestId: request.headers.get("x-request-id") ?? randomUUID(), + }) + return Response.json({ ok: true, ...result }) + } catch (error) { + if (error instanceof z.ZodError) + return Response.json( + { ok: false, error: { code: "VALIDATION_ERROR", message: "请求参数不合法" } }, + { status: 400 } + ) + return betaErrorResponse(error) + } +} diff --git a/app/api/beta/waitlist/route.ts b/app/api/beta/waitlist/route.ts new file mode 100644 index 00000000..1e809894 --- /dev/null +++ b/app/api/beta/waitlist/route.ts @@ -0,0 +1,25 @@ +import { z } from "zod" +import { betaErrorResponse } from "@/lib/beta/http" +import { joinBetaWaitlist } from "@/lib/beta/waitlist" + +const inputSchema = z + .object({ + email: z.email().max(320), + locale: z.enum(["zh-CN", "en"]), + }) + .strict() + +export async function POST(request: Request) { + try { + const input = inputSchema.parse(await request.json()) + await joinBetaWaitlist(input) + return Response.json({ ok: true, accepted: true }, { status: 202 }) + } catch (error) { + if (error instanceof z.ZodError) + return Response.json( + { ok: false, error: { code: "VALIDATION_ERROR", message: "请求参数不合法" } }, + { status: 400 } + ) + return betaErrorResponse(error) + } +} diff --git a/app/api/chat/request-context.ts b/app/api/chat/request-context.ts index b15c771f..4155e6eb 100644 --- a/app/api/chat/request-context.ts +++ b/app/api/chat/request-context.ts @@ -10,6 +10,7 @@ import { } from "@/constants/model" import { isModelConfigured } from "@/lib/ai/llm/model-routes" import { hasPositiveBalance } from "@/lib/billing/credits" +import { decideBetaAccess } from "@/lib/beta/entitlements" type ChatRequestBody = { messages: UIMessage[] @@ -41,6 +42,7 @@ type ChatRequestContextDependencies = { modelConfigured: typeof isModelConfigured unbilledPreview: typeof isUnbilledPreviewModel positiveBalance: typeof hasPositiveBalance + accessDecision?: typeof decideBetaAccess } const defaultDependencies: ChatRequestContextDependencies = { @@ -50,6 +52,7 @@ const defaultDependencies: ChatRequestContextDependencies = { modelConfigured: isModelConfigured, unbilledPreview: isUnbilledPreviewModel, positiveBalance: hasPositiveBalance, + accessDecision: decideBetaAccess, } /** 鉴权、解析并完成模型/余额门禁,返回可直接进入生成编排的请求上下文。 */ @@ -68,6 +71,25 @@ export async function prepareChatRequestContext( } } + const accessDecision = dependencies.accessDecision + ? await dependencies.accessDecision(userId) + : { allowed: true as const, userId, modelId: null } + if (!accessDecision.allowed) { + return { + kind: "response" as const, + response: Response.json( + { + error: + accessDecision.code === "ACCOUNT_SUSPENDED" + ? "账号已暂停。" + : "Beta 权限尚未激活。", + code: accessDecision.code, + }, + { status: 403 } + ), + } + } + let input: unknown try { input = await req.json() @@ -114,6 +136,26 @@ export async function prepareChatRequestContext( const modelId = typeof rawModelId === "string" ? rawModelId : DEFAULT_MODEL_ID const model = dependencies.getModel(modelId)! + const modelDecision = dependencies.accessDecision + ? await dependencies.accessDecision(userId, modelId) + : { allowed: true as const, userId, modelId } + if (!modelDecision.allowed) { + return { + kind: "response" as const, + response: Response.json( + { + error: + modelDecision.code === "MODEL_NOT_ALLOWED" + ? "当前权益不可使用该模型。" + : modelDecision.code === "ACCOUNT_SUSPENDED" + ? "账号已暂停。" + : "Beta 权限尚未激活。", + code: modelDecision.code, + }, + { status: 403 } + ), + } + } if (!dependencies.linearModelAllowed(modelId)) { return { kind: "response" as const, diff --git a/app/api/webhooks/resend/route.ts b/app/api/webhooks/resend/route.ts new file mode 100644 index 00000000..4d4a2241 --- /dev/null +++ b/app/api/webhooks/resend/route.ts @@ -0,0 +1,22 @@ +import { BetaHttpError, betaErrorResponse } from "@/lib/beta/http" +import { recordBetaEmailEvent } from "@/lib/beta/email-events" +import { verifyEmailWebhook } from "@/lib/email/client" + +export async function POST(request: Request) { + try { + const eventId = request.headers.get("svix-id") + if (!eventId) + throw new BetaHttpError("WEBHOOK_ID_REQUIRED", "缺少事件 ID", 400) + const payload = await request.text() + let event + try { + event = verifyEmailWebhook(payload, request.headers) + } catch { + throw new BetaHttpError("WEBHOOK_SIGNATURE_INVALID", "签名无效", 401) + } + const result = await recordBetaEmailEvent({ eventId, event }) + return Response.json({ ok: true, ...result }) + } catch (error) { + return betaErrorResponse(error) + } +} diff --git a/app/beta/invite/page.tsx b/app/beta/invite/page.tsx new file mode 100644 index 00000000..342b9457 --- /dev/null +++ b/app/beta/invite/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next" +import Link from "next/link" +import { InviteActivation } from "@/components/beta/invite-activation" +import { signInWithRedirect } from "@/constants/routes" +import { getSession } from "@/lib/auth/server" + +export const metadata: Metadata = { + title: "激活 ThreadChat 私测邀请", + robots: { index: false, follow: false }, + referrer: "no-referrer", +} + +export default async function BetaInvitePage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }> +}) { + const token = (await searchParams).token?.trim() + const session = await getSession() + const returnPath = token + ? `/beta/invite?token=${encodeURIComponent(token)}` + : "/beta/invite" + + return ( +
+
+

ThreadChat Private Beta

+

激活私测资格

+ {!token ? ( +

邀请链接不完整或已损坏,请使用邮件中的完整链接。

+ ) : !session ? ( +
+

请先使用收到邀请的邮箱登录。登录不会自动消耗邀请。

+ + 前往登录 + +
+ ) : !session.user.emailVerified ? ( +

请先完成邮箱验证,再返回此页面激活。

+ ) : ( + <> +

+ 当前登录邮箱:{session.user.email}。只有与邀请一致的已验证邮箱才能激活。 +

+ + + )} +
+
+ ) +} diff --git a/app/beta/page.tsx b/app/beta/page.tsx new file mode 100644 index 00000000..64aa7d26 --- /dev/null +++ b/app/beta/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next" +import Link from "next/link" +import { WaitlistForm } from "@/components/beta/waitlist-form" +import { ROUTES } from "@/constants/routes" + +export const metadata: Metadata = { + title: "ThreadChat 私测申请", + description: "申请 ThreadChat Private Beta 访问资格。", +} + +export default function BetaWaitlistPage() { + return ( +
+
+

ThreadChat Private Beta

+

加入私测候选名单

+

+ 我们会分批审核申请。获批后,你需要用受邀邮箱登录并主动激活;打开邮件链接本身不会消耗邀请。 +

+ +

+ 已有邀请? 登录账号 +

+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 748593fe..48622859 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,21 @@ import type { Metadata } from "next" import Landing from "@/components/landing/landing" +import BetaWaitlistPage from "@/app/beta/page" +import { BETA_ACCESS_ENFORCED } from "@/constants/beta-access" import "@/components/landing/landing.css" -export const metadata: Metadata = { - title: "ThreadChat · 一款能开分叉的 AI", - description: "做调研、写方案、学知识:带着背景开启分支,分栏并排阅读,分支树找回讨论,再把有用的结论带回主线。", -} +export const metadata: Metadata = BETA_ACCESS_ENFORCED + ? { + title: "ThreadChat 私测申请", + description: "申请 ThreadChat Private Beta 访问资格。", + } + : { + title: "ThreadChat · 一款能开分叉的 AI", + description: + "做调研、写方案、学知识:带着背景开启分支,分栏并排阅读,分支树找回讨论,再把有用的结论带回主线。", + } export default function LandingPage() { + if (BETA_ACCESS_ENFORCED) return return } diff --git a/app/start-chat/page.tsx b/app/start-chat/page.tsx index 01a77b0b..1fefcfa2 100644 --- a/app/start-chat/page.tsx +++ b/app/start-chat/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation" import { ROUTES, signInWithRedirect, threadTreeRoute } from "@/constants/routes" import { getSession } from "@/lib/auth/server" +import { decideBetaAccess } from "@/lib/beta/entitlements" // A fresh tree ID must be generated for every request, never at build time. export const dynamic = "force-dynamic" @@ -22,5 +23,7 @@ export default async function StartChatPage(): Promise { if (!session) { redirect(signInWithRedirect(ROUTES.startChat)) } + const access = await decideBetaAccess(session.user.id) + if (!access.allowed) redirect(ROUTES.beta) redirect(threadTreeRoute(randomUUID())) } diff --git a/app/thread-chat/layout.tsx b/app/thread-chat/layout.tsx index 1a60a23f..a7eff07e 100644 --- a/app/thread-chat/layout.tsx +++ b/app/thread-chat/layout.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation" import { getSession } from "@/lib/auth/server" import { ROUTES, signInWithRedirect } from "@/constants/routes" +import { BetaAccessNotice } from "@/components/beta/beta-access-notice" +import { decideBetaAccess } from "@/lib/beta/entitlements" import { ProjectListStoreProvider } from "./core/project-list-store" import "./thread-chat.css" @@ -16,5 +18,8 @@ export default async function ThreadChatLayout({ }) { const session = await getSession() if (!session) redirect(signInWithRedirect(ROUTES.flagship)) + const access = await decideBetaAccess(session.user.id) + if (!access.allowed) + return return {children} } diff --git a/components/admin/beta-waitlist-actions.tsx b/components/admin/beta-waitlist-actions.tsx new file mode 100644 index 00000000..c2dc6d19 --- /dev/null +++ b/components/admin/beta-waitlist-actions.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export function BetaWaitlistActions({ + entryId, + approved, +}: { + entryId: string + approved: boolean +}) { + const router = useRouter() + const [reason, setReason] = useState("") + const [state, setState] = useState<"idle" | "sending" | "error">("idle") + + async function approve() { + if (!reason.trim()) return + const verb = approved ? "撤销旧邀请并发送新邀请" : "批准并发送邀请" + if (!window.confirm(`确认${verb}?此操作会写入审计记录。`)) return + setState("sending") + try { + const response = await fetch(`/api/admin/beta/waitlist/${entryId}/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason }), + }) + if (!response.ok) { + setState("error") + return + } + setReason("") + setState("idle") + router.refresh() + } catch { + setState("error") + } + } + + return ( +
+ setReason(event.target.value)} + /> + + {state === "error" ? ( + 操作失败 + ) : null} +
+ ) +} diff --git a/components/beta/beta-access-notice.tsx b/components/beta/beta-access-notice.tsx new file mode 100644 index 00000000..635f396b --- /dev/null +++ b/components/beta/beta-access-notice.tsx @@ -0,0 +1,26 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { ROUTES } from "@/constants/routes" + +export function BetaAccessNotice({ suspended }: { suspended: boolean }) { + return ( +
+
+

ThreadChat Private Beta

+

+ {suspended ? "账号访问已暂停" : "私测资格尚未激活"} +

+

+ {suspended + ? "该账号暂时不能发起新任务。如需复核,请联系支持人员。" + : "你仍可登录和管理账号;收到邀请后,请使用同一已验证邮箱完成激活。"} +

+ {!suspended ? ( + + ) : null} +
+
+ ) +} diff --git a/components/beta/invite-activation.tsx b/components/beta/invite-activation.tsx new file mode 100644 index 00000000..7a974837 --- /dev/null +++ b/components/beta/invite-activation.tsx @@ -0,0 +1,46 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" + +export function InviteActivation({ token }: { token: string }) { + const router = useRouter() + const [state, setState] = useState<"idle" | "sending" | "error">("idle") + const [message, setMessage] = useState("") + + async function activate() { + setState("sending") + try { + const response = await fetch("/api/beta/invites/redeem", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }) + const body = (await response.json()) as { + error?: { message?: string } + } + if (!response.ok) { + setMessage(body.error?.message ?? "邀请激活失败") + setState("error") + return + } + router.replace("/thread-chat") + router.refresh() + } catch { + setMessage("网络异常,请稍后重试") + setState("error") + } + } + + return ( +
+ +

+ {state === "error" ? message : ""} +

+
+ ) +} diff --git a/components/beta/waitlist-form.tsx b/components/beta/waitlist-form.tsx new file mode 100644 index 00000000..2172e1fb --- /dev/null +++ b/components/beta/waitlist-form.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +export function WaitlistForm() { + const [email, setEmail] = useState("") + const [state, setState] = useState<"idle" | "sending" | "accepted" | "error">("idle") + + async function submit(event: React.FormEvent) { + event.preventDefault() + setState("sending") + try { + const response = await fetch("/api/beta/waitlist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, locale: "zh-CN" }), + }) + setState(response.ok ? "accepted" : "error") + if (response.ok) setEmail("") + } catch { + setState("error") + } + } + + return ( +
+ +
+ setEmail(event.target.value)} + placeholder="you@example.com" + className="h-10" + /> + +
+

+ {state === "accepted" && "申请已收到;若获批,我们会向该邮箱发送邀请。"} + {state === "error" && "暂时无法提交,请稍后重试。"} + {state === "idle" && "我们不会通过响应透露该邮箱是否已经申请。"} +

+
+ ) +} diff --git a/constants/admin.ts b/constants/admin.ts index 6731469c..fe8a842a 100644 --- a/constants/admin.ts +++ b/constants/admin.ts @@ -1,5 +1,6 @@ /** 后台导航;添加功能时增加独立页面与对应导航项。 */ -export const ADMIN_ROUTES = { root: "/admin" } as const +export const ADMIN_ROUTES = { root: "/admin", beta: "/admin/beta" } as const export const ADMIN_NAVIGATION = [ { title: "后台样板页", href: ADMIN_ROUTES.root }, + { title: "Private Beta", href: ADMIN_ROUTES.beta }, ] as const diff --git a/constants/beta-access.ts b/constants/beta-access.ts new file mode 100644 index 00000000..b12c0299 --- /dev/null +++ b/constants/beta-access.ts @@ -0,0 +1,17 @@ +// Private Beta 的服务端准入策略与安全边界。 + +export const BETA_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1_000 +export const BETA_INVITE_TOKEN_BYTES = 32 +export const BETA_INVITE_AAD = "threadchat-beta-invite-v1" +export const BETA_EMAIL_MAX_ATTEMPTS = 5 + +export const BETA_ACCESS_ENFORCED = + process.env.BETA_ACCESS_ENFORCED === "true" + +/** 只接受经过价格与渠道验收的精确 modelId;空列表时 Beta 不开放付费模型。 */ +export const BETA_MODEL_IDS = new Set( + (process.env.BETA_MODEL_IDS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) +) diff --git a/constants/routes.ts b/constants/routes.ts index cfaf9855..c829a351 100644 --- a/constants/routes.ts +++ b/constants/routes.ts @@ -8,6 +8,7 @@ export const ROUTES = { flagship: "/thread-chat", // 旗舰跳板(裸路径 → /thread-chat/{uuid}) signIn: "/sign-in", account: "/account", + beta: "/beta", } as const export type RouteKey = keyof typeof ROUTES diff --git a/e2e/beta-access/beta-access-foundation-db.test.mjs b/e2e/beta-access/beta-access-foundation-db.test.mjs new file mode 100644 index 00000000..01c8190a --- /dev/null +++ b/e2e/beta-access/beta-access-foundation-db.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict" + +assert.ok(process.env.DATABASE_URL, "测试需要隔离的 DATABASE_URL") +assert.ok( + process.env.BETA_INVITE_ENCRYPTION_KEY, + "测试需要 BETA_INVITE_ENCRYPTION_KEY" +) +process.env.BETTER_AUTH_URL ??= "http://localhost:4040" + +const [{ eq, inArray }, { db }, schema, waitlist, invites, emailEvents, cryptoModule, pricing] = + await Promise.all([ + import("drizzle-orm"), + import("../../lib/db/index.ts"), + import("../../lib/db/schema.ts"), + import("../../lib/beta/waitlist.ts"), + import("../../lib/beta/invites.ts"), + import("../../lib/beta/email-events.ts"), + import("../../lib/beta/crypto.ts"), + import("../../constants/pricing.ts"), + ]) + +const prefix = `beta-${crypto.randomUUID()}` +const adminId = `${prefix}-admin` +const userId = `${prefix}-user` +const invitedEmail = `${prefix}@example.test` + +try { + await db.insert(schema.user).values([ + { + id: adminId, + name: "Beta Admin", + email: `${prefix}-admin@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: userId, + name: "Beta User", + email: invitedEmail, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await Promise.all([ + waitlist.joinBetaWaitlist({ email: ` ${invitedEmail.toUpperCase()} `, locale: "en" }), + waitlist.joinBetaWaitlist({ email: invitedEmail, locale: "zh-CN" }), + ]) + const entries = await db + .select() + .from(schema.betaWaitlistEntries) + .where(eq(schema.betaWaitlistEntries.emailNormalized, invitedEmail)) + assert.equal(entries.length, 1, "重复申请必须归一化并去重") + + const approved = await invites.approveBetaWaitlist({ + waitlistId: entries[0].id, + actorId: adminId, + reason: "foundation test", + requestId: crypto.randomUUID(), + }) + const [outbox] = await db + .select() + .from(schema.betaEmailOutbox) + .where(eq(schema.betaEmailOutbox.id, approved.outboxId)) + assert.ok(outbox.encryptedPayload) + assert.equal(outbox.encryptedPayload.includes(invitedEmail), false) + const payload = cryptoModule.decryptInvitePayload(outbox.encryptedPayload) + assert.equal(payload.email, invitedEmail) + assert.equal(payload.token.length >= 40, true) + + const [storedInvite] = await db + .select() + .from(schema.betaInvites) + .where(eq(schema.betaInvites.id, approved.inviteId)) + assert.notEqual(storedInvite.tokenHash, payload.token) + assert.equal(storedInvite.tokenHash, cryptoModule.hashInviteToken(payload.token)) + + const providerMessageId = `email-${crypto.randomUUID()}` + await db + .update(schema.betaEmailOutbox) + .set({ providerMessageId, status: "sent" }) + .where(eq(schema.betaEmailOutbox.id, approved.outboxId)) + const deliveredEvent = { + type: "email.delivered", + created_at: new Date().toISOString(), + data: { + created_at: new Date().toISOString(), + email_id: providerMessageId, + from: "noreply@example.test", + to: [invitedEmail], + subject: "Beta invite", + }, + } + const webhookEventId = crypto.randomUUID() + assert.deepEqual( + await emailEvents.recordBetaEmailEvent({ + eventId: webhookEventId, + event: deliveredEvent, + }), + { accepted: true, replayed: false } + ) + assert.deepEqual( + await emailEvents.recordBetaEmailEvent({ + eventId: webhookEventId, + event: deliveredEvent, + }), + { accepted: true, replayed: true } + ) + await emailEvents.recordBetaEmailEvent({ + eventId: crypto.randomUUID(), + event: { ...deliveredEvent, type: "email.sent" }, + }) + const [delivery] = await db + .select({ status: schema.betaEmailOutbox.status }) + .from(schema.betaEmailOutbox) + .where(eq(schema.betaEmailOutbox.id, approved.outboxId)) + assert.equal(delivery.status, "delivered", "乱序 sent 事件不能回退 delivered") + + await assert.rejects( + invites.redeemBetaInvite({ + token: payload.token, + userId, + email: `other-${invitedEmail}`, + emailVerified: true, + requestId: crypto.randomUUID(), + }), + (error) => error?.code === "INVITE_EMAIL_MISMATCH" + ) + + const raced = await Promise.all([ + invites.redeemBetaInvite({ + token: payload.token, + userId, + email: invitedEmail, + emailVerified: true, + requestId: crypto.randomUUID(), + }), + invites.redeemBetaInvite({ + token: payload.token, + userId, + email: invitedEmail, + emailVerified: true, + requestId: crypto.randomUUID(), + }), + ]) + assert.equal(raced.filter((result) => result.replayed === false).length, 1) + assert.equal(raced.filter((result) => result.replayed === true).length, 1) + + const [entitlement] = await db + .select() + .from(schema.userEntitlements) + .where(eq(schema.userEntitlements.userId, userId)) + assert.equal(entitlement.plan, "beta") + assert.equal(entitlement.accountStatus, "active") + + const [credits] = await db + .select() + .from(schema.userCredits) + .where(eq(schema.userCredits.userId, userId)) + assert.equal(credits.balanceMicros, pricing.INITIAL_CREDIT_MICROS) + const ledger = await db + .select() + .from(schema.creditLedger) + .where(eq(schema.creditLedger.userId, userId)) + assert.equal( + ledger.filter((entry) => entry.idempotencyKey === `beta-welcome-v1:${userId}`).length, + 1, + "并发核销只能发放一次欢迎额度" + ) + + console.log("PASS beta invite hashing, email matching, atomic redemption, and grant idempotency") +} finally { + await db + .delete(schema.adminAuditLogs) + .where(inArray(schema.adminAuditLogs.actorId, [userId, adminId])) + await db + .delete(schema.betaWaitlistEntries) + .where(eq(schema.betaWaitlistEntries.emailNormalized, invitedEmail)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + await db.delete(schema.user).where(eq(schema.user.id, adminId)) + await globalThis.__dbClient?.end({ timeout: 5 }) +} diff --git a/e2e/thread-chat/chat-request-context.test.mjs b/e2e/thread-chat/chat-request-context.test.mjs index df143d5e..691aec32 100644 --- a/e2e/thread-chat/chat-request-context.test.mjs +++ b/e2e/thread-chat/chat-request-context.test.mjs @@ -24,6 +24,11 @@ function dependencies(overrides = {}) { modelConfigured: () => true, unbilledPreview: () => false, positiveBalance: async () => true, + accessDecision: async (userId, modelId) => ({ + allowed: true, + userId, + modelId: modelId ?? null, + }), ...overrides, } } @@ -42,6 +47,36 @@ assert.equal(unauthorized.kind, "response") assert.equal(unauthorized.response.status, 401) assert.equal(parsedWithoutAuth, false) +const accessDenied = await prepareChatRequestContext( + request({ messages, modelId: "known" }), + dependencies({ + accessDecision: async () => ({ + allowed: false, + code: "BETA_ACCESS_REQUIRED", + }), + }) +) +assert.equal(accessDenied.kind, "response") +assert.equal(accessDenied.response.status, 403) +assert.equal((await accessDenied.response.json()).code, "BETA_ACCESS_REQUIRED") + +let modelAccessChecks = 0 +const modelAccessDenied = await prepareChatRequestContext( + request({ messages, modelId: "known" }), + dependencies({ + accessDecision: async (_userId, modelId) => { + modelAccessChecks++ + return modelId + ? { allowed: false, code: "MODEL_NOT_ALLOWED" } + : { allowed: true, userId: "user-1", modelId: null } + }, + }) +) +assert.equal(modelAccessDenied.kind, "response") +assert.equal(modelAccessDenied.response.status, 403) +assert.equal((await modelAccessDenied.response.json()).code, "MODEL_NOT_ALLOWED") +assert.equal(modelAccessChecks, 2) + const malformed = await prepareChatRequestContext( new Request("http://localhost/api/chat", { method: "POST", diff --git a/lib/auth/index.ts b/lib/auth/index.ts index 40ee997a..5dfaee7c 100644 --- a/lib/auth/index.ts +++ b/lib/auth/index.ts @@ -8,6 +8,7 @@ import { grantWelcomeCreditsOnce } from "@/lib/billing/ledger" import { isEmailConfigured, sendEmail } from "@/lib/email/client" import { verificationEmail, resetPasswordEmail } from "@/lib/email/templates" import { getGoogleAuthConfig } from "@/lib/auth/social" +import { BETA_ACCESS_ENFORCED } from "@/constants/beta-access" // 邮箱验证是否可用:需已配置邮件服务。未配置时(如本地开发)优雅降级为「注册即用」, // 避免用户因收不到验证邮件而被锁死。 @@ -63,7 +64,8 @@ export const auth = betterAuth({ }, // 关键防薅:初始额度改到「邮箱验证通过后」才发放,抬高白嫖门槛。 afterEmailVerification: async (verifiedUser) => { - await grantWelcomeCreditsOnce(verifiedUser.id) + if (!BETA_ACCESS_ENFORCED) + await grantWelcomeCreditsOnce(verifiedUser.id) }, }, databaseHooks: { @@ -75,7 +77,10 @@ export const auth = betterAuth({ // 未启用邮箱验证则「注册即赠额」。 // - 社交登录(Google):邮箱已由提供方验证(创建时 emailVerified=true),不会走 // afterEmailVerification,故在此按已验证发放。账本幂等键保证双路径不重复发。 - if (!emailReady || createdUser.emailVerified) { + if ( + !BETA_ACCESS_ENFORCED && + (!emailReady || createdUser.emailVerified) + ) { await grantWelcomeCreditsOnce(createdUser.id) } }, diff --git a/lib/beta/crypto.ts b/lib/beta/crypto.ts new file mode 100644 index 00000000..90864c87 --- /dev/null +++ b/lib/beta/crypto.ts @@ -0,0 +1,60 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto" +import { BETA_INVITE_AAD, BETA_INVITE_TOKEN_BYTES } from "@/constants/beta-access" + +const VERSION = "v1" + +function encryptionKey(): Buffer { + const encoded = process.env.BETA_INVITE_ENCRYPTION_KEY?.trim() + if (!encoded) throw new Error("BETA_INVITE_ENCRYPTION_KEY_NOT_CONFIGURED") + const key = Buffer.from(encoded, "base64") + if (key.length !== 32) throw new Error("BETA_INVITE_ENCRYPTION_KEY_INVALID") + return key +} + +export function createInviteToken(): string { + return randomBytes(BETA_INVITE_TOKEN_BYTES).toString("base64url") +} + +export function hashInviteToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex") +} + +export function encryptInvitePayload(payload: unknown): string { + const iv = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv) + cipher.setAAD(Buffer.from(BETA_INVITE_AAD, "utf8")) + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify(payload), "utf8"), + cipher.final(), + ]) + return [ + VERSION, + iv.toString("base64url"), + cipher.getAuthTag().toString("base64url"), + ciphertext.toString("base64url"), + ].join(".") +} + +export function decryptInvitePayload(value: string): T { + const [version, ivValue, tagValue, ciphertextValue, extra] = value.split(".") + if ( + version !== VERSION || + !ivValue || + !tagValue || + !ciphertextValue || + extra !== undefined + ) + throw new Error("BETA_INVITE_PAYLOAD_INVALID") + const decipher = createDecipheriv( + "aes-256-gcm", + encryptionKey(), + Buffer.from(ivValue, "base64url") + ) + decipher.setAAD(Buffer.from(BETA_INVITE_AAD, "utf8")) + decipher.setAuthTag(Buffer.from(tagValue, "base64url")) + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertextValue, "base64url")), + decipher.final(), + ]) + return JSON.parse(plaintext.toString("utf8")) as T +} diff --git a/lib/beta/email-events.ts b/lib/beta/email-events.ts new file mode 100644 index 00000000..2961083c --- /dev/null +++ b/lib/beta/email-events.ts @@ -0,0 +1,70 @@ +import { and, eq, inArray } from "drizzle-orm" +import type { WebhookEventPayload } from "resend" +import { db } from "@/lib/db" +import { betaEmailEvents, betaEmailOutbox } from "@/lib/db/schema" + +export async function recordBetaEmailEvent(input: { + eventId: string + event: WebhookEventPayload +}): Promise<{ accepted: boolean; replayed: boolean }> { + const event = input.event + if (!("email_id" in event.data)) + return { accepted: false, replayed: false } + const providerMessageId = event.data.email_id + + return db.transaction(async (tx) => { + const [delivery] = await tx + .select({ id: betaEmailOutbox.id }) + .from(betaEmailOutbox) + .where(eq(betaEmailOutbox.providerMessageId, providerMessageId)) + if (!delivery) return { accepted: false, replayed: false } + + const [inserted] = await tx + .insert(betaEmailEvents) + .values({ + eventId: input.eventId, + providerMessageId, + type: event.type, + payload: event as unknown as Record, + }) + .onConflictDoNothing({ target: betaEmailEvents.eventId }) + .returning({ eventId: betaEmailEvents.eventId }) + if (!inserted) return { accepted: true, replayed: true } + + if (event.type === "email.delivered") { + await tx + .update(betaEmailOutbox) + .set({ status: "delivered", updatedAt: new Date() }) + .where(eq(betaEmailOutbox.providerMessageId, providerMessageId)) + } else if (event.type === "email.bounced") { + await tx + .update(betaEmailOutbox) + .set({ status: "bounced", nextAttemptAt: null, updatedAt: new Date() }) + .where(eq(betaEmailOutbox.providerMessageId, providerMessageId)) + } else if (event.type === "email.complained") { + await tx + .update(betaEmailOutbox) + .set({ status: "complained", nextAttemptAt: null, updatedAt: new Date() }) + .where(eq(betaEmailOutbox.providerMessageId, providerMessageId)) + } else if ( + event.type === "email.failed" || + event.type === "email.suppressed" + ) { + await tx + .update(betaEmailOutbox) + .set({ status: "failed", nextAttemptAt: null, updatedAt: new Date() }) + .where(eq(betaEmailOutbox.providerMessageId, providerMessageId)) + } else if (event.type === "email.sent") { + await tx + .update(betaEmailOutbox) + .set({ status: "sent", updatedAt: new Date() }) + .where( + and( + inArray(betaEmailOutbox.status, ["queued", "sending", "sent"]), + eq(betaEmailOutbox.providerMessageId, providerMessageId) + ) + ) + } + return { accepted: true, replayed: false } + }) +} diff --git a/lib/beta/entitlements.ts b/lib/beta/entitlements.ts new file mode 100644 index 00000000..ff5a35dc --- /dev/null +++ b/lib/beta/entitlements.ts @@ -0,0 +1,62 @@ +import { eq } from "drizzle-orm" +import { BETA_ACCESS_ENFORCED, BETA_MODEL_IDS } from "@/constants/beta-access" +import { db } from "@/lib/db" +import { userEntitlements } from "@/lib/db/schema" + +export type EntitlementDenialCode = + | "BETA_ACCESS_REQUIRED" + | "ACCOUNT_SUSPENDED" + | "MODEL_NOT_ALLOWED" + +export type EntitlementDecision = + | { allowed: true; userId: string; modelId: string | null } + | { allowed: false; code: EntitlementDenialCode } + +export async function decideBetaAccess( + userId: string, + modelId?: string +): Promise { + if (!BETA_ACCESS_ENFORCED) + return { allowed: true, userId, modelId: modelId ?? null } + + const [entitlement] = await db + .select({ + plan: userEntitlements.plan, + accountStatus: userEntitlements.accountStatus, + planExpiresAt: userEntitlements.planExpiresAt, + }) + .from(userEntitlements) + .where(eq(userEntitlements.userId, userId)) + + if (!entitlement) return { allowed: false, code: "BETA_ACCESS_REQUIRED" } + if (entitlement.accountStatus === "suspended") + return { allowed: false, code: "ACCOUNT_SUSPENDED" } + + const proActive = + entitlement.plan === "pro" && + (!entitlement.planExpiresAt || entitlement.planExpiresAt > new Date()) + if (modelId && !proActive && !BETA_MODEL_IDS.has(modelId)) + return { allowed: false, code: "MODEL_NOT_ALLOWED" } + return { allowed: true, userId, modelId: modelId ?? null } +} + +export async function requireBetaAccess( + userId: string, + modelId?: string +): Promise { + const decision = await decideBetaAccess(userId, modelId) + if (!decision.allowed) throw new BetaAccessError(decision.code) +} + +export class BetaAccessError extends Error { + constructor(readonly code: EntitlementDenialCode) { + super( + code === "ACCOUNT_SUSPENDED" + ? "账号已暂停" + : code === "MODEL_NOT_ALLOWED" + ? "当前权益不可使用该模型" + : "Beta 权限尚未激活" + ) + this.name = "BetaAccessError" + } +} diff --git a/lib/beta/http.ts b/lib/beta/http.ts new file mode 100644 index 00000000..5eb73251 --- /dev/null +++ b/lib/beta/http.ts @@ -0,0 +1,29 @@ +export class BetaHttpError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: number + ) { + super(message) + this.name = "BetaHttpError" + } +} + +export function assertSameOrigin(request: Request): void { + const origin = request.headers.get("origin") + const expected = process.env.BETTER_AUTH_URL + if (!origin || !expected || origin !== new URL(expected).origin) + throw new BetaHttpError("INVALID_ORIGIN", "请求来源不合法", 403) +} + +export function betaErrorResponse(error: unknown): Response { + if (error instanceof BetaHttpError) + return Response.json( + { ok: false, error: { code: error.code, message: error.message } }, + { status: error.status } + ) + return Response.json( + { ok: false, error: { code: "INTERNAL_ERROR", message: "服务暂时不可用" } }, + { status: 500 } + ) +} diff --git a/lib/beta/invites.ts b/lib/beta/invites.ts new file mode 100644 index 00000000..039b75c1 --- /dev/null +++ b/lib/beta/invites.ts @@ -0,0 +1,297 @@ +import { randomUUID } from "node:crypto" +import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm" +import { + BETA_EMAIL_MAX_ATTEMPTS, + BETA_INVITE_TTL_MS, +} from "@/constants/beta-access" +import { grantWelcomeCreditsInTransaction } from "@/lib/billing/ledger" +import { + createInviteToken, + decryptInvitePayload, + encryptInvitePayload, + hashInviteToken, +} from "@/lib/beta/crypto" +import { BetaHttpError } from "@/lib/beta/http" +import { normalizeWaitlistEmail, type BetaLocale } from "@/lib/beta/waitlist" +import { db } from "@/lib/db" +import { + adminAuditLogs, + betaEmailOutbox, + betaInvites, + betaWaitlistEntries, + user, + userEntitlements, +} from "@/lib/db/schema" +import { sendEmail } from "@/lib/email/client" +import { betaInviteEmail } from "@/lib/email/templates" + +type InvitePayload = { + email: string + locale: BetaLocale + token: string + expiresAt: string +} + +function inviteUrl(token: string): string { + const baseUrl = process.env.BETTER_AUTH_URL?.trim() + if (!baseUrl) throw new Error("BETTER_AUTH_URL_NOT_CONFIGURED") + const url = new URL("/beta/invite", baseUrl) + url.searchParams.set("token", token) + return url.toString() +} + +export async function approveBetaWaitlist(input: { + waitlistId: string + actorId: string + reason: string + requestId: string +}): Promise<{ inviteId: string; outboxId: string }> { + const reason = input.reason.trim() + if (!reason) throw new BetaHttpError("REASON_REQUIRED", "请填写审核原因", 400) + + const token = createInviteToken() + const tokenHash = hashInviteToken(token) + const inviteId = randomUUID() + const outboxId = randomUUID() + const expiresAt = new Date(Date.now() + BETA_INVITE_TTL_MS) + + return db.transaction(async (tx) => { + const [entry] = await tx + .select() + .from(betaWaitlistEntries) + .where(eq(betaWaitlistEntries.id, input.waitlistId)) + .for("update") + if (!entry) throw new BetaHttpError("WAITLIST_NOT_FOUND", "申请不存在", 404) + if (["registered", "rejected", "withdrawn"].includes(entry.status)) + throw new BetaHttpError("WAITLIST_STATE_CONFLICT", "当前状态不可批准", 409) + + const revoked = await tx + .update(betaInvites) + .set({ revokedAt: new Date() }) + .where( + and( + eq(betaInvites.waitlistId, entry.id), + isNull(betaInvites.usedAt), + isNull(betaInvites.revokedAt) + ) + ) + .returning({ id: betaInvites.id }) + if (revoked.length > 0) + await tx + .update(betaEmailOutbox) + .set({ + encryptedPayload: null, + status: "failed", + nextAttemptAt: null, + lastError: "InviteRotated", + updatedAt: new Date(), + }) + .where( + inArray( + betaEmailOutbox.inviteId, + revoked.map((item) => item.id) + ) + ) + + const encryptedPayload = encryptInvitePayload({ + email: entry.emailNormalized, + locale: entry.locale, + token, + expiresAt: expiresAt.toISOString(), + } satisfies InvitePayload) + await tx.insert(betaInvites).values({ + id: inviteId, + waitlistId: entry.id, + tokenHash, + expiresAt, + createdBy: input.actorId, + }) + await tx.insert(betaEmailOutbox).values({ + id: outboxId, + inviteId, + encryptedPayload, + }) + await tx + .update(betaWaitlistEntries) + .set({ status: "approved", approvedAt: new Date(), updatedAt: new Date() }) + .where(eq(betaWaitlistEntries.id, entry.id)) + await tx.insert(adminAuditLogs).values({ + id: randomUUID(), + actorId: input.actorId, + action: "beta.waitlist.approve", + targetType: "beta_waitlist", + targetId: entry.id, + reason, + requestId: input.requestId, + metadata: { inviteId }, + }) + return { inviteId, outboxId } + }) +} + +export async function deliverBetaInvite(outboxId: string): Promise { + const [claimed] = await db + .update(betaEmailOutbox) + .set({ + status: "sending", + attempts: sql`${betaEmailOutbox.attempts} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(betaEmailOutbox.id, outboxId), + inArray(betaEmailOutbox.status, ["queued", "failed"]), + lt(betaEmailOutbox.attempts, BETA_EMAIL_MAX_ATTEMPTS) + ) + ) + .returning({ + encryptedPayload: betaEmailOutbox.encryptedPayload, + attempts: betaEmailOutbox.attempts, + }) + if (!claimed?.encryptedPayload) + throw new BetaHttpError("OUTBOX_NOT_AVAILABLE", "邀请邮件不可发送", 409) + + try { + const payload = decryptInvitePayload(claimed.encryptedPayload) + const message = betaInviteEmail(inviteUrl(payload.token), payload.locale) + const providerMessageId = await sendEmail({ + to: payload.email, + subject: message.subject, + html: message.html, + idempotencyKey: `beta-invite:${outboxId}`, + }) + await db + .update(betaEmailOutbox) + .set({ + status: "sent", + providerMessageId, + encryptedPayload: null, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(betaEmailOutbox.id, outboxId)) + } catch (error) { + await db + .update(betaEmailOutbox) + .set({ + status: "failed", + lastError: error instanceof Error ? error.name : "UnknownError", + nextAttemptAt: + claimed.attempts < BETA_EMAIL_MAX_ATTEMPTS + ? new Date(Date.now() + 30_000 * 2 ** (claimed.attempts - 1)) + : null, + updatedAt: new Date(), + }) + .where(eq(betaEmailOutbox.id, outboxId)) + throw new BetaHttpError("EMAIL_DELIVERY_FAILED", "邀请已批准,但邮件发送失败", 503) + } +} + +export async function redeemBetaInvite(input: { + token: string + userId: string + email: string + emailVerified: boolean + requestId: string +}): Promise<{ activated: true; replayed: boolean }> { + if (!input.emailVerified) + throw new BetaHttpError("EMAIL_NOT_VERIFIED", "请先验证邮箱", 403) + const tokenHash = hashInviteToken(input.token) + + return db.transaction(async (tx) => { + const [invite] = await tx + .select() + .from(betaInvites) + .where(eq(betaInvites.tokenHash, tokenHash)) + .for("update") + if (!invite) + throw new BetaHttpError("INVITE_INVALID", "邀请无效或已过期", 400) + + const [entry] = await tx + .select() + .from(betaWaitlistEntries) + .where(eq(betaWaitlistEntries.id, invite.waitlistId)) + .for("update") + const [account] = await tx + .select({ id: user.id, email: user.email, emailVerified: user.emailVerified }) + .from(user) + .where(eq(user.id, input.userId)) + .for("update") + if (!entry || !account || !account.emailVerified) + throw new BetaHttpError("INVITE_INVALID", "邀请无效或已过期", 400) + if ( + normalizeWaitlistEmail(account.email) !== entry.emailNormalized || + normalizeWaitlistEmail(input.email) !== entry.emailNormalized + ) + throw new BetaHttpError("INVITE_EMAIL_MISMATCH", "请使用收到邀请的邮箱登录", 403) + + if (invite.usedAt) { + if (entry.registeredUserId === input.userId) + return { activated: true, replayed: true } + throw new BetaHttpError("INVITE_INVALID", "邀请无效或已过期", 400) + } + if (invite.revokedAt || invite.expiresAt <= new Date()) + throw new BetaHttpError("INVITE_INVALID", "邀请无效或已过期", 400) + + const [entitlement] = await tx + .select() + .from(userEntitlements) + .where(eq(userEntitlements.userId, input.userId)) + .for("update") + if (!entitlement) { + await tx.insert(userEntitlements).values({ + userId: input.userId, + plan: "beta", + accountStatus: "active", + grantSource: "beta-invite", + grantedBy: invite.createdBy, + }) + } else if (entitlement.accountStatus === "suspended") { + throw new BetaHttpError("ACCOUNT_SUSPENDED", "账号已暂停", 403) + } else if ( + entitlement.plan === "pro" && + entitlement.planExpiresAt && + entitlement.planExpiresAt <= new Date() + ) { + await tx + .update(userEntitlements) + .set({ + plan: "beta", + planExpiresAt: null, + grantSource: "beta-invite", + grantedBy: invite.createdBy, + updatedAt: new Date(), + }) + .where(eq(userEntitlements.userId, input.userId)) + } + + await grantWelcomeCreditsInTransaction(tx, input.userId) + await tx + .update(betaInvites) + .set({ usedAt: new Date() }) + .where(eq(betaInvites.id, invite.id)) + await tx + .update(betaEmailOutbox) + .set({ encryptedPayload: null, nextAttemptAt: null, updatedAt: new Date() }) + .where(eq(betaEmailOutbox.inviteId, invite.id)) + await tx + .update(betaWaitlistEntries) + .set({ + status: "registered", + registeredUserId: input.userId, + updatedAt: new Date(), + }) + .where(eq(betaWaitlistEntries.id, entry.id)) + await tx.insert(adminAuditLogs).values({ + id: randomUUID(), + actorId: input.userId, + action: "beta.invite.redeem", + targetType: "beta_invite", + targetId: invite.id, + reason: "invited-user-activation", + requestId: input.requestId, + metadata: { waitlistId: entry.id, userId: input.userId }, + }) + return { activated: true, replayed: false } + }) +} diff --git a/lib/beta/waitlist.ts b/lib/beta/waitlist.ts new file mode 100644 index 00000000..fa09043a --- /dev/null +++ b/lib/beta/waitlist.ts @@ -0,0 +1,29 @@ +import { randomUUID } from "node:crypto" +import { eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { betaWaitlistEntries } from "@/lib/db/schema" + +export type BetaLocale = "zh-CN" | "en" + +export function normalizeWaitlistEmail(email: string): string { + return email.trim().toLowerCase() +} + +export async function joinBetaWaitlist(input: { + email: string + locale: BetaLocale +}): Promise { + const emailNormalized = normalizeWaitlistEmail(input.email) + await db + .insert(betaWaitlistEntries) + .values({ + id: randomUUID(), + emailNormalized, + locale: input.locale, + }) + .onConflictDoUpdate({ + target: betaWaitlistEntries.emailNormalized, + set: { locale: input.locale, updatedAt: new Date() }, + setWhere: eq(betaWaitlistEntries.status, "pending"), + }) +} diff --git a/lib/billing/ledger.ts b/lib/billing/ledger.ts index c359c84c..188d3292 100644 --- a/lib/billing/ledger.ts +++ b/lib/billing/ledger.ts @@ -46,7 +46,7 @@ export async function appendLedgerEntryOnce( return true } -async function grantWelcomeCreditsInTransaction( +export async function grantWelcomeCreditsInTransaction( tx: BillingTransaction, userId: string ): Promise<{ granted: boolean; balanceMicros: number }> { @@ -99,4 +99,3 @@ async function grantWelcomeCreditsInTransaction( export function grantWelcomeCreditsOnce(userId: string) { return db.transaction((tx) => grantWelcomeCreditsInTransaction(tx, userId)) } - diff --git a/lib/db/beta-access-schema.ts b/lib/db/beta-access-schema.ts new file mode 100644 index 00000000..d662bf38 --- /dev/null +++ b/lib/db/beta-access-schema.ts @@ -0,0 +1,169 @@ +import { sql } from "drizzle-orm" +import { + index, + integer, + jsonb, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core" +import { dbSchema } from "./pg-schema" +import { user } from "./auth-schema" + +export const betaWaitlistEntries = dbSchema.table( + "beta_waitlist_entries", + { + id: text("id").primaryKey(), + emailNormalized: text("email_normalized").notNull(), + locale: text("locale", { enum: ["zh-CN", "en"] }).notNull(), + status: text("status", { + enum: ["pending", "approved", "registered", "rejected", "withdrawn"], + }) + .notNull() + .default("pending"), + approvedAt: timestamp("approved_at", { withTimezone: true }), + registeredUserId: text("registered_user_id").references(() => user.id, { + onDelete: "set null", + }), + reviewReason: text("review_reason"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("beta_waitlist_email_uq").on(table.emailNormalized), + index("beta_waitlist_status_created_idx").on(table.status, table.createdAt), + ] +) + +export const betaInvites = dbSchema.table( + "beta_invites", + { + id: text("id").primaryKey(), + waitlistId: text("waitlist_id") + .notNull() + .references(() => betaWaitlistEntries.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdBy: text("created_by") + .notNull() + .references(() => user.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("beta_invites_token_hash_uq").on(table.tokenHash), + uniqueIndex("beta_invites_active_waitlist_uq") + .on(table.waitlistId) + .where(sql`${table.usedAt} is null and ${table.revokedAt} is null`), + index("beta_invites_expires_idx").on(table.expiresAt), + ] +) + +export const userEntitlements = dbSchema.table("user_entitlements", { + userId: text("user_id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + plan: text("plan", { enum: ["beta", "pro"] }).notNull(), + accountStatus: text("account_status", { + enum: ["active", "suspended"], + }) + .notNull() + .default("active"), + planExpiresAt: timestamp("plan_expires_at", { withTimezone: true }), + grantSource: text("grant_source", { + enum: ["beta-invite", "owner", "admin"], + }).notNull(), + grantedBy: text("granted_by").references(() => user.id), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}) + +/** token 明文只存在加密 payload 中;投递完成或邀请失效后清空。 */ +export const betaEmailOutbox = dbSchema.table( + "beta_email_outbox", + { + id: text("id").primaryKey(), + inviteId: text("invite_id") + .notNull() + .references(() => betaInvites.id, { onDelete: "cascade" }), + encryptedPayload: text("encrypted_payload"), + providerMessageId: text("provider_message_id"), + status: text("status", { + enum: [ + "queued", + "sending", + "sent", + "delivered", + "bounced", + "complained", + "failed", + ], + }) + .notNull() + .default("queued"), + attempts: integer("attempts").notNull().default(0), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("beta_email_outbox_invite_uq").on(table.inviteId), + uniqueIndex("beta_email_outbox_provider_message_uq").on( + table.providerMessageId + ), + index("beta_email_outbox_status_retry_idx").on( + table.status, + table.nextAttemptAt + ), + ] +) + +export const betaEmailEvents = dbSchema.table("beta_email_events", { + eventId: text("event_id").primaryKey(), + providerMessageId: text("provider_message_id").notNull(), + type: text("type").notNull(), + payload: jsonb("payload").$type>().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}) + +export const adminAuditLogs = dbSchema.table( + "admin_audit_logs", + { + id: text("id").primaryKey(), + actorId: text("actor_id") + .notNull() + .references(() => user.id), + action: text("action").notNull(), + targetType: text("target_type").notNull(), + targetId: text("target_id").notNull(), + reason: text("reason").notNull(), + requestId: text("request_id").notNull(), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("admin_audit_actor_created_idx").on(table.actorId, table.createdAt), + index("admin_audit_target_idx").on(table.targetType, table.targetId), + ] +) + diff --git a/lib/db/schema.ts b/lib/db/schema.ts index a20e6beb..c2f7b8c4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -39,6 +39,7 @@ export { dbSchema } export * from "./auth-schema" export * from "./admin-schema" export * from "./billing-schema" +export * from "./beta-access-schema" export * from "./payment-schema" export const attachments = dbSchema.table( diff --git a/lib/email/client.ts b/lib/email/client.ts index 94764c21..115d3752 100644 --- a/lib/email/client.ts +++ b/lib/email/client.ts @@ -1,4 +1,4 @@ -import { Resend } from "resend" +import { Resend, type WebhookEventPayload } from "resend" // Resend 邮件服务封装。未配置 RESEND_API_KEY 时 isEmailConfigured() 为 false, // 上层据此优雅降级(如开发环境不强制邮箱验证)。 @@ -18,17 +18,45 @@ export function isEmailConfigured(): boolean { return Boolean(API_KEY) } -export type SendEmailInput = { to: string; subject: string; html: string } +export type SendEmailInput = { + to: string + subject: string + html: string + idempotencyKey?: string +} /** 发送邮件。未配置时抛错(调用方应先判断 isEmailConfigured 或允许失败)。 */ -export async function sendEmail(input: SendEmailInput): Promise { +export async function sendEmail(input: SendEmailInput): Promise { const resend = getClient() if (!resend) throw new Error("邮件服务未配置(缺少 RESEND_API_KEY)") - const { error } = await resend.emails.send({ - from: FROM, - to: input.to, - subject: input.subject, - html: input.html, - }) + const { data, error } = await resend.emails.send( + { + from: FROM, + to: input.to, + subject: input.subject, + html: input.html, + }, + input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined + ) if (error) throw new Error(`发送邮件失败:${error.message}`) + if (!data?.id) throw new Error("发送邮件失败:服务商未返回消息 ID") + return data.id +} + +export function verifyEmailWebhook( + payload: string, + headers: Headers +): WebhookEventPayload { + const secret = process.env.RESEND_WEBHOOK_SECRET?.trim() + if (!secret) throw new Error("RESEND_WEBHOOK_SECRET_NOT_CONFIGURED") + const id = headers.get("svix-id") + const timestamp = headers.get("svix-timestamp") + const signature = headers.get("svix-signature") + if (!id || !timestamp || !signature) + throw new Error("RESEND_WEBHOOK_HEADERS_INVALID") + return new Resend(API_KEY).webhooks.verify({ + payload, + headers: { id, timestamp, signature }, + webhookSecret: secret, + }) } diff --git a/lib/email/templates.ts b/lib/email/templates.ts index d4e3102a..be3367f9 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -51,3 +51,28 @@ export function resetPasswordEmail(url: string): { ), } } + +export function betaInviteEmail( + url: string, + locale: "zh-CN" | "en" +): { subject: string; html: string } { + if (locale === "en") + return { + subject: `Your ${APP_NAME} private beta invitation`, + html: layout( + "Your private beta access is ready", + `

Sign in with this invited email address, then explicitly activate your access. Opening this link alone will not consume the invitation.

+

${button(url, "Review invitation")}

+

This invitation expires in 7 days. If you did not request it, you can ignore this email.

` + ), + } + return { + subject: `你的 ${APP_NAME} 私测邀请`, + html: layout( + "私测资格已准备好", + `

请使用收到邀请的邮箱登录,再明确确认激活。仅打开此链接不会消耗邀请。

+

${button(url, "查看邀请")}

+

邀请 7 天内有效;若并非你本人申请,可忽略本邮件。

` + ), + } +} diff --git a/lib/thread-chat/contracts/errors.ts b/lib/thread-chat/contracts/errors.ts index f23e2190..4acfc1c4 100644 --- a/lib/thread-chat/contracts/errors.ts +++ b/lib/thread-chat/contracts/errors.ts @@ -12,6 +12,8 @@ export const apiErrorCodeSchema = z.enum([ "RUN_RESERVATION_INSUFFICIENT", "MODEL_PRICING_UNAVAILABLE", "TOO_MANY_ACTIVE_RUNS", + "BETA_ACCESS_REQUIRED", + "ACCOUNT_SUSPENDED", ]) export const apiErrorSchema = z diff --git a/lib/thread-chat/server/handlers.ts b/lib/thread-chat/server/handlers.ts index f10ee8d1..b284617d 100644 --- a/lib/thread-chat/server/handlers.ts +++ b/lib/thread-chat/server/handlers.ts @@ -48,7 +48,9 @@ import { failOrphanedGeneratingMessage } from "@/lib/thread-chat/streaming/final import { getSessionStore } from "@/lib/thread-chat/streaming/session-store" import { createSessionSseResponse } from "@/lib/thread-chat/streaming/sse" import { GENERATION_CANCEL_REASONS } from "@/constants/generation" +import { THREAD_TITLE_MODEL_ID } from "@/constants/model" import { scheduleFeedbackMirrorAfterCommit } from "@/lib/observability/feedback-post-commit" +import { requireBetaAccess } from "@/lib/beta/entitlements" const idSchema = z.uuid() @@ -93,6 +95,7 @@ export function handleStartProject( ): Promise { return withThreadChatRoute(request, async (userId) => { const command = await parseJson(request, startProjectCommandSchema) + await requireBetaAccess(userId, command.modelId) if (command.projectId !== parseId(projectId)) validation("path projectId 与请求体不一致") const result = await startProject(userId, command) @@ -212,9 +215,12 @@ export function handleGenerateThreadTitle( request: Request, threadId: string ): Promise { - return withThreadChatRoute(request, async (userId) => - jsonNoCache(await generateAndSaveThreadTitle(userId, parseId(threadId))) - ) + return withThreadChatRoute(request, async (userId) => { + await requireBetaAccess(userId, THREAD_TITLE_MODEL_ID) + return jsonNoCache( + await generateAndSaveThreadTitle(userId, parseId(threadId)) + ) + }) } export function handleSendMessage( @@ -223,6 +229,7 @@ export function handleSendMessage( ): Promise { return withThreadChatRoute(request, async (userId) => { const command = await parseJson(request, sendMessageCommandSchema) + await requireBetaAccess(userId, command.modelId) const result = await sendMessage(userId, parseId(threadId), command) if (!result.replayed) startSessionAfterCommit( @@ -240,6 +247,7 @@ export function handleForkThread( ): Promise { return withThreadChatRoute(request, async (userId) => { const command = await parseJson(request, forkThreadCommandSchema) + await requireBetaAccess(userId, command.modelId) const result = await forkThread(userId, parseId(threadId), command) if (!result.replayed && result.result.generation) startSessionAfterCommit( @@ -257,6 +265,7 @@ export function handleEditMessage( ): Promise { return withThreadChatRoute(request, async (userId) => { const command = await parseJson(request, editLatestTurnCommandSchema) + await requireBetaAccess(userId, command.modelId) const result = await editLatestTurn(userId, parseId(messageId), command) if (!result.replayed) { if (result.result.abortMessageId) @@ -283,6 +292,7 @@ export function handleRetryMessage( ): Promise { return withThreadChatRoute(request, async (userId) => { const command = await parseJson(request, retryMessageCommandSchema) + await requireBetaAccess(userId, command.modelId) const result = await retryMessage(userId, parseId(messageId), command) if (!result.replayed) startSessionAfterCommit( diff --git a/lib/thread-chat/server/route-utils.ts b/lib/thread-chat/server/route-utils.ts index a7d665cc..780bf412 100644 --- a/lib/thread-chat/server/route-utils.ts +++ b/lib/thread-chat/server/route-utils.ts @@ -12,6 +12,7 @@ import { requireThreadChatUser, ThreadChatUnauthorizedError, } from "@/lib/thread-chat/server/auth" +import { BetaAccessError, requireBetaAccess } from "@/lib/beta/entitlements" const JSON_NO_CACHE_HEADERS = { "Cache-Control": "private, no-store, max-age=0", @@ -72,6 +73,8 @@ function errorResponse( } export function mapRouteError(error: unknown): Response { + if (error instanceof BetaAccessError) + return errorResponse(403, error.code, error.message) if (error instanceof ThreadChatUnauthorizedError) return errorResponse(401, "NOT_FOUND", error.message) if (error instanceof ZodError) { @@ -119,6 +122,7 @@ export async function withThreadChatRoute( try { await ensureThreadChatRuntimeInitialized() const userId = await requireThreadChatUser(request.headers) + await requireBetaAccess(userId) return await execute(userId) } catch (error) { return mapRouteError(error) diff --git a/openspec/changes/private-beta-access/.openspec.yaml b/openspec/changes/private-beta-access/.openspec.yaml new file mode 100644 index 00000000..f2cbbe6a --- /dev/null +++ b/openspec/changes/private-beta-access/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-18 diff --git a/openspec/changes/private-beta-access/design.md b/openspec/changes/private-beta-access/design.md new file mode 100644 index 00000000..14a364e6 --- /dev/null +++ b/openspec/changes/private-beta-access/design.md @@ -0,0 +1,120 @@ +## Context + +既有 Resend 和 Auth/Admin 能力继续复用。已有账务首次读/写时的隐式赠额需由 #164 分离,避免本 change 激活时二次赠送。所有下列新增类型为拟议契约。 + +## Goals / Non-Goals + +最小路径是申请 → 批准 → 邮件 → 验证身份 → 激活 → 获得额度。不把 approved、邮件 delivered、registered 混成同一状态,不做第二套身份验证系统。 + +## Decisions + +### 1. 模块与组件 + +| 模块 | 负责 | 不负责 | +| --- | --- | --- | +| beta/waitlist | 申请归一化、查重、审核、撤回 | 发放余额 | +| beta/invites | token 签发/核销/撤销/轮换 | 自建认证 | +| beta/entitlements | 账户状态、Beta/Pro、模型资格 | 计算模型成本 | +| 现有 email 模块 | 双语模板、发送、webhook、抑制 | 以投递成功替代注册 | +| admin/audit | 操作者、目标、原因、变更记录 | 普通产品分析 | + +组件为 WaitlistForm、InviteLanding、BetaAccessNotice、AdminWaitlistTable、AdminUserEntitlement;复用 Base UI/shadcn Dialog、Drawer、表格与已有 Admin 框架。审批与撤销有明确状态,危险操作确认,不暴露后台邮箱列表给访客。 + +### 2. 类型与 DTO + +```ts +type WaitlistStatus = 'pending' | 'approved' | 'registered' | 'rejected' | 'withdrawn'; +type WaitlistEntry = { + id: string; + emailNormalized: string; + locale: 'zh-CN' | 'en'; // 实现时 import Locale + status: WaitlistStatus; + createdAt: string; + approvedAt: string | null; + registeredUserId: string | null; +}; +type BetaInvite = { + id: string; + waitlistId: string; + tokenHash: string; + expiresAt: string; + usedAt: string | null; + revokedAt: string | null; +}; +type UserEntitlement = { + userId: string; + plan: 'beta' | 'pro'; + accountStatus: 'active' | 'suspended'; + planExpiresAt: string | null; + grantSource: 'beta-invite' | 'owner' | 'admin'; +}; +type EmailDelivery = { + id: string; + inviteId: string; + providerMessageId: string | null; + status: 'queued' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed'; + attempts: number; +}; +type JoinWaitlistInput = { email: string; locale: 'zh-CN' | 'en' }; +type RedeemInviteInput = { token: string }; +type EntitlementDecision = + | { allowed: true; userId: string; modelId: string } + | { allowed: false; code: 'BETA_ACCESS_REQUIRED' | 'ACCOUNT_SUSPENDED' | 'MODEL_NOT_ALLOWED' }; +``` + +Locale 导入 #163 的唯一类型,不保留示例内联枚举副本。User role 复用现有 USER/ADMIN,不与 plan 混合。拟议 POST /api/beta/waitlist 返回统一 accepted 响应,避免枚举是否已注册;POST /api/beta/invites/redeem 需已验证 session,邮箱从 session 获取,不能相信客户端 userId。Admin approve/resend/revoke 必须执行现有 admin guard、来源/CSRF 校验、原因和 requestId 审计。Zod 校验输入、长度和速率。 + +### 3. 数据与事务 + +waitlist(emailNormalized) 唯一;只去首尾空白和按现有认证一致的大小写策略归一化,不删除 +tag 或 Gmail 点。invites(tokenHash) 唯一;同一 waitlist 至多一个未使用且未撤销的有效邀请,重发事务先撤销旧 token 再签发。发放权益以 userId 唯一。 + +核销事务锁 invite + user/account,验证未使用/未撤销/未过期及已验证邮箱匹配,再写 usedAt、registeredUserId、entitlement、grantWelcomeOnce 和审计。欢迎额度唯一键 beta-welcome-v1:userId;重复回调只返回已激活状态。数据库失败则不核销、不赠额。旧用户迁移沿用余额/历史赠额标记,不能重新发送 ¥5。 + +批准只表示审核完成,生成 email_delivery/outbox 与邀请后提交;发邮件不在持有 DB 锁的事务中。复用现有 outbox 领取/重试约定,不把 feedback_score_outbox 内容混作邮件;不引入通用消息总线。 + +### 4. token 与邮件可靠性 + +邀请使用高熵随机 token,invite 表只存 hash;不能在日志、分析、referrer 或错误中输出 token。邀请落地页不加载可选第三方脚本,设置严格 referrer 策略。GET 仅展示,不核销,防止邮件安全扫描器消费链接;核销必须显式 POST 且身份邮箱匹配。 + +可靠邮件需要恢复链接明文:只在最短生命周期的加密 outbox payload 中保存待发送链接/token,使用专用服务端密钥、限制读取并在投递完成/失效后销毁。不能一边只存不可逆 hash 一边假定重试能还原 token。重发产生新 token,旧链接失效;加密失败时不批准为可投递成功。 + +Resend webhook 验签、按事件 ID 去重。delivered 后到来的旧 sent 事件不回退状态;bounce/complaint 单独保留事件事实并停止重复投递。sent 不是 delivered。配置有限次指数退避和人工重发;超出次数显式 failed,不能静默丢失。交易邮件关闭打开/点击追踪;邀请通知不隐式订阅营销。实际域名、SPF/DKIM/DMARC 和退信处理是上线检查项。 + +### 5. 准入与模型策略 + +默认无权益账号可登录并看到等待状态,但不能调用付费服务。新邮箱注册、OAuth 首次建号回调、老账号登录后、直接 API 都执行同一权益判断。对象访问仍逐 Project/Thread/Artifact/附件/Generation 校验所有权;通过登录不等于能访问任何 ID。 + +Beta 可用模型按已核验精确 modelId 的 allowlist 发布;目标系列为用户选定的 DeepSeek v4.1、GLM 5.3 Flash、GLM 5.3、GPT Luna。展示名称不能被当成真实 provider ID,未验证价格/渠道的条目保持关闭。其余新注册模型默认 Pro-only,未知模型拒绝。Pro 可用全已启用模型,但必须有有效权益与额度/预算,Owner 不绕过成本账。 + +暂停账号阻止新任务。现有任务不因普通 plan 变更或余额耗尽被硬停;明确安全事件可单独取消并记录原因。过期 Pro 的后续请求按 Beta 权益或拒绝处理,不能缓存永久全权限。管理员角色提升/Pro 配额/赠额都要审计,禁止用户自改 plan。 + +未完成隔离的个人 PAT、私有仓库与沙箱写入不向公众开放,保留 Owner-only feature flag。 + +```mermaid +flowchart TD + A[邮箱申请] --> B[Admin 审核] + B -->|拒绝| R[保留审核状态] + B -->|批准事务| C[邀请和加密待发邮件] + C --> D[发送与投递回执] + D --> E[用户打开链接并验证邮箱] + E --> F{核销事务} + F -->|失败| G[失效或身份不匹配提示] + F -->|成功| H[激活权益和一次性赠额] + H --> I[进入 Beta] +``` + +### 6. 观测与验收 + +业务审计记录 waitlist/invite/user/request/actor/release,不记录明文 token。申请、批准、邮件送达、注册分别统计;可选漏斗遵守 #165。测试重复提交、approve、webhook、并发核销、邮箱不匹配、GET 扫描、旧链接、撤销、退信、OAuth 绕过、两账号 ID 替换、Pro 过期和历史赠额。 + +## Migration Plan + +schema 增量可空,列出现有用户/Owner 授权名单并审计迁移;不能把所有旧账户自动变 Pro。先部署兼容 guard 与 Admin,再切换首页为 waiting list。数据库 migration 由 develop 统一生成验证。回滚暂停新的邀请/批准,不撤销已合法发放余额,不放开默认注册绕过。 + +## Risks / Trade-offs + +邀请加密 outbox 增加一个必要秘密处理点,但避免不可恢复邮件。手工审核足以支持早期批次;不设计复杂邀请码市场。账号是否存在的回应和速率限制防止申请接口成为枚举/邮件轰炸工具。 + +## Open Questions + +开放前填入邮件发件域名、支持邮箱、邀请有效期、明确的模型 ID/价格审批和 Owner 迁移名单。邀请有效期初始建议 7 天作为可配置产品值,不是服务商默认;未配置生产密钥/发件条件应保持批准发送关闭。 diff --git a/openspec/changes/private-beta-access/proposal.md b/openspec/changes/private-beta-access/proposal.md new file mode 100644 index 00000000..ee66ab92 --- /dev/null +++ b/openspec/changes/private-beta-access/proposal.md @@ -0,0 +1,33 @@ +## Why + +对外内测需要可运营的准入,而不是只在首页显示 waiting list。必须打通申请、审核、邮件、验证注册、唯一赠额,并保证 OAuth、直接 API 和旧账号都遵守同一服务端权限。 + +## What Changes + +- 首页申请邮箱,Admin 批准/拒绝/重发/撤销;邀请链接绑定邮箱,一次核销,无公开手填邀请码。 +- 复用现有 Auth、Resend、Admin role;新增 Beta/Pro 权益与账号状态,不引入复杂 RBAC。 +- 邀请核销、权益激活和 billing 欢迎额度在同一事务中执行。 +- 邮件与审核独立记录状态;可靠重试、退信/投诉抑制、管理员操作审计。 +- 所有聊天/模型/API 按用户与对象授权;未隔离的个人连接/沙箱保持 Owner-only。 + +## Capabilities + +### New Capabilities + +- `private-beta-access`: 申请、邀请、激活、模型权益、邮件及运营审计。 + +### Modified Capabilities + +无。复用已存在的 Auth 和赠额实现,新的领域契约在本 change 描述。 + +## Impact + +计划增加 lib/beta、现有邮件扩展、用户权益 schema、首页/Auth/Admin 组件与服务端 guards。本 PR 只有文档。 + +## Dependencies + +直接 Git 父分支 spec/beta-02-billing-quota(#164),消费事务内 grantWelcomeOnce。双语依赖 #163;开放前隐私策略依赖 #165。模块依赖保持单向:准入调用 billing 赠额,服务端编排先授权再调用 billing.reserve,billing 不反向依赖准入。 + +## Non-goals + +不做公开邀请码分发、推荐奖励、复杂角色系统、在线充值/订阅支付或自动开通付费套餐。Pro 第一版由管理员授权并配置预算,仍完整计费。 diff --git a/openspec/changes/private-beta-access/specs/private-beta-access/spec.md b/openspec/changes/private-beta-access/specs/private-beta-access/spec.md new file mode 100644 index 00000000..44864993 --- /dev/null +++ b/openspec/changes/private-beta-access/specs/private-beta-access/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Accept and approve waitlist applications safely + +系统 SHALL 提供邮箱申请和管理员审核,重复申请返回统一接受结果,不泄露已有账号状态。 + +#### Scenario: Repeated application +- **WHEN** 相同归一化邮箱重复申请 +- **THEN** 复用记录,不重复创建邀请或发送邮件 + +### Requirement: Keep approval and email delivery distinct + +系统 SHALL 分别保存审核、邀请、邮件投递和注册状态,失败投递可有限重试并人工处理。 + +#### Scenario: Approval succeeds but email fails +- **WHEN** 审核事务成功但邮件服务失败 +- **THEN** 申请保持 approved 且邮件可见 failed/queued 状态,不伪装 delivered 或 registered + +#### Scenario: Complaint webhook +- **WHEN** 收到验签通过的投诉事件 +- **THEN** 更新抑制状态并停止重复投递,重复 webhook 不产生重复副作用 + +### Requirement: Redeem invitations once for the verified identity + +系统 SHALL 仅通过显式核销请求将邀请授予匹配的已验证邮箱,并在同一事务激活权益和执行一次欢迎赠额。 + +#### Scenario: Link opened by scanner +- **WHEN** 邮件扫描器 GET 邀请链接 +- **THEN** 邀请不被消费 + +#### Scenario: Concurrent redemption +- **WHEN** 相同 token 被并发核销 +- **THEN** 仅一次激活/赠额,其余请求返回已处理结果 + +#### Scenario: Mismatched email or revoked token +- **WHEN** 已登录邮箱不匹配,或 token 已过期/撤销 +- **THEN** 不核销、不激活、不赠额 + +### Requirement: Enforce access and ownership server side + +系统 SHALL 在邮箱/OAuth/旧账号/直接 API 入口执行统一准入和对象所有权检查,而不是只隐藏首页按钮。 + +#### Scenario: Unapproved OAuth account +- **WHEN** 未批准用户通过 OAuth 登录后直接调用聊天 API +- **THEN** 返回 BETA_ACCESS_REQUIRED 且没有付费请求 + +#### Scenario: Cross-user object ID +- **WHEN** 用户 A 将请求中的对象 ID 替换为 B 的 Project、Thread、Artifact、附件或 Generation +- **THEN** 请求被拒绝且不泄露 B 的内容 + +### Requirement: Separate model entitlement from budget + +系统 SHALL 将管理员角色、Beta/Pro 权益、账号状态和余额分开,所有用户均受预算和价格约束。 + +#### Scenario: Pro user has exhausted credit +- **WHEN** Pro 用户尝试启动新任务但无法预占额度 +- **THEN** 仍被账务拒绝,不能因 Pro 绕过 + +#### Scenario: Newly registered model +- **WHEN** 管理员添加了未列入 Beta allowlist 的模型 +- **THEN** 不自动开放给 Beta,缺价时任何计划都不可启动 + +### Requirement: Audit sensitive administration and protect personal connectors + +系统 SHALL 审计批准、撤销、暂停、权益和额度变更,并将未隔离的个人连接及沙箱权限保持 Owner-only。 + +#### Scenario: Admin grants Pro +- **WHEN** 管理员修改用户计划 +- **THEN** 保存 actor、target、reason、时间和前后值,普通用户不能执行同一操作 diff --git a/openspec/changes/private-beta-access/tasks.md b/openspec/changes/private-beta-access/tasks.md new file mode 100644 index 00000000..bc130e03 --- /dev/null +++ b/openspec/changes/private-beta-access/tasks.md @@ -0,0 +1,26 @@ +## 1. 数据与权限 + +- [ ] 1.1 复用 Auth/Admin/Resend,定义 waitlist/invite/entitlement/email/audit schema 与唯一约束。 +- [ ] 1.2 列出历史赠额/Owner 迁移名单;对接 #164 的开户与 grantWelcomeOnce,不重复赠送。 +- [ ] 1.3 在邮箱/OAuth/老用户/API 和全部对象入口接入服务端 guard。 + +## 2. 邮件与邀请 + +- [ ] 2.1 实现申请去重、防枚举/限流、批准/拒绝/撤回。 +- [ ] 2.2 实现高熵 token/hash、加密邮件 outbox、轮换、POST 核销事务及 GET 扫描保护。 +- [ ] 2.3 配置双语邮件、验证域名、有限重试、回调验签/去重/乱序及退信投诉抑制。 + +## 3. UI 与运营 + +- [ ] 3.1 接入 Waiting List 首页、邀请状态、等待资格页和 Admin 审核详情。 +- [ ] 3.2 发布已核验的 Beta 模型 allowlist 与 Pro 权益/预算策略,个人连接保持 Owner-only。 +- [ ] 3.3 接入业务审计和受 consent 控制的漏斗事件。 + +## 4. 验收与上线 + +- [ ] 4.1 测试重复/并发核销、过期/撤销、邮箱不匹配、GET 扫描、webhook 乱序和旧用户。 +- [ ] 4.2 执行两账号越权、OAuth/API 绕过、Pro 过期/欠额测试。 +- [ ] 4.3 真实邮箱演练申请至首次成功聊天,中英文各一次;审批批次可暂停。 +- [ ] 4.4 develop 生成验证迁移;运行相关测试/typecheck 和 pnpm exec openspec validate private-beta-access --strict。 + +本 PR 不发送真实邀请,不执行上述实现任务。 diff --git a/package.json b/package.json index f6a3265a..2549f910 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:observability:eval-loop": "node --import tsx e2e/observability/eval-loop.test.mjs", "test:observability": "pnpm test:observability:foundation && pnpm test:observability:trace && pnpm test:observability:provider-attempt && pnpm test:observability:feedback && pnpm test:observability:release && pnpm test:observability:eval-foundation && pnpm test:observability:eval-scorers && pnpm test:observability:eval-loop", "test:billing:foundation": "node --import tsx e2e/billing/billing-foundation-db.test.mjs", + "test:beta-access:foundation": "node --import tsx e2e/beta-access/beta-access-foundation-db.test.mjs", "test:agent-evals": "pnpm test:observability:eval-foundation && pnpm test:observability:eval-scorers && pnpm test:observability:eval-loop && pnpm eval:agent:ci", "eval:agent": "node --import tsx evals/agent/cli.ts --mode=smoke", "eval:agent:ci": "node --import tsx evals/agent/cli.ts --mode=ci",