From 82dfab49eab1069e3894078620c3fe1c4ba85026 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 07:02:58 +0000 Subject: [PATCH] fix(rest): attribute a bearer-authenticated metadata write to its caller (#7749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin's ordinary `PUT /api/v1/meta//` was attributed to nobody: the `sys_metadata_audit` row recorded the sentinel `actor: 'system'` and the `sys_metadata_history` row recorded `recorded_by: NULL`. The real identity appeared only when the caller hand-set a non-standard `X-Actor` header, so the audit trail could not answer "who changed this" for any normal console or API client. The cause was a fallback chain with no producer. Five `/meta` write sites — save, delete, publish, rollback and the compound save — each resolved the actor inline as req.headers['x-actor'] ?? req.headers['X-Actor'] ?? req.user?.id ?? req.userId and nothing on this transport ever sets `req.user` or `req.userId`: REST resolves identity through `resolveExecCtx` (better-auth → `resolveAuthzContext`), which puts it on the returned ExecutionContext and never back onto the raw request. The bearer token was validated — its identity simply never reached the handlers that read for it. Rather than widen the chain with a third limb (which would leave the same "a value everything reads and nothing writes" shape one level down), the two dead limbs are replaced by a single shared producer, `resolveMetaWriteActor`, reading the SAME identity resolution the route's own `manage_metadata` capability gate reads a few lines earlier. The caller a write is ATTRIBUTED to can no longer drift from the caller it was AUTHORIZED against, and all five sites share one rule instead of five copies — which also means the audit rows #7748 will add to publish and rollback inherit the fix rather than the bug. Deliberately unchanged: `X-Actor` still outranks the authenticated identity, exactly as the original expression read. That ordering was masked while the other limbs were always `undefined` and becomes load-bearing now; whether an authenticated caller may keep attributing a write to somebody else is a security-semantics decision for the audit contract, measured and reported on the issue rather than settled as a side effect here. Also unchanged: anonymous and internal system writes resolve no principal, so they still record `'system'` / `NULL` — a machine write is never stamped with a real user. Tests: `meta-write-actor-identity.test.ts` boots a real better-sqlite3 engine, the real `sys_metadata*` objects and a real protocol, then asserts the PERSISTED rows rather than the call arguments — because the two defaults that swallowed the identity differ ('system' vs NULL) and a fix satisfying only one of them would otherwise pass. Reverse-verified: with the producer reverted the admin case reads `{ audit: 'system', history: null }` while the system-write, anonymous and explicit-`X-Actor` cases stay green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WVchQDTf3UjRFWY3JPkdki --- .changeset/meta-write-actor-identity.md | 33 ++ .../src/meta-write-actor-identity.test.ts | 292 ++++++++++++++++++ packages/rest/src/rest-server.ts | 98 +++++- packages/rest/src/rest.test.ts | 9 +- 4 files changed, 414 insertions(+), 18 deletions(-) create mode 100644 .changeset/meta-write-actor-identity.md create mode 100644 packages/rest/src/meta-write-actor-identity.test.ts diff --git a/.changeset/meta-write-actor-identity.md b/.changeset/meta-write-actor-identity.md new file mode 100644 index 0000000000..d341674cc5 --- /dev/null +++ b/.changeset/meta-write-actor-identity.md @@ -0,0 +1,33 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): a bearer-authenticated metadata write is attributed to the caller, not to `system` (#7749) + +An admin's ordinary `PUT /api/v1/meta//` was attributed to nobody: +the `sys_metadata_audit` row recorded the sentinel `actor: 'system'` and the +`sys_metadata_history` row recorded `recorded_by: NULL`. The real identity +appeared only if the caller hand-set a non-standard `X-Actor` header — so the +audit trail could not answer "who changed this" for any normal console or API +client. + +The cause was a fallback chain with no producer. Five `/meta` write sites +(save, delete, publish, rollback, compound save) each resolved the actor as +`req.headers['x-actor'] ?? req.headers['X-Actor'] ?? req.user?.id ?? req.userId`, +and **nothing on this transport ever sets `req.user` or `req.userId`** — REST +resolves identity through `resolveExecCtx` (better-auth → `resolveAuthzContext`), +which puts it on the returned ExecutionContext, never back onto the raw request. +The token was validated; its identity simply never reached the handlers. + +The two dead limbs are replaced — not widened with a third — by a single shared +producer, `resolveMetaWriteActor`, which reads the SAME identity resolution the +route's own `manage_metadata` capability gate reads a few lines earlier. The +caller a write is attributed to can no longer drift from the caller it was +authorized against, and all five sites share one rule rather than five copies. + +Unchanged, deliberately: `X-Actor` still outranks the authenticated identity, +exactly as the original expression read — that precedence is a security-semantics +question for the audit contract, tracked on the issue, not something to settle as +a side effect of fixing the producer. Also unchanged: anonymous and internal +system writes resolve no principal, so they still record `'system'` / `NULL`. A +machine write is never stamped with a real user. diff --git a/packages/rest/src/meta-write-actor-identity.test.ts b/packages/rest/src/meta-write-actor-identity.test.ts new file mode 100644 index 0000000000..692fa014a5 --- /dev/null +++ b/packages/rest/src/meta-write-actor-identity.test.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7749 — a bearer-authenticated admin's metadata write is attributed to that + * admin, not to `system`. + * + * ## The defect + * + * Five `/meta` write sites in `rest-server.ts` resolved the acting identity as + * + * ``` + * req.headers['x-actor'] ?? req.headers['X-Actor'] ?? req.user?.id ?? req.userId + * ``` + * + * and nothing on this transport ever sets `req.user` / `req.userId` — REST + * resolves identity through `resolveExecCtx`, onto the returned + * ExecutionContext, never back onto the raw request. So an ordinary + * console/API `PUT /api/v1/meta//` produced `actor === undefined` + * and the protocol's defaults took over. The trail could not answer "who + * changed this" for any client that did not hand-set a non-standard header. + * + * ## Why this file boots the real stack + * + * The two defaults that swallowed the identity live DOWNSTREAM of REST and + * they differ — `sys_metadata_audit.actor` falls back to the sentinel string + * `'system'` (`recordMetadataAudit`), `sys_metadata_history.recorded_by` falls + * back to SQL `NULL` (#4556). A mock protocol asserting "REST passed an + * `actor` field" would prove neither row, and a fix that satisfied only one of + * them would pass. So nothing here is hand-built: a REAL better-sqlite3 + * `:memory:` engine, the REAL `sys_metadata*` object definitions, a REAL + * `ObjectStackProtocolImplementation`, the REAL route — and the assertions + * read the persisted ROWS, not the call arguments. + * + * ## What is pinned + * + * 1. authenticated admin, no `X-Actor` → BOTH rows carry the admin's id; + * 2. an internal system write (`isSystem`, no principal) still records + * `'system'` / `NULL` — the fix must not stamp a real user onto machine + * writes, and an anonymous caller is still refused outright; + * 3. an explicit `X-Actor` behaves exactly as it did before, so this change + * stays separable from the precedence question the issue raises (the + * header still outranks the session identity — deliberately unchanged + * here, see `resolveMetaWriteActor`). + * + * The final case closes the loop the other three stub: that a bearer token + * really does land on `resolveExecCtx().userId` for this route, driven through + * a real `authServiceProvider` with no stub in the identity path at all. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +// The REAL stores the protocol writes to — not a mirror. A hand-declared +// `sys_metadata_audit` would only reset the drift clock #5785 stopped. +import { + SysMetadata, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/platform-objects/metadata'; +import { RestServer } from './rest-server.js'; + +const ADMIN = 'usr_admin_7749'; + +/** + * `registry.registerObject` takes `(schema, packageId, …)`. Passed explicitly + * rather than left off: the argument is REQUIRED by the signature, and omitting + * it is one of the type errors `check:type-check-debt` freezes for this package + * — a new test must not add to that pile (#5278). + */ +const TEST_PACKAGE_ID = 'objectstack-test'; + +/** The real backend, constructed the canonical way (`examples/app-crm`). */ +function makeSqliteDriver() { + return new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); +} + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const TASK = { + name: 'task', label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const VIEW = (name: string) => ({ + name, + label: 'Actor probe', + object: 'task', + columns: [{ field: 'name', label: 'Name' }], +}); + +/** + * Boot the real stack. + * + * `execCtx` stands in for the auth boundary ONLY — "better-auth says this + * bearer belongs to X" — which is the same seam every neighbouring `/meta` + * test uses for the `manage_metadata` capability gate (#6603). Everything + * downstream of it, which is where #7749 lived, is real. The last test in this + * file removes even that stub. + */ +async function boot(execCtx: unknown, opts: { authServiceProvider?: any } = {}) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + // Real DDL — the audit and history tables the assertions read are + // physically there, with their real column types. + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer( + createMockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + opts.authServiceProvider, // authServiceProvider + ); + if (execCtx !== undefined) { + (rest as any).resolveExecCtx = async () => execCtx; + } + rest.registerRoutes(); + const route = rest.getRoutes() + .find((r: any) => r.method === 'PUT' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('PUT /api/v1/meta/:type/:name is not registered'); + return { engine, protocol, rest, route }; +} + +const putMeta = async (route: any, name: string, headers: Record = {}) => { + const res = makeRes(); + await route.handler( + { params: { type: 'view', name }, query: {}, headers, body: VIEW(name) } as any, + res, + ); + return res; +}; + +/** The `sys_metadata_audit` row this save recorded — the compliance trail. */ +async function auditActor(engine: any, name: string) { + const rows = await engine.find('sys_metadata_audit', { + where: { name }, context: { isSystem: true }, + }); + expect(rows.length).toBeGreaterThan(0); + return rows[rows.length - 1].actor; +} + +/** The `sys_metadata_history` row this save recorded — the version trail. */ +async function historyActor(engine: any, name: string) { + const rows = await engine.find('sys_metadata_history', { + where: { name }, context: { isSystem: true }, + }); + expect(rows.length).toBeGreaterThan(0); + return rows[rows.length - 1].recorded_by; +} + +describe('[#7749] PUT /meta/:type/:name records the authenticated caller as the actor', () => { + it('a bearer-authenticated admin with NO X-Actor is named in BOTH the audit and the history row', async () => { + const { engine, route } = await boot({ + userId: ADMIN, systemPermissions: ['manage_metadata'], + }); + + const res = await putMeta(route, 'actor_probe_view'); + expect(res._json?.success).toBe(true); + + // The whole point of the card. Before the fix these read `'system'` + // and `null` respectively — two different defaults for one missing + // value, which is why both are asserted, together, in one object: a + // fix that satisfied only one of them would otherwise pass on the + // strength of the first assertion alone. + expect({ + audit: await auditActor(engine, 'actor_probe_view'), + history: await historyActor(engine, 'actor_probe_view'), + }).toEqual({ audit: ADMIN, history: ADMIN }); + }, 60_000); + + it('an internal system write (no principal) still records `system` / NULL', async () => { + // The machine-write shape: `isSystem` clears the capability gate, and + // there is NO user behind it. The fix must not invent one. + const { engine, route } = await boot({ isSystem: true }); + + const res = await putMeta(route, 'system_probe_view'); + expect(res._json?.success).toBe(true); + + expect(await auditActor(engine, 'system_probe_view')).toBe('system'); + expect(await historyActor(engine, 'system_probe_view')).toBeFalsy(); + }, 60_000); + + it('an anonymous caller is still refused outright — no row, no attribution', async () => { + // No stub and no auth service: `resolveExecCtx` yields undefined for an + // anonymous request, exactly as it does in production + // (`resolveAuthzContext` → no principal → no context). + const { engine, route } = await boot(undefined); + + const res = await putMeta(route, 'anon_probe_view'); + + // 401 (not 403): the `/meta` umbrella auth gate refuses before the + // `manage_metadata` capability gate is even reached. + expect(res._status).toBe(401); + const rows = await engine.find('sys_metadata_audit', { + where: { name: 'anon_probe_view' }, context: { isSystem: true }, + }); + expect(rows).toHaveLength(0); + }, 60_000); + + it('an explicit X-Actor still wins over the session identity — precedence unchanged', async () => { + // ⚠️ Pinned as-is, NOT endorsed: with the producer fixed this ordering + // is live for the first time, so an authenticated caller can attribute + // a write to somebody else. Changing whose name lands in an audit row + // is a security-semantics decision for the audit contract, tracked + // separately on #7749 — this test exists to keep that decision + // separable from this fix, and it is the test to CHANGE when the + // maintainer rules on the ordering. + const { engine, route } = await boot({ + userId: ADMIN, systemPermissions: ['manage_metadata'], + }); + + const res = await putMeta(route, 'header_probe_view', { 'x-actor': 'user_42' }); + expect(res._json?.success).toBe(true); + + expect(await auditActor(engine, 'header_probe_view')).toBe('user_42'); + expect(await historyActor(engine, 'header_probe_view')).toBe('user_42'); + }, 60_000); + + it('the identity comes from the REAL bearer→session→execCtx chain, no stub', async () => { + // No `resolveExecCtx` override: the route runs the production identity + // resolution (`resolveExecCtx` → `resolveAuthzContext` → better-auth + // `getSession`) against a real auth service that honours the bearer + // token. This is the link the card said was missing — the token IS + // validated, its identity just never reached the handler. + const authServiceProvider = async () => ({ + api: { + getSession: async ({ headers }: any) => ( + headers?.get?.('authorization') === `Bearer token-for-${ADMIN}` + ? { user: { id: ADMIN }, session: { userId: ADMIN } } + : null + ), + }, + }); + const { rest } = await boot(undefined, { authServiceProvider }); + + const authed = await (rest as any).resolveExecCtx(undefined, { + headers: { authorization: `Bearer token-for-${ADMIN}` }, + }); + expect(authed?.userId).toBe(ADMIN); + + // …and the shared producer turns exactly that into the recorded actor. + const actor = await (rest as any).resolveMetaWriteActor(undefined, { + headers: { authorization: `Bearer token-for-${ADMIN}` }, + }); + expect(actor).toBe(ADMIN); + + // A request with no credentials resolves to nobody — so the write path + // falls through to the protocol's `'system'` / NULL defaults. + const anon = await (rest as any).resolveMetaWriteActor(undefined, { headers: {} }); + expect(anon).toBeUndefined(); + }, 60_000); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 0d0e2d0998..40b5a41783 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2723,6 +2723,69 @@ export class RestServer { return this.resolveExecCtx(environmentId, req).catch(() => undefined); } + /** + * [#7749] The acting identity recorded on a metadata WRITE — the single + * producer for every `/meta` route that stamps an `actor` (save, delete, + * publish, rollback, compound save). + * + * ## What was wrong + * + * All five sites resolved the actor inline as + * + * ``` + * req.headers['x-actor'] ?? req.headers['X-Actor'] ?? req.user?.id ?? req.userId + * ``` + * + * and NOTHING on this transport ever sets `req.user` or `req.userId` — + * this server resolves identity through {@link resolveExecCtx} (better-auth + * → `resolveAuthzContext`), which puts it on the returned ExecutionContext, + * never back onto the raw request. So a bearer-authenticated admin's PUT + * yielded `undefined`, and the protocol's own defaults took over: the audit + * row recorded the sentinel `'system'` (`recordMetadataAudit`: + * `actor ?? 'system'`) and the history row recorded `NULL` (#4556: + * `actor ?? null`). The trail could not answer "who changed this" for any + * client that did not know to hand-set a non-standard header. + * + * The two dead limbs are not widened here with a third — that would leave + * the same "a value everything reads and nothing writes" shape one level + * down. They are replaced by the identity resolution this server actually + * performs, the SAME one the route's own `manage_metadata` capability gate + * reads a few lines earlier, so the caller a write is ATTRIBUTED to can + * never drift from the caller it was AUTHORIZED against. `resolveExecCtx` + * is memoized per request, so the three routes that already resolved a + * context for their gate pay nothing extra for this. + * + * ## Precedence — deliberately unchanged (#7749) + * + * `X-Actor` still outranks the authenticated identity, exactly as the + * expression above read. That ordering was masked while the other limbs + * were always `undefined`; it becomes load-bearing the moment this method + * produces one. Whether an authenticated caller may keep attributing a + * metadata write to somebody else by sending a header is a security + * semantics question for the audit contract, not something to settle as a + * side effect of fixing the producer — so it is measured and reported on + * the issue rather than quietly reordered here. + * + * Anonymous / internal writes are unaffected: no resolved principal → no + * context → `undefined` → the protocol's `'system'` / `NULL` defaults still + * apply. A machine write is never stamped with a real user. + */ + private async resolveMetaWriteActor( + environmentId: string | undefined, + req: any, + ): Promise { + const header = req?.headers?.['x-actor'] ?? req?.headers?.['X-Actor']; + // A well-formed header wins, as before. A PRESENT-but-unusable header + // (repeated → array, or empty) falls through to the session rather than + // suppressing attribution: recording the real caller beats recording + // `'system'` for a malformed request, and it keeps "no usable header → + // the authenticated identity" a single rule. + if (typeof header === 'string' && header) return header; + const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + const userId = (ctx as any)?.userId; + return typeof userId === 'string' && userId ? userId : undefined; + } + /** * [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc * gating. `permissionSets` resolves through the security service's @@ -5928,9 +5991,9 @@ export class RestServer { const parentVersion = typeof ifMatchHeader === 'string' ? ifMatchHeader.replace(/^"|"$/g, '') // strip ETag-style quotes : undefined; - const actorHeader = req.headers?.['x-actor'] ?? req.headers?.['X-Actor'] - ?? req.user?.id ?? req.userId; - const actor = typeof actorHeader === 'string' ? actorHeader : undefined; + // [#7749] Header, else the request's authenticated identity — one + // producer, shared by every `/meta` write (see resolveMetaWriteActor). + const actor = await this.resolveMetaWriteActor(environmentId, req); // Phase 3a-destructive: `?force=true` opts past the // destructive-change safety check. Accept any truthy // string ('true', '1', 'yes') for resilience. @@ -6041,15 +6104,16 @@ export class RestServer { // Mirror saveMetaItem's OCC + actor plumbing (ADR-0008 // PR-10d wiring): `If-Match` pins the expected current // version so concurrent edits get a 409 instead of a - // silent reset; `X-Actor` (or req.user) flows into the - // history tombstone row. + // silent reset; `X-Actor` — or, since #7749, the request's + // authenticated identity — flows into the history + // tombstone row. const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; const parentVersion = typeof ifMatchHeader === 'string' ? ifMatchHeader.replace(/^"|"$/g, '') : undefined; - const actorHeader = req.headers?.['x-actor'] ?? req.headers?.['X-Actor'] - ?? req.user?.id ?? req.userId; - const actor = typeof actorHeader === 'string' ? actorHeader : undefined; + // [#7749] Header, else the request's authenticated identity — one + // producer, shared by every `/meta` write (see resolveMetaWriteActor). + const actor = await this.resolveMetaWriteActor(environmentId, req); // [#6877] `?state=` and the destructive `?dropStorage=` // both fail SAFE on an array today (the comparisons stop @@ -6192,9 +6256,9 @@ export class RestServer { }); return; } - const actorHeader = req.headers?.['x-actor'] ?? req.headers?.['X-Actor'] - ?? req.user?.id ?? req.userId; - const actor = typeof actorHeader === 'string' ? actorHeader : undefined; + // [#7749] Header, else the request's authenticated identity — one + // producer, shared by every `/meta` write (see resolveMetaWriteActor). + const actor = await this.resolveMetaWriteActor(environmentId, req); const body = (req.body && typeof req.body === 'object') ? req.body : {}; const message = typeof body.message === 'string' ? body.message : undefined; const result = await (p as any).publishMetaItem({ @@ -6246,9 +6310,9 @@ export class RestServer { }); return; } - const actorHeader = req.headers?.['x-actor'] ?? req.headers?.['X-Actor'] - ?? req.user?.id ?? req.userId; - const actor = typeof actorHeader === 'string' ? actorHeader : undefined; + // [#7749] Header, else the request's authenticated identity — one + // producer, shared by every `/meta` write (see resolveMetaWriteActor). + const actor = await this.resolveMetaWriteActor(environmentId, req); const message = typeof body.message === 'string' ? body.message : undefined; const result = await (p as any).rollbackMetaItem({ type: req.params.type, @@ -6598,9 +6662,9 @@ export class RestServer { const parentVersion = typeof ifMatchHeader === 'string' ? ifMatchHeader.replace(/^"|"$/g, '') : undefined; - const actorHeader = req.headers?.['x-actor'] ?? req.headers?.['X-Actor'] - ?? req.user?.id ?? req.userId; - const actor = typeof actorHeader === 'string' ? actorHeader : undefined; + // [#7749] Header, else the request's authenticated identity — one + // producer, shared by every `/meta` write (see resolveMetaWriteActor). + const actor = await this.resolveMetaWriteActor(environmentId, req); // [#6877] The `typeof` guard below dropped a repeated // `?package=` to `undefined`, i.e. wrote the row as an diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 5674f9ec09..106955c032 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1879,7 +1879,14 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const arg = (protocol.saveMetaItem as any).mock.calls[0][0]; expect(arg).not.toHaveProperty('parentVersion'); - expect(arg).not.toHaveProperty('actor'); + // [#7749] `actor` used to be absent here too, and that absence was the + // defect rather than the contract: `req.user` / `req.userId` — the only + // non-header limbs of the old fallback chain — are never set on this + // transport, so an authenticated caller's write was recorded against + // `'system'`. With no `X-Actor`, the actor is now the request's + // authenticated identity, which on this stub is `test-user`. The row-level + // pins live in `meta-write-actor-identity.test.ts`. + expect(arg.actor).toBe('test-user'); }); it('maps a thrown METADATA_CONFLICT (409) to a 409 response with the code', async () => {