Repository navigation
feat(cli): the authored public form path /forms/:slug redirects to the console form page when the anonymous door serves it - #22098
Conversation
…:slug to /_console/f/:slug when the anonymous door serves it Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
… changeset Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
…ports as a test input Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dbf59b0b69cafe400c7bebfb8dd35eab44f4e994 && git checkout dbf59b0b69cafe400c7bebfb8dd35eab44f4e994
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b04a5295f773045446a878860a0999a568681425 30e610afb203a659ac092255120b5796efaafe90 && git checkout -B drift-repro b04a5295f773045446a878860a0999a568681425 && git merge --no-ff 30e610afb203a659ac092255120b5796efaafe90
node scripts/docs-audit/affected-docs.mjs --json b04a5295f773045446a878860a0999a568681425
|
…g, which the public form page reads for prefill Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
Contract reviewServed-tier: ① Derived judgmentsRead, GitHub REST at 2026-10-07T16:07:29Z (check-runs and PR head re-read 16:12:55Z): card #22079 body and all six comments (triage ruling 6038401972; claim 6039093290 as amended 15:37Z; os-dev-reports 6041182117 and 6041737948; REWORK 6041249256; ACCEPT 6041774911); PR #22098 body and 7-file list;
Side-effects of the mechanism, judged not contract changes: the in-process probe passes through every Hono lane once more, so an armed rate-limit budget and the response-observation seam each see two requests per visit; the outer request's unmatched mark is unaffected ( ② Semver level
Clause-②: yes (widening) — a new answered path on the public door: anonymous ③ Boundary flags
No flag is left open; nothing is escalated. Implemented-by: VERDICT: PASS |
|
Queue reading for this PR's owner ( This PR is the head of the merge queue (its queue ref What can move it, in the owner's hands: (a) re-run the merge-group Generated by Claude Code |
Re-queue receipt — PR #22098, once
Signature: the merge-group build never measured the required
Action: the queue removed this entry 2026-10-07T17:22:29Z ( |
Refs #22079 (the /forms/SLUG redirect; the publicLink describe goes to the spec lane)
Clause-②: yes
The widened surface, precisely
GET /forms/:slug(andHEAD, which Hono answers from theGETroute), mounted on the host app by the console static plugin (createConsoleStaticPlugin,packages/cli/src/utils/console.ts). It is mounted only when the Console is:os serve/os devwith a built Console, or any host that mounts that plugin from@objectstack/cli/console. With--no-ui,--no-console,OS_DISABLE_CONSOLE=1, no Console package, no builtdist/, or the dev drift refusal, there is no plugin and no redirect.GET /api/v1/forms/SLUGanswers200to the same request. The redirect asks that door in-process: the visitor's request is re-addressed to the door path and dispatched through the same Hono app (app.fetch), with the visitor's headers (theHostand cookies that pick the environment) and connection (c.env), through the same middleware. So the door's one decision is asked, not copied: thesharingswitches and the slug (anonymousFormIntakeCandidates), a withdrawal in another layer (anonymousFormIntakeWithdrawnIn), the per-request organization read, and the posture check (anonymousFormIntakeUnavailability). No line ofpackages/restchanged.302,Location: /_console/f/SLUGfollowed by the request's query string. The path is built fromCONSOLE_PATHand the router-decoded slug passed throughencodeURIComponent, so it is always one path segment under/_console/f/on the same origin. The query string is then appended verbatim (as the URL parser holds it), because the public form page seeds its fields from?prefill_FIELD=(objectuiFormPage.tsx,readPrefill):/forms/contact-us?prefill_source=websitelands on/_console/f/contact-us?prefill_source=website. A query cannot carry a#, a control character or a path, so it changes only the query of the page the visitor lands on, never its origin or path; the open-redirect argument below holds with it. The door is still asked without the query.next()and writes nothing, so the adapter's not-found seam answers exactly as for any unrouted path:404 {"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not found"}}, the request still marked unmatched for response observation. That covers a disabled form, a non-anonymous form, an unknown slug, a form the posture withholds, a door answering5xxor throwing, every other method (still404, not405: the route is on the raw app, soallowedMethodsForPathdoes not see it), a trailing slash, the bare prefix and deeper paths./SLUGcatch-all;/_console/forms/:nameuntouched; no new error code; no change to any door's answer; no public type or export change (createConsoleStaticPlugin's pinned signature is unchanged).The PM's readings, measured
origin/main's behaviour for this path: on a real showcase boot with the console plugin mounted,GET /forms/contact-usanswered404(expected 404 to be 302) while the same file's fall-through pins, which compare against an unrouted path byte for byte, stayed green. So the 404 isENDPOINT_NOT_FOUNDfrom the adapter's fallback, as read.GET /_console/f/contact-usis answered by the unchangedGET /_console/*SPA fallback (measured200 text/htmlon this branch).multiTenant: 'posture-only') the door answers404 FORM_NOT_FOUNDfor the publishedcontact-usform (intake unavailable), and the redirect follows it:GET /forms/contact-usanswers the unrouted 404 byte for byte. A redirect built from the candidates rule alone would have redirected there, to a page that answers not-found; ablation M2 shows that pin goes red.createConsoleStaticPlugin, so it exists exactly when/_console/*does;serve.tsis not touched. Ledger row inconsole-route-ledger.ts: dispositionstatic-asset. The reading "a redirect is notstatic-asset" is falsified by the ledger's own type:static-assetis defined as "serves bytes off disk (or redirects to something that does)", and its two existing redirect rows,GET /andGET /_console, are filed under it. The discriminator is the peer group (check-auth-mount-ledger.mjs): this route's peers are those two redirects, navigation to the bundle that no client method builds and none should. Notpublic: it is not an API endpoint, it answers a 302 or the standard 404 and no body; the anonymous decision it consults is already ledgered where it lives,GET /api/v1/forms/:slug(public, REST ledgerformsfamily), and the row's note names that door. The guard's containment assertion (the whole ledger staysstatic-asset, every note names its mechanism) passes unedited. Cloud-connected arm (createUnknownHostnameGuardPlugin): on an unknown platform host the guard refuses before the route is reached (it is installed ininit()); on a mapped tenant host it passes the request through and the door resolves the environment from the host; on a reserved or apex host withOS_CLOUD_URLset,/forms/SLUGis not a Console path, so the guard passes it through, and the door is asked for that host. If it ever answered 200 there, the redirect target/_console/f/SLUGwould get the guard's existing redirect to the cloud Console root, as it does today for anyone who opens/_console/f/SLUGon that host. The redirect adds no answer the host did not already give.302, never permanent.Locationpath fromCONSOLE_PATHand the encoded slug only, then the request's query string. Trailing slash: Hono's strict routing does not match it, so it keeps the 404 and the door is never asked.HEAD: same 302 andLocation, door asked withGET. Percent-encoded slug: decoded by the router, asked re-encoded, redirected re-encoded (/forms/contact%2Dusanswers/_console/f/contact-us). Pinned against a door that serves every slug (the worst case):%2F%2Fevil.example,%5C%5Cevil.example,https%3A%2F%2Fevil.example,a%2F..%2F..%2Fadmin, an encoded CR LFSet-Cookie, and an encoded?/#each stay one encoded segment on the same origin, with noSet-Cookieheader and, since those requests carry none, no query or fragment. A query on the request is carried and cannot move the target:?next=https://evil.example, an encoded CR LFSet-Cookieand?/..//evil.exampleeach leave origin and path at/_console/f/contact-us. A slug that needs encoding with a query (/forms/caf%C3%A9%20form?prefill_source=website&lang=fr) has its path re-encoded and its query untouched. An empty?adds nothing, and a fragment never reaches the server./forms/*: the REST doors are under the API prefix (/api/v1/forms/*, plus the scoped/api/v1/environments/:environmentId/forms/*), the dispatcher's fallback handles only declared endpoint paths under the API prefix (isAppEndpointPath), and the/forms/:namein spec comments is the Console's own client route under/_console. Grep overpackages/andexamples/for the prefix: onlypublicLinkvalues and prose. The route registers in Phase 2start(); the 404 is thenotFoundseam, which runs only after every matched handler declines, so the route answers first and, by callingnext(), never shadows anything mounted later (listen()'s static-root/*included).X-Environment-Id, then the single-environment default). The/f/SLUGpage asks the same unscoped door from the same origin withcredentials: 'include'and no environment header (objectuiFormPage.tsx,API_BASE = '/api/v1'), so it lands in the same environment and nothing needs carrying. A hostname-routed multi-environment deployment is served per host. A deployment that tells environments apart only by scoped URL or header cannot address a non-default environment from/f/SLUGat all, redirect or not; withprojectResolution: 'required'the unscoped door does not exist, so the redirect asks it, gets a 404, and keeps today's 404. Both are noted below, not changed here.Changeset and semver
@objectstack/cli: minor.Clause-②: yes (widening)takes at least minor (AGENTS.md, Post-Task Checklist step 3): a new answered public path, and no key, export or signature changes.@objectstack/restgains no export and is untouched, so it carries no changeset.Files
packages/cli/src/utils/console.ts: the route andanonymousFormDoorServes.packages/cli/src/utils/console-route-ledger.ts: theGET /forms/:slugrow.packages/cli/src/utils/console.public-form-redirect.test.ts: unit pins on a realHonoHttpServerwith a stub door.packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts: the triage pins on a real showcase boot (per-file temporary cwd from the suite's setup file; the consoledist/is a temporaryindex.html).scripts/cross-package-test-inputs.mjs,turbo.json: declarepackages/cli/src/utils/console.tsas an input of@objectstack/dogfood#test, which the dogfood file imports as source (the remedycheck:cross-package-test-inputsprescribes; per-file, like the route-ledger entries beside it)..changeset/22079-forms-slug-redirect.md.Verification
Readings at head
30e610af(rework round 1: the query string is carried) unless named otherwise.@objectstack/cli,unittier).src/utils/console.public-form-redirect.test.ts(on a realHonoHttpServerwith the not-found seam installed and a stub door) plusconsole-route-ledger.conformance.test.ts:Test Files 2 passed (2) · Tests 50 passed (50). Every fall-through case compares status and body byte for byte against the same request to the same app without the plugin. The query cases: threeprefill_queries (with a second parameter, encoded values,+, a repeated key) arrive onLocationunchanged; a slug that needs encoding together with a query; the door asked without the query;HEAD; an empty?and a fragment; an unserved slug with a query keeps the same 404. The wholeunittier at98168b73, before the round-1 change:Test Files 260 passed (260) · Tests 3810 passed (3810). Theintegrationtier is declared to CI: the diff touches no integration file and no spawn entry.test/showcase-public-form-redirect.dogfood.test.ts:Test Files 1 passed (1) · Tests 9 passed (9), includingGET /forms/contact-us?prefill_company=Analytical%20Engines&utm_source=websiteanswering302to/_console/f/contact-uswith the same query (companyis a field the form declares). ShardOS_TEST_SHARD=3/3, the shard that holds the file (its verbose log lists the file's 9 cases):Test Files 72 passed | 1 skipped (73) · Tests 655 passed | 8 skipped (663).pnpm --filter @objectstack/cli run typecheck(tsc --noEmitandcheck:test-typecheck: OK) andpnpm --filter @objectstack/dogfood run typecheck: both exit 0.scripts/ablation-replace.mjs: anchor hit 1 to 0, blob changed, restored to theHEADblob withgit diff HEADempty; the subject is imported as source, so no build sits between the edit and the run):0736c337, the redirect removed (origin/main's behaviour): dogfood3 failed | 5 passed. The redirect pin answersexpected 404 to be 302; the disabled and non-anonymous pins pass their 404 half and fail on the republished redirect; the unknown-slug, walled and control pins stay green.0736c337, redirect whatever the door answers: dogfood4 failed | 4 passed. Unknown slug, disabled, non-anonymous and walled all answer302where the unrouted 404 was expected.0736c337, the ledger row's route renamed:console-route-ledger.conformance.test.ts2 failed | 16 passed(an unledgered mount, and a row nothing mounts).30e610af, the query dropped again: unit7 failed | 25 passed(every query case that expects a query onLocation), dogfood1 failed | 8 passed(theprefill_case); the door-without-query and unserved-slug cases stay green.30e610afwith each exit code recorded:dispatch-gates --ran: 87 derived famil(ies) accounted for, 87 run, 0 NOT-MEASURED, every one exit 0.check:dual-build-cjs-loadsfirst refused withPREREQUISITE NOT MET(eight packages outside the dogfood closure had nodist/in the fresh worktree); after building them it measured green (106 entry points across 66 packages). Plus the dispatch'scheck:authz-resolver,check:cli-test-child-envandcheck:route-envelope, exit 0.pnpm lint(eslint . --no-inline-config) at30e610af: exit 0, no output.Acceptance notes
publicLinkdescribe inpackages/spec/src/ui/sharing.zod.tsstill reads "Generated public share URL". It should say the value is a slug and name the URL this PR makes answer (/forms/SLUG, redirected to/_console/f/SLUG).#22079 remains openfor it.content/docs/ui/forms.mdx(the mode table) andcontent/docs/ui/public-data-collection.mdx(section 1) name the API endpoints but not the link a visitor opens. Not edited here (outside this claim).docs/qa/platform-checklist/areas/api-backend.jsondescribes the console ledger as "the four static-asset rows"; it is five now. Noted, not edited./forms/SLUGcounts twice against the visitor's key (the visit and the in-process door request). Observation only./api/v1(objectuiFormPage.tsx). On a deployment withprojectResolution: 'required'or a customapi.apiPath,/_console/f/SLUGcannot load a form today, and the redirect, which asks the same URL, does not fire there. Read from code, not measured on such a deployment.200 text/html), and the door the page reads serves the form and accepts a submission that lands. The Console's own rendering is objectui's and is not booted here (this suite has no built Console).Generated by Claude Code