Repository navigation
docs(rest,app-crm): the public form submit is authorized by its declaration, not by a guest_portal set - #24237
Conversation
…ration, not by a guest_portal set The submit route's docstring said a deployment without a `guest_portal` profile "falls open", and the CRM example said that profile authorizes the anonymous write. Neither is what runs: the SecurityPlugin admits on the declaration-derived `publicFormGrant` before any permission set resolves. The comments now state that mechanism, what the `guest_portal` name on the submit context does reach (the grant's result masker), and why the CRM keeps `GuestPortalProfile` (ADR-0138's reference declaration). The stale `lead.hook.ts` / `case.hook.ts` pointer now names the patterns that exist. Comments only; no behaviour changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…y what guest_portal reaches, add the rest patch changeset The registerFormEndpoints docstring ships in @objectstack/rest's published declarations and JS (measured in dist), so the correction is a patch changeset. The CRM lead form's defaults include `source`, filled from its `default: true` select option. public-data-collection.mdx now states that the guest_portal name on the submit context authorizes nothing and is read only as the result masker's field permissions. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 96de960758d46a45b259b0a4c91c58c579e84df3 && git checkout 96de960758d46a45b259b0a4c91c58c579e84df3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7be3cc7280b9625af914b7b4a978396368f0ee92 860ca38a92bb1c8cb1b7d63fb193a3bea528cf86 && git checkout -B drift-repro 7be3cc7280b9625af914b7b4a978396368f0ee92 && git merge --no-ff 860ca38a92bb1c8cb1b7d63fb193a3bea528cf86
node scripts/docs-audit/affected-docs.mjs --json 7be3cc7280b9625af914b7b4a978396368f0ee92
|
…crm_lead.status says which default the write uses forms.mdx said the guest_portal set and the anonymous flag are attached "for back-compat" with object hooks, that hooks may branch on the guest_portal permission, and that a registered set must be kept INSERT-only. No hook sees either key, and a registered set's object bits are not read on the submit path; only its field permissions feed the result masker over the record the route gets back, and the visitor gets the id only. Section 2 is retitled and rewritten to say that, and drops the INSERT-only snippet whose object bits nothing reads. The crm_lead.status comment called a select option's `default` "only a UI preselect"; the engine falls back to it on write when no defaultValue is declared. The comment now says the declared defaultValue is what the write uses and why it takes precedence. Text only; no behaviour changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…not a profile The error table said `403 PERMISSION_DENIED` means "the resolved profile does not allow create on the target object". No permission set admits or refuses the public submit's create. Measured through the real route, it answers 403 when the security layer fails closed (permission sets or the object's security posture cannot be resolved), when a write under the submission's context leaves the grant (an afterInsert hook writing another object through ctx.api), or when a code-registered hook throws a 403 refusal; a declarative hook's throw answers 400. Text only; no behaviour changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
席位核对 — ACCEPT · PR #24237 at
|
Fixes #24130
Clause-②: no
What changes
Comments, one published docstring, one docs paragraph, and a
patchchangeset. No behaviour of the form routes changes.packages/rest/src/rest-server.ts— the JSDoc aboveregisterFormEndpoints, and nothing else in the file. It said security is delegated to theguest_portalset, 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-derivedpublicFormGrantis admitted before any permission set resolves; it says what the grant admits, refuses and strips, and what theguest_portalname on the context reaches.packages/rest/src/form-routes.ts— three comments:guest_portalpermission set attached");lead.hook.ts/case.hook.tspointer;guest_portal+anonymousare kept for back-compat with object hooks").examples/app-crm/src/views/lead.view.ts— theweb_to_leadcomment no longer says theguest_portalprofile authorizes the write.examples/app-crm/src/security/sales-positions.ts—GuestPortalProfileis kept, and its comment now says why (H3 below).content/docs/ui/public-data-collection.mdx— one paragraph in section 2 saying what theguest_portalname 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 oforigin/main2bcd406a2into this branch.H1 — what authorizes the anonymous INSERT, and what
permissions: ['guest_portal']doesI read
packages/plugins/plugin-security/src/security-plugin.tsand did not change it.publicFormGrantbranch admitsinsert/find/findOne/counton the grant's object and throwsPermissionDeniedErrorfor any other operation or object. It decides this before any permission set resolves, and it stripsPUBLIC_FORM_SERVER_MANAGED_FIELDSfrom inserted rows.guest_portalname. The branch then callsresolvePermissionSetsForContext(opCtx.context), which requests the context'spermissionsnames by name. The answer is used only as input toresolveCallerPostureandmaskOperationResultover the operation's result.fields(getFieldPermissions) and its system permissions (getSystemPermissions, held against a field'srequiredPermissions). The set'sobjectsbits are never read on this branch.{ id }only.Live check: a real boot of
examples/app-crm, throughbootStackfrom@objectstack/verify, in a throwaway dogfood probe that is not committed. It ran with and withoutGuestPortalProfilein the stack'spermissions:GuestPortalProfilecrm_sales_user,guest_portalcrm_sales_userPOST /forms/contact-us/submit201, body{ id }201, body{ id }POSTthat also sendsstatus: 'qualified',lead_score: 99, and forgedowner_id/organization_id201, body{ id }201, body{ id }status: new,lead_score: 0,source: web,owner_id: null, forged organization not storedGET /data/crm_lead401401GET /data/crm_lead/ID401401beforeInserthook oncrm_lead,ctx.session/ctx.usernull/nullnull/nullThe 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):beforeInserthook'sctx.sessionandctx.userare both unset.userId: 'u_ctl',positions: ['p_ctl'],permissions: ['guest_portal'],anonymous: truereaches the hook assession: { userId, positions }anduser: { id }. Neitherpermissionsnoranonymousgets through, becausebuildSession/buildUserinpackages/objectql/src/engine.tscopy 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/holdsindex.tsandopportunity.hook.tsonly. Neitherlead.hook.tsnorcase.hook.tsexists. The comment now names the three patterns that do exist:defaultValue(crm_lead.status);defaultValue, itsdefault: trueoption.packages/objectql/src/engine.ts(about:7086): "A field that declares NOdefaultValuefalls back to the option markeddefault: true." This is howsourcelands aswebin the table above;beforeInserthook where the value needs logic:StampInquiryDefaultsHookinexamples/app-showcase/src/data/hooks/index.ts, the hook on the showcase's public-form target.H3 —
GuestPortalProfile: kept, and the example says whyThe dispatch preferred dropping the profile if nothing reads it. Two things read it:
guest_portalset name |packages/rest/src/rest-server.ts#RestServer.registerFormEndpoints; the reference declaration isexamples/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:
security/index.ts,objectstack.config.tsandaccess-matrix.jsonare unchanged.Changeset —
patch, because the docstring shipsregisterFormEndpointsis private, but its docstring is in the published files. After the build:packages/rest/dist/index.d.ts,index.d.cts,index.jsandindex.cjs;form-routes.tsbody comment (Symmetric with #6601) is insrconce and indistzero times, and so is the newform-routes.tstext.So a reader of the published
@objectstack/restsees the corrected docstring, and that takes apatch, notskip-changeset.examples/app-crmisprivate: true.Tests and gates (head
c564b0e45)pnpm turbo run build --filter=@objectstack/dogfood --filter=@objectstack/example-crm --concurrency=2covers rest's dependency closure plus rest, plugin-security and verify. Result:Tasks: 66 successful, 66 total, lockVERDICT command-exit 0.objectstack buildran in the same run:✓ Build complete,Security: 3 Positions 2 Permissions, with no access-matrix drift.@objectstack/restvitest run --project local:Test Files 294 passed (294),Tests 6028 passed | 326 skipped (6354), exit 0.@objectstack/resttypecheck(tsc --noEmit && check:test-typecheck): exit 0.@objectstack/example-crmvitest run:Test Files 6 passed (6),Tests 46 passed (46), exit 0.@objectstack/example-crmtypecheck: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 93 commands. All 93 exit 0.PREREQUISITE NOT MET, unbuilt packages outside this closure):check:skill-examplesandcheck: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."pnpm exec eslint --no-inline-configover the four changed.tsfiles: exit 0, no output.test:repoand the repo-widepnpm lintare CI's.Patch round 1
The seat's review of
c564b0e45folded two adjacent sites into this PR. The claim's file surface was revised in place: comment 6243651386, "Revised 2026-10-18T01:09Z". Head:91ca7f3b7.mainmoved 5 commits since2bcd406a2, none on a file this PR touches, so it was not merged in. Each round-0 sentence above still describesc564b0e45, and nothing in it changed.content/docs/ui/forms.mdx, theguest_portalpassages only.anonymousflag are attached "for back-compat" with object hooks. It now says neither key authorizes anything or reaches an object hook.guest_portalpermission set", is kept, retitled and rewritten: "No permission set to create — what theguest_portalname does". It now says what is true:publicFormGrant, decided before any set resolves;requiredPermissions, feed the result masker over the record the route gets back, and the visitor gets theidonly;permissionsoranonymous, so a public submission is told apart by the missingctx.user.os:checksnippet, which declared an INSERT-only set, is removed: its whole content was object bits that nothing reads. The section stays because a reader who seespermissions: ['guest_portal']in the context shape needs to know what that name does.permissions: ['guest_portal']"is retained for back-compat".examples/app-crm/src/objects/lead.object.ts, thestatuscomment only. It no longer says an option default is "only a UI preselect". It now says the declareddefaultValue: 'new'is what the write uses, and that this takes precedence over thedefault: trueoption, which the engine falls back to on write only for a select that declares nodefaultValue.Evidence:
GuestPortalProfile, the answers were identical, and thebeforeInserthook sawctx.session/ctx.userunset. On the engine, the positive control showed thatpermissionsandanonymousnever reach a hook. The CRM probe also storedsource: webfor a select that declares nodefaultValue.packages/objectql/src/engine.ts, about:7086: "A field that declares NOdefaultValuefalls back to the option markeddefault: true({@link resolveOptionDefault}, [finding]SelectOption.defaultis an authorable key that nothing on the insert path ever reads — the select idiom is inert #7246)". TheapplyFieldDefaultsbranch reachesresolveOptionDefault(f)only whenf.defaultValue == null, so a declareddefaultValuewins.Tests and gates on
91ca7f3b7, every exit captured before any pipe:pnpm turbo run buildover the round-0 closure plus the eight packages the two dist-reading gates need:Tasks: 74 successful, 74 total, lockVERDICT command-exit 0. The CRM'sobjectstack buildre-ran on the newlead.object.ts:✓ Build complete,Security: 3 Positions 2 Permissions.@objectstack/example-crmvitest run:Test Files 6 passed (6),Tests 46 passed (46), exit 0.typecheck: exit 0.pnpm exec eslint --no-inline-config examples/app-crm/src/objects/lead.object.ts: exit 0, no output.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgave the same 93 commands as round 0, and all 93 exit 0 on the first pass.check:skill-examplesreads "262 prose examples type-check" (263 before: the removed snippet);check:doc-anchorsreads "536 internal #fragment link(s) … all resolve".--ran: "Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN".origin/mainand that one derived gate,scripts/check-position-name-fold-loaders.mjs, changed there (fix(scripts): name-fold loader gate rule 2 resolves the fixtures directory, not one spelling of it #24236). I also ranmain's copy of that script at its own path against this tree, restored bygit checkout HEADand proved by blob hash (bd90f10a6, the HEAD blob): scan exit 0,--self-testexit 0 (39 cases).Patch round 2
Patch round 2 fixes the
403 PERMISSION_DENIEDrow ofcontent/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.maindid not move onforms.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
403for, each measured by a throwaway dogfood probe (deleted, never committed) through the realPOST /api/v1/forms/:slug/submiton onebootStackboot:201SecurityPlugininstance)403 PERMISSION_DENIED"[Security] Access denied: permission subsystem unavailable for operation 'insert' …"; after restore201403 PERMISSION_DENIED"… the security posture of object '…' could not be resolved …"; after restore201beforeInserthook throwsPermissionDeniedError403 PERMISSION_DENIED, the hook's messageError(control)500 INTERNAL_ERRORafterInserthook writes a different object throughctx.api, unelevated403 PERMISSION_DENIED"[Security] Access denied: public-form grant permits only create/read-back on 'pfx_cross', not 'insert' on 'pfx_other'"beforeInserthook throws400, the hook's messageThe 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/objectstackgave 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-anchorsandcheck:doc-authoringare green. The same stale-tree note as round 1 applies, andmain's copy ofscripts/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 wasTasks: 74 successful, 74 total(all cached), lockVERDICTexit 0.Acceptance notes
None of these sites is changed here. Each needs a carrier. The
forms.mdxandlead.object.tsitems that were listed here in round 0 are fixed in patch round 1 above, and theforms.mdx403row listed here in round 1 is fixed in patch round 2.skills/objectstack-api/SKILL.mdabout:87describes the submit context as{ permissions: ['guest_portal'], anonymous: true }and omitspublicFormGrant. This is a governed surface (Tier H); the seat files it as a card to its lane.anonymous: trueon the submit context has no reader. There are zero non-test property reads ofanonymousinpackages/(pattern(\.|\?\.)anonymous\b|\[['"]anonymous['"]\]), against 1 for the control keypublicFormGrantwith the same pattern, at5a9a80bd2. The card changes no behaviour, so the key stays.rest-server.ts#enforceAuthreads "fall open whenguest_portalunregistered (partly mitigated by thepublicFormGrant)". It is an accepted record's census, a governed surface, and left as is.Generated by Claude Code