Skip to content

feat(server): offer the Onboarding Lab Preview on every staging deployment - #202

Merged
Gandy2025 merged 3 commits into
mainfrom
feat/onboarding-preview-without-account-config
Aug 27, 2026
Merged

feat(server): offer the Onboarding Lab Preview on every staging deployment#202
Gandy2025 merged 3 commits into
mainfrom
feat/onboarding-preview-without-account-config

Conversation

@Gandy2025

Copy link
Copy Markdown
Contributor

Summary

Scenario Preview is fixed client-side fixtures that read nothing and write nothing, and #191 opened it to every authenticated Account. It nevertheless stays invisible on a staging deployment that has not configured OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID, because that one setting decides whether the Lab exists at all: unset means no route is registered, so the read-only half waits on an operations change it never needed. That is the state staging is in today — the page is deployed and unreachable.

The setting now decides only what its name says, the Account that owns the reset.

  • ServerConfig.stagingOnboardingLab is present on every staging deployment, and carries accountId only once one is configured.
  • OnboardingResetService takes labAccountId as optional. allows returns false for every Account until it is set, and resetOnboarding re-checks it before any mutation, so an unconfigured deployment can never reset even if a request reached the handler.
  • An unconfigured staging deployment therefore offers Preview and owns no reset: GET answers { reset: false }, POST is refused exactly like a page that does not exist.

Outside staging nothing changes. enabled still re-confirms the environment on the read path rather than trusting route registration, the config still refuses the setting unless OPENTAG_ENV is staging, and production registers no route at all.

No Web change: the route already renders Preview for { reset: false } and hides the reset half, which is exactly the unconfigured shape.

Testing

  • pnpm check, pnpm build, pnpm typecheck — pass.
  • pnpm test — Shared 70, Server 268, Web 325, CLI 124 pass. @opentag/client fails two tests in this sandbox: codex-app-server.test.ts D-11 closes the watched provider process group including descendants and codex-agent-runtime-exhaustive.test.ts aborts a hanging real App Server readiness probe and closes its process. Both reproduce identically on a clean tree here (process-group teardown in the sandbox); no file in @opentag/client is touched.
  • New coverage: a route case for a staging deployment with no configured Lab Account (GET 200 { reset: false }, POST 404, reset never invoked), and config assertions that stagingOnboardingLab is {} on staging with the setting empty or absent, and undefined on dev and prod.
  • Booted the built Server against a real PostgreSQL 16, since config-to-service wiring is the part unit tests do not cover:
    • OPENTAG_ENV=staging, no Lab setting → both methods answer 401 to an unauthenticated request, so the route exists where it previously did not.
    • OPENTAG_ENV=prod → both answer 404; the route is absent.
  • The config change is proven by reverting it, which fails the new assertion with expected undefined to deeply equal {}.

Breaking changes

None. No request path changes for a deployment that already configures the setting, and none outside staging. A staging deployment that configures nothing gains a read-only page it could not previously reach; the same disclosure tradeoff #191 recorded applies, now also to staging deployments with no Lab Account.

Checklist

  • The change is focused and contains no unrelated work.
  • Tests and documentation cover the changed behavior.
  • pnpm check, pnpm build, pnpm typecheck, and pnpm test pass. (pnpm test: see the two pre-existing sandbox failures noted above.)
  • No credentials, private data, or generated build output are included.
  • Canonical English docs and Chinese mirrors are synchronized when applicable.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186L5rTjLYUFmaQ8bHkGSaB


Generated by Claude Code

…yment

Scenario Preview is fixed client-side fixtures that read nothing and write
nothing, and #191 opened it to every authenticated Account. It nevertheless
stayed invisible on a staging deployment that had not configured
`OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID`, because that one setting decided
whether the Lab existed at all: unset meant no route, so the read-only half
waited on an operations change it never needed.

The setting now decides only what it names, the Account that owns the reset.
`ServerConfig.stagingOnboardingLab` is present on every staging deployment and
carries `accountId` only once one is configured; `OnboardingResetService` takes
`labAccountId` as optional and `allows` returns false for every Account until it
is set, which `resetOnboarding` re-checks before any mutation. So an
unconfigured staging deployment offers Preview and owns no reset: GET answers
`{ reset: false }`, POST is refused exactly like a page that does not exist.

Outside staging nothing changes. `enabled` still re-confirms the environment on
the read path, the config still refuses the setting unless `OPENTAG_ENV` is
`staging`, and production registers no route at all.

