diff --git a/.changeset/22220-package-door-before-gates.md b/.changeset/22220-package-door-before-gates.md new file mode 100644 index 00000000000..f2cbea796f4 --- /dev/null +++ b/.changeset/22220-package-door-before-gates.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): a save of a packaged item whose type allows no overlay answers `403 NOT_OVERRIDABLE` before any check that judges its body, on every kernel topology + +Clause-②: no + +- **What was wrong.** `PUT /api/v1/meta/:type/:name` refuses an in-place write onto an item a code package ships when the type has no per-organization overlay channel (`allowOrgOverride: false`, for example `object`, `permission`, `position`). An environment-scoped kernel answered that refusal before it judged the body. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) answered it only at the repository write, after every other check. So on that kernel a publish of a packaged object whose body the authoring gate refuses answered `422 INVALID_METADATA` with findings the author could not land through this door, and a body the spec parse refuses answered `422` in draft and publish mode. After fixing the findings, the author got the `403`. +- **What changes.** The package check now runs on every topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and every check that reads the body or the store. The same request now gets the same refusal on both kernels: `403 NOT_OVERRIDABLE`, or `403 ITEM_LOCKED` when the save names the read-only package, with the same sentence. On a host-config kernel that sentence replaces the repository's "is not allowOrgOverride in the registry" text for these saves. +- **What does not change.** Every request refused before is still refused, and every request admitted before is still admitted. The repository refused the same writes on every topology, and it still does, for the doors that reach it without this check. An environment-local item, an item of a type that allows overlays, and a save with `OS_METADATA_WRITABLE` open for the type are judged by the same checks as before, and drafts are still not judged by the authoring gate. diff --git a/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts new file mode 100644 index 00000000000..ff2184455a7 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22220, #8184] The package door answers BEFORE the checks that judge the + * body, on both kernel topologies — one request, one refusal. + * + * `saveMetaItem`'s package door (`refusePackagedBaseOverride`) refuses an + * in-place write onto an item a code package ships, on a type with no per-org + * overlay channel (`allowOrgOverride: false`). It used to be asked only on an + * environment kernel (`environmentId !== undefined`); a host-config kernel — + * the CLI's assembler, the showcase's boot shape — met the same predicate only + * at the repository write (`SysMetadataRepository.assertAllowed`, the first + * statement of `repo.put`), which is that method's last act. So on that kernel + * every refusal in between answered first: a publish of a packaged object + * whose body the runtime authoring gate refuses answered `422 + * INVALID_METADATA`, while an environment kernel answered `403 + * NOT_OVERRIDABLE` for the same request; a body the spec-conformance parse + * refuses did the same in draft and publish mode. The author was told to fix + * findings no write through this door could ever land. + * + * This file pins the request against BOTH kernels, row by row, asserting the + * ADR-0112 envelope (`code` + `status`) per kernel: + * + * 1. a packaged `object`, `position` and `permission` — whatever the body — + * answers the package door's refusal on both kernels, and the gate that + * would have judged the body is never reached; + * 2. controls, unchanged: an environment-local object with a gate-refused or + * a spec-refused body still answers `422 INVALID_METADATA` on both + * kernels; a packaged item of a type that allows overlays + * (`allowOrgOverride: true`) and a packaged object with the + * `OS_METADATA_WRITABLE` hatch open are still judged by the gates; + * 3. every type in `DEFAULT_METADATA_TYPE_REGISTRY`: the same packaged + * publish gives the same envelope on both kernels, and every type the + * door governs answers it. + * + * Nothing in a refused row is persisted. `@objectstack/objectql` cannot be + * imported here: it depends on this package. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +const PACKAGE_ID = 'com.example.pkg'; +const ENV_ID = 'env_1'; + +type Kernel = 'environment' | 'host-config'; +const KERNELS: ReadonlyArray = [ + ['environment', ENV_ID], + ['host-config', undefined], +]; + +interface Envelope { code: unknown; status: unknown } + +/** `field` artifacts are nested in their object, so the packaged field is the packaged object's own. */ +const packagedName = (type: string): string => (type === 'field' ? 'pkg_object.title' : `pkg_${type}`); + +/** What a code package's loader registered: one artifact per registry type, package-stamped. */ +function packagedArtifacts(): Map>> { + const out = new Map>>(); + for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) { + if (type === 'field') continue; // shipped inside `pkg_object`, below + const name = packagedName(type); + out.set(type, new Map([[name, { + name, + label: name, + ...(type === 'object' ? { fields: { title: { type: 'text', label: 'Title' } } } : {}), + _packageId: PACKAGE_ID, + _provenance: 'package', + }]])); + } + return out; +} + +/** + * The engine double: the registry answers what the loader registered; the + * store holds nothing; `insert` records every row it is handed, so a refused + * row can be proven unpersisted. `manifests` holds the booted code package, + * which makes it a read-only base (`isWritablePackage`). + */ +function harness(environmentId: string | undefined) { + const artifacts = packagedArtifacts(); + const inserted: Array<{ table: string; data: Record }> = []; + const registry = { + getArtifactItem(type: string, name: string) { + const hit = artifacts.get(type)?.get(name); + return hit && isCodeArtifactBody(hit) ? hit : undefined; + }, + getItem(type: string, name: string) { + return artifacts.get(type)?.get(name); + }, + listItems(type: string) { + return [...(artifacts.get(type)?.values() ?? [])]; + }, + getObject: () => undefined, + registerObject: () => undefined, + registerItem: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + const engine: any = { + async find(_table: string, opts?: { limit?: number }) { + // `check:objectql-double-limit` — the caller's bound, applied after the (empty) filter. + const matched: unknown[] = []; + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + return null; + }, + async insert(table: string, data: Record) { + inserted.push({ table, data }); + return { id: `r_${inserted.length}` }; + }, + manifests: new Map([[PACKAGE_ID, {}]]), + registry, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); + const persisted = () => inserted.filter((r) => r.table === 'sys_metadata'); + return { protocol, persisted }; +} + +/** A body the runtime authoring gate refuses and the spec parse accepts: the autonumber names a field the object lacks. */ +const gateRefusedObject = (name: string) => ({ + name, + label: name, + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + task_no: { type: 'autonumber', label: 'Task No', autonumberFormat: '{plan_no}{000}' }, + }, +}); + +/** A body the spec-conformance parse refuses: an undeclared top-level key. */ +const specRefused = (body: Record) => ({ ...body, zz_undeclared_key: 1 }); + +/** A body every check before the store accepts (the `sharingModel` keeps `security-owd-unset` quiet). */ +const servedObject = (name: string) => ({ + name, + label: name, + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, +}); + +interface Probe { + envelope: Envelope; + issues: unknown; + gateReached: boolean; + persistedRows: number; +} + +async function probe( + environmentId: string | undefined, + request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish'; packageId?: string }, +): Promise { + const { protocol, persisted } = harness(environmentId); + const gate = vi.spyOn(protocol as any, 'assertRuntimeAuthoringRules'); + try { + const outcome = await protocol.saveMetaItem(request).then( + () => null, + (e: unknown) => e as Record, + ); + expect(outcome, `${request.type}/${request.name}: the save was ADMITTED`).not.toBeNull(); + return { + envelope: { code: outcome!.code, status: outcome!.status }, + issues: outcome!.issues, + gateReached: gate.mock.calls.length > 0, + persistedRows: persisted().length, + }; + } finally { + gate.mockRestore(); + } +} + +const NOT_OVERRIDABLE: Envelope = { code: 'NOT_OVERRIDABLE', status: 403 }; +const ITEM_LOCKED: Envelope = { code: 'ITEM_LOCKED', status: 403 }; +const INVALID_METADATA: Envelope = { code: 'INVALID_METADATA', status: 422 }; + +let warn: ReturnType; +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + warn.mockRestore(); + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); +}); + +describe('[#22220] a packaged publish answers the package door on both kernels', () => { + interface Row { + label: string; + request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish'; packageId?: string }; + expected: Envelope; + } + const rows: Row[] = [ + { + label: 'packaged object, the served body, publish', + request: { type: 'object', name: 'pkg_object', item: servedObject('pkg_object') }, + expected: NOT_OVERRIDABLE, + }, + { + label: 'packaged object, a body the authoring gate refuses, publish', + request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object') }, + expected: NOT_OVERRIDABLE, + }, + { + label: 'packaged object, a body the authoring gate refuses, draft (drafts stay ungated)', + request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object'), mode: 'draft' }, + expected: NOT_OVERRIDABLE, + }, + { + label: 'packaged object, a body the spec parse refuses, publish', + request: { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) }, + expected: NOT_OVERRIDABLE, + }, + { + label: 'packaged object, a body the spec parse refuses, draft', + request: { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')), mode: 'draft' }, + expected: NOT_OVERRIDABLE, + }, + { + label: 'packaged object named under its read-only package, a body the authoring gate refuses, publish', + request: { type: 'object', name: 'pkg_object', item: gateRefusedObject('pkg_object'), packageId: PACKAGE_ID }, + expected: ITEM_LOCKED, + }, + { + label: 'packaged position (security domain), a body the spec parse refuses, publish', + request: { type: 'position', name: 'pkg_position', item: specRefused({ name: 'pkg_position', label: 'Pkg' }) }, + expected: NOT_OVERRIDABLE, + }, + { + label: 'packaged permission (security domain), a body the spec parse refuses, publish', + request: { type: 'permission', name: 'pkg_permission', item: specRefused({ name: 'pkg_permission', label: 'Pkg' }) }, + expected: NOT_OVERRIDABLE, + }, + ]; + + for (const row of rows) { + for (const [kernel, environmentId] of KERNELS) { + it(`${row.label} → ${row.expected.status} ${row.expected.code} (${kernel} kernel)`, async () => { + const result = await probe(environmentId, row.request); + expect(result.envelope).toEqual(row.expected); + expect(result.gateReached, 'the door answers before the authoring gate').toBe(false); + expect(result.persistedRows, 'a refusal persists nothing').toBe(0); + }); + } + } +}); + +describe('[#22220] controls: the gates still judge what the door does not refuse', () => { + it('an environment-local object with a body the authoring gate refuses → 422 INVALID_METADATA, gate reached (both kernels)', async () => { + for (const [kernel, environmentId] of KERNELS) { + const result = await probe(environmentId, { type: 'object', name: 'local_object', item: gateRefusedObject('local_object') }); + expect(result.envelope, kernel).toEqual(INVALID_METADATA); + expect(result.gateReached, kernel).toBe(true); + // The ONE finding, so the rest of the body is gate-clean: the packaged rows above are refused for the door alone. + const rules = (result.issues as Array<{ rule?: string }>).map((i) => i.rule); + expect(rules, kernel).toEqual(['autonumber-references-unknown-field']); + expect(result.persistedRows, kernel).toBe(0); + } + }); + + it('an environment-local object with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { + for (const [kernel, environmentId] of KERNELS) { + const result = await probe(environmentId, { type: 'object', name: 'local_object', item: specRefused(servedObject('local_object')) }); + expect(result.envelope, kernel).toEqual(INVALID_METADATA); + const codes = (result.issues as Array<{ code?: string }>).map((i) => i.code); + expect(codes, kernel).toContain('unrecognized_keys'); + expect(result.persistedRows, kernel).toBe(0); + } + }); + + it('a packaged item of a type that allows overlays (view) with a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { + for (const [kernel, environmentId] of KERNELS) { + const result = await probe(environmentId, { + type: 'view', name: 'pkg_view', item: specRefused({ name: 'pkg_view', label: 'Pkg' }), + }); + expect(result.envelope, kernel).toEqual(INVALID_METADATA); + expect(result.persistedRows, kernel).toBe(0); + } + }); + + it('a packaged object with the OS_METADATA_WRITABLE hatch open and a body the spec parse refuses → 422 INVALID_METADATA (both kernels)', async () => { + process.env.OS_METADATA_WRITABLE = 'object'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + for (const [kernel, environmentId] of KERNELS) { + const result = await probe(environmentId, { type: 'object', name: 'pkg_object', item: specRefused(servedObject('pkg_object')) }); + expect(result.envelope, kernel).toEqual(INVALID_METADATA); + expect(result.persistedRows, kernel).toBe(0); + } + }); +}); + +describe('[#22220] every registry type: one packaged publish, one envelope, on both kernels', () => { + const governed = new Set( + DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate).map((e) => e.type), + ); + + for (const { type } of DEFAULT_METADATA_TYPE_REGISTRY) { + it(`${type}${governed.has(type) ? ' (the door governs it)' : ''}`, async () => { + const name = packagedName(type); + const request = { type, name, item: specRefused({ name, label: name }) }; + const byKernel: Record = {}; + for (const [kernel, environmentId] of KERNELS) { + const result = await probe(environmentId, request); + byKernel[kernel] = result.envelope; + expect(result.persistedRows, `${type} (${kernel})`).toBe(0); + } + expect(byKernel['host-config'], `${type}: the kernels disagree`).toEqual(byKernel.environment); + if (governed.has(type)) expect(byKernel.environment, type).toEqual(NOT_OVERRIDABLE); + }); + } + + it('lit control: the door governs object, position and permission, and leaves view to the gates', () => { + expect([...governed]).toEqual(expect.arrayContaining(['object', 'position', 'permission'])); + expect(governed.has('view')).toBe(false); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3c2f7d11afc..b16c53a4ac9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16650,12 +16650,13 @@ export class ObjectStackProtocolImplementation implements * * ## Topology-INDEPENDENT, deliberately * - * `saveMetaItem` asks its package door behind `environmentId !== undefined` - * because on a host-config kernel the SAME predicate is enforced one layer - * down, by `SysMetadataRepository.assertAllowed` at the write itself — so - * the `/meta` door refuses a packaged item's in-place write on every - * topology. A caller whose write never reaches the repository has no such - * second layer, so it is answered here on every topology. The removal side + * [#22220] `saveMetaItem` asks its package door on every topology too, + * ahead of the gates that judge the body; the SAME predicate is enforced + * one layer down, by `SysMetadataRepository.assertAllowed` at the write + * itself, as the store-level backstop — so the `/meta` door refuses a + * packaged item's in-place write on every topology. A caller whose write + * never reaches the repository has no such second layer, so it is + * answered here on every topology. The removal side * agrees: the `/meta` door never removes a packaged base on any topology * (on a host-config kernel with no overlay row its delete is a no-op that * leaves the artifact standing, and with one the repository's delete gate @@ -17151,8 +17152,9 @@ export class ObjectStackProtocolImplementation implements * record and both emitters are that method's lines, byte for byte apart * from indentation — so {@link packagedBaseRefusal} can hand a second write * door the same verdict. `saveMetaItem` calls it at the same position - * (behind `environmentId !== undefined`, below the code-only and org-scope - * refusals, above the ADR-0010 `_lock` check). The one added line computes + * (below the code-only and org-scope refusals, above the ADR-0010 `_lock` + * check) — [#22220] on every topology, where it used to be asked behind + * `environmentId !== undefined` only. The one added line computes * `overlayAllowed` the way `saveMetaItem` computes it at its top. In the * record, "the block comment above" and "this method" mean `saveMetaItem`. * @@ -20195,6 +20197,10 @@ export class ObjectStackProtocolImplementation implements // declaration depend on deployment topology; the declaration decides // it here instead. // + // [#22220] "The rest of this block" no longer stays behind it either: + // the package door below now asks on every topology too — see its + // call site for why that moves no acceptance set. + // // `isOverlayAllowed` still consults `OS_METADATA_WRITABLE`, so the // documented operator escape hatch stays the ONE door: unlocking a // type there unlocks it here too. `deleteMetaItem` is deliberately @@ -20264,39 +20270,68 @@ export class ObjectStackProtocolImplementation implements if (intakeRefusal) throw intakeRefusal; } - if (this.environmentId !== undefined) { - // [#8184] THE PACKAGE DOOR — the refusal of a write onto an item a - // code package ships, on a type with no per-org overlay channel. - // The verdict and its full record live in - // {@link refusePackagedBaseOverride}: [#20679] lifted out of this - // method UNCHANGED, so a second write door onto the same artifact - // asks this exact predicate and gets this exact emitter through - // {@link packagedBaseRefusal}, rather than a copy that agrees with - // this one only until either of them moves. - this.refusePackagedBaseOverride(request); - } + // [#8184] THE PACKAGE DOOR — the refusal of a write onto an item a + // code package ships, on a type with no per-org overlay channel. + // The verdict and its full record live in + // {@link refusePackagedBaseOverride}: [#20679] lifted out of this + // method UNCHANGED, so a second write door onto the same artifact + // asks this exact predicate and gets this exact emitter through + // {@link packagedBaseRefusal}, rather than a copy that agrees with + // this one only until either of them moves. + // + // [#22220] ON EVERY TOPOLOGY, and HERE: ahead of every check below + // that judges the request's body or the store. It used to sit behind + // `environmentId !== undefined`, on the ground that a host-config + // kernel meets the same predicate one layer down, at the repository + // write (`SysMetadataRepository.assertAllowed`, the first statement + // of `repo.put`). It does, but `repo.put` is this method's LAST act, + // so on that kernel every refusal in between answered first. Measured + // on a host-config boot: a publish of a packaged `object` whose body + // the runtime authoring gate refuses answered `422 INVALID_METADATA` + // where an environment kernel answered `403 NOT_OVERRIDABLE` for the + // same request, and a body the spec-conformance parse refuses did the + // same in draft and publish mode. The author was told to repair + // findings that no write through this door could ever land, and was + // refused for the basic reason only after repairing them. The same + // window held the ADR-0029 D9.9 package mismatch, the destructive + // diff, the layered-envelope and save-name refusals, the flow + // conversion conflict, the stored-hook body refusal and the domain + // plugins' authoring gates; #21694 had taught the `_lock` gate below + // to defer to this door by hand. Asked once, here, the door answers + // before all of them — one request, one refusal, on both kernels — + // and none of them has to learn to defer to it. + // + // ⛔ NO ACCEPTANCE SET MOVES. This predicate is the repository's + // (the registry's `allowOrgOverride`, the `OS_METADATA_WRITABLE` + // hatch, a named read-only base through the one `isWritablePackage`), + // and `repo.put` refuses every write it refuses, on every topology — + // so a request refused here was refused before, and only which + // refusal its author reads has changed: the same code and status on + // both kernels, and this door's sentence on both. The repository's + // check stays the store-level backstop for the doors that reach `put` + // without passing here (draft promotion, restore, revert). Nor does + // this retire a single-kernel carve-out: ADR-0005 §"Whitelist + // enforcement" kept such deployments "any type writable", but the + // repository has refused these writes on them all along; the door + // only answers first. + this.refusePackagedBaseOverride(request); // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted // overlay `_lock`) blocks save independent of the L1 type-level // flag. Records the denial in `sys_metadata_audit` before // throwing so refused attempts are visible in compliance reports. // - // [#21694] On EVERY topology — it used to sit inside the block above, - // so a host-config kernel never asked it (see {@link lockWriteRefusal}). - // Its rank is unchanged and is the same on every kernel: BELOW the - // package door. On an environment kernel that door has thrown above - // whenever it refuses, so the condition is always true there; on a - // host-config kernel the same door answers at the repository write - // (`SysMetadataRepository.assertAllowed`), so a packaged base it will - // refuse is left to it. One request, one refusal code, on both kernels - // — the `_lock` gate never pre-empts `NOT_OVERRIDABLE` on one topology - // only. - if (this.packagedBaseRefusal({ - type: request.type, - name: request.name, - operation: 'save', - ...(request.packageId ? { packageId: request.packageId } : {}), - }) === null) { + // [#21694] On EVERY topology — it used to sit inside the package + // door's `environmentId` block, so a host-config kernel never asked + // it (see {@link lockWriteRefusal}). Its rank is unchanged and is the + // same on every kernel: BELOW the package door. #21694 kept that rank + // on a host-config kernel by asking {@link packagedBaseRefusal} here, + // because that kernel's door then answered only at the repository + // write; [#22220] the door above now throws on every kernel whenever + // it refuses, so that question always answered "no refusal" here and + // is gone. One request, one refusal code, on both kernels — the + // `_lock` gate never pre-empts `NOT_OVERRIDABLE` on either topology. + { const lockErr = await this.assertLockAllowsWrite({ type: request.type, name: request.name, diff --git a/packages/objectql/src/protocol-destructive.test.ts b/packages/objectql/src/protocol-destructive.test.ts index f13a50f3b21..de6c275233f 100644 --- a/packages/objectql/src/protocol-destructive.test.ts +++ b/packages/objectql/src/protocol-destructive.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol'; import { SchemaRegistry } from './registry.js'; /** @@ -39,11 +39,27 @@ describe('ObjectStackProtocolImplementation - destructive change detection', () count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue([]), }; - // No environmentId — bypass the overlay opt-in gate so we test - // only the destructive check. + // `account` is a packaged object (`'pkg'`), and `object` has no + // per-org overlay channel, so the package door refuses its in-place + // write before the destructive check on every kernel topology. This + // suite used to reach the check by leaving `environmentId` unset, + // which skipped that door; the door is now asked on every topology, + // so the suite opens the documented operator hatch instead — the one + // route by which a packaged object's write reaches the check. + process.env.OS_METADATA_WRITABLE = 'object'; + // Two memoised readers of the same env var — the protocol's gate and + // the repository's `assertAllowed`. Both are reset. + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); protocol = new ObjectStackProtocolImplementation(mockEngine); }); + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + }); + it('blocks save when a field is removed', async () => { await expect(protocol.saveMetaItem({ type: 'object', diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts index 5aa2f35962b..e59d3686e4c 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts @@ -23,7 +23,9 @@ * IDENTITY (`instanceof PackagedPermissionSetLockedError`), not only the * `code`/`status` envelope: `NOT_OVERRIDABLE`/403 is shared with the ADR-0005 * tier gate by design, so the class is the only fingerprint that proves WHICH - * layer answered. And every refusal case asserts the ROW COUNT — the defect + * layer answered. With the hatch CLOSED the protocol's own package door + * answers ahead of the seam on every topology, so that one case asserts the + * class is NOT the lock's. And every refusal case asserts the ROW COUNT — the defect * this card measured was a write that landed, so "threw" alone is half a pin. * * ## What is deliberately NOT re-pinned here (Prime Directive #8) @@ -210,12 +212,16 @@ describe('#11843 — the lock answers at the metadata door', () => { expect(metaRowsOf(engine)).toEqual([]); }, 30_000); - it('hatch CLOSED: the identical save is refused by the same lock — the refusal does not depend on the hatch', async () => { + it('hatch CLOSED: the identical save is still refused, with the same envelope — the refusal does not depend on the hatch', async () => { const { engine, protocol } = boot(); const err = await save(protocol, { type: 'permission', name: PACKAGED_SET, item: body(PACKAGED_SET) }); - expect(err).toBeInstanceOf(PackagedPermissionSetLockedError); + // With the hatch closed the protocol's own package door answers first, on + // this topology as on an environment kernel (`saveMetaItem` asks it on + // every topology, ahead of the authoring-gate seam), so the lock is not + // reached. Same condition, same envelope: NOT_OVERRIDABLE / 403. + expect(err).not.toBeInstanceOf(PackagedPermissionSetLockedError); expect(err).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); expect(metaRowsOf(engine)).toEqual([]); }, 30_000); diff --git a/packages/rest/src/meta-object-owd-gate.test.ts b/packages/rest/src/meta-object-owd-gate.test.ts index 334024fce2a..26521bdb451 100644 --- a/packages/rest/src/meta-object-owd-gate.test.ts +++ b/packages/rest/src/meta-object-owd-gate.test.ts @@ -163,10 +163,11 @@ const probeObject = (over: Record = {}) => ({ * which is the shape the lightweight assembler produces and the one under * test. Passing `'package-author'` exercises the #6710 carve-out explicitly. * @param opts.envWritableObject set `OS_METADATA_WRITABLE=object`. R1's own - * docblock names this as the path it judges — without it, - * `SysMetadataRepository.assertAllowed()` refuses an `object` overlay of a - * PACKAGED item outright (`NOT_OVERRIDABLE`), so an R1-legal overlay could - * never land and "R1 permits tightening" would be unobservable. + * docblock names this as the path it judges — without it, `saveMetaItem`'s + * package door refuses an `object` overlay of a PACKAGED item outright + * (`NOT_OVERRIDABLE`), on this topology as on an environment kernel, before + * the lint door or R1 is asked; so every case that drives a packaged overlay + * through those doors opens it. */ async function boot(opts: { channel?: MetadataAuthoringChannel; envWritableObject?: boolean } = {}) { const { channel } = opts; @@ -348,8 +349,10 @@ describe('[#8310] the 422 lint door through PUT /api/v1/meta/object/:name', () = // baseline (R1: external `public_read` > declared external `private`). // The ruling fixes the order: the lint table answers first // (`saveMetaItem` runs it before `runAuthoringGate`), so the author - // sees the 422 vocabulary, never a coin-flip between two doors. - const { put, storedRows } = await boot(); + // sees the 422 vocabulary, never a coin-flip between two doors. The + // hatch is open because, shut, the package door answers ahead of both + // (`NOT_OVERRIDABLE`) — see `boot`. + const { put, storedRows } = await boot({ envWritableObject: true }); const res = await put('qa_packaged_account', packagedOverlay({ sharingModel: 'private', @@ -370,12 +373,14 @@ describe('[#8310] the 422 lint door through PUT /api/v1/meta/object/:name', () = describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:name', () => { /** - * On a host config R1 is the ONLY guard, which is why it belongs here and - * not only in the unit suite. The ADR-0005 two-tier authorization that - * would normally refuse an overlay of a packaged `object` with - * `not_overridable` is ITSELF scoped to `environmentId !== undefined`, so on - * this topology the write sails past it and arrives at the posture gate - * with nothing else in front of it. + * R1 judges the overlay writes that reach it: with `OS_METADATA_WRITABLE` + * shut, `saveMetaItem`'s package door refuses an overlay of a packaged + * `object` (`NOT_OVERRIDABLE`) ahead of every gate, on this topology as on + * an environment kernel. That door used to be scoped to + * `environmentId !== undefined`, so on a host config the write sailed past + * it to the posture gate; it is asked on every topology now, so these + * cases open the hatch — the path R1's own docblock names — and R1 is the + * door in front of the write. * * ## [#9232] Why the wire `code` is `PERMISSION_DENIED` and the gate's own * ## spelling now rides `declaredCode` @@ -405,7 +410,7 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n // Declared baseline: `public_read`. The overlay asks for // `public_read_write`, and leaves the external side unset so R2 has // nothing to compare — only R1 can produce this refusal. - const { put, storedRows } = await boot(); + const { put, storedRows } = await boot({ envWritableObject: true }); const res = await put('qa_packaged_account', packagedOverlay({ sharingModel: 'public_read_write', @@ -428,7 +433,7 @@ describe('[#7674] R1 `owd_widening_forbidden` through PUT /api/v1/meta/object/:n // why R1 SURVIVES the #8310 retirement while R2 did not. A suite that // only ever sent an external-wider pair could not tell the doors // apart. - const { put, storedRows } = await boot(); + const { put, storedRows } = await boot({ envWritableObject: true }); const res = await put('qa_packaged_account', packagedOverlay({ sharingModel: 'public_read', @@ -474,11 +479,10 @@ describe('[#7674] what the gate must still let through', () => { it('an env overlay that TIGHTENS a packaged object is allowed (R1 is directional)', async () => { // `OS_METADATA_WRITABLE=object` is the escape hatch R1's own docblock - // names as the path it judges. Without it the overlay is refused a - // layer later by `SysMetadataRepository.assertAllowed()` - // (`NOT_OVERRIDABLE`) — a DIFFERENT door, measured on this harness — - // and this case would then pass for a reason that has nothing to do - // with the posture gate. + // names as the path it judges. Without it the overlay is refused by + // `saveMetaItem`'s package door (`NOT_OVERRIDABLE`) — a DIFFERENT + // door, ahead of the posture gate — and this case would then pass for + // a reason that has nothing to do with the posture gate. const { put, storedRows } = await boot({ envWritableObject: true }); // The packaged baseline is `public_read` / `private`; the overlay diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 9d671e8d1d8..e1d0de9bb3f 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1096,6 +1096,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts", "verb": "delete",