Skip to content

Commit bc90a53

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-11979-default-org-admin-grant
2 parents 16c43fb + a2a3777 commit bc90a53

47 files changed

Lines changed: 2416 additions & 565 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
The `/meta` object state-machine read and the published read live in their own modules, with no route, handler or answer changed
6+
7+
Clause-②: no (a move: the two 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, its per-item registrar included. The statement that registers `GET {metaPath}/object/:name/state/:field` (the legal next states of an object field's state machine) moved, re-indented, to `packages/rest/src/meta-object-state-routes.ts`, and the block that registers `GET {metaPath}/:type/:name/published` (an item's published version) moved to `packages/rest/src/meta-item-history-routes.ts`, beside the history family. The method calls each at its old position, inside the umbrella's guarded registration. The state read still answers to `api.enableMetadata` alone, and the published read still registers through the per-item registrar, so 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.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes
6+
7+
Clause-②: no
8+
9+
Eight refusals thrown from plugin-auth's better-auth hooks answered with `message` alone, so a client could only show or match English prose. Each now carries a `code` it can branch on, and keeps its HTTP status.
10+
11+
- `POST /auth/organization/create`: the reserved `__global__` id or slug answers 400 `VALIDATION_ERROR`. Where the deployment's effective tenancy posture enforces no organization wall (`/auth/config` publishes `features.multiOrgEnabled: false`), creating an organization answers 403 `ORGANIZATION_CREATION_DISABLED`.
12+
- `POST /auth/organization/invite-member` with placement intent (`businessUnitId` and `positions`): with no delegated-administration runtime to authorize it, 403 `INVITE_PLACEMENT_UNSUPPORTED`; refused by the delegated-administration gate, 403 `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`.
13+
- `POST /auth/organization/update` with a new slug: while the organization has an active environment (any environment that is neither archived nor failed), 403 `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`. Its message no longer says environments "still reference" the organization or tells you to rename their hostnames: the guard reads no hostname, and archiving those environments is the remedy. When the guard's own read cannot answer, 503 `SERVICE_UNAVAILABLE`.
14+
- Phone OTP sends (`/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset`): the per-number limit and the deployment's daily SMS quota both answer 429 `RATE_LIMIT_EXCEEDED`. It is one code for both walls, so a caller still cannot tell which budget it hit. The body is better-auth's flat `code` and `message`, with no `Retry-After` header.
15+
- **One behaviour change:** a placement check that fails before the gate reaches a verdict (the issuer's grants could not be read from the store) now answers 503 `SERVICE_UNAVAILABLE`, not a 403 scope refusal carrying the store's message. No invitation is created either way.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a hook's inline `handler` function is handed the same `ctx.title(field?)` a hook `body` gets, so a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an `objectstack.config.ts`) answers it as `objectstack dev` does (#24289)
6+
7+
Clause-②: no
8+
9+
A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('account_id')` (a lookup column) answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table, and the lint refuses that read where it is written
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No stored metadata moves and nothing is converted: what narrows is a served value (a formula field or a validation rule that reads through a reference into a stored-metadata-body table) and an author-time verdict on such an expression; no spec key, export, option, object definition or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); nothing exported is removed (not runtime-export-retired) and no type changes (not type-surface-only or runtime-interface-only). -->
11+
12+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them. The one-hop read a formula field and a validation rule share (`resolveOneHopRelated`) was an ordinary engine read, which the family's projection does not reach.
15+
16+
- **`@objectstack/objectql`.** A hop whose target is a family table is not read, whichever authority the hop is read under. A formula field reading through it is `null` with its reason, on every read, write response and `serveFormulaField` call (the seam a hook body's `ctx.title()` reads through). A validation rule reading through it has no verdict, so the write it judges is refused. The answer is the one an unreadable related record gets, so it says nothing about the row. A hop into any other object is unchanged.
17+
- **`@objectstack/lint`.** A formula field's `expression`, or a validation rule's hydrated `condition`, that reads through a reference into a family table is an error, with the prescription. `os validate`, `os build` and the object save door run the same pass, so the author learns it at save rather than from a `null`.
18+
19+
What to do. Remove the read through the reference. Stored metadata is read through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected. Reading the reference field itself (the stored id) is unchanged.

‎content/docs/api/data-api.mdx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Query records with filtering, sorting, selection, and pagination.
2323
| `top` | query | Max records to return. No default — omitting it returns all matching records. |
2424
| `skip` | query | Offset |
2525
| `expand` | query | Comma-separated list of relations to eager-load. Must name a reference field (`lookup` / `master_detail` / `user` / `tree`) — otherwise `400 INVALID_FIELD`. |
26-
| `search` | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared `searchableFields`, or a text-like auto-default when none are declared). |
26+
| `search` | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared `searchableFields`, or a text-like auto-default when none are declared). When that set is empty — no `searchableFields` entry names a stored field, and the auto-default finds none — a non-blank term is `400 INVALID_FIELD`, never every row. |
2727
| `searchFields` | query | Comma-separated subset of the searchable fields for `search` to scan — narrows the scan, never widens it. A name outside the searchable set is `400 INVALID_FIELD`. |
2828

2929
> **Note:** OData-style `$`-prefixed parameters (`$filter`, `$select`, `$orderby`, `$top`, `$skip`, `$expand`, `$count`, `$search`) are also accepted directly on this same endpoint as aliases — they're normalized internally to the parameter names above. There is no separate standalone OData endpoint.
@@ -156,14 +156,17 @@ corrupts something the earlier axes do not:
156156
| `?search=alpha&searchFields=no_such_field` | `400 INVALID_FIELD` |
157157
| `?search=alpha&searchFields=amount` | `400 INVALID_FIELD` — real field, but not searchable |
158158
| `?search=alpha&searchFields=project_id.name` | `400 INVALID_FIELD` — search scans this object's own columns; mirror the related title instead (see below) |
159+
| `?search=alpha` on an object with no searchable field (two lookups and a `formula` title, say) | `400 INVALID_FIELD` — nothing to scan; declare a stored field searchable or mirror the title (see below) |
159160
| `groupBy: ["status"]` | one bucket per status value |
160161
| `groupBy: ["no_such_field"]` | `400 INVALID_FIELD` |
161162
| `aggregations: [{function:"sum", field:"amount", alias:"total"}]` | the real total |
162163
| `aggregations: [{function:"sum", field:"no_such_field", alias:"total"}]` | `400 INVALID_FIELD` |
163164
| `aggregations: [{function:"count", alias:"n"}]` | `count(*)` — the one legitimate field-less form |
164165

165-
- **`searchFields`** — the only parameter whose failure changed which **rows**
166-
came back. An unknown name used to be dropped, and an override left empty
166+
- **`search`** and **`searchFields`** — the only parameters whose failure
167+
changed which **rows** came back. A `search` over an object with nothing to
168+
scan added no predicate, so every row came back as if every row matched. An
169+
unknown `searchFields` name used to be dropped, and an override left empty
167170
fell back to scanning *every* searchable column: a parameter that exists
168171
only to **narrow** a search failed by **widening** it. Three causes get
169172
three messages, because the fixes differ: a name that is no field (a typo in

‎content/docs/api/error-catalog.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ aggregation's own `filter`), a sort, a `groupBy`, an `aggregations[].field`, a
159159
`searchFields` entry, or an `expand` entry's own filter or sort at any depth. Such a
160160
field is never returned on a generic read, so it cannot be queried by either — the
161161
refusal names the object that declares it. Query by another field.
162+
A non-blank `search` is refused (`param: 'search'`), not answered with every row,
163+
when the object's resolved search set is empty: no `searchableFields` entry names a
164+
stored field, and the default search scans none (`internal: true` fields never
165+
count). Declare one that does, or mirror a composed title onto a stored text field.
162166
Off the request path the same code answers `backfillSummaryNulls`'s
163167
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
164168
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,10 @@ dotted `fields` path (`'customer.name'`) for a single related column:
477477

478478
`search` takes the query text; the server resolves which fields to search from object
479479
metadata (ADR-0061). Pass `searchFields` to narrow that set — it is intersected with what
480-
the object allows, so it can only narrow, never widen.
480+
the object allows, so it can only narrow, never widen. When the object allows nothing —
481+
no `searchableFields` entry names a stored field, and the default search scans none
482+
(`internal: true` fields never count) — a non-blank `search` is refused with
483+
`400 INVALID_FIELD` instead of answering every row, engine calls included.
481484

482485
```typescript
483486
{

‎content/docs/data-modeling/schema-design.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ To control exactly which fields are matched, set `searchableFields` on the objec
102102
Queries then pass a top-level `$search` parameter to match across `searchableFields`
103103
(e.g. `{ $search: 'acme' }`); a request may narrow the set with
104104
`$searchFields`. When `searchableFields` is unset, search falls back to the
105-
name/title field plus short-text fields.
105+
name/title field plus short-text fields. When neither leaves a stored field to scan — a
106+
junction object of lookups with a `formula` title, say — a list search is refused
107+
`400 INVALID_FIELD` rather than answered with every row, and global search skips the
108+
object; mirror the title onto a stored text field, as below.
106109

107110
#### Searching by a related record's title — mirror the value
108111

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ knows which object was addressed.
624624
625625
The [error catalog's `INVALID_FIELD` entry](/docs/api/error-catalog#invalid_field) carries
626626
the authoritative cause text — it enumerates every read axis this one code answers on
627-
(`select`, `expand`, `searchFields`, `groupBy`, `aggregations[].field`) and the
627+
(`select`, `expand`, `search`, `searchFields`, `groupBy`, `aggregations[].field`) and the
628628
off-request `backfillSummaryNulls` case.
629629
630630
### Resource Errors
@@ -738,8 +738,9 @@ have let them count the dependents; `dependentObject` is named either way. Treat
738738
#### `RATE_LIMIT_EXCEEDED`
739739
**HTTP Status:** 429
740740
**Meaning:** Too many requests, rate limit exceeded
741-
**Envelope:** nested — built by the inbound rate limiter, which sits **ahead of every
742-
route**, so it is never flat whichever family you were calling
741+
**Envelope:** nested from the inbound rate limiter, which sits **ahead of every
742+
route**, so its refusal is never flat whichever family you were calling. The phone-OTP
743+
send walls answer this code too, flat (see the end of `QUOTA_EXCEEDED` below)
743744
744745
**Example:**
745746
```json
@@ -835,10 +836,14 @@ Don't feed this 429 to a retry loop that only checks the status, such as the
835836
`RATE_LIMIT_EXCEEDED` example above. It has no `Retry-After` to wait on, and every
836837
retry before `resetAt` is refused again.
837838
838-
Other quota enforcement answers with its own code rather than this one
839-
(the SMS daily quota answers `TOO_MANY_REQUESTS`). For request pacing, the code
840-
on the wire is `RATE_LIMIT_EXCEEDED` above, whose `Retry-After` header and
841-
`retryAfterSeconds` / `resetAt` details are what a client acts on.
839+
Other quota enforcement answers with a code other than this one. The phone-OTP send
840+
walls (`/auth/phone-number/send-otp` and `/auth/phone-number/request-password-reset`), a
841+
phone number's own limit and the deployment's daily SMS quota, both answer 429
842+
`RATE_LIMIT_EXCEEDED`: one code for both, so a caller cannot tell which budget it hit.
843+
That body is better-auth's flat `code` and `message`, with no `Retry-After` header and no
844+
`details`. At the inbound rate limiter, the code on the wire is `RATE_LIMIT_EXCEEDED`
845+
above, whose `Retry-After` header and `retryAfterSeconds` / `resetAt` details are what a
846+
client acts on.
842847
843848
### Server Errors
844849

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,11 @@ plus short-text/enum fields), so naming a field outside that set can never widen
11241124
search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254),
11251125
because the engine-side intersection alone used to drop the unknown name and fall back to
11261126
scanning the full searchable set. Internal callers reaching `engine.find()` directly keep
1127-
the tolerant intersection. Multiple whitespace-separated terms are AND-ed and
1127+
the tolerant intersection, but not a search with nothing to scan: when no
1128+
`searchableFields` entry names a stored field and the auto-default finds none
1129+
(`internal: true` fields never count), the engine itself refuses a non-blank `search`
1130+
with `400 INVALID_FIELD` (`param: 'search'`) on `find`, `findOne`, `count` and
1131+
`aggregate` alike, rather than answering every row. Multiple whitespace-separated terms are AND-ed and
11281132
fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not
11291133
from the expansion: it emits `$icontains`, which ignores **ASCII case** by the
11301134
rule in [Case Sensitivity](#case-sensitivity) above. So a user typing `acme` does

0 commit comments

Comments
 (0)