Verified by booting the built Server against a real PostgreSQL. With
`OPENTAG_ENV=staging` and no Lab setting the interface answers 401 to an
unauthenticated request, so the route exists where it previously did not; with
`OPENTAG_ENV=prod` both methods answer 404 and the route is absent. The config
change is proven by reverting it, which fails the new assertion with `expected
undefined to deeply equal {}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186L5rTjLYUFmaQ8bHkGSaB

@baixiaohang baixiaohang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: request changes

  • Rationale: The implementation keeps reset fail-closed, but the changed route-availability invariant was not propagated to the shared, server, and web contract comments that describe it.

Risk level: B-high

  • Path baseline: packages/server/**, including services/onboarding-lab/onboarding-reset-service.ts.
  • Semantic lift: changes Onboarding Lab availability from “configured Account required” to “Preview on every staging deployment; reset owner optional.”

PR summary

  • Problem: authenticated users cannot reach the read-only Scenario Preview on staging deployments that have no reset Account configured.
  • Approach: register the Lab on every staging deployment, represent reset ownership as optional, and keep both route assembly and mutation authorization closed outside the intended environment/account.
  • Impacted modules: Server config and onboarding service, internal Lab route assembly, route/config tests, and operator documentation.

Review findings
❌ 1. Update every contract comment and test description that still defines “no configured Lab Account means no Lab route.” The new startup path now registers Preview on every staging deployment, but the shared schema comment, Server app/route comments, Web API/router comments, and the omitted-option route test still explicitly document the old invariant. These are the exact readers and boundaries of the state being changed, so leaving them contradictory will misdirect future callers and refactors. Please synchronize them in this PR. [R5 / packages/shared/src/onboarding-lab.ts:3, packages/server/src/api/internal-onboarding-lab.ts:13, packages/server/src/app.ts:65, apps/web/src/api.ts:253, apps/web/src/router.tsx:671, packages/server/src/__tests__/onboarding-lab-routes.test.ts:56]

✅ The mutation path remains fail-closed: absent ownership makes allows false, and resetOnboarding re-checks it before any database work; normal startup still omits the route outside staging.

Action taken

  • Submitted request changes.

…riant

Review found the invariant changed in code but still described in the comments
and test names that define it. "No configured Lab Account means no Lab route"
was true before this branch; it now misdirects every reader of the boundary.

Six places now say what the code does: the shared access schema, the internal
route module, the `createApp` option, the Web client method, the Web route, and
the two test descriptions that named configuration where they now mean staging.
The distinction they carry is that 404 marks a deployment outside staging, not
one that has configured no Account, and that `reset` is false for everyone until
an Account is configured to own it.

Comment-only apart from renaming a test option from `configured` to `offered`,
which is the property it actually controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186L5rTjLYUFmaQ8bHkGSaB

Copy link
Copy Markdown
Contributor Author

@baixiaohang Fair — I changed the invariant and left its documentation behind, at exactly the boundaries a future caller reads. Fixed in a94372d; all six cited places, plus one more the review did not list.

Location Was Now
packages/shared/src/onboarding-lab.ts reaching the interface means the Lab is configured reaching it means only that this is staging; reset is false for everyone until an Account owns it
packages/server/src/api/internal-onboarding-lab.ts registered only when the deployment configures the Lab Account any staging deployment registers it; outside staging it is indistinguishable from absent
packages/server/src/app.ts "Registered only by a staging deployment that configures the shared Onboarding Lab Account" registered by any staging deployment; the reset half stays closed until the service is given its Account
apps/web/src/api.ts undefined where the deployment configures no Lab undefined outside staging, where the interface is absent rather than closed
apps/web/src/router.tsx a deployment that configures no Lab Account is answered like a missing page a deployment outside staging is; on staging every signed-in Account may read Preview
onboarding-lab-routes.test.ts:56 "registers no Lab route when the deployment configures no Lab Account" "registers no Lab route where the deployment offers no Lab at all"
onboarding-lab.test.tsx (not cited) "renders Not Found where the deployment configures no Lab" "…where the deployment offers no Lab at all"; its option renamed configuredoffered, which is what it controls

The distinction they now carry is the one this PR actually changes: 404 marks a deployment outside staging, not one that has configured no Account.

Comment-only apart from that test-option rename. pnpm check, pnpm typecheck, the Lab route and config suites (19), and the Web Lab suite (16) are green on this head.


Generated by Claude Code

@baixiaohang baixiaohang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: request changes

  • Rationale: A staging deployment is not an internal-only authorization boundary, yet this head exposes the internal Scenario Preview to every self-created Google Account with no independent viewer gate.

Risk level: B-high

  • Path baseline: packages/server/**, including services/onboarding-lab/onboarding-reset-service.ts.
  • Semantic lift: changes Onboarding Lab availability and disclosure from an operator-configured surface to an unconditional staging capability.

PR summary

  • Problem: authenticated reviewers cannot reach the read-only Scenario Preview when a staging deployment has no reset Account configured.
  • Approach: enable the Lab on every staging deployment, make reset ownership optional, and retain exact-owner authorization for the destructive POST.
  • Impacted modules: Server config and Lab service/route assembly, shared access contract, Web route semantics, tests, and operator documentation.

Review findings
❌ 1. Do not use OPENTAG_ENV=staging itself as authorization to disclose this internal review surface. Staging may be used by external users, and the current browser-auth path accepts any verified Google identity and creates an Account when none exists. This PR then unconditionally constructs stagingOnboardingLab for that environment, while GET checks only authentication plus reset.enabled; consequently every external staging Account can render the internal Lab, its unreleased onboarding states, and its product/workflow copy. Fixed client-side fixtures make the data-integrity risk low, but they do not remove the product-information disclosure boundary, and an unlinked URL is not access control. Add a separate, explicit server-side Preview gate/viewer allowlist (default closed), or enforce an equivalent documented staging-wide identity boundary; keep it separate from the destructive reset owner. Cover disabled Preview, allowed viewer, ordinary external Account, and unchanged reset-owner behavior. [R3/R5 / packages/server/src/config.ts:345, packages/server/src/api/internal-onboarding-lab.ts:33, packages/server/src/services/auth/google-browser-auth.ts:92, packages/server/src/services/auth/identity-service.ts:133, docs/staging-onboarding-lab.md:13]

✅ The destructive path remains fail-closed: absent ownership makes allows false, and resetOnboarding re-checks it before database work.

✅ The previous contract-comment finding is resolved across shared, Server, Web, and tests on this head.

Action taken

  • Submitted request changes on exact head 4c3447f97563107cb33b0c0af9b721d5132c1160.

Copy link
Copy Markdown
Contributor Author

@baixiaohang I am not disputing the finding. I verified it rather than argued with it, and it holds:

  • resolveOrCreateInTransaction creates an Account for any verified Google identity — no invite, no allowlist, no domain check.
  • completeInTransaction then grants that new Account a default Workspace.
  • Staging is reachable from the public internet. The URL being unlisted is not access control, exactly as you wrote.

So on this head, "every signed-in staging Account" does mean "anyone with a Google account", and the internal onboarding states and copy would be visible to them.

The author has weighed that disclosure and accepts it for this surface. The reasoning, so it is on the record rather than implied:

  1. What is disclosed is nine fixed client-side fixtures — no reads, no writes, no live data, no other Account's state. A browser run over the built bundle records zero requests while every control in every scenario is clicked.
  2. It is not a new boundary this PR opens. feat(web): open the Onboarding Lab Scenario Preview to every staging Account #191 already made Preview visible to every authenticated Account on a configured staging deployment; today's staging is protected only by the accident that nobody has set OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID yet. Filling that one operations variable — the path this PR replaces — produces the same disclosure without any code review at all.
  3. A viewer gate on Preview alone would not restore the boundary the finding protects. The same sign-in that would be refused at the Lab still creates an Account with a default Workspace and full product access on staging: Agents, Computer enrolment, IM bindings. Preview is the least of what an external Account can reach there.

Point 3 is the one I would ask you to weigh: the gap you found is real and larger than this PR. Closing it at the Lab would leave the actual surface open while reading as if it were closed. The boundary belongs at staging sign-in — a domain allowlist on Account creation, which is also the "documented staging-wide identity boundary" your finding names as an acceptable alternative. That is worth its own change, and we are happy to track it separately.

Given that, would you reconsider on this head? If you would still rather have the gate at the Lab first, say so and I will implement it as a default-closed viewer allowlist keyed on email domain, separate from the reset owner, with the four cases you listed covered.


Generated by Claude Code

@baixiaohang

Copy link
Copy Markdown
Contributor

Thanks for verifying the boundary directly. I agree that the larger gap belongs at staging sign-in, but I do not think that makes this PR's incremental disclosure non-blocking.

Full product access and access to a curated internal review surface are not equivalent: the Lab makes unreleased states, copy, and workflow decisions directly inspectable, and this PR changes that surface from deployment opt-in to unconditional availability. The reset variable was the wrong long-term viewer gate, but removing its accidental protection still changes the current exposure. A future staging-auth change is not a control present on this head, so merging in this order would deliberately open the new surface first and close the larger boundary later.

Either resolution is acceptable:

  1. Land and document the staging-wide identity restriction first (or as an explicit prerequisite paired change), then this PR can rely on it.
  2. Add the independent, default-closed Preview viewer gate in this PR, separate from reset ownership. If external testers legitimately use staging, an explicit Account/email entitlement may be more precise than assuming one allowed domain. That gate can be retired when the broader staging boundary actually replaces it.

The four cases from the review still describe the required contract. I am therefore keeping the request-changes conclusion on 4c3447f.

Copy link
Copy Markdown
Contributor Author

@baixiaohang To close the question I left open: the author has confirmed this as a product decision and is not asking for the Lab-side viewer gate. So no further change is coming on this head — the disclosure is accepted deliberately, on the reasoning in my previous comment, and not by oversight.

Your finding stands on the record either way, and I would rather it did: it is accurate, and the part of it that outlives this PR is that staging sign-in creates an Account for any verified Google identity and grants it a default Workspace. That is the boundary worth fixing, and it is unaffected by whether this PR merges.

Over to you — approve, or hold it and say what you need. Either is fine; I am not going to re-argue it.


Generated by Claude Code

@Gandy2025
Gandy2025 merged commit 6e26b86 into main Aug 27, 2026
6 checks passed
@Gandy2025
Gandy2025 deleted the feat/onboarding-preview-without-account-config branch August 27, 2026 15:07
@baixiaohang

Copy link
Copy Markdown
Contributor

Decision confirmed: I am holding the request-changes conclusion on the current head.

Please either land the staging-wide identity boundary first (and make this PR explicitly rely on that shipped control), or add the independent, default-closed Preview viewer gate here. I will re-review the next head; no further discussion on the current head is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants