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
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 指向其基础地址。
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OnboardingLabAccess | undefined> {
const response = await this.fetchWithRefresh(HTTP_PATHS.internalOnboardingLab);
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/internal/onboarding-lab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ describe("Onboarding Lab route", () => {

function installApi(
options: {
configured?: boolean;
offered?: boolean;
resetAvailable?: boolean;
workspaces?: "none";
setupCompletedAt?: string | null;
Expand Down Expand Up @@ -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: [] });
Expand All @@ -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(<App />);

Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 14 additions & 10 deletions docs/staging-onboarding-lab.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,25 +26,28 @@ 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
```

| 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.

Expand Down
18 changes: 10 additions & 8 deletions docs/zh-CN/staging-onboarding-lab.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 保护。

Expand Down
24 changes: 23 additions & 1 deletion packages/server/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 [
{
Expand Down
24 changes: 21 additions & 3 deletions packages/server/src/__tests__/onboarding-lab-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -49,7 +53,7 @@ function browserHeaders(extra: Record<string, string> = {}) {
}

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);
Expand Down Expand Up @@ -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();

Expand Down
10 changes: 5 additions & 5 deletions packages/server/src/api/internal-onboarding-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 12 additions & 4 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
: {},
}
: {}),
};
}
2 changes: 1 addition & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export async function startServer(): Promise<void> {
agents: agentService,
database,
environment: config.environment,
labAccountId: config.stagingOnboardingLab.accountId,
...(config.stagingOnboardingLab.accountId ? { labAccountId: config.stagingOnboardingLab.accountId } : {}),
registry,
workspaceAdmins,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<void> {
Expand Down
7 changes: 4 additions & 3 deletions packages/shared/src/onboarding-lab.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down