Repository navigation
Commit dd39171
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
- .changeset
- packages
- cli/src/utils
- qa/dogfood/test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
63 | | - | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
64 | 66 | | |
65 | 67 | | |
66 | 68 | | |
| |||
134 | 136 | | |
135 | 137 | | |
136 | 138 | | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
137 | 152 | | |
138 | 153 | | |
139 | 154 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
0 commit comments