Skip to content

Commit dd39171

Browse files
feat(cli): the authored public form path /forms/:slug redirects to the console form page when the anonymous door serves it (#22098)
Refs #22079 (the /forms/SLUG redirect; the publicLink describe goes to the spec lane) Clause-②: yes ## The widened surface, precisely - **Route:** `GET /forms/:slug` (and `HEAD`, which Hono answers from the `GET` route), 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 dev` with 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 built `dist/`, or the dev drift refusal, there is no plugin and no redirect. - **Condition:** the anonymous form door `GET /api/v1/forms/SLUG` answers `200` to 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 (the `Host` and cookies that pick the environment) and connection (`c.env`), through the same middleware. So the door's one decision is asked, not copied: the `sharing` switches and the slug (`anonymousFormIntakeCandidates`), a withdrawal in another layer (`anonymousFormIntakeWithdrawnIn`), the per-request organization read, and the posture check (`anonymousFormIntakeUnavailability`). No line of `packages/rest` changed. - **Answer when served:** `302`, `Location: /_console/f/SLUG` followed by the request's query string. The path is built from `CONSOLE_PATH` and the router-decoded slug passed through `encodeURIComponent`, 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=` (objectui `FormPage.tsx`, `readPrefill`): `/forms/contact-us?prefill_source=website` lands 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. - **Answer otherwise:** the handler calls `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 answering `5xx` or throwing, every other method (still `404`, not `405`: the route is on the raw app, so `allowedMethodsForPath` does not see it), a trailing slash, the bare prefix and deeper paths. - **Not changed:** no root `/SLUG` catch-all; `/_console/forms/:name` untouched; 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 - **H1 (reproduce).** Ablation M1 below removes only the redirect, which is `origin/main`'s behaviour for this path: on a real showcase boot with the console plugin mounted, `GET /forms/contact-us` answered `404` (`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 is `ENDPOINT_NOT_FOUND` from the adapter's fallback, as read. `GET /_console/f/contact-us` is answered by the unchanged `GET /_console/*` SPA fallback (measured `200 text/html` on this branch). - **H2 (one decision point).** Holds by construction: the door is asked, so there is no second copy to drift. Measured for the posture case: on a walled boot (`multiTenant: 'posture-only'`) the door answers `404 FORM_NOT_FOUND` for the published `contact-us` form (intake unavailable), and the redirect follows it: `GET /forms/contact-us` answers 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. - **H3 (mount, ledger, cloud arm).** Mounted inside `createConsoleStaticPlugin`, so it exists exactly when `/_console/*` does; `serve.ts` is not touched. Ledger row in `console-route-ledger.ts`: disposition **`static-asset`**. The reading "a redirect is not `static-asset`" is falsified by the ledger's own type: `static-asset` is defined as "serves bytes off disk (or redirects to something that does)", and its two existing redirect rows, `GET /` and `GET /_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. Not `public`: 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 ledger `forms` family), and the row's note names that door. The guard's containment assertion (the whole ledger stays `static-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 in `init()`); 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 with `OS_CLOUD_URL` set, `/forms/SLUG` is 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/SLUG` would get the guard's existing redirect to the cloud Console root, as it does today for anyone who opens `/_console/f/SLUG` on that host. The redirect adds no answer the host did not already give. - **H4 (status and target).** `302`, never permanent. `Location` path from `CONSOLE_PATH` and 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 and `Location`, door asked with `GET`. Percent-encoded slug: decoded by the router, asked re-encoded, redirected re-encoded (`/forms/contact%2Dus` answers `/_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 LF `Set-Cookie`, and an encoded `?` / `#` each stay one encoded segment on the same origin, with no `Set-Cookie` header 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 LF `Set-Cookie` and `?/..//evil.example` each 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. - **H5 (order and shadowing).** Nothing else claims root `/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/:name` in spec comments is the Console's own client route under `/_console`. Grep over `packages/` and `examples/` for the prefix: only `publicLink` values and prose. The route registers in Phase 2 `start()`; the 404 is the `notFound` seam, which runs only after every matched handler declines, so the route answers first and, by calling `next()`, never shadows anything mounted later (`listen()`'s static-root `/*` included). - **H6 (environment).** The redirect chooses no environment. It forwards the visitor's own request to the unscoped door, whose standard chain decides (explicit id, then the host's resolver seam, then hostname, then `X-Environment-Id`, then the single-environment default). The `/f/SLUG` page asks the same unscoped door from the same origin with `credentials: 'include'` and no environment header (objectui `FormPage.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/SLUG` at all, redirect or not; with `projectResolution: '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/rest` gains no export and is untouched, so it carries no changeset. ## Files - `packages/cli/src/utils/console.ts`: the route and `anonymousFormDoorServes`. - `packages/cli/src/utils/console-route-ledger.ts`: the `GET /forms/:slug` row. - `packages/cli/src/utils/console.public-form-redirect.test.ts`: unit pins on a real `HonoHttpServer` with 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 console `dist/` is a temporary `index.html`). - `scripts/cross-package-test-inputs.mjs`, `turbo.json`: declare `packages/cli/src/utils/console.ts` as an input of `@objectstack/dogfood#test`, which the dogfood file imports as source (the remedy `check:cross-package-test-inputs` prescribes; 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. - **Unit (`@objectstack/cli`, `unit` tier).** `src/utils/console.public-form-redirect.test.ts` (on a real `HonoHttpServer` with the not-found seam installed and a stub door) plus `console-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: three `prefill_` queries (with a second parameter, encoded values, `+`, a repeated key) arrive on `Location` unchanged; 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 whole `unit` tier at `98168b73`, before the round-1 change: `Test Files 260 passed (260) · Tests 3810 passed (3810)`. The `integration` tier is declared to CI: the diff touches no integration file and no spawn entry. - **Dogfood.** `test/showcase-public-form-redirect.dogfood.test.ts`: `Test Files 1 passed (1) · Tests 9 passed (9)`, including `GET /forms/contact-us?prefill_company=Analytical%20Engines&utm_source=website` answering `302` to `/_console/f/contact-us` with the same query (`company` is a field the form declares). Shard `OS_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)`. - **Typecheck.** `pnpm --filter @objectstack/cli run typecheck` (`tsc --noEmit` and `check:test-typecheck`: OK) and `pnpm --filter @objectstack/dogfood run typecheck`: both exit 0. - **Ablations** (each through `scripts/ablation-replace.mjs`: anchor hit 1 to 0, blob changed, restored to the `HEAD` blob with `git diff HEAD` empty; the subject is imported as source, so no build sits between the edit and the run): - M1 at `0736c337`, the redirect removed (`origin/main`'s behaviour): dogfood `3 failed | 5 passed`. The redirect pin answers `expected 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. - M2 at `0736c337`, redirect whatever the door answers: dogfood `4 failed | 4 passed`. Unknown slug, disabled, non-anonymous and walled all answer `302` where the unrouted 404 was expected. - M3 at `0736c337`, the ledger row's route renamed: `console-route-ledger.conformance.test.ts` `2 failed | 16 passed` (an unledgered mount, and a row nothing mounts). - M4 at `30e610af`, the query dropped again: unit `7 failed | 25 passed` (every query case that expects a query on `Location`), dogfood `1 failed | 8 passed` (the `prefill_` case); the door-without-query and unserved-slug cases stay green. - **Gates.** The dispatched list, re-derived for the 7 paths, run at `30e610af` with 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-loads` first refused with `PREREQUISITE NOT MET` (eight packages outside the dogfood closure had no `dist/` in the fresh worktree); after building them it measured green (106 entry points across 66 packages). Plus the dispatch's `check:authz-resolver`, `check:cli-test-child-env` and `check:route-envelope`, exit 0. - **Lint.** Full `pnpm lint` (`eslint . --no-inline-config`) at `30e610af`: exit 0, no output. ## Acceptance notes - **Not in this PR (spec lane):** the `publicLink` describe in `packages/spec/src/ui/sharing.zod.ts` still 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 open` for it. - **Docs, noted for whoever carries the describe:** `content/docs/ui/forms.mdx` (the mode table) and `content/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). - **Checklist text:** `docs/qa/platform-checklist/areas/api-backend.json` describes the console ledger as "the four static-asset rows"; it is five now. Noted, not edited. - **Rate limit:** with an inbound budget armed, a visit to `/forms/SLUG` counts twice against the visitor's key (the visit and the in-process door request). Observation only. - **Console page coupling:** the public form page reads the door at a fixed unscoped `/api/v1` (objectui `FormPage.tsx`). On a deployment with `projectResolution: 'required'` or a custom `api.apiPath`, `/_console/f/SLUG` cannot 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. - **Pinned in the issue, measured here differently:** the triage pin "the form loads" is measured as the server's half: the redirect target is the Console bundle (`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](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4935c66 commit dd39171

7 files changed

Lines changed: 660 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
The path an author writes as a public form's `sharing.publicLink` now answers: `GET /forms/SLUG` redirects to the Console's public form page, `/_console/f/SLUG`, when the anonymous form door serves that slug
6+
7+
Clause-②: yes (widening)
8+
9+
- **What was wrong.** An app declares a public form as `sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }`, the spelling the platform's own examples use. The Console serves that form to an anonymous visitor at `/_console/f/contact-us`, but the path as written answered `404 ENDPOINT_NOT_FOUND`, so the link an author put on a website reached nothing.
10+
- **What it does now.** Wherever the Console is mounted (`os serve` / `os dev` with the Console, or any host that mounts `createConsoleStaticPlugin` from `@objectstack/cli/console`), `GET /forms/SLUG` answers `302` with `Location: /_console/f/SLUG`, followed by the request's query string. It does so only when the anonymous form door, `GET /api/v1/forms/SLUG`, serves the form to the same request: the form's `sharing` has `enabled` and `allowAnonymous` set and names the slug, no other metadata layer withdraws it, and the deployment's tenancy posture lets it take an anonymous submission. The redirect asks that door in-process with the visitor's own request, so it decides nothing the door does not, and it reveals nothing the door does not.
11+
- **Unchanged.** Every other request under `/forms/` answers exactly as before: a disabled form, a form not open to anonymous visitors, an unknown slug, a form the posture withholds, any method other than `GET` / `HEAD`, a trailing slash or a deeper path all get the same `404 ENDPOINT_NOT_FOUND`. Nothing is mounted at the root for a bare slug. The signed-in Console route `/_console/forms/NAME` is untouched. A deployment without the Console (`--no-ui`, `--no-console`, `OS_DISABLE_CONSOLE=1`, or no built Console) mounts no redirect.
12+
- **Status and target.** The redirect is a `302`, never a permanent one, because whether a form is served can change with its sharing. The `Location` path is built from the Console path and the slug, encoded as one path segment, so no request can point it anywhere else. The request's query string is carried verbatim after that path, because the public form page seeds its fields from `?prefill_FIELD=` parameters: `/forms/contact-us?prefill_source=website` lands on `/_console/f/contact-us?prefill_source=website`. A query string can change only the query of the page the visitor lands on, never its origin or path.
13+
- **Nothing to migrate.** No key, export or signature changes. `sharing.publicLink` keeps its meaning and its accepted spellings (`/forms/x`, `forms/x` and `x` name one slug).

‎packages/cli/src/utils/console-route-ledger.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
* SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the
6161
* filing: four routes across two plugin factories, both in `utils/console.ts`,
6262
* which the guard confirms is the ONLY file in this package's 109 sources that
63-
* mounts a route at all.
63+
* mounts a route at all. A fifth, `GET /forms/:slug`, joined the console-static
64+
* family later: a redirect to the bundle's public form page, in the same peer
65+
* group as the two redirects already here.
6466
*
6567
* This module is package-internal: it is the guard's data, not public API, and
6668
* `@objectstack/cli` is a binary rather than a consumed library surface. It
@@ -134,6 +136,19 @@ export const CONSOLE_ROUTE_LEDGER: readonly ConsoleRouteLedgerEntry[] = [
134136
+ 'with `base: \'/_console/\'`, so relative asset URLs only resolve from the slashed path. Pure navigation '
135137
+ 'plumbing for a static bundle; there is nothing here for an SDK to express.',
136138
},
139+
{
140+
route: 'GET /forms/:slug',
141+
family: 'console-static',
142+
mountedIn: 'utils/console.ts',
143+
disposition: 'static-asset',
144+
note:
145+
'redirects (302) the path an author writes as a public form\'s `sharing.publicLink` to the Console\'s public '
146+
+ 'form page, `/_console/f/<slug>`, which `GET /_console/*` serves from the bundle. The same peer group as '
147+
+ '`GET /` and `GET /_console`: navigation to a static bundle, which no client method builds. It answers '
148+
+ 'only when the anonymous form door `GET /api/v1/forms/:slug` (REST ledger, `forms` family) serves the slug '
149+
+ 'to the same request, asked in-process, and otherwise falls through to the unmatched-request 404. So it '
150+
+ 'reveals nothing that door does not, and the decision stays in one place.',
151+
},
137152
{
138153
route: 'GET /_console/*',
139154
family: 'console-static',
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The console's `GET /forms/:slug` redirect (#22079).
5+
*
6+
* An app author writes a public form's `sharing.publicLink` as `/forms/<slug>`,
7+
* and the Console serves that form at `/_console/f/<slug>`. The console static
8+
* plugin now answers the authored path with a 302 to the page, but only when
9+
* the anonymous form door (`GET /api/v1/forms/:slug`, `@objectstack/rest`)
10+
* serves the slug to the same request. Every other request keeps the answer it
11+
* got before: the transport's unmatched-request 404.
12+
*
13+
* These run the REAL plugin on a REAL Hono app (the `HonoHttpServer` production
14+
* resolves as `http.server`, with its not-found seam installed), with a stub
15+
* door registered the way `@objectstack/rest` registers the real one. The stub
16+
* is the door's answer and nothing else, so each case says which answer the
17+
* redirect follows. The real door, on a real showcase boot, is
18+
* `packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts`.
19+
*
20+
* "The answer it got before" is measured, not written down: every fall-through
21+
* case compares status AND body, byte for byte, against the same request sent
22+
* to the same app WITHOUT the plugin mounted.
23+
*/
24+
25+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
26+
import fs from 'node:fs';
27+
import os from 'node:os';
28+
import path from 'node:path';
29+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
30+
31+
import { createConsoleStaticPlugin } from './console.js';
32+
33+
const ORIGIN = 'http://forms.example.test';
34+
const OPEN = 'contact-us';
35+
36+
/** One stub door answer per slug; anything unlisted is the door's not-found. */
37+
type DoorAnswer = 'serve' | 'not-found' | 'fail' | 'throw';
38+
39+
interface DoorCall {
40+
method: string;
41+
slug: string;
42+
query: unknown;
43+
host: unknown;
44+
cookie: unknown;
45+
}
46+
47+
interface App {
48+
request(p: string, init?: RequestInit): Promise<Response>;
49+
doorCalls: DoorCall[];
50+
}
51+
52+
let consoleRoot: string;
53+
let distPath: string;
54+
55+
beforeAll(() => {
56+
consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-forms-'));
57+
distPath = path.join(consoleRoot, 'dist');
58+
fs.mkdirSync(distPath);
59+
fs.writeFileSync(path.join(distPath, 'index.html'), '<!doctype html><html><head></head><body>console</body></html>');
60+
});
61+
62+
afterAll(() => {
63+
fs.rmSync(consoleRoot, { recursive: true, force: true });
64+
});
65+
66+
async function mount(opts: {
67+
console: boolean;
68+
dist?: string;
69+
door?: (slug: string) => DoorAnswer;
70+
}): Promise<App> {
71+
const server = new HonoHttpServer(0);
72+
server.installNotFoundSeam();
73+
const doorCalls: DoorCall[] = [];
74+
const door = opts.door ?? ((slug: string) => (slug === OPEN ? 'serve' : 'not-found'));
75+
76+
server.get('/api/v1/forms/:slug', async (req: any, res: any) => {
77+
const slug = String(req.params?.slug ?? '');
78+
doorCalls.push({ method: req.method, slug, query: req.query, host: req.headers?.host, cookie: req.headers?.cookie });
79+
const answer = door(slug);
80+
if (answer === 'throw') throw new Error('door exploded');
81+
if (answer === 'fail') {
82+
res.status(500).json({ code: 'FORM_RESOLVE_FAILED', error: 'resolve failed' });
83+
return;
84+
}
85+
if (answer === 'not-found') {
86+
res.status(404).json({ code: 'FORM_NOT_FOUND', error: `No public form configured at /forms/${slug}` });
87+
return;
88+
}
89+
res.json({ slug, object: 'showcase_inquiry', form: {} });
90+
});
91+
92+
if (opts.console) {
93+
const plugin = createConsoleStaticPlugin(opts.dist ?? distPath);
94+
await plugin.start({ getServiceAsync: async () => server, logger: { warn: () => {} } });
95+
}
96+
const app = server.getRawApp();
97+
return {
98+
request: async (p: string, init?: RequestInit) => app.request(`${ORIGIN}${p}`, init),
99+
doorCalls,
100+
};
101+
}
102+
103+
async function answerOf(res: Response): Promise<{ status: number; body: string; location: string | null }> {
104+
return { status: res.status, body: await res.text(), location: res.headers.get('location') };
105+
}
106+
107+
/** The request's answer with the plugin mounted, and the same request's answer without it. */
108+
async function withAndWithout(p: string, init?: RequestInit, door?: (slug: string) => DoorAnswer) {
109+
const mounted = await mount({ console: true, door });
110+
const bare = await mount({ console: false, door });
111+
const answer = await answerOf(await mounted.request(p, init));
112+
const before = await answerOf(await bare.request(p, init));
113+
return { answer, before, doorCalls: mounted.doorCalls };
114+
}
115+
116+
describe('GET /forms/:slug — redirects when the anonymous form door serves the slug', () => {
117+
it('answers 302 to the console public form page', async () => {
118+
const app = await mount({ console: true });
119+
const res = await app.request(`/forms/${OPEN}`);
120+
expect(res.status).toBe(302);
121+
expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`);
122+
expect(app.doorCalls.map((d) => [d.method, d.slug])).toEqual([['GET', OPEN]]);
123+
});
124+
125+
it('the page it redirects to is served by the console bundle', async () => {
126+
const app = await mount({ console: true });
127+
const location = (await app.request(`/forms/${OPEN}`)).headers.get('location')!;
128+
const page = await app.request(location);
129+
expect(page.status).toBe(200);
130+
expect(page.headers.get('content-type')).toContain('text/html');
131+
expect(await page.text()).toContain('console');
132+
});
133+
134+
it('HEAD is answered like GET, and the door is still asked with GET', async () => {
135+
const app = await mount({ console: true });
136+
const res = await app.request(`/forms/${OPEN}`, { method: 'HEAD' });
137+
expect(res.status).toBe(302);
138+
expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`);
139+
expect(app.doorCalls.map((d) => d.method)).toEqual(['GET']);
140+
});
141+
142+
it('asks the door with the visitor\'s own headers, which pick the environment', async () => {
143+
const app = await mount({ console: true });
144+
// `@hono/node-server` builds the request from the socket's `Host` header;
145+
// an injected request carries it only when it is sent explicitly.
146+
await app.request(`/forms/${OPEN}`, { headers: { host: 'tenant-a.example.test', cookie: 'session=abc' } });
147+
expect(app.doorCalls).toHaveLength(1);
148+
expect(app.doorCalls[0].host).toBe('tenant-a.example.test');
149+
expect(app.doorCalls[0].cookie).toBe('session=abc');
150+
});
151+
152+
it('a percent-encoded slug is asked decoded and redirected re-encoded', async () => {
153+
const app = await mount({ console: true });
154+
const res = await app.request('/forms/contact%2Dus');
155+
expect(app.doorCalls.map((d) => d.slug)).toEqual([OPEN]);
156+
expect(res.status).toBe(302);
157+
expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`);
158+
});
159+
});
160+
161+
describe('GET /forms/:slug — the Location cannot be steered off the console page', () => {
162+
// A door that serves every slug is the worst case: the redirect then fires
163+
// for whatever the request carried, so the Location must stay one encoded
164+
// path segment under `/_console/f/` on the same origin whatever that was.
165+
const servesEverything = (): DoorAnswer => 'serve';
166+
167+
for (const [requested, expected] of [
168+
['/forms/%2F%2Fevil.example', '/_console/f/%2F%2Fevil.example'],
169+
['/forms/%5C%5Cevil.example', '/_console/f/%5C%5Cevil.example'],
170+
['/forms/https%3A%2F%2Fevil.example', '/_console/f/https%3A%2F%2Fevil.example'],
171+
['/forms/a%2F..%2F..%2Fadmin', '/_console/f/a%2F..%2F..%2Fadmin'],
172+
['/forms/x%0D%0ASet-Cookie%3A%20a%3Db', '/_console/f/x%0D%0ASet-Cookie%3A%20a%3Db'],
173+
['/forms/x%3Fnext%3D%2F%2Fevil.example%23y', '/_console/f/x%3Fnext%3D%2F%2Fevil.example%23y'],
174+
] as const) {
175+
it(`${requested} → ${expected}`, async () => {
176+
const app = await mount({ console: true, door: servesEverything });
177+
const res = await app.request(requested);
178+
expect(res.status).toBe(302);
179+
const location = res.headers.get('location')!;
180+
expect(location).toBe(expected);
181+
expect(res.headers.get('set-cookie')).toBeNull();
182+
const resolved = new URL(location, ORIGIN);
183+
expect(resolved.origin).toBe(ORIGIN);
184+
expect(resolved.pathname.startsWith('/_console/f/')).toBe(true);
185+
expect(resolved.pathname.slice('/_console/f/'.length)).not.toContain('/');
186+
expect(resolved.search).toBe('');
187+
expect(resolved.hash).toBe('');
188+
});
189+
}
190+
191+
it('a query string cannot steer it either: origin and path stay the console page', async () => {
192+
const app = await mount({ console: true, door: servesEverything });
193+
for (const query of ['?next=https://evil.example', '?x=%0D%0ASet-Cookie:%20a=b', '?/..//evil.example']) {
194+
const res = await app.request(`/forms/${OPEN}${query}`);
195+
expect(res.status).toBe(302);
196+
expect(res.headers.get('set-cookie')).toBeNull();
197+
const location = res.headers.get('location')!;
198+
expect(location).toBe(`/_console/f/${OPEN}${query}`);
199+
const resolved = new URL(location, ORIGIN);
200+
expect(resolved.origin).toBe(ORIGIN);
201+
expect(resolved.pathname).toBe(`/_console/f/${OPEN}`);
202+
}
203+
});
204+
});
205+
206+
describe('GET /forms/:slug — the request\'s query string travels to the page', () => {
207+
// The public form page seeds its fields from `?prefill_<field>=`, so a link
208+
// that carries one must land with it, byte for byte.
209+
for (const query of [
210+
'?prefill_source=website&utm_campaign=spring',
211+
'?prefill_name=Ada%20Lovelace&prefill_email=ada%40example.com&ref=a+b',
212+
'?prefill_message=line%0Aone&prefill_message=dup',
213+
]) {
214+
it(`${query} arrives on Location unchanged`, async () => {
215+
const app = await mount({ console: true });
216+
const res = await app.request(`/forms/${OPEN}${query}`);
217+
expect(res.status).toBe(302);
218+
expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}${query}`);
219+
});
220+
}
221+
222+
it('a slug that needs encoding, together with a query: the path is re-encoded, the query is untouched', async () => {
223+
const app = await mount({ console: true, door: () => 'serve' });
224+
const query = '?prefill_source=website&lang=fr';
225+
const res = await app.request(`/forms/caf%C3%A9%20form${query}`);
226+
expect(app.doorCalls.map((d) => d.slug)).toEqual(['café form']);
227+
expect(res.status).toBe(302);
228+
const location = res.headers.get('location')!;
229+
expect(location).toBe(`/_console/f/caf%C3%A9%20form${query}`);
230+
const resolved = new URL(location, ORIGIN);
231+
expect(resolved.pathname).toBe('/_console/f/caf%C3%A9%20form');
232+
expect(resolved.searchParams.get('prefill_source')).toBe('website');
233+
expect(resolved.searchParams.get('lang')).toBe('fr');
234+
});
235+
236+
it('the door is asked without the query', async () => {
237+
const app = await mount({ console: true });
238+
await app.request(`/forms/${OPEN}?prefill_source=website&utm_campaign=spring`);
239+
expect(app.doorCalls).toHaveLength(1);
240+
expect(app.doorCalls[0].query).toEqual({});
241+
});
242+
243+
it('HEAD carries it too', async () => {
244+
const app = await mount({ console: true });
245+
const res = await app.request(`/forms/${OPEN}?prefill_source=website`, { method: 'HEAD' });
246+
expect(res.status).toBe(302);
247+
expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}?prefill_source=website`);
248+
});
249+
250+
it('an empty query or a fragment adds nothing', async () => {
251+
const app = await mount({ console: true });
252+
expect((await app.request(`/forms/${OPEN}?`)).headers.get('location')).toBe(`/_console/f/${OPEN}`);
253+
expect((await app.request(`/forms/${OPEN}?a=1#frag`)).headers.get('location')).toBe(`/_console/f/${OPEN}?a=1`);
254+
});
255+
256+
it('a slug the door does not serve keeps the same 404 with a query, byte for byte', async () => {
257+
const { answer, before } = await withAndWithout('/forms/unknown-slug?prefill_source=website');
258+
expect(before.status).toBe(404);
259+
expect(answer).toEqual(before);
260+
});
261+
});
262+
263+
describe('GET /forms/:slug — every other request keeps the answer it got before', () => {
264+
it('a slug the door does not serve: the same 404, byte for byte', async () => {
265+
const { answer, before, doorCalls } = await withAndWithout('/forms/unknown-slug');
266+
expect(before.status).toBe(404);
267+
expect(JSON.parse(before.body)).toEqual({
268+
success: false,
269+
error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' },
270+
});
271+
expect(answer).toEqual(before);
272+
expect(doorCalls.map((d) => d.slug)).toEqual(['unknown-slug']);
273+
});
274+
275+
it('a door that answers 500: the same 404, not a redirect', async () => {
276+
const { answer, before } = await withAndWithout(`/forms/${OPEN}`, undefined, () => 'fail');
277+
expect(before.status).toBe(404);
278+
expect(answer).toEqual(before);
279+
});
280+
281+
it('a door that throws: the same 404, not a redirect', async () => {
282+
const { answer, before } = await withAndWithout(`/forms/${OPEN}`, undefined, () => 'throw');
283+
expect(before.status).toBe(404);
284+
expect(answer).toEqual(before);
285+
});
286+
287+
for (const [label, p, init] of [
288+
['a POST to an open slug (still 404, not 405)', `/forms/${OPEN}`, { method: 'POST' }],
289+
['a trailing slash', `/forms/${OPEN}/`, undefined],
290+
['the bare prefix', '/forms', undefined],
291+
['the prefix with a slash', '/forms/', undefined],
292+
['a nested path', `/forms/${OPEN}/submit`, undefined],
293+
['a root-level slug (no catch-all)', `/${OPEN}`, undefined],
294+
] as const) {
295+
it(`${label}: the same answer, and the door is never asked`, async () => {
296+
const { answer, before, doorCalls } = await withAndWithout(p, init as RequestInit | undefined);
297+
expect(before.status).toBe(404);
298+
expect(answer).toEqual(before);
299+
expect(doorCalls).toEqual([]);
300+
});
301+
}
302+
});
303+
304+
describe('GET /forms/:slug — mounted only with the console', () => {
305+
it('no console plugin: an open slug answers the unmatched 404', async () => {
306+
const app = await mount({ console: false });
307+
const res = await app.request(`/forms/${OPEN}`);
308+
expect(res.status).toBe(404);
309+
expect(app.doorCalls).toEqual([]);
310+
});
311+
312+
it('a console plugin with no built dist mounts nothing, the redirect included', async () => {
313+
const app = await mount({ console: true, dist: path.join(consoleRoot, 'missing-dist') });
314+
const res = await app.request(`/forms/${OPEN}`);
315+
expect(res.status).toBe(404);
316+
expect(app.doorCalls).toEqual([]);
317+
});
318+
319+
it('control: the signed-in console route /_console/forms/:name is still the console bundle', async () => {
320+
const { answer, doorCalls } = await withAndWithout('/_console/forms/showcase_inquiry.contact');
321+
expect(answer.status).toBe(200);
322+
expect(answer.location).toBeNull();
323+
expect(answer.body).toContain('console');
324+
expect(doorCalls).toEqual([]);
325+
});
326+
});

0 commit comments

Comments
 (0)