Skip to content

Commit 30e610a

Browse files
committed
fix(cli): the /forms/:slug redirect carries the request's query string, which the public form page reads for prefill
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU
1 parent 44f007c commit 30e610a

4 files changed

Lines changed: 103 additions & 9 deletions

File tree

‎.changeset/22079-forms-slug-redirect.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The path an author writes as a public form's `sharing.publicLink` now answers: `
77
Clause-②: yes (widening)
88

99
- **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`. 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.
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.
1111
- **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` 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 not carried; the public form page reads none.
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.
1313
- **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.public-form-redirect.test.ts‎

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type DoorAnswer = 'serve' | 'not-found' | 'fail' | 'throw';
3939
interface DoorCall {
4040
method: string;
4141
slug: string;
42+
query: unknown;
4243
host: unknown;
4344
cookie: unknown;
4445
}
@@ -74,7 +75,7 @@ async function mount(opts: {
7475

7576
server.get('/api/v1/forms/:slug', async (req: any, res: any) => {
7677
const slug = String(req.params?.slug ?? '');
77-
doorCalls.push({ method: req.method, slug, host: req.headers?.host, cookie: req.headers?.cookie });
78+
doorCalls.push({ method: req.method, slug, query: req.query, host: req.headers?.host, cookie: req.headers?.cookie });
7879
const answer = door(slug);
7980
if (answer === 'throw') throw new Error('door exploded');
8081
if (answer === 'fail') {
@@ -187,10 +188,75 @@ describe('GET /forms/:slug — the Location cannot be steered off the console pa
187188
});
188189
}
189190

190-
it('the query string of the request is not carried', async () => {
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 () => {
191237
const app = await mount({ console: true });
192-
const res = await app.request(`/forms/${OPEN}?next=https://evil.example`);
193-
expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`);
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);
194260
});
195261
});
196262

‎packages/cli/src/utils/console.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,9 @@ async function resolveHttpServer(ctx: any): Promise<any> {
489489
*
490490
* It also answers the path an author writes as a public form's
491491
* `sharing.publicLink`: `GET /forms/<slug>` redirects (302) to the Console's
492-
* public form page, `/_console/f/<slug>`, when the anonymous form door serves
493-
* that slug, and otherwise gets the same not-found answer as before. Mounted
492+
* public form page, `/_console/f/<slug>` with the request's query string, when
493+
* the anonymous form door serves that slug, and otherwise gets the same
494+
* not-found answer as before. Mounted
494495
* only with the Console, because without it there is no page to send anyone to.
495496
*/
496497
export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: boolean; rootRedirect?: boolean }) {
@@ -567,10 +568,18 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
567568
// there only when the anonymous form door serves that slug to this
568569
// request; anything else falls through to the unmatched-request answer
569570
// every other unrouted path gets.
571+
//
572+
// The request's query string travels with it: the public form page reads
573+
// `?prefill_<field>=` to seed its fields, so a link that carries one must
574+
// land with it. The path is built from `CONSOLE_PATH` and the encoded
575+
// slug alone; the query, as the URL parser holds it (it cannot carry a
576+
// `#`, a control character or a path), is appended after it, so it can
577+
// change nothing but the query of the page the visitor lands on.
570578
app.get('/forms/:slug', async (c: any, next: () => Promise<void>) => {
571579
const slug = String(c.req.param('slug') ?? '');
572580
if (slug && (await anonymousFormDoorServes(app, c, slug))) {
573-
return c.redirect(`${CONSOLE_PATH}/f/${encodeURIComponent(slug)}`, 302);
581+
const query = new URL(c.req.url).search;
582+
return c.redirect(`${CONSOLE_PATH}/f/${encodeURIComponent(slug)}${query}`, 302);
574583
}
575584
await next();
576585
});

‎packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
// - an anonymous `GET /forms/contact-us` answers 302 to `/_console/f/contact-us`,
1414
// that page is the Console bundle, the door it reads serves the form, and
1515
// the form accepts a submission that lands;
16+
// - a `prefill_` query on the authored link (the page seeds fields from it)
17+
// arrives on the redirect's `Location` unchanged;
1618
// - a disabled form, a non-anonymous form and an unknown slug each answer
1719
// exactly the unmatched-request 404 an unrouted path gets (compared byte for
1820
// byte against a path nothing mounts, on the same boot), and the form comes
@@ -146,6 +148,23 @@ describe('showcase: the authored public form path redirects to the console form
146148
expect(landed).toHaveLength(1);
147149
});
148150

151+
it('a prefill_ query on the authored link arrives on the page it redirects to', async () => {
152+
// The page seeds a field from `?prefill_<field>=`; `company` is one of the
153+
// fields the form declares, so this is a link an author would publish.
154+
const spec = await stack.api(`/forms/${SLUG}`);
155+
const fields = Object.keys(((await spec.json()) as { objectSchema: { fields: Record<string, unknown> } }).objectSchema.fields);
156+
expect(fields).toContain('company');
157+
158+
const query = '?prefill_company=Analytical%20Engines&utm_source=website';
159+
const redirect = await answerOf(await stack.raw(`${AUTHORED}${query}`));
160+
expect(redirect.status).toBe(302);
161+
expect(redirect.location).toBe(`${PAGE}${query}`);
162+
163+
const page = await stack.raw(redirect.location!);
164+
expect(page.status).toBe(200);
165+
expect(await page.text()).toContain(BUNDLE_MARKER);
166+
});
167+
149168
it('an unknown slug answers the unmatched-request 404, byte for byte', async () => {
150169
expect(await answerOf(await stack.raw('/forms/no-such-form'))).toEqual(unrouted);
151170
});

0 commit comments

Comments
 (0)