diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 673fa766..38e97d0c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -381,7 +381,7 @@ processes. | `OPENTAG_OTEL_SAMPLE_RATE` | `1` | Global trace head sample rate from `0` to `1` | | `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | Access-token lifetime | | `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | Refresh-JWT lifetime | -| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | empty | Staging-only Account UUID allowed to use the [Onboarding Lab](./docs/staging-onboarding-lab.md) | +| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | empty | Staging-only Account UUID allowed to reset the [Onboarding Lab](./docs/staging-onboarding-lab.md) Account; Scenario Preview needs no configuration | | `OPENTAG_HOME` | channel-specific | Root for lifecycle-separated `config/`, `data/`, `state/`, and `logs/` (`~/.opentag-dev` in source) | If `doctor` fails, its error category distinguishes configuration, network, HTTP, and invalid-response failures. Confirm diff --git a/DEVELOPMENT.zh-CN.md b/DEVELOPMENT.zh-CN.md index e4d82b35..3693d4d4 100644 --- a/DEVELOPMENT.zh-CN.md +++ b/DEVELOPMENT.zh-CN.md @@ -361,7 +361,7 @@ setup attempt 并记录结果,然后把一条已授权的 binding 写入数据 | `OPENTAG_OTEL_SAMPLE_RATE` | `1` | `0` 到 `1` 的全局 trace head sample rate | | `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | access token 有效期 | | `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | refresh JWT 有效期 | -| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | 空 | 仅限 staging,允许使用 [Onboarding Lab](./docs/zh-CN/staging-onboarding-lab.md) 的 Account UUID | +| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | 空 | 仅限 staging,允许 reset [Onboarding Lab](./docs/zh-CN/staging-onboarding-lab.md) Account 的 Account UUID;Scenario Preview 不需要配置 | | `OPENTAG_HOME` | 随 channel 而定 | 按生命周期分层的 `config/`、`data/`、`state/`、`logs/` 根目录(源码默认为 `~/.opentag-dev`) | 如果 `doctor` 失败,其错误类别会区分配置、网络、HTTP 和无效响应。请确认 Server 已启动,且配置的 URL 指向其基础地址。 diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 1c9a8187..db40f975 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -251,8 +251,8 @@ export class BrowserApi { } /** - * Reports what this Account may do in the staging Onboarding Lab, and `undefined` where the - * deployment configures no Lab at all — the interface is then absent, not merely closed. + * Reports what this Account may do in the staging Onboarding Lab, and `undefined` outside staging, + * where the interface is absent rather than merely closed. */ async onboardingLabAccess(): Promise { const response = await this.fetchWithRefresh(HTTP_PATHS.internalOnboardingLab); diff --git a/apps/web/src/internal/onboarding-lab.test.tsx b/apps/web/src/internal/onboarding-lab.test.tsx index be1abed5..75eed7db 100644 --- a/apps/web/src/internal/onboarding-lab.test.tsx +++ b/apps/web/src/internal/onboarding-lab.test.tsx @@ -198,7 +198,7 @@ describe("Onboarding Lab route", () => { function installApi( options: { - configured?: boolean; + offered?: boolean; resetAvailable?: boolean; workspaces?: "none"; setupCompletedAt?: string | null; @@ -241,7 +241,7 @@ describe("Onboarding Lab route", () => { resetRequests += 1; return new Response(null, { status: 204 }); } - if (options.configured === false) return new Response(null, { status: 404 }); + if (options.offered === false) return new Response(null, { status: 404 }); return json({ reset: options.resetAvailable !== false }); } if (path.endsWith("/computers")) return json({ computers: [] }); @@ -267,8 +267,8 @@ describe("Onboarding Lab route", () => { expect(window.location.pathname).toBe("/internal/onboarding-lab"); }); - it("renders Not Found where the deployment configures no Lab", async () => { - installApi({ configured: false }); + it("renders Not Found where the deployment offers no Lab at all", async () => { + installApi({ offered: false }); render(); diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 537524de..d64453ab 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -669,9 +669,9 @@ function OnboardingRoute() { } /** - * The staging-only Onboarding Lab. A deployment that configures no Lab Account is answered exactly - * like a page that does not exist; where one is configured, every signed-in Account may read the - * Scenario Preview, and the Server still decides which single Account may run the reset. + * The staging-only Onboarding Lab. A deployment outside staging is answered exactly like a page that + * does not exist; on staging every signed-in Account may read the Scenario Preview, and the Server + * still decides which single Account, if any, may run the reset. */ function OnboardingLabRoute() { const { me, refreshMe } = useAccount(); diff --git a/docs/staging-onboarding-lab.md b/docs/staging-onboarding-lab.md index b3d9321c..c16960d4 100644 --- a/docs/staging-onboarding-lab.md +++ b/docs/staging-onboarding-lab.md @@ -11,8 +11,9 @@ runtime readiness and IM bindings immediately advance the fact-derived onboardin return to a first-run state on its own. The Lab is not available in production and it never resets an Account other than the authenticated one. Its two halves -are gated differently: Scenario Preview reads nothing and writes nothing, so every signed-in Account on a configured -staging deployment may open it; the destructive reset stays limited to the single configured Lab Account. +are gated differently: Scenario Preview reads nothing and writes nothing, so every signed-in Account on any staging +deployment may open it, with no configuration at all; the destructive reset stays limited to the single configured Lab +Account, and no Account owns it until a deployment names one. ## Shared staging Account @@ -25,7 +26,10 @@ for keeping a personal staging login signed in at the same time. ## Configuration -Add one optional Server setting to the staging deployment: +Scenario Preview needs none. A staging deployment offers it as soon as it runs, because the scenarios are fixed +client-side fixtures. + +The reset needs one optional Server setting: ```bash OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID=00000000-0000-0000-0000-000000000000 @@ -33,17 +37,17 @@ OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID=00000000-0000-0000-0000-000000000000 | Variable | Default | Purpose | | --- | --- | --- | -| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | empty | Account UUID allowed to use the staging Onboarding Lab | +| `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | empty | Account UUID allowed to reset the staging Onboarding Lab Account | Rules the Server enforces: -- the value must be an Account UUID; an empty value leaves the Lab unconfigured; +- the value must be an Account UUID; an empty value leaves the reset unowned, and Preview unaffected; - the setting is valid only with `OPENTAG_ENV=staging`, so configuring it in production fails Server startup; -- when it is unset, no Lab route is registered at all; -- every request must be authenticated; on a configured staging deployment any Account may read the Lab and see Scenario - Preview, and the response reports whether that Account owns the reset; -- an unconfigured deployment, and any deployment outside staging, answers both requests like a page that does not exist; -- only the configured Account may reset; any other Account is refused exactly like a page that does not exist; +- every request must be authenticated; on a staging deployment any Account may read the Lab and see Scenario Preview, + and the response reports whether that Account owns the reset; +- any deployment outside staging answers both requests like a page that does not exist; +- only the configured Account may reset; every other Account, and every Account while none is configured, is refused + exactly like a page that does not exist; - reset always targets the authenticated Account and accepts no client-selected Account; - reset requires the normal browser CSRF protection. diff --git a/docs/zh-CN/staging-onboarding-lab.md b/docs/zh-CN/staging-onboarding-lab.md index 15cbc4cf..eca6f2de 100644 --- a/docs/zh-CN/staging-onboarding-lab.md +++ b/docs/zh-CN/staging-onboarding-lab.md @@ -9,7 +9,8 @@ Onboarding Lab 是仅限 staging 的页面,用于迭代首次使用体验。 推进由事实推导出的 onboarding 流程,因此一个已完成的 Account 无法自行回到首次使用状态。 Lab 在 production 不可用,也永远不会 reset 除已认证 Account 之外的任何 Account。它的两半门禁不同:Scenario Preview 不读取也不 -写入任何内容,因此在已配置的 staging 部署上,任何已登录 Account 都可以打开;破坏性的 reset 仍然只属于那个被配置的 Lab Account。 +写入任何内容,因此在任何 staging 部署上,任何已登录 Account 都可以打开,且不需要任何配置;破坏性的 reset 仍然只属于那个被配置的 +Lab Account,在部署指定之前没有任何 Account 拥有它。 ## 共享 staging Account @@ -21,7 +22,9 @@ Lab 在 production 不可用,也永远不会 reset 除已认证 Account 之外 ## 配置 -在 staging 部署中增加一个可选的 Server 设置: +Scenario Preview 不需要任何配置。staging 部署一运行就提供它,因为场景是固定的客户端 fixture。 + +reset 需要一个可选的 Server 设置: ```bash OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID=00000000-0000-0000-0000-000000000000 @@ -33,13 +36,12 @@ OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID=00000000-0000-0000-0000-000000000000 Server 强制执行的规则: -- 取值必须是 Account UUID;空值表示 Lab 未配置; +- 取值必须是 Account UUID;空值表示 reset 无人拥有,不影响 Preview; - 该设置只在 `OPENTAG_ENV=staging` 时有效,因此在 production 配置会导致 Server 启动失败; -- 未配置时完全不注册任何 Lab 路由; -- 每个请求都必须完成认证;在已配置的 staging 部署上,任何 Account 都可以读取 Lab 并查看 Scenario Preview,响应会说明该 Account - 是否拥有 reset; -- 未配置的部署,以及 staging 之外的任何部署,对两种请求的响应都与页面不存在完全一致; -- 只有被配置的 Account 可以 reset;其他 Account 得到的拒绝与页面不存在完全一致; +- 每个请求都必须完成认证;在 staging 部署上,任何 Account 都可以读取 Lab 并查看 Scenario Preview,响应会说明该 Account 是否 + 拥有 reset; +- staging 之外的任何部署,对两种请求的响应都与页面不存在完全一致; +- 只有被配置的 Account 可以 reset;其他 Account,以及尚未配置时的所有 Account,得到的拒绝与页面不存在完全一致; - reset 始终作用于已认证 Account,不接受客户端选择的 Account; - reset 需要常规的浏览器 CSRF 保护。 diff --git a/packages/server/src/__tests__/config.test.ts b/packages/server/src/__tests__/config.test.ts index 78306406..eb21a0bf 100644 --- a/packages/server/src/__tests__/config.test.ts +++ b/packages/server/src/__tests__/config.test.ts @@ -10,7 +10,7 @@ const required = { }; describe("parseServerConfig", () => { - it("registers the staging Onboarding Lab Account only for an explicit staging environment", () => { + it("offers the staging Onboarding Lab on every staging deployment and its reset only when configured", () => { const accountId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; expect( parseServerConfig({ @@ -21,10 +21,32 @@ describe("parseServerConfig", () => { }).stagingOnboardingLab, ).toEqual({ accountId }); + // Scenario Preview is fixed client-side fixtures, so staging offers the Lab with no Account + // configured; only the reset half waits for one. + expect( + parseServerConfig({ + ...required, + OPENTAG_ENV: "staging", + OPENTAG_PUBLIC_URL: "https://staging.example.com", + OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID: "", + }).stagingOnboardingLab, + ).toEqual({}); + expect( + parseServerConfig({ + ...required, + OPENTAG_ENV: "staging", + OPENTAG_PUBLIC_URL: "https://staging.example.com", + }).stagingOnboardingLab, + ).toEqual({}); + expect(parseServerConfig({ ...required, OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID: "" }).stagingOnboardingLab).toBe( undefined, ); expect(parseServerConfig(required).stagingOnboardingLab).toBe(undefined); + expect( + parseServerConfig({ ...required, OPENTAG_ENV: "prod", OPENTAG_PUBLIC_URL: "https://example.com" }) + .stagingOnboardingLab, + ).toBe(undefined); for (const invalid of [ { diff --git a/packages/server/src/__tests__/onboarding-lab-routes.test.ts b/packages/server/src/__tests__/onboarding-lab-routes.test.ts index e99f4c05..82ca7f34 100644 --- a/packages/server/src/__tests__/onboarding-lab-routes.test.ts +++ b/packages/server/src/__tests__/onboarding-lab-routes.test.ts @@ -12,7 +12,11 @@ afterEach(async () => { }); function fixture( - options: { environment?: "dev" | "staging" | "prod"; labAccountId?: string; registered?: boolean } = {}, + options: { + environment?: "dev" | "staging" | "prod"; + labAccountId?: string | "unconfigured"; + registered?: boolean; + } = {}, ) { const accountId = randomUUID(); const labAccountId = options.labAccountId ?? accountId; @@ -21,7 +25,7 @@ function fixture( agents: { suspendById: vi.fn(), deleteById: vi.fn() }, database: {} as never, environment: options.environment ?? "staging", - labAccountId, + ...(labAccountId === "unconfigured" ? {} : { labAccountId }), workspaceAdmins: {} as never, }); vi.spyOn(reset, "resetOnboarding").mockImplementation(resetOnboarding); @@ -49,7 +53,7 @@ function browserHeaders(extra: Record = {}) { } describe("internal Onboarding Lab interface", () => { - it("registers no Lab route when the deployment configures no Lab Account", async () => { + it("registers no Lab route where the deployment offers no Lab at all", async () => { const value = fixture({ registered: false }); expect(value.app.hasRoute({ method: "GET", url: INTERNAL_ONBOARDING_LAB_PATH })).toBe(false); @@ -77,6 +81,20 @@ describe("internal Onboarding Lab interface", () => { expect(read.json()).toEqual({ reset: true }); }); + it("offers Preview but no reset owner where staging configures no Lab Account", async () => { + const value = fixture({ labAccountId: "unconfigured" }); + + const [read, reset] = await Promise.all([ + value.app.inject({ method: "GET", url: INTERNAL_ONBOARDING_LAB_PATH, headers: browserHeaders() }), + value.app.inject({ method: "POST", url: INTERNAL_ONBOARDING_LAB_PATH, headers: browserHeaders() }), + ]); + + expect(read.statusCode).toBe(200); + expect(read.json()).toEqual({ reset: false }); + expect(reset.statusCode).toBe(404); + expect(value.resetOnboarding).not.toHaveBeenCalled(); + }); + it("resets the authenticated Account and never a client-selected Account", async () => { const value = fixture(); diff --git a/packages/server/src/api/internal-onboarding-lab.ts b/packages/server/src/api/internal-onboarding-lab.ts index 5ed681f2..a587557f 100644 --- a/packages/server/src/api/internal-onboarding-lab.ts +++ b/packages/server/src/api/internal-onboarding-lab.ts @@ -11,11 +11,11 @@ function accountId(request: FastifyRequest): string { } /** - * The staging-only Onboarding Lab interface. It is registered only when the deployment configures - * the Lab Account, so an unconfigured deployment stays indistinguishable from one that never had - * the feature. Reading access is open to any authenticated Account, because Scenario Preview is - * client-side fixtures that read nothing and write nothing; the destructive reset stays closed and - * never accepts a client-selected Account. + * The staging-only Onboarding Lab interface. Any staging deployment registers it, and every + * deployment outside staging stays indistinguishable from one that never had the feature. Reading + * access is open to any authenticated Account, because Scenario Preview is client-side fixtures that + * read nothing and write nothing; the destructive reset stays closed, is refused until a deployment + * configures the Account that owns it, and never accepts a client-selected Account. */ export function registerInternalOnboardingLabRoutes( app: FastifyInstance, diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 9e2a964b..9b7770a3 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -62,7 +62,10 @@ export interface CreateAppOptions { readiness?: BootstrapReadiness; runtime?: RuntimeRoutesOptions; slackEvents?: SlackEventsRouteOptions; - /** Registered only by a staging deployment that configures the shared Onboarding Lab Account. */ + /** + * Registered by any staging deployment. Scenario Preview needs no Account configuration; the reset + * half is closed until the service is given the Account that owns it. + */ stagingOnboardingLab?: { reset: OnboardingResetService }; taskService?: TaskService; workspaceService?: WorkspaceAdminService; diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index b3bad996..7f4d9798 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -242,8 +242,12 @@ export interface ServerConfig { port: number; publicUrl: string; refreshTokenTtlSeconds: number; - /** Present only when a staging deployment configures the shared Onboarding Lab Account. */ - stagingOnboardingLab?: { accountId: string }; + /** + * Present on every staging deployment. Scenario Preview is fixed client-side fixtures, so it needs + * no Account configuration; `accountId` names the one Account that additionally owns the reset, + * and stays absent until a deployment configures it. + */ + stagingOnboardingLab?: { accountId?: string }; } export interface DatabaseConfig { @@ -338,8 +342,12 @@ export function parseServerConfig(environment: NodeJS.ProcessEnv): ServerConfig port: parsed.OPENTAG_PORT, publicUrl: parsed.OPENTAG_PUBLIC_URL, refreshTokenTtlSeconds: parsed.OPENTAG_REFRESH_TOKEN_TTL_SECONDS, - ...(parsed.OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID - ? { stagingOnboardingLab: { accountId: parsed.OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID } } + ...(parsed.OPENTAG_ENV === "staging" + ? { + stagingOnboardingLab: parsed.OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID + ? { accountId: parsed.OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID } + : {}, + } : {}), }; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4fb13640..3a52ae17 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -271,7 +271,7 @@ export async function startServer(): Promise { agents: agentService, database, environment: config.environment, - labAccountId: config.stagingOnboardingLab.accountId, + ...(config.stagingOnboardingLab.accountId ? { labAccountId: config.stagingOnboardingLab.accountId } : {}), registry, workspaceAdmins, }), diff --git a/packages/server/src/services/onboarding-lab/onboarding-reset-service.ts b/packages/server/src/services/onboarding-lab/onboarding-reset-service.ts index 3764f251..8d71f7c0 100644 --- a/packages/server/src/services/onboarding-lab/onboarding-reset-service.ts +++ b/packages/server/src/services/onboarding-lab/onboarding-reset-service.ts @@ -49,8 +49,11 @@ export interface OnboardingResetServiceOptions { agents: OnboardingResetAgentLifecycle; database: DatabaseClient; environment: ChannelName; - /** The single staging Account this deployment may reset. */ - labAccountId: string; + /** + * The single staging Account this deployment may reset. Absent where no Account is configured: + * the Lab then offers Scenario Preview alone, and no Account owns the reset. + */ + labAccountId?: string; now?: () => Date; registry?: OnboardingResetConnectionRegistry; workspaceAdmins?: WorkspaceAdminAccess; @@ -74,7 +77,7 @@ export class OnboardingResetService { readonly #agents: OnboardingResetAgentLifecycle; readonly #database: DatabaseClient; readonly #environment: ChannelName; - readonly #labAccountId: string; + readonly #labAccountId: string | undefined; readonly #now: () => Date; readonly #registry: OnboardingResetConnectionRegistry | undefined; readonly #workspaceAdmins: WorkspaceAdminAccess; @@ -100,9 +103,9 @@ export class OnboardingResetService { return this.#environment === "staging"; } - /** Whether this Account owns the Lab's destructive half. */ + /** Whether this Account owns the Lab's destructive half; no Account does until one is configured. */ allows(accountId: string): boolean { - return this.enabled && accountId === this.#labAccountId; + return this.enabled && this.#labAccountId !== undefined && accountId === this.#labAccountId; } async resetOnboarding(accountId: string): Promise { diff --git a/packages/shared/src/onboarding-lab.ts b/packages/shared/src/onboarding-lab.ts index 431446f1..7972990e 100644 --- a/packages/shared/src/onboarding-lab.ts +++ b/packages/shared/src/onboarding-lab.ts @@ -1,9 +1,10 @@ import { z } from "zod"; /** - * What the authenticated Account may do in the staging Onboarding Lab. The interface exists only - * where a deployment configures the Lab Account, so reaching it at all already means the Lab is - * configured; `reset` then reports whether this particular Account owns the destructive half. + * What the authenticated Account may do in the staging Onboarding Lab. The interface exists on every + * staging deployment, so reaching it at all means only that this is staging; `reset` reports whether + * this particular Account owns the destructive half, and is false for everyone until a deployment + * configures an Account to own it. */ export const OnboardingLabAccessSchema = z .object({