diff --git a/.changeset/19441-plugin-security-ledger-rows.md b/.changeset/19441-plugin-security-ledger-rows.md new file mode 100644 index 00000000000..772c8ddfc4e --- /dev/null +++ b/.changeset/19441-plugin-security-ledger-rows.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': minor +--- + +`ERROR_CODE_LEDGER['@objectstack/plugin-security']` now lists the three codes the package stamps as class fields and ships in `dist`: `INVALID_STATE` (`PermissionSetOverlayStateError`, 409), `NOT_FOUND` (`PermissionSetNotFoundError`, 404) and `NOT_OVERRIDABLE` (`PackagedPermissionSetLockedError` and `PackagedPermissionSetProvenanceUnknownError`, 403) (#19441). + +Clause-②: yes + +Provenance, not identity: each code was already registered under another package (`@objectstack/rest`, `@objectstack/metadata-protocol`), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/plugin-security']`. The `NOT_FOUND` synonym waiver's `reason` text now names plugin-security among its emitters; its `code` and `shadows` are unchanged. Nothing to migrate. diff --git a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts index 5e7d88a0584..e0b5607290d 100644 --- a/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts +++ b/packages/plugins/plugin-security/src/packaged-permission-set-lock.ts @@ -245,8 +245,12 @@ export function classifyPackagedPermissionSet( * * `NOT_OVERRIDABLE` / 403 is deliberately the SAME envelope the metadata * protocol's ADR-0005 tier gate already answers with for this exact condition - * — one condition, one vocabulary (ADR-0112's closed set; the code is a - * StandardErrorCode, so no ledger entry is minted). What changes is the + * — one condition, one vocabulary. The code is NOT a `StandardErrorCode` + * member: it is an ADR-0112 registered extension code, and because this + * package stamps it, `ERROR_CODE_LEDGER['@objectstack/plugin-security']` + * lists it beside metadata-protocol's row (provenance, not identity — a code + * shipped in `dist` is registered under every package that stamps it; the + * same holds for the two overlay-discard codes). What changes is the * MESSAGE: the producer's says the type has not opted into overlay writes, * which tells an admin nothing they can act on. The ruling's whole point is * that the refusal teaches the sanctioned path. diff --git a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts index 341a90a6751..808c75e26f8 100644 --- a/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts +++ b/packages/plugins/plugin-security/src/permission-set-overlay-discard.ts @@ -86,7 +86,11 @@ import { readDeclared } from './bootstrap-declared-permissions.js'; import { PermissionDeniedError } from './errors.js'; import { isTenantAdmin } from './delegated-admin-gate.js'; -/** Thrown when the referenced `sys_permission_set` row does not exist → HTTP 404. */ +/** + * Thrown when the referenced `sys_permission_set` row does not exist → HTTP 404. + * `NOT_FOUND` is listed under `ERROR_CODE_LEDGER['@objectstack/plugin-security']` + * (provenance row — this package stamps it). + */ export class PermissionSetNotFoundError extends Error { readonly code = 'NOT_FOUND'; readonly statusCode = 404; @@ -96,7 +100,11 @@ export class PermissionSetNotFoundError extends Error { } } -/** Thrown when there is no active overlay to discard → HTTP 409. */ +/** + * Thrown when there is no active overlay to discard → HTTP 409. + * `INVALID_STATE` is listed under `ERROR_CODE_LEDGER['@objectstack/plugin-security']` + * (provenance row — this package stamps it). + */ export class PermissionSetOverlayStateError extends Error { readonly code = 'INVALID_STATE'; readonly statusCode = 409; diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index d0c089e0403..b2d277e4282 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -260,6 +260,31 @@ describe('ErrorCode (standard ∪ registered)', () => { expect(ERROR_CODE_LEDGER['@objectstack/objectql']).toContain('NAMESPACE_CONFLICT'); }); + it('lists the plugin-security class-field stamps under their stamping package (#19441)', () => { + // Provenance, not identity: each of these codes was already registered by + // another package, and `@objectstack/plugin-security` stamps it too, as a + // class field (`readonly code = '…'`) — the spelling the provenance gate + // declares itself blind to, so this suite is what holds the rows. + const stamps: Record = { + INVALID_STATE: '@objectstack/rest', // PermissionSetOverlayStateError, 409 + NOT_FOUND: '@objectstack/rest', // PermissionSetNotFoundError, 404 + NOT_OVERRIDABLE: '@objectstack/metadata-protocol', // PackagedPermissionSet{Locked,ProvenanceUnknown}Error, 403 + }; + for (const [code, firstOwner] of Object.entries(stamps)) { + expect(ERROR_CODE_LEDGER['@objectstack/plugin-security'], `${code} listed under plugin-security`) + .toContain(code); + expect(ERROR_CODE_LEDGER[firstOwner], `${code} still listed under ${firstOwner}`).toContain(code); + expect(ErrorCode.parse(code)).toBe(code); + } + // The fact the exempting comment in `packaged-permission-set-lock.ts` had + // backwards: NOT_OVERRIDABLE is an extension code, not a standard member — + // so "no ledger entry is minted" never followed. Control leg: the same + // membership test answers true for a standard member. + const standard = new Set(StandardErrorCode.options); + expect(standard.has('NOT_OVERRIDABLE')).toBe(false); + expect(standard.has('PERMISSION_DENIED')).toBe(true); + }); + it('rejects unregistered, lowercase, and numeric codes', () => { expect(() => ErrorCode.parse('TOTALLY_MADE_UP_CODE')).toThrow(); expect(() => ErrorCode.parse('validation_error')).toThrow(); // pre-ADR-0112 dialect diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 09f38b4dda0..d8ad728505e 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1090,6 +1090,36 @@ export const ERROR_CODE_LEDGER = { // packages is listed once per emitting package — provenance, not identity // (see above; #7504). 'INVALID_METADATA', + // [#19441] Provenance rows for the class-field stamps + // (`readonly code = '…'`) this package ships in `dist` — a spelling + // `check:error-code-provenance` declares itself blind to, which is how + // they went unlisted. Each code is already registered by another package; + // per this file's header a code emitted by several packages is listed + // once per emitting package — provenance, not identity — so the union, + // its casing and every other package's rows are unchanged. + // + // `PermissionSetOverlayStateError` (`permission-set-overlay-discard.ts`), + // 409: `discardPermissionSetOverlay` found no active overlay to discard. + // Served by `@objectstack/rest`'s `POST …/security/permission-sets/:id/ + // discard-overlay` route, whose `handleError` reads the thrown `code` + // ahead of its `INTERNAL` default. + 'INVALID_STATE', + // `PermissionSetNotFoundError` (`permission-set-overlay-discard.ts`), + // 404: the addressed `sys_permission_set` row does not exist. Same route + // and door as the row above. A waived synonym of `RESOURCE_NOT_FOUND` + // (`STANDARD_SYNONYM_WAIVERS` admits it per code, not per package — this + // row widens that waiver's emitter list, never the waiver table). + 'NOT_FOUND', + // `PackagedPermissionSetLockedError` and + // `PackagedPermissionSetProvenanceUnknownError` + // (`packaged-permission-set-lock.ts`), 403: a data-path write targets a + // package-declared permission set, or one whose provenance cannot be + // determined (fail-closed). Deliberately the SAME envelope + // `@objectstack/metadata-protocol`'s ADR-0005 tier gate answers for this + // condition — one condition, one vocabulary. NOT a `StandardErrorCode` + // member: it is an extension code registered here, under that package and + // now this one. + 'NOT_OVERRIDABLE', 'SUGGESTION_NOT_FOUND', 'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state // [#19307] The data door's duplicate-name refusal on `sys_permission_set` @@ -1507,8 +1537,9 @@ export const STANDARD_SYNONYM_WAIVERS: readonly StandardSynonymWaiver[] = [ { code: 'NOT_FOUND', shadows: 'RESOURCE_NOT_FOUND', - reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing. ' + - 'Wire value kept; consolidation deferred per #8211.', + reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing; ' + + '#19441 added the plugin-security provenance row for the same pre-existing wire value ' + + '(its permission-set overlay-discard 404). Wire value kept; consolidation deferred per #8211.', }, { code: 'UNAUTHORIZED',