Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,21 @@ The console is available only on the host at <http://127.0.0.1:3001>. Wait until
bash scripts/selfhost.sh create-user --email you@example.com
```

For a public deployment, create the review account with the `admin` role (use your
existing owner subject if it must keep the current account data):

```bash
bash scripts/selfhost.sh create-user --email admin@inalpha.dev --roles admin --subject console:dev
```

Visitors can then choose **Request a trial** on the sign-in page. Registration creates a
`pending` account without accepting a password. Approval in **Trial Waitlist** generates a
one-time activation link: send it to the applicant's stated email address to verify mailbox
ownership. The applicant sets a password from the link and only then becomes active. Links
expire after 48 hours and can be regenerated; rejected applications remain blocked.
Because the application endpoint is public, keep a persistent per-IP rate limit or challenge
enabled at the reverse proxy / CDN layer; the in-process limits are only a second line of defence.

Sign in at <http://127.0.0.1:3001>, open **LLM Settings**, and add your provider, model, and personal API key. The Dashboard encrypts it with `LLM_CONFIG_ENCRYPTION_KEY`; the orchestrator and Evolver resolve that owner-scoped credential. The standalone Research service still uses the deployment-level `LLM_PROVIDER` / `LLM_MODEL` and matching provider key in `infra/.env.selfhost`, so configure that block if you need deep dives and treat it as a shared-credential boundary until per-owner propagation lands.

Useful operations:
Expand Down
14 changes: 14 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,20 @@ bash scripts/selfhost.sh up
bash scripts/selfhost.sh create-user --email you@example.com
```

公网部署时,用 `admin` 角色创建审核账号;如果该账号需要继承现有 owner 数据,继续使用
原来的 `console:dev` subject:

```bash
bash scripts/selfhost.sh create-user --email admin@inalpha.dev --roles admin --subject console:dev
```

访客可在登录页选择“申请试用”。注册只创建 `pending` 账号且不收密码。管理员在侧边栏
“试用审核”批准后会得到一次性激活链接:必须通过邮件把链接发到申请人填写的邮箱,以此
验证邮箱归属;申请人从链接设置密码后才变为 active。链接 48 小时过期,可重新生成;
被拒绝的申请继续禁止访问,已有账号迁移后保持 active。
注册端点对公网开放时,反向代理 / CDN 必须启用持久化的 per-IP 限流或人机验证;进程内
限流只是第二道防线,不能替代边缘防护。

在 <http://127.0.0.1:3001> 登录,打开 **LLM Settings**,填写你的 provider、model 与个人 API key。控制台用 `LLM_CONFIG_ENCRYPTION_KEY` 加密后写入数据库,orchestrator 与 Evolver 按 owner 临时解析。独立 Research service 当前仍读取 `infra/.env.selfhost` 中部署级的 `LLM_PROVIDER` / `LLM_MODEL` 与对应 provider key;需要 deep dive 时须配置该段,并在 per-owner 透传落地前把它视为共享凭据边界。

常用操作:
Expand Down
28 changes: 27 additions & 1 deletion apps/dashboard/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,33 @@
"collapse": "Collapse sidebar",
"expand": "Expand sidebar",
"logout": "Sign out",
"config": "Config"
"config": "Config",
"waitlist": "Trial Waitlist"
},
"adminWaitlist": {
"title": "Trial Waitlist",
"subtitle": "Review access requests before applicants can enter the operator console.",
"loading": "Loading applications…",
"loadFailed": "Could not load the waitlist. You may not have administrator access.",
"retry": "Retry",
"empty": "No applications are waiting for review.",
"unnamed": "Unnamed applicant",
"appliedAt": "Applied {time}",
"note": "Exploration plan",
"noNote": "No note provided.",
"pending": "Pending review",
"invited": "Activation link issued",
"approve": "Approve trial",
"regenerate": "New activation link",
"regenerateConfirm": "Generate a new activation link? Any link already sent to this applicant will stop working.",
"reject": "Reject",
"rejectConfirm": "Reject this trial request? This action cannot be undone from the dashboard.",
"activationReady": "Activation link ready",
"activationInstructions": "Send this one-time link to {email} by email. It expires in 48 hours; sending it to that address verifies mailbox ownership.",
"activationLink": "Activation link",
"copy": "Copy link",
"copied": "Copied",
"actionFailed": "The review could not be saved. The list has been refreshed."
},
"theme": {
"label": "Theme",
Expand Down
28 changes: 27 additions & 1 deletion apps/dashboard/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,33 @@
"collapse": "收起侧边栏",
"expand": "展开侧边栏",
"logout": "登出",
"config": "配置"
"config": "配置",
"waitlist": "试用审核"
},
"adminWaitlist": {
"title": "试用候审名单",
"subtitle": "审核注册申请;批准后申请人才可以进入操作者控制台。",
"loading": "正在加载申请…",
"loadFailed": "无法加载候审名单,你可能没有管理员权限。",
"retry": "重试",
"empty": "目前没有等待审核的申请。",
"unnamed": "未填写姓名",
"appliedAt": "申请于 {time}",
"note": "探索计划",
"noNote": "申请人没有填写说明。",
"pending": "等待审核",
"invited": "已生成激活链接",
"approve": "批准试用",
"regenerate": "重新生成激活链接",
"regenerateConfirm": "确定重新生成激活链接吗?之前已经发送给申请人的链接会立即失效。",
"reject": "拒绝",
"rejectConfirm": "确定拒绝这条试用申请吗?控制台暂不支持撤销该操作。",
"activationReady": "激活链接已生成",
"activationInstructions": "请通过邮件把这个一次性链接发送到 {email}。链接 48 小时内有效;发到该邮箱可验证申请人确实拥有邮箱。",
"activationLink": "激活链接",
"copy": "复制链接",
"copied": "已复制",
"actionFailed": "审核结果保存失败,名单已刷新。"
},
"theme": {
"label": "主题",
Expand Down
18 changes: 18 additions & 0 deletions apps/dashboard/src/app/[locale]/admin/waitlist/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getTranslations } from "next-intl/server";

