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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
52 changes: 52 additions & 0 deletions .changeset/rest-provider-seam-sync-throw-normalised.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/rest": patch
---

fix(rest): a provider seam that throws SYNCHRONOUSLY no longer discards the whole execution context (#13280)

`RestServer.computeExecCtx` reached its host-wired providers as
`provider(environmentId).catch(() => undefined)`. That handler is attached to
the promise the call RETURNS, so it can only ever see a *rejection*. A provider
that throws BEFORE returning a promise — an ordinary non-`async` function,
which the seam's own type (`(environmentId?: string) => Promise<T>`) cannot
stop a host from wiring — threw while the expression was still being
evaluated, so there was no promise to attach to and the `.catch` was never
reached. The throw escaped to `computeExecCtx`'s outer `catch`, which discards
the ENTIRE execution context, identity included.

Measured on a real `RestServer` with a real `registerPackageRoutes`, both
callers holding a valid session and identical grants, the fault differing ONLY
in how the provider fails:

| seam | fails as | before | after |
|:--|:--|:--|:--|
| `settingsServiceProvider` | rejecting promise | 200 | 200 |
| `settingsServiceProvider` | synchronous throw | **401 UNAUTHENTICATED** | **200** |
| `objectQLProvider` | rejecting promise | 403 | 403 |
| `objectQLProvider` | synchronous throw | **401 UNAUTHENTICATED** | **403** |
| `authServiceProvider` | either | 401 | 401 |

⇒ the wire answer was decided by whether the host happened to declare its
provider `async`. A localization/settings fault, occurring AFTER identity had
already resolved and having nothing to do with authorization, told an
authenticated administrator "Authentication is required to access this
endpoint."

`computeExecCtx` now reaches those seams through one helper that invokes the
provider inside a `try`, so a synchronous throw and a rejected promise reach
the same answer. Each seam still degrades according to what it supplies — the
three do NOT collapse to a common answer (200 / 403 / 401), and that is pinned.

⚠️ This IS an observable wire-behaviour change for one fault shape
(a synchronously-throwing post-identity provider: 401 → 200). It is graded
`patch` because it is a defect repair with no surface change: no export is
added, removed or renamed, no authorable key or schema moves, and the built
`dist/index.d.ts` is byte-identical with and without it (measured by building
the package twice at the same commit; `dist/index.js` differs, which is the
control proving the rebuild saw the change). No host can reasonably have
depended on a settings outage revoking its callers' identity.

⛔ Deliberately NOT changed: `computeExecCtx`'s outer `catch`. Whether a
post-identity fault SHOULD discard identity is a separate, unruled behaviour
decision on a public door; this change only makes the two ways of failing
agree, which is correct under either answer to that question.
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff 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:1240`, `:1269`), and neither
cannot set it (`packages/rest/src/rest-server.ts:1302`, `:1331`), and neither
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
written by internal callers only, as an option on the engine call:

Expand DownExpand 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:246` |
| 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:1272` |
| 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:1334` |

### 2. Write pipeline and data integrity

Expand DownExpand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 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:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4470`, `:5833`, `:6081`, `:6512`, `:6705` |
| 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:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `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 DownExpand 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:1451` (#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:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1302`, `:1331`; `domains/actions.ts:404` |

---

Expand Down
156 changes: 134 additions & 22 deletions packages/rest/src/package-door-execctx-fault-reachability.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,33 @@
* 5's first case is unchanged), and `DATA_ENGINE_UNRESOLVABLE` remains a 403.
* Both are recorded here as measurements, exactly as before.
*
* ## ⭐ [#13280] The SEAM ASYMMETRY is repaired; section 7 pins the repair
*
* Section 7 was filed as a finding of its own: at one and the same provider
* seam, a REJECTION was absorbed and a SYNCHRONOUS throw lost the whole
* execution context — `settingsServiceProvider` answered **200** when it
* returned a rejecting promise and **401** when it threw synchronously, both
* callers holding a valid session and identical grants. The wire answer was
* decided by whether the host happened to declare its provider `async`.
*
* `computeExecCtx` now reaches its seams through `seamOrUndefined`
* (`rest-server.ts`), so a sync throw and a rejection reach the same answer.
* Section 7 is INVERTED IN PLACE — it asserts agreement, and the superseded
* text is quoted beside it. Verifying the card's table also turned up a
* SECOND divergent seam it had not measured: `objectQLProvider`, 403 when
* rejecting and 401 when throwing synchronously; it now agrees at 403.
*
* ⚠️ What this did NOT change, deliberately: `computeExecCtx`'s outer `catch`.
* Whether a post-identity fault SHOULD discard identity is a behaviour change
* on a public door — the second of the two directions the finding recorded,
* and still unruled. Normalising the seams is decision-independent: under
* ANY answer to that question, one fault yielding 200 or 401 depending on how
* the host spelled its provider is a defect.
*
* ⚠️ `SETTINGS_PROVIDER_SYNC_THROW` is consequently GONE from the section-2
* class table — it is no longer a context-lost class. See the block that
* replaces it there before concluding that coverage was dropped.
*
* ## Reading discipline
*
* Every class is driven beside a POSITIVE CONTROL that is the same wiring with
Expand DownExpand Up@@ -335,12 +362,25 @@ const CLASSES: FaultClass[] = [
faulted: () => ({ ...healthy(), authServiceProvider: async () => ({ api: { getSession: async () => { throw new Error('session store down'); } } }) }),
ctx: 'lost', read: DENY, write: DENY,
},
{
id: 'SETTINGS_PROVIDER_SYNC_THROW',
what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings provider blew up'); }) as any }),
ctx: 'lost', read: DENY, write: DENY,
},
// ⭐ [#13280] `SETTINGS_PROVIDER_SYNC_THROW` USED TO LIVE HERE, and its
// removal from this table is the repair, not a gap in it. The row read:
//
// id: 'SETTINGS_PROVIDER_SYNC_THROW',
// what: 'a post-identity provider seam throws SYNCHRONOUSLY — the caller IS authenticated',
// faulted: () => ({ ...healthy(), settingsServiceProvider: (() => { throw … }) as any }),
// ctx: 'lost', read: DENY, write: DENY,
//
// i.e. a SYNCHRONOUS throw at a post-identity settings seam discarded the
// whole execution context and the authenticated caller was answered 401 —
// while the SAME seam rejecting asynchronously was absorbed and served 200.
// The seams are normalised now (`seamOrUndefined`, `rest-server.ts`), so the
// sync throw is absorbed exactly as the rejection always was: this is no
// longer a CONTEXT-LOST class at all, and a table of degraded classes is the
// wrong home for it. Its measurement did not disappear — it MOVED to
// section 7, which now pins the two shapes as EQUAL rather than recording
// them as divergent. ⛔ Do not re-add it here to "restore coverage": section
// 6's "no degraded class is ever served" would then be asserting that a
// repaired seam is still broken.
{
id: 'PERMISSION_STORE_DOWN',
what: 'identity resolves, then every permission-store read throws',
Expand DownExpand Up@@ -618,26 +658,98 @@ describe('[#13255] no degraded class is ever served as anonymous ACCESS or as a
});

// ---------------------------------------------------------------------------
// 7. ⭐ A SEAM ASYMMETRY worth recording: at one and the same provider seam, a
// REJECTION degrades softly and a SYNCHRONOUS THROW loses the whole
// identity. Same fault, two different wire answers.
// 7. ⭐ [#13280] SEAM AGREEMENT — the same provider seam, the same fault, and
// now the SAME answer whichever way the provider fails.
//
// ⭐ INVERTED IN PLACE, not re-baselined. As written for #13255 this section
// RECORDED a divergence and asserted it, under the heading "sync-throw and
// rejection do not agree":
//
// it('a REJECTING settings provider is absorbed … the caller is still served')
// -> expect(captured.status).toBe(200)
// it('the SAME seam, throwing synchronously, escapes that `.catch` … refused 401')
// -> expect(captured.status).toBe(ANONYMOUS_DENY_STATUS)
//
// Both callers held a valid session and identical grants; the wire answer
// was decided by whether the host happened to declare its provider `async`.
// `computeExecCtx` now reaches every one of these seams through
// `seamOrUndefined`, so the two shapes agree — the assertions are inverted
// rather than deleted, which is what keeps this a regression pin on the
// repair instead of a rubber stamp.
//
// ⚠️ The pins below assert AGREEMENT and the AGREED VALUE, never merely
// "both are 200". Two of these seams do not agree at 200, and asserting a
// bare equality would let a future blanket-swallow regression — every seam
// degrading to a served 200 — pass this section unchanged.
// ---------------------------------------------------------------------------

describe('[#13255] at a post-identity provider seam, sync-throw and rejection do not agree', () => {
it('a REJECTING settings provider is absorbed by the seam\'s own `.catch` — the caller is still served', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: async () => { throw new Error('settings unavailable'); } })),
'GET', PKGS,
);
expect(captured.status).toBe(200);
describe('[#13280] at a post-identity provider seam, sync-throw and rejection AGREE', () => {
/** The same seam, failed both ways; the door's answer to each. */
const bothShapes = async (seam: 'settingsServiceProvider' | 'objectQLProvider' | 'authServiceProvider') => {
const rejecting = await drive(
mount(serverWith({ ...healthy(), [seam]: async () => { throw new Error('seam unavailable'); } })), 'GET', PKGS);
const syncThrowing = await drive(
mount(serverWith({ ...healthy(), [seam]: (() => { throw new Error('seam unavailable'); }) as any })), 'GET', PKGS);
return { rejecting, syncThrowing };
};

it('⭐ settings — a POST-IDENTITY seam: both shapes are absorbed and the caller is SERVED', async () => {
const { rejecting, syncThrowing } = await bothShapes('settingsServiceProvider');
// The agreed value, named: identity survives a settings fault, because
// localization has nothing to do with authorization.
expect(rejecting.status).toBe(200);
expect(syncThrowing.status).toBe(200);
expect(syncThrowing.body?.success).toBe(true);
// ⭐ The card's headline, as an equality rather than a table: 401 vs 200
// was the defect, and this is the assertion that fails if it returns.
expect(syncThrowing.status).toBe(rejecting.status);
});

it('the SAME seam, throwing synchronously, escapes that `.catch` and the caller is refused 401', async () => {
const captured = await drive(
mount(serverWith({ ...healthy(), settingsServiceProvider: (() => { throw new Error('settings unavailable'); }) as any })),
'GET', PKGS,
it('⭐ objectQL — the SECOND divergent seam the card did not measure: both shapes answer 403', async () => {
// [#13280] Not in the card's table, found while verifying it: this seam
// diverged too, 403 (reject) vs 401 (sync throw). It agrees at 403 — the
// engine is unresolvable either way, so the caller reaches an EMPTY grant
// set and is refused on capability, NOT on identity.
const { rejecting, syncThrowing } = await bothShapes('objectQLProvider');
expect(rejecting.status).toBe(403);
expect(syncThrowing.status).toBe(403);
expect(syncThrowing.body?.error?.code).toBe('FORBIDDEN');
expect(syncThrowing.status).toBe(rejecting.status);
});

it('auth — a PRE-IDENTITY seam: both shapes were ALREADY 401, and still are', async () => {
// ⚠️ This seam was mechanically asymmetric too (the sync throw escaped its
// `.catch` to the outer one) but never OBSERVABLY so: an absorbed auth
// provider yields `undefined`, and the next line is `if (!authService)
// return undefined`. Pinned precisely because it must NOT move — it is the
// control showing the normalisation did not turn every seam into a 200.
const { rejecting, syncThrowing } = await bothShapes('authServiceProvider');
expect(rejecting.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.status).toBe(ANONYMOUS_DENY_STATUS);
expect(syncThrowing.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(syncThrowing.status).toBe(rejecting.status);
});

it('⭐ the three seams do NOT agree with EACH OTHER — 200 / 403 / 401, so agreement is not a blanket swallow', async () => {
// The guard against the rival repair. "Every seam absorbs everything"
// would satisfy each per-seam pin above; it would NOT satisfy this. Each
// seam still degrades according to what it supplies.
const answers = await Promise.all(
(['settingsServiceProvider', 'objectQLProvider', 'authServiceProvider'] as const)
.map(async (seam) => (await bothShapes(seam)).syncThrowing.status),
);
expect(captured.status).toBe(ANONYMOUS_DENY_STATUS);
expect(captured.body?.error?.code).toBe(ANONYMOUS_DENY_CODE);
expect(answers).toEqual([200, 403, ANONYMOUS_DENY_STATUS]);
expect(new Set(answers).size).toBe(3);
});

it('⭐ [#13279] the loud permission-store outage is NOT absorbed by the normalised seams', async () => {
// The regression that would matter most: `seamOrUndefined` swallows at the
// seam, so a reader must be able to see that the branded outage still
// travels. It does — `AuthzStoreUnavailableError` is raised by `tryFind`
// inside `resolveAuthzContext`, downstream of every seam here, so no
// normalised seam is on its path.
const captured = await drive(mount(serverWith({ ...healthy(), objectQLProvider: async () => qlDown() })), 'GET', PKGS);
expect(captured.status).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS);
expect(captured.body?.error?.code).toBe(AUTHZ_STORE_UNAVAILABLE_CODE);
});
});
Loading
Loading