You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 07b1e21
Browse filesBrowse the repository at this point in the historyBrowse files
The liveness ledger's `/meta` cache-flag, per-item switch, doc and book rows cite the code that reads their keys where it now lives
6
+
7
+
Clause-②: no (only the rows' evidence paths and notes move and every status stays `live`: `MetadataEndpointsConfigSchema`, `DocSchema` and `BookSchema` accept exactly what they accepted before, and no state count moves)
8
+
9
+
-`liveness/metadata_endpoints.json`: the `enableCache` row and the `if (metadata.endpoints.item !== false)` half of the `endpoints.item` row cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`. `@objectstack/rest` moved the per-item reads to `packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes` with no behaviour change, so those references follow them. The `registerPerItemRoute` half of the `endpoints.item` row keeps its citation: that call still lives in the inner method's head.
10
+
-`liveness/doc.json`: the `content` row's single-item branch, and the `_note`'s book-tree and single-item branches, cite the new module. The `doc` list branch keeps its citation.
11
+
-`liveness/book.json`: the `_note` names the new module for the `book/:name/tree` branch.
12
+
- The quoted code, the producers, the verdicts and the row notes are unchanged.
The `/meta` per-item read routes live in their own module, with no route, handler or answer changed
6
+
7
+
Clause-②: no (a move: the four routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical)
8
+
9
+
-`RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statement that registers `GET {metaPath}/:type/:name/references`, `GET {metaPath}/:type/:name/layers`, `GET {metaPath}/book/:name/tree` and `GET {metaPath}/:type/:name` moved, re-indented, to `packages/rest/src/meta-item-read-routes.ts`, and the method calls it at its old position, inside the umbrella's guarded registration. The `metadata.endpoints.item` switch gates the same mounts.
10
+
- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved.
fix(plugin-auth): the invitation role cap's refusals on `POST /organization/invite-member` carry an error code, and a membership read that fails answers `503` instead of a refusal
6
+
7
+
Clause-②: no
8
+
9
+
The role cap in `beforeCreateInvitation` refused with `403` and a message but no `code`, while better-auth's own `owner` refusal on the same endpoint carries one. A client, the console's Invite User dialog among them, could not tell the refusals apart without reading English prose. Each refusal now carries the code the error-code ledger registers for it under `@objectstack/plugin-auth`, in the same `{ message, code }` body:
10
+
11
+
-`INVITE_ROLE_ABOVE_ISSUER`: the requested role grades above the issuer's own, for example a `delegated_admin` inviting as `admin`.
12
+
-`INVITE_ISSUER_MEMBER_ONLY`: an issuer below admin grade asked for a role other than a plain `member`, for example a `delegated_admin` inviting as `delegated_admin`.
13
+
-`INVITE_ISSUER_UNVERIFIED`: fail-closed. The issuer's membership role was not established: the read found no usable `sys_member` row, or the `AuthManager` has no data engine to read from.
14
+
15
+
A membership read that throws is a store outage, not a verdict about the issuer. It used to answer the same `403` as an unverified issuer. It now answers `503` with the code `SERVICE_UNAVAILABLE`, as the organization slug guard does for its own read faults. No invitation is created either way.
16
+
17
+
Unchanged: which invitations are admitted, the status of every refusal (`403`), better-auth's own `owner` refusal, and the exports.
docs(rest): the public form routes' docstring says what authorizes an anonymous submit — the form's own declaration (`publicFormGrant`), not a `guest_portal` permission set — and that nothing falls open without one (#24130)
6
+
7
+
Clause-②: no
8
+
9
+
The docstring of `RestServer#registerFormEndpoints`, which ships in `@objectstack/rest`'s published declarations, said the submit route delegates security to a `guest_portal` permission set on its execution context, that a deployment without that set "falls open", and that secure-by-default deployments must pair the form routes with one. None of that is what runs. The submit hands the engine a `publicFormGrant` scoped to the form's target object, and the security plugin admits on that grant alone, before any permission set resolves: the create and the read-back on that object only, every other operation and object refused, the server-managed anchors stripped from the payload. The grant never falls open and admits the same operations whether or not a `guest_portal` set is registered. The docstring now says this, and says what the `guest_portal` name on the context does reach: where a deployment registers a set under it, that set is what the grant's result masker reads over the record the grant hands back, and the submit answers the created id only.
feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId`
12
+
13
+
Clause-②: yes (widening)
14
+
15
+
-**`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged.
16
+
-**The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`).
17
+
-**Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it.
fix(runtime): on the standalone server, every verb on `/api/v1/actions/...` reaches the `actions` domain, so an anonymous caller is answered `401`; and an `Allow` header no longer lists verbs a route mounts only to refuse
6
+
7
+
Clause-②: no
8
+
9
+
On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), a request that matched no route under its verb was answered by the HTTP adapter: `405 METHOD_NOT_ALLOWED` with an `Allow` header built from the verbs registered on the path. Two answers were wrong.
10
+
11
+
-**`/api/v1/actions/...` (all three path shapes, unscoped and `/environments/:environmentId`-scoped).** Only `POST` is mounted, so an anonymous `GET`, `HEAD`, `PUT`, `PATCH`, `DELETE` or `TRACE` was answered `405` + `Allow: POST` instead of the published `401 UNAUTHENTICATED`. Every such request now reaches the `actions` domain, as it does on a `createHonoApp` host: an anonymous caller gets `401 UNAUTHENTICATED` with no `Allow`, and a signed-in caller gets the domain's `405` + `Allow: POST`. An anonymous `OPTIONS` (only reachable with CORS off) gets the domain's `405` + `Allow: POST` on both hosts, because the shared anonymous decision lets a CORS preflight through.
12
+
-**`Allow` on `/api/v1/keys`, the three `/api/v1/notifications` faces and `/api/v1/mcp/skill`.** These paths mount verbs that exist only so the domain can refuse them. A verb that cannot be mounted (`OPTIONS` with CORS off, `TRACE`, an extension method), and `PUT`/`PATCH` on `/mcp/skill`, got the adapter's `405` with those refusal-only verbs listed as supported (`/keys`: `Allow: DELETE, GET, HEAD, PATCH, POST, PUT`). They now reach the domain too: `/keys` answers `401` anonymous and `405` + `Allow: POST` signed in, `/notifications` answers `401` anonymous and `404 ROUTE_NOT_FOUND` signed in, and `/mcp/skill` answers `405` + `Allow: GET`.
13
+
14
+
Every request that was served is served as before, and no route is added: the dispatcher's existing fallback seam hands these requests to the domain. A transport that implements `setFallbackHandler` but not `resolveMountedRoute` keeps the adapter's answer, and the dispatcher says so once at boot, at `warn`.
Copy file name to clipboardExpand all lines: content/docs/ui/forms.mdx
+27-31Lines changed: 27 additions & 31 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -47,9 +47,10 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o
47
47
> exactly that object, never anything else and never the anonymous fall-open. So
48
48
> public forms work under the unconditional anonymous-deny posture (v17
49
49
> retired the `requireAuth: false` opt-out, #3963) with **no**
50
-
> `guest_portal` permission set. The `guest_portal` permission set + `anonymous` flag
51
-
> are still attached for **back-compat** (object hooks that detect a guest via a
52
-
> falsy `ctx.user?.id`), but they are no longer the authorization mechanism.
50
+
> `guest_portal` permission set. The submit's context also carries
51
+
> `permissions: ['guest_portal']` and `anonymous: true`. Neither authorizes
52
+
> anything and neither reaches an object hook; section 2 says the one thing the
53
+
> `guest_portal` name does.
53
54
54
55
## 1. Declare the form view
55
56
@@ -102,32 +103,27 @@ export default defineView({
102
103
> - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed.
103
104
> - On a **walled** tenancy posture (`group` or `isolated` in force), a form whose object is walled by an organization column is **not offered**. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (`404 FORM_NOT_FOUND`). The administrator is told why, at the form's `sharing`: the view's read (`GET /api/v1/meta/view/:name`) carries it in `_diagnostics.warnings`, and a save or publish of the view answers success with a `public-form-intake-unavailable` warning in `advisories`. If the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it and the form is offered again.
104
105
105
-
## 2. (Optional) Create the `guest_portal` permission set
106
-
107
-
The submit's authorization no longer depends on this set — the
108
-
declaration-derived `publicFormGrant` (see the note above) is what permits the
109
-
insert. For the submit you only need a `guest_portal` permission set if you rely
110
-
on the legacy back-compat path (e.g. an older runtime, or object hooks that
111
-
branch on the `guest_portal` permission). When present the set is attached to
112
-
the anonymous context, so keep it INSERT-only on the form's target object — the
|`403 PERMISSION_DENIED`| the resolved profile does not allow create on the target object |
225
+
|`403 PERMISSION_DENIED`|never a permission set refusing the create (section 2). It answers when the security layer fails closed: the permission sets for the submission could not be resolved ("permission subsystem unavailable"), or the target object's security posture could not be resolved. It also answers when a write made under the submission's context leaves the grant, such as an `afterInsert` hook writing a **different** object through `ctx.api` (the grant covers only the form's object), or when a hook registered in code throws a `403` refusal of its own (`PermissionDeniedError`). A declarative hook's `throw` answers `400`, not `403`.|
230
226
|`404 FORM_NOT_FOUND`| slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true`|
231
227
|`5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code |
232
228
@@ -247,7 +243,7 @@ lookup route.
247
243
### Auth model
248
244
249
245
- Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
250
-
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant`first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal`profile. `permissions: ['guest_portal']` is retained for back-compat.
246
+
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin admits on `publicFormGrant`alone — a create + read-back grant scoped to exactly the declared object, decided before any permission set resolves — so no `guest_portal`set is needed. Neither `permissions` nor `anonymous` authorizes anything or reaches an object hook; where a set named `guest_portal` is registered, the result masker reads its field permissions (section 2).
251
247
- No CSRF or auth header is needed; embed the form on any domain.
Copy file name to clipboardExpand all lines: content/docs/ui/public-data-collection.mdx
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry
46
46
47
47
Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.
48
48
49
+
The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id` only.
50
+
49
51
System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).
0 commit comments