Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/references-door-refusal-envelope-converged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/rest": patch
---

`GET /api/v1/meta/:type/:name/references`: both of the door's 501 refusals now answer the same ADR-0112 nested envelope, and the unanswerable-target refusal keeps the prescriptive message ADR-0110 D3 requires of it.

The route can refuse in two ways, and the two answers agreed on neither the envelope nor the message:

```
A the protocol cannot answer for this TARGET type (a `field`)
501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
B the resolved kernel has no `findReferencesToMeta` at all
501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}}
```

A now answers in B's shape, carrying the producer's own sentence:

```
501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}}
```

Why the message matters more than it looks. This door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator whose next click is a delete. A `field` target can never MATCH a reference site — fields are addressed by the composite `<object>.<field>` key while every property naming one holds the bare name — so the protocol refuses instead of answering an empty list, and its message names the question that IS answerable: ask the owning object. Relayed as "Internal server error", that instruction never reached the operator.

Two consequences for a caller:

- `body.error.code` now reads `NOT_IMPLEMENTED` on **both** refusals; the top-level sibling `body.code` this route used to answer on refusal A is gone. `@objectstack/client` reads either position, so `err.code` is unchanged for SDK callers; `err.message` improves from `Internal server error` to the prescriptive sentence. A raw HTTP caller branching on `body.code` for this route's 501 should read `body.error.code`, which is what the route's other refusal has always answered.
- Nothing else on the door moves. A genuine server fault reaching this route — the 503 a `sys_metadata` outage raises — keeps its withheld generic message and its flat body, and 200 answers are untouched.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ not on any flag.
## How the flag is set

`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
cannot set it (`packages/rest/src/rest-server.ts:1651`, `:1680`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1739`, `:1768`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
written by internal callers only, as an option on the engine call:

Expand Down Expand Up @@ -103,7 +103,7 @@ that silently does not happen.
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` |
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1683` |
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1771` |

### 2. Write pipeline and data integrity

Expand Down Expand Up @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5319`, `:6766`, `:7014`, `:7445`, `:7638` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down Expand Up @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1651`, `:1680`; `domains/actions.ts:414` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1739`, `:1768`; `domains/actions.ts:414` |

---

Expand Down
8 changes: 7 additions & 1 deletion packages/rest/src/error-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,14 @@ function armSentence(error: any): unknown {
* `statusCode` rather than blocking it, which is what makes better-auth's
* `APIError` (`{ statusCode: 403, status: 'FORBIDDEN' }` — the status field is a
* STRING there) resolve to the status it meant instead of to nothing.
*
* [#15685] Exported so the `/meta/:type/:name/references` door can ask THIS
* question — "did the producer declare a status, in either spelling" — instead
* of re-deriving it beside its own refusal arm. A read, not a policy: the
* export moves no wire byte, and `error-response.ts` is not part of
* `@objectstack/rest`'s package entry, so nothing published changes either.
*/
function declaredHttpStatus(error: any): number | undefined {
export function declaredHttpStatus(error: any): number | undefined {
const declared =
(typeof error?.status === 'number' ? error.status : undefined) ??
(typeof error?.statusCode === 'number' ? error.statusCode : undefined);
Expand Down
11 changes: 10 additions & 1 deletion packages/rest/src/execctx-consumer-census.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,17 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
//
// [#15685] 77 → 77 sites / 99 → 100 mentions. The `/references` door's
// refusal arm added ONE prose mention and NO call site: its comment
// records that the arm's catch is scoped to the protocol call alone, so
// the `resolveExecCtx` seam above it cannot reach the arm whatever that
// seam declares. That is the safety claim the narrow catch rests on, so
// naming the seam is the point of the sentence — and the sentence
// moving only the mention count is this control working: a site was not
// added, and the number that tracks sites did not move.
expect(SITES.length).toBe(77);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(99);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(100);
});

it('the split is 24 locally caught / 53 bare — NOT 16 / 53, which does not add to 77', () => {
Expand Down
29 changes: 19 additions & 10 deletions packages/rest/src/rest-server-meta-read-org-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,16 +1121,25 @@ describe('#13753 GET /meta/:type/:name/references states the org partition', ()
// reads as "the question was never asked", so a scope repair that
// moved either would be moving the destructive-action clearance.
//
// ⚠️ The code is read through BOTH refusal dialects on purpose.
// Measured on this boot, the two 501s this route can answer do not
// agree: the missing-method branch hand-builds the ADR-0112 NESTED
// `{ error: { code, message } }`, while the protocol-raised
// unanswerable-target refusal reaches the wire as the FLAT
// `{ error: 'Internal server error', code }` — the prescriptive
// "ask the owning object instead" message scrubbed. That is a
// finding of its own, filed as #15685; it is NOT this card's
// subject, and reading both keeps this pin measuring the thing it
// is about.
// ⚠️ The code is read through BOTH refusal dialects on purpose,
// and the reason CHANGED with #15685 — so the sentence is rewritten
// rather than left standing as a falsified one.
//
// It used to accommodate a real divergence: the missing-method
// branch hand-built the ADR-0112 NESTED `{ error: { code, message } }`
// while the protocol-raised unanswerable-target refusal reached the
// wire as the FLAT `{ error: 'Internal server error', code }`, its
// prescriptive "ask the owning object instead" message scrubbed.
// #15685 closed that: both exits now answer the nested envelope, and
// `body.error.code` reads the same way on each.
//
// The tolerant read STAYS, deliberately. The envelope and the
// message are pinned — positionally, and on both refusals at once —
// by `rest-server-meta-references-refusal-envelope.test.ts`, which
// is where a regression in either belongs. What THIS pin measures is
// that a SCOPE repair moves neither the code nor the status, and
// reading the code wherever it sits is what keeps it measuring that
// and not a second copy of the envelope contract.
const refused = await b.references('field', 'account.owner');
const body = refused.body as any;
const observed = refused.thrown
Expand Down
Loading
Loading