diff --git a/.changeset/22079-forms-slug-redirect.md b/.changeset/22079-forms-slug-redirect.md new file mode 100644 index 00000000000..64b8dbae3bc --- /dev/null +++ b/.changeset/22079-forms-slug-redirect.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': minor +--- + +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 + +Clause-②: yes (widening) + +- **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. +- **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. +- **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. +- **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. +- **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). diff --git a/packages/cli/src/utils/console-route-ledger.ts b/packages/cli/src/utils/console-route-ledger.ts index 9dcd401d647..50df99aca32 100644 --- a/packages/cli/src/utils/console-route-ledger.ts +++ b/packages/cli/src/utils/console-route-ledger.ts @@ -60,7 +60,9 @@ * SCOPE, re-derived on `origin/main` @ 2ba4329e rather than inherited from the * filing: four routes across two plugin factories, both in `utils/console.ts`, * which the guard confirms is the ONLY file in this package's 109 sources that - * mounts a route at all. + * mounts a route at all. A fifth, `GET /forms/:slug`, joined the console-static + * family later: a redirect to the bundle's public form page, in the same peer + * group as the two redirects already here. * * This module is package-internal: it is the guard's data, not public API, and * `@objectstack/cli` is a binary rather than a consumed library surface. It @@ -134,6 +136,19 @@ export const CONSOLE_ROUTE_LEDGER: readonly ConsoleRouteLedgerEntry[] = [ + 'with `base: \'/_console/\'`, so relative asset URLs only resolve from the slashed path. Pure navigation ' + 'plumbing for a static bundle; there is nothing here for an SDK to express.', }, + { + route: 'GET /forms/:slug', + family: 'console-static', + mountedIn: 'utils/console.ts', + disposition: 'static-asset', + note: + 'redirects (302) the path an author writes as a public form\'s `sharing.publicLink` to the Console\'s public ' + + 'form page, `/_console/f/`, which `GET /_console/*` serves from the bundle. The same peer group as ' + + '`GET /` and `GET /_console`: navigation to a static bundle, which no client method builds. It answers ' + + 'only when the anonymous form door `GET /api/v1/forms/:slug` (REST ledger, `forms` family) serves the slug ' + + 'to the same request, asked in-process, and otherwise falls through to the unmatched-request 404. So it ' + + 'reveals nothing that door does not, and the decision stays in one place.', + }, { route: 'GET /_console/*', family: 'console-static', diff --git a/packages/cli/src/utils/console.public-form-redirect.test.ts b/packages/cli/src/utils/console.public-form-redirect.test.ts new file mode 100644 index 00000000000..cad97340868 --- /dev/null +++ b/packages/cli/src/utils/console.public-form-redirect.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The console's `GET /forms/:slug` redirect (#22079). + * + * An app author writes a public form's `sharing.publicLink` as `/forms/`, + * and the Console serves that form at `/_console/f/`. The console static + * plugin now answers the authored path with a 302 to the page, but only when + * the anonymous form door (`GET /api/v1/forms/:slug`, `@objectstack/rest`) + * serves the slug to the same request. Every other request keeps the answer it + * got before: the transport's unmatched-request 404. + * + * These run the REAL plugin on a REAL Hono app (the `HonoHttpServer` production + * resolves as `http.server`, with its not-found seam installed), with a stub + * door registered the way `@objectstack/rest` registers the real one. The stub + * is the door's answer and nothing else, so each case says which answer the + * redirect follows. The real door, on a real showcase boot, is + * `packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts`. + * + * "The answer it got before" is measured, not written down: every fall-through + * case compares status AND body, byte for byte, against the same request sent + * to the same app WITHOUT the plugin mounted. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; + +import { createConsoleStaticPlugin } from './console.js'; + +const ORIGIN = 'http://forms.example.test'; +const OPEN = 'contact-us'; + +/** One stub door answer per slug; anything unlisted is the door's not-found. */ +type DoorAnswer = 'serve' | 'not-found' | 'fail' | 'throw'; + +interface DoorCall { + method: string; + slug: string; + query: unknown; + host: unknown; + cookie: unknown; +} + +interface App { + request(p: string, init?: RequestInit): Promise; + doorCalls: DoorCall[]; +} + +let consoleRoot: string; +let distPath: string; + +beforeAll(() => { + consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-forms-')); + distPath = path.join(consoleRoot, 'dist'); + fs.mkdirSync(distPath); + fs.writeFileSync(path.join(distPath, 'index.html'), 'console'); +}); + +afterAll(() => { + fs.rmSync(consoleRoot, { recursive: true, force: true }); +}); + +async function mount(opts: { + console: boolean; + dist?: string; + door?: (slug: string) => DoorAnswer; +}): Promise { + const server = new HonoHttpServer(0); + server.installNotFoundSeam(); + const doorCalls: DoorCall[] = []; + const door = opts.door ?? ((slug: string) => (slug === OPEN ? 'serve' : 'not-found')); + + server.get('/api/v1/forms/:slug', async (req: any, res: any) => { + const slug = String(req.params?.slug ?? ''); + doorCalls.push({ method: req.method, slug, query: req.query, host: req.headers?.host, cookie: req.headers?.cookie }); + const answer = door(slug); + if (answer === 'throw') throw new Error('door exploded'); + if (answer === 'fail') { + res.status(500).json({ code: 'FORM_RESOLVE_FAILED', error: 'resolve failed' }); + return; + } + if (answer === 'not-found') { + res.status(404).json({ code: 'FORM_NOT_FOUND', error: `No public form configured at /forms/${slug}` }); + return; + } + res.json({ slug, object: 'showcase_inquiry', form: {} }); + }); + + if (opts.console) { + const plugin = createConsoleStaticPlugin(opts.dist ?? distPath); + await plugin.start({ getServiceAsync: async () => server, logger: { warn: () => {} } }); + } + const app = server.getRawApp(); + return { + request: async (p: string, init?: RequestInit) => app.request(`${ORIGIN}${p}`, init), + doorCalls, + }; +} + +async function answerOf(res: Response): Promise<{ status: number; body: string; location: string | null }> { + return { status: res.status, body: await res.text(), location: res.headers.get('location') }; +} + +/** The request's answer with the plugin mounted, and the same request's answer without it. */ +async function withAndWithout(p: string, init?: RequestInit, door?: (slug: string) => DoorAnswer) { + const mounted = await mount({ console: true, door }); + const bare = await mount({ console: false, door }); + const answer = await answerOf(await mounted.request(p, init)); + const before = await answerOf(await bare.request(p, init)); + return { answer, before, doorCalls: mounted.doorCalls }; +} + +describe('GET /forms/:slug — redirects when the anonymous form door serves the slug', () => { + it('answers 302 to the console public form page', async () => { + const app = await mount({ console: true }); + const res = await app.request(`/forms/${OPEN}`); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`); + expect(app.doorCalls.map((d) => [d.method, d.slug])).toEqual([['GET', OPEN]]); + }); + + it('the page it redirects to is served by the console bundle', async () => { + const app = await mount({ console: true }); + const location = (await app.request(`/forms/${OPEN}`)).headers.get('location')!; + const page = await app.request(location); + expect(page.status).toBe(200); + expect(page.headers.get('content-type')).toContain('text/html'); + expect(await page.text()).toContain('console'); + }); + + it('HEAD is answered like GET, and the door is still asked with GET', async () => { + const app = await mount({ console: true }); + const res = await app.request(`/forms/${OPEN}`, { method: 'HEAD' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`); + expect(app.doorCalls.map((d) => d.method)).toEqual(['GET']); + }); + + it('asks the door with the visitor\'s own headers, which pick the environment', async () => { + const app = await mount({ console: true }); + // `@hono/node-server` builds the request from the socket's `Host` header; + // an injected request carries it only when it is sent explicitly. + await app.request(`/forms/${OPEN}`, { headers: { host: 'tenant-a.example.test', cookie: 'session=abc' } }); + expect(app.doorCalls).toHaveLength(1); + expect(app.doorCalls[0].host).toBe('tenant-a.example.test'); + expect(app.doorCalls[0].cookie).toBe('session=abc'); + }); + + it('a percent-encoded slug is asked decoded and redirected re-encoded', async () => { + const app = await mount({ console: true }); + const res = await app.request('/forms/contact%2Dus'); + expect(app.doorCalls.map((d) => d.slug)).toEqual([OPEN]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}`); + }); +}); + +describe('GET /forms/:slug — the Location cannot be steered off the console page', () => { + // A door that serves every slug is the worst case: the redirect then fires + // for whatever the request carried, so the Location must stay one encoded + // path segment under `/_console/f/` on the same origin whatever that was. + const servesEverything = (): DoorAnswer => 'serve'; + + for (const [requested, expected] of [ + ['/forms/%2F%2Fevil.example', '/_console/f/%2F%2Fevil.example'], + ['/forms/%5C%5Cevil.example', '/_console/f/%5C%5Cevil.example'], + ['/forms/https%3A%2F%2Fevil.example', '/_console/f/https%3A%2F%2Fevil.example'], + ['/forms/a%2F..%2F..%2Fadmin', '/_console/f/a%2F..%2F..%2Fadmin'], + ['/forms/x%0D%0ASet-Cookie%3A%20a%3Db', '/_console/f/x%0D%0ASet-Cookie%3A%20a%3Db'], + ['/forms/x%3Fnext%3D%2F%2Fevil.example%23y', '/_console/f/x%3Fnext%3D%2F%2Fevil.example%23y'], + ] as const) { + it(`${requested} → ${expected}`, async () => { + const app = await mount({ console: true, door: servesEverything }); + const res = await app.request(requested); + expect(res.status).toBe(302); + const location = res.headers.get('location')!; + expect(location).toBe(expected); + expect(res.headers.get('set-cookie')).toBeNull(); + const resolved = new URL(location, ORIGIN); + expect(resolved.origin).toBe(ORIGIN); + expect(resolved.pathname.startsWith('/_console/f/')).toBe(true); + expect(resolved.pathname.slice('/_console/f/'.length)).not.toContain('/'); + expect(resolved.search).toBe(''); + expect(resolved.hash).toBe(''); + }); + } + + it('a query string cannot steer it either: origin and path stay the console page', async () => { + const app = await mount({ console: true, door: servesEverything }); + for (const query of ['?next=https://evil.example', '?x=%0D%0ASet-Cookie:%20a=b', '?/..//evil.example']) { + const res = await app.request(`/forms/${OPEN}${query}`); + expect(res.status).toBe(302); + expect(res.headers.get('set-cookie')).toBeNull(); + const location = res.headers.get('location')!; + expect(location).toBe(`/_console/f/${OPEN}${query}`); + const resolved = new URL(location, ORIGIN); + expect(resolved.origin).toBe(ORIGIN); + expect(resolved.pathname).toBe(`/_console/f/${OPEN}`); + } + }); +}); + +describe('GET /forms/:slug — the request\'s query string travels to the page', () => { + // The public form page seeds its fields from `?prefill_=`, so a link + // that carries one must land with it, byte for byte. + for (const query of [ + '?prefill_source=website&utm_campaign=spring', + '?prefill_name=Ada%20Lovelace&prefill_email=ada%40example.com&ref=a+b', + '?prefill_message=line%0Aone&prefill_message=dup', + ]) { + it(`${query} arrives on Location unchanged`, async () => { + const app = await mount({ console: true }); + const res = await app.request(`/forms/${OPEN}${query}`); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}${query}`); + }); + } + + it('a slug that needs encoding, together with a query: the path is re-encoded, the query is untouched', async () => { + const app = await mount({ console: true, door: () => 'serve' }); + const query = '?prefill_source=website&lang=fr'; + const res = await app.request(`/forms/caf%C3%A9%20form${query}`); + expect(app.doorCalls.map((d) => d.slug)).toEqual(['café form']); + expect(res.status).toBe(302); + const location = res.headers.get('location')!; + expect(location).toBe(`/_console/f/caf%C3%A9%20form${query}`); + const resolved = new URL(location, ORIGIN); + expect(resolved.pathname).toBe('/_console/f/caf%C3%A9%20form'); + expect(resolved.searchParams.get('prefill_source')).toBe('website'); + expect(resolved.searchParams.get('lang')).toBe('fr'); + }); + + it('the door is asked without the query', async () => { + const app = await mount({ console: true }); + await app.request(`/forms/${OPEN}?prefill_source=website&utm_campaign=spring`); + expect(app.doorCalls).toHaveLength(1); + expect(app.doorCalls[0].query).toEqual({}); + }); + + it('HEAD carries it too', async () => { + const app = await mount({ console: true }); + const res = await app.request(`/forms/${OPEN}?prefill_source=website`, { method: 'HEAD' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`/_console/f/${OPEN}?prefill_source=website`); + }); + + it('an empty query or a fragment adds nothing', async () => { + const app = await mount({ console: true }); + expect((await app.request(`/forms/${OPEN}?`)).headers.get('location')).toBe(`/_console/f/${OPEN}`); + expect((await app.request(`/forms/${OPEN}?a=1#frag`)).headers.get('location')).toBe(`/_console/f/${OPEN}?a=1`); + }); + + it('a slug the door does not serve keeps the same 404 with a query, byte for byte', async () => { + const { answer, before } = await withAndWithout('/forms/unknown-slug?prefill_source=website'); + expect(before.status).toBe(404); + expect(answer).toEqual(before); + }); +}); + +describe('GET /forms/:slug — every other request keeps the answer it got before', () => { + it('a slug the door does not serve: the same 404, byte for byte', async () => { + const { answer, before, doorCalls } = await withAndWithout('/forms/unknown-slug'); + expect(before.status).toBe(404); + expect(JSON.parse(before.body)).toEqual({ + success: false, + error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' }, + }); + expect(answer).toEqual(before); + expect(doorCalls.map((d) => d.slug)).toEqual(['unknown-slug']); + }); + + it('a door that answers 500: the same 404, not a redirect', async () => { + const { answer, before } = await withAndWithout(`/forms/${OPEN}`, undefined, () => 'fail'); + expect(before.status).toBe(404); + expect(answer).toEqual(before); + }); + + it('a door that throws: the same 404, not a redirect', async () => { + const { answer, before } = await withAndWithout(`/forms/${OPEN}`, undefined, () => 'throw'); + expect(before.status).toBe(404); + expect(answer).toEqual(before); + }); + + for (const [label, p, init] of [ + ['a POST to an open slug (still 404, not 405)', `/forms/${OPEN}`, { method: 'POST' }], + ['a trailing slash', `/forms/${OPEN}/`, undefined], + ['the bare prefix', '/forms', undefined], + ['the prefix with a slash', '/forms/', undefined], + ['a nested path', `/forms/${OPEN}/submit`, undefined], + ['a root-level slug (no catch-all)', `/${OPEN}`, undefined], + ] as const) { + it(`${label}: the same answer, and the door is never asked`, async () => { + const { answer, before, doorCalls } = await withAndWithout(p, init as RequestInit | undefined); + expect(before.status).toBe(404); + expect(answer).toEqual(before); + expect(doorCalls).toEqual([]); + }); + } +}); + +describe('GET /forms/:slug — mounted only with the console', () => { + it('no console plugin: an open slug answers the unmatched 404', async () => { + const app = await mount({ console: false }); + const res = await app.request(`/forms/${OPEN}`); + expect(res.status).toBe(404); + expect(app.doorCalls).toEqual([]); + }); + + it('a console plugin with no built dist mounts nothing, the redirect included', async () => { + const app = await mount({ console: true, dist: path.join(consoleRoot, 'missing-dist') }); + const res = await app.request(`/forms/${OPEN}`); + expect(res.status).toBe(404); + expect(app.doorCalls).toEqual([]); + }); + + it('control: the signed-in console route /_console/forms/:name is still the console bundle', async () => { + const { answer, doorCalls } = await withAndWithout('/_console/forms/showcase_inquiry.contact'); + expect(answer.status).toBe(200); + expect(answer.location).toBeNull(); + expect(answer.body).toContain('console'); + expect(doorCalls).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index 22d9c5763ea..74c52b936b0 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -53,6 +53,14 @@ import { pathToFileURL, fileURLToPath } from 'url'; /** URL mount path for the Console portal inside the ObjectStack server */ export const CONSOLE_PATH = '/_console'; +/** + * The anonymous form door the Console's public form page (`/f/:slug`) reads: + * the page fetches `GET /api/v1/forms/` and renders what it answers. + * The `/forms/:slug` redirect below asks this same door, so it redirects + * exactly when the page it redirects to would be served the form. + */ +const PUBLIC_FORM_DOOR = '/api/v1/forms'; + /** Canonical npm package name that ships the Console SPA. */ const CONSOLE_PACKAGE = '@objectstack/console'; @@ -478,6 +486,13 @@ async function resolveHttpServer(ctx: any): Promise { * - Hashed asset paths under `/_console/assets/*` never SPA-fallback — * a real 404 surfaces a rebuild/deploy mismatch instead of the * dreaded "asset returns text/html" silent failure. + * + * It also answers the path an author writes as a public form's + * `sharing.publicLink`: `GET /forms/` redirects (302) to the Console's + * public form page, `/_console/f/` with the request's query string, when + * the anonymous form door serves that slug, and otherwise gets the same + * not-found answer as before. Mounted + * only with the Console, because without it there is no page to send anyone to. */ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: boolean; rootRedirect?: boolean }) { return { @@ -548,6 +563,27 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: // Redirect bare path to trailing-slash (SPA convention) app.get(CONSOLE_PATH, (c: any) => c.redirect(`${CONSOLE_PATH}/`)); + // The path an author writes as a public form's `sharing.publicLink` + // (`/forms/`) is served by the Console at `/f/`. Redirect it + // there only when the anonymous form door serves that slug to this + // request; anything else falls through to the unmatched-request answer + // every other unrouted path gets. + // + // The request's query string travels with it: the public form page reads + // `?prefill_=` to seed its fields, so a link that carries one must + // land with it. The path is built from `CONSOLE_PATH` and the encoded + // slug alone; the query, as the URL parser holds it (it cannot carry a + // `#`, a control character or a path), is appended after it, so it can + // change nothing but the query of the page the visitor lands on. + app.get('/forms/:slug', async (c: any, next: () => Promise) => { + const slug = String(c.req.param('slug') ?? ''); + if (slug && (await anonymousFormDoorServes(app, c, slug))) { + const query = new URL(c.req.url).search; + return c.redirect(`${CONSOLE_PATH}/f/${encodeURIComponent(slug)}${query}`, 302); + } + await next(); + }); + // Serve static files with SPA fallback app.get(`${CONSOLE_PATH}/*`, async (c: any) => { const reqPath = c.req.path.substring(CONSOLE_PATH.length) || '/'; @@ -639,6 +675,38 @@ export function createRuntimeAssetsPlugin(distPath: string) { // ─── Helpers ──────────────────────────────────────────────────────── +/** + * Does the anonymous form door serve `slug` to this request? + * + * The door is asked, not copied. Whether a slug is open is one decision, made + * inside `@objectstack/rest`'s form doors: the form's `sharing` switches, a + * withdrawal in another metadata layer, the organization an anonymous request + * reads, and whether the deployment's tenancy posture lets the form take an + * anonymous submission. A second copy of any of that here would drift from the + * doors, and the redirect would then send a visitor to a page that answers + * not-found. So the visitor's own request is re-addressed to the door and + * dispatched in-process through the same app: same headers (the host and + * cookies that pick the environment), same connection (`c.env`), same + * middleware. Only a `200` counts as served; any other answer, a thrown + * dispatch included, is not. + */ +async function anonymousFormDoorServes(app: any, c: any, slug: string): Promise { + let res: Response; + try { + const url = new URL(`${PUBLIC_FORM_DOOR}/${encodeURIComponent(slug)}`, c.req.url); + const probe = new Request(url, { method: 'GET', headers: new Headers(c.req.raw.headers) }); + res = await app.fetch(probe, c.env); + } catch { + return false; + } + try { + await res.body?.cancel(); + } catch { + // The body is never read; a stream that cannot be cancelled changes nothing. + } + return res.status === 200; +} + const MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', diff --git a/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts new file mode 100644 index 00000000000..bc21624206c --- /dev/null +++ b/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22079] The path an author writes as a public form's `sharing.publicLink` +// answers, on a real showcase boot with the Console mounted. +// +// The showcase ships `showcase_inquiry.contact` with `publicLink: +// '/forms/contact-us'`. The Console serves that form to an anonymous visitor at +// `/_console/f/contact-us`; the path as authored answered the transport's +// unmatched-request 404. The console static plugin now redirects it, and only +// when the anonymous form door (`GET /api/v1/forms/:slug`) serves the slug to +// the same request. Pinned here: +// +// - an anonymous `GET /forms/contact-us` answers 302 to `/_console/f/contact-us`, +// that page is the Console bundle, the door it reads serves the form, and +// the form accepts a submission that lands; +// - a `prefill_` query on the authored link (the page seeds fields from it) +// arrives on the redirect's `Location` unchanged; +// - a disabled form, a non-anonymous form and an unknown slug each answer +// exactly the unmatched-request 404 an unrouted path gets (compared byte for +// byte against a path nothing mounts, on the same boot), and the form comes +// back as a redirect when it is republished; +// - the signed-in console route `/_console/forms/` is the Console bundle +// as before, with or without a session (control); +// - on a WALLED posture, where the door does not offer the published form +// because it cannot take an anonymous submission there, the redirect does +// not fire either: it follows the door, not a copy of the door's switches. +// +// The Console's built bundle is not part of this suite's build, so the plugin +// is pointed at a temporary `dist/` holding an `index.html`, which is all the +// plugin needs to mount. The plugin is imported as SOURCE by relative path, +// like the other package-internal reads in this suite, so the verdict is about +// this checkout and not about the last build of `@objectstack/cli`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { createConsoleStaticPlugin } from '../../../cli/src/utils/console.js'; + +const SLUG = 'contact-us'; +const AUTHORED = `/forms/${SLUG}`; +const PAGE = `/_console/f/${SLUG}`; +const VIEW = '/meta/view/showcase_inquiry.contact'; +const SYS = { isSystem: true } as const; +const BUNDLE_MARKER = 'console-bundle-22079'; + +interface Answer { + status: number; + location: string | null; + body: string; +} + +const answerOf = async (res: Response): Promise => ({ + status: res.status, + location: res.headers.get('location'), + body: await res.text(), +}); + +let consoleRoot: string; +let distPath: string; + +beforeAll(() => { + consoleRoot = mkdtempSync(join(tmpdir(), 'os-dogfood-console-')); + distPath = join(consoleRoot, 'dist'); + mkdirSync(distPath); + writeFileSync(join(distPath, 'index.html'), `${BUNDLE_MARKER}`); +}); + +afterAll(() => { + rmSync(consoleRoot, { recursive: true, force: true }); +}); + +const boot = (extra: { multiTenant?: 'posture-only' } = {}): Promise => + bootStack(showcaseStack, { + ...extra, + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), + extraPlugins: [createConsoleStaticPlugin(distPath)], + }); + +describe('showcase: the authored public form path redirects to the console form page', () => { + let stack: VerifyStack; + let admin: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let published: Record; + /** What an unrouted path answers on this boot: "today's 404". */ + let unrouted: Answer; + + /** Save the form env-wide with these `sharing` keys replaced. */ + const saveSharing = async (patch: Record) => { + const body = structuredClone(published); + Object.assign(body.config.sharing, patch); + const res = await stack.apiAs(admin, 'PUT', VIEW, body); + expect(res.status, await res.clone().text()).toBe(200); + }; + + beforeAll(async () => { + stack = await boot(); + admin = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const res = await stack.apiAs(admin, 'GET', VIEW); + expect(res.status).toBe(200); + const json = (await res.json()) as { item?: Record }; + const item = (json.item ?? json) as Record; + published = Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))); + expect(published.config?.sharing).toMatchObject({ enabled: true, allowAnonymous: true, publicLink: AUTHORED }); + unrouted = await answerOf(await stack.raw('/no-route-mounted-here-22079')); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: an unrouted path answers the transport\'s unmatched-request 404', () => { + expect(unrouted.status).toBe(404); + expect(JSON.parse(unrouted.body)).toEqual({ + success: false, + error: { code: 'ENDPOINT_NOT_FOUND', message: 'Not found' }, + }); + }); + + it('an anonymous GET of the authored path redirects, the page loads, and the form accepts a submission', async () => { + const redirect = await answerOf(await stack.raw(AUTHORED)); + expect(redirect.status).toBe(302); + expect(redirect.location).toBe(PAGE); + + const page = await stack.raw(redirect.location!); + expect(page.status).toBe(200); + expect(page.headers.get('content-type')).toContain('text/html'); + expect(await page.text()).toContain(BUNDLE_MARKER); + + // What the page reads and posts: the anonymous form doors. + const spec = await stack.api(`/forms/${SLUG}`); + expect(spec.status).toBe(200); + expect(((await spec.json()) as { object: string }).object).toBe('showcase_inquiry'); + + const marker = 'redirect_probe_submission'; + const submit = await stack.api(`/forms/${SLUG}/submit`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: marker, email: 'probe@example.com', message: 'probe' }), + }); + expect(submit.status, await submit.clone().text()).toBe(201); + const landed = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS }); + expect(landed).toHaveLength(1); + }); + + it('a prefill_ query on the authored link arrives on the page it redirects to', async () => { + // The page seeds a field from `?prefill_=`; `company` is one of the + // fields the form declares, so this is a link an author would publish. + const spec = await stack.api(`/forms/${SLUG}`); + const fields = Object.keys(((await spec.json()) as { objectSchema: { fields: Record } }).objectSchema.fields); + expect(fields).toContain('company'); + + const query = '?prefill_company=Analytical%20Engines&utm_source=website'; + const redirect = await answerOf(await stack.raw(`${AUTHORED}${query}`)); + expect(redirect.status).toBe(302); + expect(redirect.location).toBe(`${PAGE}${query}`); + + const page = await stack.raw(redirect.location!); + expect(page.status).toBe(200); + expect(await page.text()).toContain(BUNDLE_MARKER); + }); + + it('an unknown slug answers the unmatched-request 404, byte for byte', async () => { + expect(await answerOf(await stack.raw('/forms/no-such-form'))).toEqual(unrouted); + }); + + it('a disabled form answers the unmatched-request 404, and redirects again once republished', async () => { + await saveSharing({ enabled: false }); + try { + expect((await stack.api(`/forms/${SLUG}`)).status, 'the door no longer serves it').toBe(404); + expect(await answerOf(await stack.raw(AUTHORED))).toEqual(unrouted); + } finally { + await saveSharing({ enabled: true }); + } + const again = await answerOf(await stack.raw(AUTHORED)); + expect([again.status, again.location]).toEqual([302, PAGE]); + }); + + it('a non-anonymous form answers the unmatched-request 404, and redirects again once republished', async () => { + await saveSharing({ allowAnonymous: false }); + try { + expect((await stack.api(`/forms/${SLUG}`)).status, 'the door no longer serves it').toBe(404); + expect(await answerOf(await stack.raw(AUTHORED))).toEqual(unrouted); + } finally { + await saveSharing({ allowAnonymous: true }); + } + const again = await answerOf(await stack.raw(AUTHORED)); + expect([again.status, again.location]).toEqual([302, PAGE]); + }); + + it('control: the signed-in console route /_console/forms/ is the console bundle, with or without a session', async () => { + const sessions: Array> = [{}, { authorization: `Bearer ${admin}` }]; + for (const headers of sessions) { + const res = await answerOf(await stack.raw('/_console/forms/showcase_inquiry.contact', { headers })); + expect(res.status).toBe(200); + expect(res.location).toBeNull(); + expect(res.body).toContain(BUNDLE_MARKER); + } + }); +}); + +describe('showcase, walled posture: the redirect follows the door, which does not offer the form', () => { + let stack: VerifyStack; + let unrouted: Answer; + + beforeAll(async () => { + stack = await boot({ multiTenant: 'posture-only' }); + unrouted = await answerOf(await stack.raw('/no-route-mounted-here-22079')); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('PRECONDITION: the posture is walled and the published form is not offered by the door', async () => { + expect(stack.tenancy().posture).toBe('isolated'); + const door = await stack.api(`/forms/${SLUG}`); + expect(door.status).toBe(404); + expect(((await door.json()) as { code?: string }).code).toBe('FORM_NOT_FOUND'); + }); + + it('the authored path answers the unmatched-request 404, not a redirect', async () => { + expect(unrouted.status).toBe(404); + expect(await answerOf(await stack.raw(AUTHORED))).toEqual(unrouted); + }); +}); diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index c32d442d7ff..2bd9409af2d 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1128,6 +1128,11 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // compile pipeline and the metadata plugin produce. 'examples/app-showcase/**', 'packages/cli/src/commands/**', + // test/showcase-public-form-redirect.dogfood.test.ts IMPORTS the console + // static plugin from source and mounts it on a real showcase boot, so the + // plugin is the pin's subject. Per-file, matching what the import names: + // the module imports nothing but Node built-ins. + 'packages/cli/src/utils/console.ts', 'packages/metadata/src/**', // `realtime-protocol.mdx` is named in a comment rather than read, the // same shape as `check-nul-bytes.mjs` on the @objectstack/cli entry diff --git a/turbo.json b/turbo.json index 8571e12da60..c68f3beb138 100644 --- a/turbo.json +++ b/turbo.json @@ -525,6 +525,7 @@ "$TURBO_ROOT$/packages/spec/src/data/**", "$TURBO_ROOT$/examples/app-showcase/**", "$TURBO_ROOT$/packages/cli/src/commands/**", + "$TURBO_ROOT$/packages/cli/src/utils/console.ts", "$TURBO_ROOT$/packages/metadata/src/**", "$TURBO_ROOT$/content/docs/protocol/kernel/realtime-protocol.mdx" ]