Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@yourdomain.com>
# Resend webhook signing secret,用于 /api/webhooks/resend 验签与投递状态去重。
RESEND_WEBHOOK_SECRET=
# 可选:覆盖 Resend 端点(自建/测试)。
# RESEND_BASE_URL=

Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/beta-access.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions app/admin/beta/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Private Beta 审核</h1>
<p className="mt-2 text-sm text-muted-foreground">
批准会签发新 token、加密写入邮件 outbox,并在事务提交后发送。重发会先撤销旧邀请。
</p>
</div>
<Card>
<CardHeader>
<CardTitle>待处理与已批准申请</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>邮箱</TableHead>
<TableHead>语言</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell>{entry.emailNormalized}</TableCell>
<TableCell>{entry.locale}</TableCell>
<TableCell><Badge variant="secondary">{entry.status}</Badge></TableCell>
<TableCell>{entry.createdAt.toISOString()}</TableCell>
<TableCell>
<BetaWaitlistActions entryId={entry.id} approved={entry.status === "approved"} />
</TableCell>
</TableRow>
))}
{entries.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
暂无待处理申请
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)
}
40 changes: 40 additions & 0 deletions app/api/admin/beta/waitlist/[id]/approve/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
31 changes: 31 additions & 0 deletions app/api/beta/invites/redeem/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 25 additions & 0 deletions app/api/beta/waitlist/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
42 changes: 42 additions & 0 deletions app/api/chat/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -41,6 +42,7 @@ type ChatRequestContextDependencies = {
modelConfigured: typeof isModelConfigured
unbilledPreview: typeof isUnbilledPreviewModel
positiveBalance: typeof hasPositiveBalance
accessDecision?: typeof decideBetaAccess
}

const defaultDependencies: ChatRequestContextDependencies = {
Expand All @@ -50,6 +52,7 @@ const defaultDependencies: ChatRequestContextDependencies = {
modelConfigured: isModelConfigured,
unbilledPreview: isUnbilledPreviewModel,
positiveBalance: hasPositiveBalance,
accessDecision: decideBetaAccess,
}

/** 鉴权、解析并完成模型/余额门禁,返回可直接进入生成编排的请求上下文。 */
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions app/api/webhooks/resend/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading