Repository navigation
Commit 9975c26
docs(rest,app-crm): the public form submit is authorized by its declaration, not by a guest_portal set (#24237)
Fixes #24130
Clause-②: no
## What changes
Comments, one published docstring, one docs paragraph, and a `patch`
changeset. No behaviour of the form routes changes.
- `packages/rest/src/rest-server.ts` — the JSDoc above
`registerFormEndpoints`, and nothing else in the file. It said security
is delegated to the `guest_portal` set, that a deployment without one
"falls open", and that secure-by-default deployments must pair the
routes with one. It now states the mechanism the runtime runs: the
declaration-derived `publicFormGrant` is admitted before any permission
set resolves; it says what the grant admits, refuses and strips, and
what the `guest_portal` name on the context reaches.
- `packages/rest/src/form-routes.ts` — three comments:
- the route header ("with the `guest_portal` permission set attached");
- the stale `lead.hook.ts` / `case.hook.ts` pointer;
- the comment above the submit context ("`guest_portal` + `anonymous`
are kept for back-compat with object hooks").
- `examples/app-crm/src/views/lead.view.ts` — the `web_to_lead` comment
no longer says the `guest_portal` profile authorizes the write.
- `examples/app-crm/src/security/sales-positions.ts` —
`GuestPortalProfile` is **kept**, and its comment now says why (H3
below).
- `content/docs/ui/public-data-collection.mdx` — one paragraph in
section 2 saying what the `guest_portal` name on the submit context does
and does not do.
- `.changeset/24130-form-submit-docstring.md` — `@objectstack/rest`:
`patch`. The docstring ships; measured below.
## Measured
Tree: `c564b0e45`, the merge of `origin/main` `2bcd406a2` into this
branch.
### H1 — what authorizes the anonymous INSERT, and what `permissions:
['guest_portal']` does
I read `packages/plugins/plugin-security/src/security-plugin.ts` and did
not change it.
- **Admission.** The middleware's `publicFormGrant` branch admits
`insert` / `find` / `findOne` / `count` on the grant's object and throws
`PermissionDeniedError` for any other operation or object. It decides
this before any permission set resolves, and it strips
`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from inserted rows.
- **The `guest_portal` name.** The branch then calls
`resolvePermissionSetsForContext(opCtx.context)`, which requests the
context's `permissions` names by name. The answer is used only as input
to `resolveCallerPosture` and `maskOperationResult` over the operation's
result.
- **What a registered set contributes.** The masker reads the set's
`fields` (`getFieldPermissions`) and its system permissions
(`getSystemPermissions`, held against a field's `requiredPermissions`).
The set's `objects` bits are never read on this branch.
- **What the visitor sees.** The route answers `{ id }` only.
Live check: a real boot of `examples/app-crm`, through `bootStack` from
`@objectstack/verify`, in a throwaway dogfood probe that is not
committed. It ran with and without `GuestPortalProfile` in the stack's
`permissions`:
| probe | with `GuestPortalProfile` | without |
|---|---|---|
| permission sets the stack declares | `crm_sales_user`, `guest_portal`
| `crm_sales_user` |
| clean `POST /forms/contact-us/submit` | `201`, body `{ id }` | `201`,
body `{ id }` |
| `POST` that also sends `status: 'qualified'`, `lead_score: 99`, and
forged `owner_id` / `organization_id` | `201`, body `{ id }` | `201`,
body `{ id }` |
| both stored rows, read as system | `status: new`, `lead_score: 0`,
`source: web`, `owner_id: null`, forged organization not stored | same |
| anonymous `GET /data/crm_lead` | `401` | `401` |
| anonymous `GET /data/crm_lead/ID` | `401` | `401` |
| a `beforeInsert` hook on `crm_lead`, `ctx.session` / `ctx.user` |
`null` / `null` | `null` / `null` |
The probe asserted the two shapes' answers equal and passed (`Tests 1
passed`). This matches the card's measurement on 17.7.0.
A second throwaway probe ran at the engine level (`packages/objectql`,
not committed):
- **Route context.** With the route's context verbatim, a `beforeInsert`
hook's `ctx.session` and `ctx.user` are both unset.
- **Positive control.** A context with `userId: 'u_ctl'`, `positions:
['p_ctl']`, `permissions: ['guest_portal']`, `anonymous: true` reaches
the hook as `session: { userId, positions }` and `user: { id }`. Neither
`permissions` nor `anonymous` gets through, because `buildSession` /
`buildUser` in `packages/objectql/src/engine.ts` copy neither key.
So "kept for back-compat with object hooks" was false: no hook can read
either key.
### H2 — the hook-file pointer
`examples/app-crm/src/hooks/` holds `index.ts` and `opportunity.hook.ts`
only. Neither `lead.hook.ts` nor `case.hook.ts` exists. The comment now
names the three patterns that do exist:
- a field's `defaultValue` (`crm_lead.status`);
- on a select that declares no `defaultValue`, its `default: true`
option. `packages/objectql/src/engine.ts` (about `:7086`): "A field that
declares NO `defaultValue` falls back to the option marked `default:
true`." This is how `source` lands as `web` in the table above;
- a `beforeInsert` hook where the value needs logic:
`StampInquiryDefaultsHook` in
`examples/app-showcase/src/data/hooks/index.ts`, the hook on the
showcase's public-form target.
### H3 — `GuestPortalProfile`: kept, and the example says why
The dispatch preferred dropping the profile if nothing reads it. Two
things read it:
- The grant's masker resolves the set by name on every form submit (H1).
- ADR-0138 (Accepted) records this exact declaration in its fate table:
"the form doors' `guest_portal` set name |
`packages/rest/src/rest-server.ts#RestServer.registerFormEndpoints`; the
reference declaration is
`examples/app-crm/src/security/sales-positions.ts#GuestPortalProfile` |
live: the form doors' context names that set, and the result masker
resolves it where a deployment registers one … | **keep**".
Dropping the profile would leave an accepted record citing a deleted
symbol, and correcting the ADR is a Tier H edit outside this card's
surface. So the example keeps it.
Its comment now says:
- what it does not do: authorize the form;
- what the name reaches: the masker's field permissions;
- that, as declared here (object bits only), it changes nothing on the
form path. The table above shows this.
`security/index.ts`, `objectstack.config.ts` and `access-matrix.json`
are unchanged.
### Changeset — `patch`, because the docstring ships
`registerFormEndpoints` is private, but its docstring is in the
published files. After the build:
- the new phrase "admits on that grant alone" appears once each in
`packages/rest/dist/index.d.ts`, `index.d.cts`, `index.js` and
`index.cjs`;
- the old phrase "falls open with" appears zero times;
- control: a pre-existing `form-routes.ts` body comment (`Symmetric with
#6601`) is in `src` once and in `dist` zero times, and so is the new
`form-routes.ts` text.
So a reader of the published `@objectstack/rest` sees the corrected
docstring, and that takes a `patch`, not `skip-changeset`.
`examples/app-crm` is `private: true`.
## Tests and gates (head `c564b0e45`)
- **① Build.** `pnpm turbo run build --filter=@objectstack/dogfood
--filter=@objectstack/example-crm --concurrency=2` covers rest's
dependency closure plus rest, plugin-security and verify. Result:
`Tasks: 66 successful, 66 total`, lock `VERDICT command-exit 0`.
- The CRM's `objectstack build` ran in the same run: `✓ Build complete`,
`Security: 3 Positions 2 Permissions`, with no access-matrix drift.
- **② Package tests.**
- `@objectstack/rest` `vitest run --project local`: `Test Files 294
passed (294)`, `Tests 6028 passed | 326 skipped (6354)`, exit 0.
- `@objectstack/rest` `typecheck` (`tsc --noEmit &&
check:test-typecheck`): exit 0.
- `@objectstack/example-crm` `vitest run`: `Test Files 6 passed (6)`,
`Tests 46 passed (46)`, exit 0.
- `@objectstack/example-crm` `typecheck`: exit 0.
- **③ Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 93 commands. All 93 exit 0.
- On the first pass, two exited 3 (`PREREQUISITE NOT MET`, unbuilt
packages outside this closure): `check:skill-examples` and
`check:dual-build-cjs-loads`. I built the eight packages they named,
re-ran both, and both exited 0.
- `--ran`: "Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0
UNRUN."
- **Lint on the changed files.** `pnpm exec eslint --no-inline-config`
over the four changed `.ts` files: exit 0, no output.
- **Not run locally.** `test:repo` and the repo-wide `pnpm lint` are
CI's.
## Patch round 1
The seat's review of `c564b0e45` folded two adjacent sites into this PR.
The claim's file surface was revised in place: comment 6243651386,
"Revised 2026-10-18T01:09Z". Head: `91ca7f3b7`.
`main` moved 5 commits since `2bcd406a2`, none on a file this PR
touches, so it was not merged in. Each round-0 sentence above still
describes `c564b0e45`, and nothing in it changed.
- **`content/docs/ui/forms.mdx`, the `guest_portal` passages only.**
- The ADR-0056 callout no longer says the set and the `anonymous` flag
are attached "for back-compat" with object hooks. It now says neither
key authorizes anything or reaches an object hook.
- Section 2, "(Optional) Create the `guest_portal` permission set", is
**kept, retitled and rewritten**: "No permission set to create — what
the `guest_portal` name does". It now says what is true:
- admission is the declaration-derived `publicFormGrant`, decided before
any set resolves;
- a registered set's object bits are not read on this path;
- only its field permissions, and the capabilities it holds against a
field's `requiredPermissions`, feed the result masker over the record
the route gets back, and the visitor gets the `id` only;
- so a deployment registers one only to mask fields on that record, and
nothing else needs it;
- no hook can read `permissions` or `anonymous`, so a public submission
is told apart by the missing `ctx.user`.
- Section 2's `os:check` snippet, which declared an INSERT-only set, is
removed: its whole content was object bits that nothing reads. The
section stays because a reader who sees `permissions: ['guest_portal']`
in the context shape needs to know what that name does.
- The auth-model bullet no longer says `permissions: ['guest_portal']`
"is retained for back-compat".
- **`examples/app-crm/src/objects/lead.object.ts`, the `status` comment
only.** It no longer says an option default is "only a UI preselect". It
now says the declared `defaultValue: 'new'` is what the write uses, and
that this takes precedence over the `default: true` option, which the
engine falls back to on write only for a select that declares no
`defaultValue`.
Evidence:
- The two probes in H1 above. On the CRM with and without
`GuestPortalProfile`, the answers were identical, and the `beforeInsert`
hook saw `ctx.session` / `ctx.user` unset. On the engine, the positive
control showed that `permissions` and `anonymous` never reach a hook.
The CRM probe also stored `source: web` for a select that declares no
`defaultValue`.
- `packages/objectql/src/engine.ts`, about `:7086`: "A field that
declares NO `defaultValue` falls back to the option marked `default:
true` ({@link resolveOptionDefault}, #7246)". The `applyFieldDefaults`
branch reaches `resolveOptionDefault(f)` only when `f.defaultValue ==
null`, so a declared `defaultValue` wins.
Tests and gates on `91ca7f3b7`, every exit captured before any pipe:
- **Build.** `pnpm turbo run build` over the round-0 closure plus the
eight packages the two dist-reading gates need: `Tasks: 74 successful,
74 total`, lock `VERDICT command-exit 0`. The CRM's `objectstack build`
re-ran on the new `lead.object.ts`: `✓ Build complete`, `Security: 3
Positions 2 Permissions`.
- **CRM tests and typecheck.** `@objectstack/example-crm` `vitest run`:
`Test Files 6 passed (6)`, `Tests 46 passed (46)`, exit 0. `typecheck`:
exit 0.
- **Lint.** `pnpm exec eslint --no-inline-config
examples/app-crm/src/objects/lead.object.ts`: exit 0, no output.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` gave the same 93 commands as round 0,
and all 93 exit 0 on the first pass. `check:skill-examples` reads "262
prose examples type-check" (263 before: the removed snippet);
`check:doc-anchors` reads "536 internal #fragment link(s) … all
resolve". `--ran`: "Run reconciliation — 93 derived, 93 run, 0
NOT-MEASURED, 0 UNRUN".
- **Stale-tree note.** The derivation warned that the tree is 5 commits
behind `origin/main` and that one derived gate,
`scripts/check-position-name-fold-loaders.mjs`, changed there (#24236).
I also ran `main`'s copy of that script at its own path against this
tree, restored by `git checkout HEAD` and proved by blob hash
(`bd90f10a6`, the HEAD blob): scan exit 0, `--self-test` exit 0 (39
cases).
## Patch round 2
**Patch round 2** fixes the `403 PERMISSION_DENIED` row of
`content/docs/ui/forms.mdx`'s submit error table, and nothing else. The
claim was revised for it (comment 6243651386, "Revised 2026-10-18T01:33Z
… patch round 2"). Head: `860ca38a9`. `main` did not move on
`forms.mdx`, so it was not merged.
The row said "the resolved profile does not allow create on the target
object", which contradicted section 2 on the same page. It now names the
conditions the submit answers `403` for, each measured by a throwaway
dogfood probe (deleted, never committed) through the real `POST
/api/v1/forms/:slug/submit` on one `bootStack` boot:
| case | answer |
|---|---|
| baseline submit | `201` |
| permission-set resolution throws (fault injected on the boot's own
`SecurityPlugin` instance) | `403 PERMISSION_DENIED` "[Security] Access
denied: permission subsystem unavailable for operation 'insert' …";
after restore `201` |
| the object's security posture unresolved (fault injected) | `403
PERMISSION_DENIED` "… the security posture of object '…' could not be
resolved …"; after restore `201` |
| a code-registered `beforeInsert` hook throws `PermissionDeniedError` |
`403 PERMISSION_DENIED`, the hook's message |
| a code-registered hook throws a plain `Error` (control) | `500
INTERNAL_ERROR` |
| an `afterInsert` hook writes a different object through `ctx.api`,
unelevated | `403 PERMISSION_DENIED` "[Security] Access denied:
public-form grant permits only create/read-back on 'pfx_cross', not
'insert' on 'pfx_other'" |
| a declarative (sandboxed) `beforeInsert` hook throws | `400`, the
hook's message |
The first two arms are the grant branch's fail-closed throws in
`security-plugin.ts`; the cross-object arm is its refusal of any
operation outside the grant, and it shows that a hook's nested write
carries the submission's context. A permission set's object bits appear
in none of them.
Gates on `860ca38a9`, every exit captured before any pipe: `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` gave the same 93 commands as round 1, and
all 93 exit 0. `--ran`: "Run reconciliation — 93 derived, 93 run, 0
NOT-MEASURED, 0 UNRUN". `check:doc-anchors` and `check:doc-authoring`
are green. The same stale-tree note as round 1 applies, and `main`'s
copy of `scripts/check-position-name-fold-loaders.mjs`, run again at its
own path with a trap restore proved by blob hash (`bd90f10a6`), exits 0
on scan and on `--self-test`. The build for the probe was `Tasks: 74
successful, 74 total` (all cached), lock `VERDICT` exit 0.
## Acceptance notes
None of these sites is changed here. Each needs a carrier. The
`forms.mdx` and `lead.object.ts` items that were listed here in round 0
are fixed in patch round 1 above, and the `forms.mdx` `403` row listed
here in round 1 is fixed in patch round 2.
- **`skills/objectstack-api/SKILL.md` about `:87`** describes the submit
context as `{ permissions: ['guest_portal'], anonymous: true }` and
omits `publicFormGrant`. This is a governed surface (Tier H); the seat
files it as a card to its lane.
- **`anonymous: true` on the submit context has no reader.** There are
zero non-test property reads of `anonymous` in `packages/` (pattern
`(\.|\?\.)anonymous\b|\[['"]anonymous['"]\]`), against 1 for the control
key `publicFormGrant` with the same pattern, at `5a9a80bd2`. The card
changes no behaviour, so the key stays.
- **ADR-0096's census row** for `rest-server.ts#enforceAuth` reads "fall
open when `guest_portal` unregistered (partly mitigated by the
`publicFormGrant`)". It is an accepted record's census, a governed
surface, and left as is.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent acacab0 commit 9975c26
8 files changed
Lines changed: 114 additions & 60 deletions
File tree
- .changeset
- content/docs/ui
- examples/app-crm/src
- objects
- security
- views
- packages/rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
51 | | - | |
52 | | - | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
53 | 54 | | |
54 | 55 | | |
55 | 56 | | |
| |||
102 | 103 | | |
103 | 104 | | |
104 | 105 | | |
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 | | - | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
131 | 127 | | |
132 | 128 | | |
133 | 129 | | |
| |||
226 | 222 | | |
227 | 223 | | |
228 | 224 | | |
229 | | - | |
| 225 | + | |
230 | 226 | | |
231 | 227 | | |
232 | 228 | | |
| |||
247 | 243 | | |
248 | 244 | | |
249 | 245 | | |
250 | | - | |
| 246 | + | |
251 | 247 | | |
252 | 248 | | |
253 | 249 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
| 50 | + | |
49 | 51 | | |
50 | 52 | | |
51 | 53 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | | - | |
46 | | - | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
47 | 49 | | |
48 | 50 | | |
49 | 51 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
65 | | - | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
66 | 68 | | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
71 | 82 | | |
72 | 83 | | |
73 | 84 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
103 | | - | |
104 | | - | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
105 | 110 | | |
106 | 111 | | |
107 | 112 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
407 | 407 | | |
408 | 408 | | |
409 | 409 | | |
410 | | - | |
411 | | - | |
| 410 | + | |
| 411 | + | |
412 | 412 | | |
413 | 413 | | |
414 | 414 | | |
| |||
432 | 432 | | |
433 | 433 | | |
434 | 434 | | |
435 | | - | |
436 | | - | |
437 | | - | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
438 | 441 | | |
439 | 442 | | |
440 | 443 | | |
| |||
522 | 525 | | |
523 | 526 | | |
524 | 527 | | |
525 | | - | |
526 | | - | |
527 | | - | |
528 | | - | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
529 | 548 | | |
530 | 549 | | |
531 | 550 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7312 | 7312 | | |
7313 | 7313 | | |
7314 | 7314 | | |
7315 | | - | |
7316 | | - | |
7317 | | - | |
7318 | | - | |
7319 | | - | |
7320 | | - | |
7321 | | - | |
7322 | | - | |
7323 | | - | |
| 7315 | + | |
| 7316 | + | |
| 7317 | + | |
| 7318 | + | |
| 7319 | + | |
| 7320 | + | |
| 7321 | + | |
| 7322 | + | |
| 7323 | + | |
| 7324 | + | |
| 7325 | + | |
| 7326 | + | |
| 7327 | + | |
| 7328 | + | |
| 7329 | + | |
| 7330 | + | |
| 7331 | + | |
7324 | 7332 | | |
7325 | 7333 | | |
7326 | 7334 | | |
| |||
0 commit comments