From f766813ffc2e4dc37f13160a4ffa77e52244ec79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:01:53 +0000 Subject: [PATCH 1/3] wip: #6603 manage_metadata gate on PUT /meta/:type/:name + tests --- .../meta-item-save-capability-gate.test.ts | 279 ++++++++++++++++++ packages/rest/src/rest-server.ts | 42 +++ 2 files changed, 321 insertions(+) create mode 100644 packages/rest/src/meta-item-save-capability-gate.test.ts diff --git a/packages/rest/src/meta-item-save-capability-gate.test.ts b/packages/rest/src/meta-item-save-capability-gate.test.ts new file mode 100644 index 0000000000..bb150d0107 --- /dev/null +++ b/packages/rest/src/meta-item-save-capability-gate.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#6603] `PUT /api/v1/meta/:type/:name` demands the `manage_metadata` + * authoring capability (ADR-0066 D1) — the same gate, by the same mechanism, + * that `POST /meta/_migrate-stored` already demands next door. + * + * ## What this suite exists to stop + * + * ADR-0106 D1 removes an unreadable field **whole** from a served object + * schema, and this route persists the body it is handed. Until this gate, a + * non-exempt caller's most ordinary sequence — + * + * 1. `GET /meta/object/account` → a schema with `salary_grade` and + * `bonus_formula` absent (correct: that is the whole point of D1); + * 2. edit something unrelated — a label; + * 3. `PUT /meta/object/account` with that body, + * + * — stored the schema back MINUS the two fields, i.e. the caller deleted + * exactly the fields they were never allowed to see, and nothing in the + * exchange said so. The headline case below drives that real sequence against + * a real store, so what is pinned is the DATA LOSS, not just a status code: a + * gate that answers 403 after `saveMetaItem` has already run would still be + * the bug, and would still pass a status-only assertion. + * + * The gate also closes a hole that has nothing to do with masking: before it, + * any authenticated session could clobber any metadata item. + * + * ## Rejection cases assert the ENVELOPE (ADR-0112) + * + * Every refusal here asserts `code` AND `status`, never a bare "it threw" — + * this route answers by *sending* rather than throwing, so a throw-shaped + * assertion could not tell "refused with the wrong envelope" from "did not + * refuse at all". + */ + +import { describe, it, expect, vi } from 'vitest'; +import { FLS_CONTRACT_OBJECT } from '@objectstack/metadata-core/testing'; +import { RestServer } from './rest-server'; + +const copy = (value: T): T => JSON.parse(JSON.stringify(value)); + +/** The four fields `FLS_CONTRACT_OBJECT` declares, sorted. */ +const ALL_FIELDS = ['bonus_formula', 'id', 'name', 'salary_grade']; +/** What the security double lets a restricted caller read. */ +const READABLE_TO_RESTRICTED = ['id', 'name']; + +const SINGLE_PATH = '/api/v1/meta/:type/:name'; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(), + }; + return res; +} + +interface BootOptions { + /** The caller, as `resolveExecCtx` resolves it. `undefined` = anonymous. */ + context: Record | undefined; + /** What `security.getMetadataReadableFields` answers; omit for no security service. */ + readable?: readonly string[]; + /** Drop `saveMetaItem` from the protocol (the 501 kernel). */ + withoutSave?: boolean; +} + +/** + * Boot the route over a protocol backed by a REAL in-memory store, so a GET → + * edit → PUT sequence actually round-trips and the stored document can be + * inspected after the write is refused. + */ +function boot(opts: BootOptions) { + const stored: Record = { account: copy(FLS_CONTRACT_OBJECT as unknown as Record) }; + + const saveMetaItem = vi.fn(async ({ name, item }: any) => { + stored[name] = copy(item); + return { success: true, type: 'object', name }; + }); + + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async () => Object.values(stored).map(copy)), + // No `getMetaItemCached` — the uncached branch, so the read always + // reflects the store rather than a fixture snapshot. + getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: copy(stored[name]), lock: 'none' })), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + }; + if (!opts.withoutSave) protocol.saveMetaItem = saveMetaItem; + + const security = opts.readable === undefined ? undefined : { + getReadableFields: async () => [...opts.readable!], + getMetadataReadableFields: async () => [...opts.readable!], + }; + + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + security ? (async () => security as any) : undefined, + ); + (rest as any).resolveExecCtx = async () => opts.context; + rest.registerRoutes(); + + const route = (method: string) => (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === SINGLE_PATH, + ); + + return { + rest, + saveMetaItem, + /** Field names currently in the STORE (not in any response). */ + storedFields: () => Object.keys(stored.account.fields ?? {}).sort(), + storedLabel: () => stored.account.label, + get: async () => { + const res = mockRes(); + await route('GET')!.handler({ params: { type: 'object', name: 'account' }, query: {}, headers: {} }, res); + return { res, body: res.json.mock.calls.at(-1)?.[0] }; + }, + put: async (item: unknown) => { + const res = mockRes(); + await route('PUT')!.handler( + { params: { type: 'object', name: 'account' }, query: {}, headers: {}, body: item }, + res, + ); + return { res, body: res.json.mock.calls.at(-1)?.[0] }; + }, + }; +} + +describe('#6603 — PUT /meta/:type/:name: the ADR-0106 GET → edit → PUT round trip', () => { + it('refuses a restricted caller\'s round-trip write, and the masked fields SURVIVE in the store', async () => { + const stack = boot({ + context: { userId: 'u_portal', systemPermissions: [] }, + readable: READABLE_TO_RESTRICTED, + }); + + // 1. The read is masked — the premise. Asserted rather than assumed so + // this case cannot go quietly green by the masking disappearing. + const read = await stack.get(); + expect(Object.keys(read.body.item.fields).sort()).toEqual(READABLE_TO_RESTRICTED); + expect(read.body.item.fields).not.toHaveProperty('salary_grade'); + expect(read.body.item.fields).not.toHaveProperty('bonus_formula'); + + // 2. The caller edits something unrelated and sends the body back. + const edited = { ...copy(read.body.item), label: 'Account (renamed)' }; + + // 3. The write is refused — envelope, not just "it failed". + const write = await stack.put(edited); + expect(write.res.statusCode).toBe(403); + expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + + // 4. THE POINT: nothing was written. A gate that 403s *after* the + // store has already been overwritten is the failure mode worth + // guarding, and a status-only assertion cannot see it. + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + expect(stack.storedFields()).toEqual(ALL_FIELDS); + expect(stack.storedLabel()).toBe('Account'); + }); + + it('the refusal is the gate, not the masking: an UNRESTRICTED but uncapable caller is refused too', async () => { + // Everything readable ⇒ no field would have been lost. The write is + // still refused, because reason (2) — any authenticated session could + // clobber any metadata item — is independent of ADR-0106. + const stack = boot({ + context: { userId: 'u_staff', systemPermissions: [] }, + readable: ALL_FIELDS, + }); + const read = await stack.get(); + expect(Object.keys(read.body.item.fields).sort()).toEqual(ALL_FIELDS); + + const write = await stack.put({ ...copy(read.body.item), label: 'clobbered' }); + expect(write.res.statusCode).toBe(403); + expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + expect(stack.storedLabel()).toBe('Account'); + }); +}); + +describe('#6603 — the gate itself', () => { + it('fires BEFORE the protocol is probed, so 403-vs-501 leaks no kernel capability', async () => { + const stack = boot({ context: { userId: 'u1', systemPermissions: [] }, withoutSave: true }); + const write = await stack.put({ name: 'account' }); + // An authorized caller would get 501 here. An unauthorized one must + // not be able to tell the two kernels apart. + expect(write.res.statusCode).toBe(403); + expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); + }); + + it('an anonymous caller never reaches the capability gate — 401 from the /meta umbrella', async () => { + // Every `/meta` route inherits the anonymous-deny wrapper, so this gate + // is the second layer rather than the only one. + const stack = boot({ context: undefined }); + const write = await stack.put({ name: 'account' }); + expect(write.res.statusCode).toBe(401); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('allows a caller holding `manage_metadata`', async () => { + const stack = boot({ context: { userId: 'u_author', systemPermissions: ['manage_metadata'] } }); + const write = await stack.put({ name: 'account', label: 'Account', fields: {} }); + expect(write.res.statusCode).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it('`isSystem` bypasses, matching every other capability gate on the platform', async () => { + const stack = boot({ context: { isSystem: true } }); + const write = await stack.put({ name: 'account', label: 'Account', fields: {} }); + expect(write.res.statusCode).toBe(200); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + /** + * MEASURED, and deliberately pinned as-is: the capability this gate demands + * (`manage_metadata`) and the ADR-0106 D4 mask-exemption set + * (`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES` = `studio.access`, + * `setup.access`) are DIFFERENT SETS. So holding a D4 exemption is not by + * itself permission to write. + * + * In the permission sets the platform ships this never separates on the + * write side: `admin_full_access` carries `manage_metadata` AND + * `studio.access` AND `setup.access`, and it is the only shipped set with + * `studio.access`. `organization_admin` carries `setup.access` without + * `manage_metadata` — D4-exempt (so it never had the round-trip hazard) but + * refused here, which is consistent with its own declaration that a tenant + * does not mutate shared metadata. + */ + it.each([ + ['no capabilities at all', [] as string[], 403], + ['`studio.access` alone — D4-exempt, but not an authoring capability', ['studio.access'], 403], + ['`setup.access` alone — likewise (this is `organization_admin`)', ['setup.access'], 403], + ['`manage_metadata` alone', ['manage_metadata'], 200], + ['the shipped `admin_full_access` shape', ['manage_metadata', 'studio.access', 'setup.access'], 200], + ])('%s → %i', async (_label, systemPermissions, expected) => { + const stack = boot({ context: { userId: 'u1', systemPermissions } }); + const write = await stack.put({ name: 'account', label: 'Account', fields: {} }); + expect(write.res.statusCode).toBe(expected); + }); +}); + +describe('#6603 — the exempt authoring caller is unaffected', () => { + /** + * A GUARD, not evidence: this case is green both before and after the gate + * (a platform admin could always write, and being D4-exempt their read was + * never masked, so their round trip was never lossy). It is here so a + * future tightening of the gate cannot silently lock the platform + * administrator out of the console's own schema designer. + */ + it('an `admin_full_access`-shaped caller round-trips losslessly', async () => { + const stack = boot({ + context: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + readable: READABLE_TO_RESTRICTED, // the service would restrict — D4 exemption outranks it + }); + + const read = await stack.get(); + // D4 — exempt callers are served the UNMASKED schema. + expect(Object.keys(read.body.item.fields).sort()).toEqual(ALL_FIELDS); + + const write = await stack.put({ ...copy(read.body.item), label: 'Account (renamed)' }); + expect(write.res.statusCode).toBe(200); + expect(stack.storedFields()).toEqual(ALL_FIELDS); + expect(stack.storedLabel()).toBe('Account (renamed)'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c6daae931d..b85150b0b2 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5126,6 +5126,48 @@ export class RestServer { handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#6603] Authoring capability gate — the SAME mechanism + // `POST /meta/_migrate-stored` uses next door, deliberately + // not a second way of demanding the same capability. + // + // Two independent reasons, either sufficient: + // + // 1. **The ADR-0106 round-trip.** D1 removes an unreadable + // field WHOLE from a served object schema, and this + // route persists the body it is handed. So a non-exempt + // caller's ordinary GET → edit a label → PUT used to + // store the schema back MINUS the fields masked out of + // their own read — silent deletion of fields they were + // never allowed to see, with nothing in the exchange + // saying so. Refusing the write is the write-side answer + // the masking needs: it makes "whoever may write a + // schema is whoever sees all of it" an enforced + // invariant instead of a coincidence, rather than + // teaching `saveMetaItem` that absent means keep (which + // would make field DELETION inexpressible for everyone). + // 2. It closes a hole that predates masking entirely: any + // authenticated session could clobber any metadata item. + // + // Gate FIRST — before the protocol is resolved — so an + // unauthorized caller cannot use the 501-vs-200 answer to + // probe which kernels implement saving, and so nothing is + // written before the refusal. `manage_metadata` is + // ADR-0066 D1's authoring capability and saving a metadata + // item is authoring; `isSystem` bypasses, matching every + // other capability gate on the platform. + const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + const held = new Set( + Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], + ); + if (!ctx?.isSystem && !held.has('manage_metadata')) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Saving a metadata item requires the `manage_metadata` capability.', + }, + }); + return; + } const p = await this.resolveProtocol(environmentId, req); if (!p.saveMetaItem) { res.status(501).json({ error: 'Save operation not supported by protocol implementation', code: 'NOT_IMPLEMENTED' }); From 804f41104654fc2a19d0cfff95a3ec754f14feef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:23:14 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(rest):=20PUT=20/meta/:type/:name=20?= =?UTF-8?q?=E8=A6=81=E6=B1=82=20manage=5Fmetadata=20=E8=83=BD=E5=8A=9B=20(?= =?UTF-8?q?#6603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/meta-save-manage-metadata-gate.md | 39 +++++++++++++++++++ .../meta-item-save-capability-gate.test.ts | 30 ++++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 .changeset/meta-save-manage-metadata-gate.md diff --git a/.changeset/meta-save-manage-metadata-gate.md b/.changeset/meta-save-manage-metadata-gate.md new file mode 100644 index 0000000000..ea7346ddca --- /dev/null +++ b/.changeset/meta-save-manage-metadata-gate.md @@ -0,0 +1,39 @@ +--- +"@objectstack/rest": minor +--- + +feat(rest): `PUT /api/v1/meta/:type/:name` 要求 `manage_metadata` 能力 (#6603) + +**这是一次访问面收紧,线上可见。** 保存单个元数据项的这条路由此前只有 +`enforceAuth` —— 任何已认证会话都能写任意元数据项。现在它与隔壁的 +`POST /api/v1/meta/_migrate-stored` 用同一道门、同一套机制:调用方必须持有 +ADR-0066 D1 的 `manage_metadata` 能力,`isSystem` 照例放行。 + +## 谁开始吃 403,需要什么 + +**任何不持 `manage_metadata` 的已认证调用方**,对这条路由的 `PUT` 一律 +403 `FORBIDDEN`(匿名调用方仍先吃 `/meta` 伞下的 401,门是第二层)。 +平台自带的 `admin_full_access` 权限集本就带 `manage_metadata`,所以 +Studio / Setup 里的管理员与 CLI 的 dev admin **不受影响**;受影响的是 +自建集成、自建权限集,以及只持 `setup.access` 的 `organization_admin`。 + +**要恢复写入:给该调用方的权限集加上 `manage_metadata`**(Setup → +Permission Sets → `systemPermissions`),而不是绕过这条路由。 + +## 为什么必须收紧 + +ADR-0106 D1 会把调用方不可读的字段**整个**从服务出的对象 schema 里摘掉, +而这条路由原样持久化收到的 body。于是一次最普通的 +GET → 改个 label → PUT,就把调用方**从来没被允许看见的字段删掉了**, +整个交互过程中没有任何东西提示。GET-改-PUT 正是 AI agent 编写元数据的 +标准动作,原先这个动作会静默销毁它看不见的字段;现在它在写入时得到一个 +**响亮的 403**。 + +同时这也关掉一个与掩码无关、更早就存在的洞:任何已认证会话都能覆写 +任意 schema。 + +## 尚未关闭的部分 + +本次只收紧这一条路由。同形的 `PUT /meta/:type/:section/:name`(复合名) +与运行时 dispatcher 自己的 `/meta` PUT 仍无能力门,同一次往返丢失仍可经 +它们复现 —— 已另立 #7019 跟踪,不在本次范围内。 diff --git a/packages/rest/src/meta-item-save-capability-gate.test.ts b/packages/rest/src/meta-item-save-capability-gate.test.ts index bb150d0107..463e1282d2 100644 --- a/packages/rest/src/meta-item-save-capability-gate.test.ts +++ b/packages/rest/src/meta-item-save-capability-gate.test.ts @@ -26,6 +26,14 @@ * The gate also closes a hole that has nothing to do with masking: before it, * any authenticated session could clobber any metadata item. * + * ## Scope of what is pinned here + * + * THIS ROUTE ONLY. The same round trip is still reachable through the + * compound-name save `PUT /meta/:type/:section/:name` (measured) and the + * runtime dispatcher's own `/meta` PUT — filed as #7019, out of this change's + * region. A reader who takes this suite as proof that the defect is closed + * platform-wide has read more into it than it asserts. + * * ## Rejection cases assert the ENVELOPE (ADR-0112) * * Every refusal here asserts `code` AND `status`, never a bare "it threw" — @@ -230,7 +238,13 @@ describe('#6603 — the gate itself', () => { * (`manage_metadata`) and the ADR-0106 D4 mask-exemption set * (`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES` = `studio.access`, * `setup.access`) are DIFFERENT SETS. So holding a D4 exemption is not by - * itself permission to write. + * itself permission to write — and, in the other direction, passing this + * gate is not by itself an exemption from the mask. What this route now + * enforces is "a writer holds `manage_metadata`", which is NOT the same + * sentence as the ruling's rationale, "a writer sees the whole schema"; + * the two coincide only because `admin_full_access` happens to carry both. + * Recorded as #7020 — do not "fix" this matrix to match the rationale + * without a ruling. * * In the permission sets the platform ships this never separates on the * write side: `admin_full_access` carries `manage_metadata` AND @@ -241,15 +255,15 @@ describe('#6603 — the gate itself', () => { * does not mutate shared metadata. */ it.each([ - ['no capabilities at all', [] as string[], 403], - ['`studio.access` alone — D4-exempt, but not an authoring capability', ['studio.access'], 403], - ['`setup.access` alone — likewise (this is `organization_admin`)', ['setup.access'], 403], - ['`manage_metadata` alone', ['manage_metadata'], 200], - ['the shipped `admin_full_access` shape', ['manage_metadata', 'studio.access', 'setup.access'], 200], - ])('%s → %i', async (_label, systemPermissions, expected) => { + { held: 'no capabilities at all', systemPermissions: [] as string[], status: 403 }, + { held: '`studio.access` alone — D4-exempt, but not an authoring capability', systemPermissions: ['studio.access'], status: 403 }, + { held: '`setup.access` alone — likewise; this is `organization_admin`', systemPermissions: ['setup.access'], status: 403 }, + { held: '`manage_metadata` alone', systemPermissions: ['manage_metadata'], status: 200 }, + { held: 'the shipped `admin_full_access` shape', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], status: 200 }, + ])('$held → $status', async ({ systemPermissions, status }) => { const stack = boot({ context: { userId: 'u1', systemPermissions } }); const write = await stack.put({ name: 'account', label: 'Account', fields: {} }); - expect(write.res.statusCode).toBe(expected); + expect(write.res.statusCode).toBe(status); }); }); From 2b977305d9a8caf9fcb89b3c4f4042a60112fc44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:49:53 +0000 Subject: [PATCH 3/3] =?UTF-8?q?test(rest):=20=E8=AE=A9=E6=97=A2=E6=9C=89?= =?UTF-8?q?=20PUT=20/meta=20=E8=B7=AF=E7=94=B1=E6=9C=BA=E5=88=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8C=81=E6=9C=89=20manage=5Fmetadata=20(#6603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既有的 header 转发 / 收据信封 / 错误信封等路由机制单测都以「只有 session」的 调用方驱动 PUT /meta/:type/:name,新门落下后它们先吃 403。给这些 boot 桩加上 manage_metadata,测的仍是原来的机制。 同时在 rest-route-ledger 的该行记下这道门,与 _migrate-stored 的记法一致。 --- .../rest/src/rest-4xx-message-truncation.test.ts | 3 ++- .../rest/src/rest-5xx-message-sanitization.test.ts | 3 ++- .../rest/src/rest-meta-save-receipt-envelope.test.ts | 3 ++- packages/rest/src/rest-route-ledger.ts | 3 ++- .../rest/src/rest-unknown-object-heuristic.test.ts | 3 ++- packages/rest/src/rest.test.ts | 12 ++++++++---- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/rest/src/rest-4xx-message-truncation.test.ts b/packages/rest/src/rest-4xx-message-truncation.test.ts index 5949bd9826..48503483e5 100644 --- a/packages/rest/src/rest-4xx-message-truncation.test.ts +++ b/packages/rest/src/rest-4xx-message-truncation.test.ts @@ -203,7 +203,8 @@ function setup(protocolOverrides: Record = {}) { protocol, { api: { requireAuth: false } } as any, ); - (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); return rest; } diff --git a/packages/rest/src/rest-5xx-message-sanitization.test.ts b/packages/rest/src/rest-5xx-message-sanitization.test.ts index 0b8e797e1c..141fd64d57 100644 --- a/packages/rest/src/rest-5xx-message-sanitization.test.ts +++ b/packages/rest/src/rest-5xx-message-sanitization.test.ts @@ -98,7 +98,8 @@ function mountRest(protocol: any) { protocol, { api: { requireAuth: false, enableBatch: true } } as any, ); - (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); return rest; } diff --git a/packages/rest/src/rest-meta-save-receipt-envelope.test.ts b/packages/rest/src/rest-meta-save-receipt-envelope.test.ts index a7cff2eae6..c45499a09f 100644 --- a/packages/rest/src/rest-meta-save-receipt-envelope.test.ts +++ b/packages/rest/src/rest-meta-save-receipt-envelope.test.ts @@ -87,7 +87,8 @@ async function boot() { const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); const route = rest.getRoutes() .find((r: any) => r.method === 'PUT' && r.path === '/api/v1/meta/:type/:name'); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 8256b9413c..d5c40d9819 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -157,7 +157,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem', responseSchema: 'GetMetaItemResponseSchema', note: '[#5950] answers BARE, so the named schema is the whole body. Filled now that meta-item-layered-route.test.ts parses BOTH branches of this mount (cached and uncached) against it — the uncached branch carries the ADR-0010 protection envelope this schema newly declares' }, - { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem' }, + { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', + note: '[#6603] gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see' }, { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' }, { route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory', diff --git a/packages/rest/src/rest-unknown-object-heuristic.test.ts b/packages/rest/src/rest-unknown-object-heuristic.test.ts index cebc6d5f1d..e6f0a092ca 100644 --- a/packages/rest/src/rest-unknown-object-heuristic.test.ts +++ b/packages/rest/src/rest-unknown-object-heuristic.test.ts @@ -91,7 +91,8 @@ function mountRest(protocol: any) { protocol, { api: { requireAuth: false, enableBatch: true } } as any, ); - (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); return rest; } diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 9f7d506095..630c475aa5 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1777,7 +1777,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1807,7 +1808,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1830,7 +1832,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. const protocol = createMockProtocol(); protocol.saveMetaItem = vi.fn().mockResolvedValue({ success: true }); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name'); @@ -1856,7 +1859,8 @@ describe('PUT /meta/:type/:name handler — header → request plumbing (PR-10d. err.status = 409; protocol.saveMetaItem = vi.fn().mockRejectedValue(err); const rest = new RestServer(server as any, protocol as any, ANON_API as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + // [#6603] this route now demands `manage_metadata` — an authoring capability, not just a session. + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', systemPermissions: ['manage_metadata'] }); rest.registerRoutes(); const route = getPutRoute(rest, '/api/v1/meta/:type/:name');