import { WaitlistClient } from "@/components/admin/WaitlistClient";

/** 管理员试用审核页;API 仍会在服务端实时校验 admin 角色。 */
export default async function AdminWaitlistPage() {
const t = await getTranslations("adminWaitlist");
return (
<section>
<div className="mb-8">
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-cyan">Admin</div>
<h1 className="mt-2 font-display text-3xl text-fg">{t("title")}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-fg-muted">{t("subtitle")}</p>
</div>
<WaitlistClient />
</section>
);
}
14 changes: 14 additions & 0 deletions apps/dashboard/src/app/activate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Suspense } from "react";

import { ActivateForm } from "@/components/auth/ActivateForm";

/** 公开账号激活页;令牌从 URL fragment 读取,不进入服务器日志。 */
export default function ActivatePage() {
return (
<main className="flex min-h-dvh items-center justify-center bg-bg-deep px-4 py-10">
<Suspense fallback={null}>
<ActivateForm />
</Suspense>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";

/** 将 paper 审核失败收敛为不泄露后端细节的稳定 BFF 错误契约。 */
export function reviewErrorResponse(error: unknown): Response {
const status =
typeof error === "object" && error !== null && "status" in error
? (error as { status?: unknown }).status
: undefined;
if (status === 401 || status === 403) {
return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
}
if (status === 409) {
return NextResponse.json({ error: "ALREADY_REVIEWED" }, { status: 409 });
}
return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { backendFetch } from "@/lib/backend";

import { reviewErrorResponse } from "./review-error";
import { POST } from "./route";

vi.mock("server-only", () => ({}));
vi.mock("@/lib/backend", () => ({ backendFetch: vi.fn() }));

const mockedBackendFetch = vi.mocked(backendFetch);

function request(body: unknown): Request {
return new Request("http://dashboard.test/api/admin/waitlist/user/review", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

function context(subject = "user:applicant/one") {
return { params: Promise.resolve({ subject }) };
}

describe("admin waitlist review BFF", () => {
beforeEach(() => mockedBackendFetch.mockReset());

it("encodes the subject and forwards a valid decision", async () => {
mockedBackendFetch.mockResolvedValue({
subject: "user:applicant/one",
access_status: "invited",
activation_token: "one-time-token",
});

const response = await POST(
request({ decision: "approve", expected_reviewed_at: null }),
context(),
);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
subject: "user:applicant/one",
access_status: "invited",
activation_token: "one-time-token",
});
expect(response.headers.get("cache-control")).toBe("no-store");
expect(mockedBackendFetch).toHaveBeenCalledWith(
"paper",
"/auth/waitlist/user%3Aapplicant%2Fone/review",
{
method: "POST",
body: { decision: "approve", expected_reviewed_at: null },
},
);
});

it("rejects malformed decisions before calling the backend", async () => {
const response = await POST(request({ decision: "activate" }), context());

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "INVALID_DECISION" });
expect(mockedBackendFetch).not.toHaveBeenCalled();
});

async function expectMappedFailure(
backendStatus: number,
status: number,
code: string,
) {
const response = reviewErrorResponse({ status: backendStatus });

expect(response.status).toBe(status);
expect(await response.json()).toEqual({ error: code });
}

it("maps forbidden review failures", async () => {
await expectMappedFailure(403, 403, "FORBIDDEN");
});

it("maps repeat review conflicts", async () => {
await expectMappedFailure(409, 409, "ALREADY_REVIEWED");
});

it("maps unexpected review failures", async () => {
await expectMappedFailure(500, 502, "SERVICE_UNAVAILABLE");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";

import { backendFetch } from "@/lib/backend";

import { reviewErrorResponse } from "./review-error";

/**
* 管理员审核 BFF。
*
* 功能:批准或拒绝一条 pending 申请。
* 何时用:waitlist 页面提交单次审核动作时。
* 何时不用:修改已审核用户;后端状态机只接受 pending 转换。
* 坑:subject 必须 URL 编码,且并发重复审核会返回 409。
*/
export async function POST(
req: Request,
context: { params: Promise<{ subject: string }> },
): Promise<Response> {
const { subject } = await context.params;
let decision: unknown;
let expectedReviewedAt: unknown;
try {
({ decision, expected_reviewed_at: expectedReviewedAt } = await req.json());
} catch {
return NextResponse.json({ error: "INVALID_REQUEST" }, { status: 400 });
}
if (decision !== "approve" && decision !== "reject") {
return NextResponse.json({ error: "INVALID_DECISION" }, { status: 400 });
}
if (expectedReviewedAt !== null && typeof expectedReviewedAt !== "string") {
return NextResponse.json({ error: "INVALID_REVIEW_VERSION" }, { status: 400 });
}

try {
const result = await backendFetch<{ subject: string; access_status: string }>(
"paper",
`/auth/waitlist/${encodeURIComponent(subject)}/review`,
{
method: "POST",
body: { decision, expected_reviewed_at: expectedReviewedAt },
},
);
return NextResponse.json(result, {
headers: { "Cache-Control": "no-store" },
});
} catch (err) {
return reviewErrorResponse(err);
}
}
24 changes: 24 additions & 0 deletions apps/dashboard/src/app/api/admin/waitlist/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { backendFetch } from "@/lib/backend";

import { GET } from "./route";

vi.mock("@/lib/backend", () => ({ backendFetch: vi.fn() }));

const mockedBackendFetch = vi.mocked(backendFetch);

describe("admin waitlist BFF", () => {
beforeEach(() => mockedBackendFetch.mockReset());

it("returns only the backend-approved waitlist payload", async () => {
const payload = { users: [{ subject: "user:one", email: "one@example.com" }] };
mockedBackendFetch.mockResolvedValue(payload);

const response = await GET();

expect(response.status).toBe(200);
expect(await response.json()).toEqual(payload);
expect(mockedBackendFetch).toHaveBeenCalledWith("paper", "/auth/waitlist");
});
});
27 changes: 27 additions & 0 deletions apps/dashboard/src/app/api/admin/waitlist/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";

import { backendFetch } from "@/lib/backend";

/**
* 管理员待审列表 BFF。
*
* 功能:读取 paper 中的 pending 用户。
* 何时用:管理员打开 waitlist 页面时。
* 何时不用:普通用户查询自己的申请状态;登录接口会给出该状态。
* 坑:权限必须由 paper 查询数据库角色判定,不能只信 session 或前端菜单。
*/
export async function GET(): Promise<Response> {
try {
const result = await backendFetch<{ users: unknown[] }>("paper", "/auth/waitlist");
return NextResponse.json(result);
} catch (err) {
const status =
typeof err === "object" && err !== null && "status" in err
? (err as { status?: unknown }).status
: undefined;
if (status === 401 || status === 403) {
return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
}
return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 });
}
}
22 changes: 22 additions & 0 deletions apps/dashboard/src/app/api/auth/activate/activate-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";

/** 将激活后端错误映射为稳定且不泄露内部细节的公开响应。 */
export function activateErrorResponse(error: unknown): Response {
const status =
typeof error === "object" && error !== null && "status" in error
? (error as { status?: unknown }).status
: undefined;
if (status === 400 || status === 422) {
return NextResponse.json({ error: "INVALID_ACTIVATION" }, { status: 400 });
}
if (status === 409) {
return NextResponse.json({ error: "ACTIVATION_USED" }, { status: 409 });
}
if (status === 429) {
return NextResponse.json(
{ error: "ACTIVATION_BUSY" },
{ status: 429, headers: { "Retry-After": "5" } },
);
}
return NextResponse.json({ error: "SERVICE_UNAVAILABLE" }, { status: 502 });
}
Loading
Loading