diff --git a/.changeset/19307-permission-set-duplicate-name-refusal-code.md b/.changeset/19307-permission-set-duplicate-name-refusal-code.md new file mode 100644 index 00000000000..eb5768f3eb0 --- /dev/null +++ b/.changeset/19307-permission-set-duplicate-name-refusal-code.md @@ -0,0 +1,71 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/spec': minor +--- + +fix(plugin-security): the `sys_permission_set` duplicate-name refusal carries `UNIQUE_VIOLATION`, and the packaged-set lock answers first (#19307) + +Clause-②: yes + +Two halves of one defect on the data door's insert leg for `sys_permission_set` +(`permission-set-projection.ts`), both measured live on `examples/app-showcase` +with a seeded admin over a cookie session. + +**1. The refusal carried no machine-readable code.** It threw a bare `Error` +with `.status = 409` and no `.code`, and the flat `{ error, code }` responder +invents nothing for a producer that declared nothing, so the client got prose: + +``` +POST /api/v1/data/sys_permission_set {"name":"dev_local_set"} +→ 409 {"error":"[Security] permission set 'dev_local_set' already exists","object":"sys_permission_set"} +``` + +ADR-0112's 2026-08-17 amendment closed `error.code` at the flat door too, so a +409 with no code is that contract unhonoured — and a UI that has to branch on +the refusal was pushed back to string-matching. The same request now answers +`409 … "code":"UNIQUE_VIOLATION"`, message byte-identical. + +⚠️ `UNIQUE_VIOLATION` is REUSED, not minted. `sys_permission_set` declares +`{ fields: ['name'], unique: 'organization' }`, so this very collision already +answers `409 UNIQUE_VIOLATION` when the index catches it instead of this +pre-check; a second spelling would make one condition answer two envelopes +depending only on which layer got there first. The ledger gains a provenance +row for `@objectstack/plugin-security` — the union, its casing and every other +package's rows are unchanged, and no schema shape moves. + +**2. It ran BEFORE the packaged-set lock, so the most likely path answered the +less useful of two true refusals.** A package-declared set has a projected row, +so its name is duplicate AND locked at once. An admin who opened the Clone +dialog on a packaged set and typed the base set's own name — the single most +likely thing to type — got `already exists`, which names no remedy, and never +reached `NOT_OVERRIDABLE`, which names the clone path. The lock now runs first: + +``` +POST /api/v1/data/sys_permission_set {"name":"showcase_manager"} +→ 403 {"error":"[Security] Permission set 'showcase_manager' is declared by package + 'com.example.showcase' and is locked … Choose a different name for your set, or clone + 'showcase_manager' …","code":"NOT_OVERRIDABLE","object":"sys_permission_set"} +``` + +**What did NOT move**, measured on the same runtime: an ordinary +(non-package-declared) duplicate **whose provenance the lock can resolve** still +answers the duplicate refusal and not `NOT_OVERRIDABLE` — that qualifier is +load-bearing, and the corner below is the case it excludes; an unauthenticated +write on the same resource still answers `401 UNAUTHENTICATED`; and an `update` +targeting a packaged set answers `403 NOT_OVERRIDABLE` exactly as before. + +⚠️ **One corner moved with the order**: an ordinary duplicate attempted while no +artifact source can answer now takes the lock's fail-closed `unknown` refusal — +`403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry +once the metadata layer is readable") — instead of the 409. Both are refusals and +neither writes; it is pinned so the behaviour is declared rather than incidental. + +⚠️ **And the order has a cost, stated rather than discovered**: the lock's probe +(`protocol.getMetaItemLayered`) used to be evaluated only AFTER the duplicate +check passed, so a duplicate insert never paid for it. It is now evaluated +unconditionally, ahead of that check. Two consequences, both deliberate: every +**duplicate** insert on `sys_permission_set` costs one extra metadata round trip +(the accepted path's cost is unchanged — it always paid this probe), and the +duplicate path is now COUPLED to metadata-layer reachability, where before it +answered from the record alone. That coupling is the mechanism behind the corner +above, and it is the price of putting the refusal that names the remedy first. diff --git a/packages/plugins/plugin-security/src/errors.ts b/packages/plugins/plugin-security/src/errors.ts index 98c8a48b896..7b0914b1749 100644 --- a/packages/plugins/plugin-security/src/errors.ts +++ b/packages/plugins/plugin-security/src/errors.ts @@ -364,6 +364,90 @@ export class ExplainObjectNotFoundError extends Error { } } +/** + * The ADR-0112 code {@link PermissionSetNameConflictError} stamps — see that + * class for why this collision is `UNIQUE_VIOLATION` and why the value is a + * named constant rather than a class-field literal. + */ +export const PERMISSION_SET_NAME_CONFLICT_CODE = 'UNIQUE_VIOLATION'; + +/** The HTTP status {@link PermissionSetNameConflictError} declares. */ +export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409; + +/** + * [#19307] The data door's duplicate-name refusal on `sys_permission_set`: + * a set with this machine name already exists in the caller's organization, so + * the insert is refused. + * + * ## Why this is a CLASS and not a bare `Error` with `.status = 409` + * + * It was the bare form until now, and the bare form has no `code`. The flat + * `{ error, code }` responder in `packages/rest` puts a thrown `code` on the + * wire and invents nothing when the producer declared none, so the refusal + * reached the client as prose alone — against ADR-0112's 2026-08-17 amendment + * (#9232), under which the flat door carries the closed member too. Measured + * before the fix: `409 {"error":"[Security] permission set 'showcase_manager' + * already exists","object":"sys_permission_set"}`, with no `code` key at all, + * while an unauthenticated write on the same resource answered + * `401 UNAUTHENTICATED` — so the absence was this producer's, never the door's. + * A dialog that has to branch on the refusal was pushed to string-matching. + * + * ## Why `UNIQUE_VIOLATION` and not a newly minted code + * + * It is the wire identity this platform ALREADY answers for this exact + * condition on this exact column. `sys_permission_set` declares + * `{ fields: ['name'], unique: 'organization' }`, and a collision that reaches + * the storage layer comes back as `409 UNIQUE_VIOLATION` — the reading + * recorded on that index's own comment (#8554) is `org_yi 409 + * UNIQUE_VIOLATION`. This middleware refuses the same collision one layer + * earlier, so a second spelling here would make ONE condition answer two + * envelopes depending only on whether the projection's pre-check or the index + * caught it — the drift `@objectstack/rest` and `@objectstack/driver-memory` + * already registered the SAME code to avoid ("the wire identity is + * deliberately the SAME"). #5240's one-condition-one-wording, on the code axis. + * + * ⛔ Not `RESOURCE_CONFLICT` (the standard member 409 derives from): that is + * what the door would supply for a producer that named no condition, and it + * would be the second spelling described above. + * + * ## Why BOTH `status` and `statusCode` + * + * The same reason every class above records: the two transports read different + * property names (`mapDataError` passes a domain error through on `.status`; + * the runtime dispatcher's `errorFromThrown` reads `.status` then falls back to + * `.statusCode`), and this throws on the DATA path, which reaches both. + * + * The message is byte-identical to the bare `Error`'s — the wording was never + * the defect, and the flat door's 4xx arm ships it verbatim. + * + * ## Why the code is a NAMED CONSTANT and not a bare class-field literal + * + * Same spelling `@objectstack/driver-memory` uses for its own registration of + * this code ("via the package's exported `UNIQUE_VIOLATION_CODE` / + * `UNIQUE_VIOLATION_STATUS`"), and the reason is mechanical rather than + * stylistic: `check:error-code-provenance` recognises `objlit`, `assign` and + * `*_CODE` `constdef` stamp sites and is blind to class fields by its own + * declared bounds. Written as a class-field literal this package would have + * become an unlisted EMITTER of a registered code with every gate in the repo + * green — the exact invisibility the ledger header names ("no admission rule + * checks WHO emits, so an unlisted emitter is invisible to every gate the repo + * has", three hand sweeps, #7504 / #13254 / #13353). The constant puts this + * emitter inside the gate's field of view, so the provenance row under + * `@objectstack/plugin-security` is enforced and not merely intended. + */ +export class PermissionSetNameConflictError extends Error { + readonly code = PERMISSION_SET_NAME_CONFLICT_CODE; + readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS; + readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS; + /** The permission-set machine name that was already taken. */ + readonly setName: string; + constructor(setName: string) { + super(`[Security] permission set '${setName}' already exists`); + this.name = 'PermissionSetNameConflictError'; + this.setName = setName; + } +} + export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError { if (!e || typeof e !== 'object') return false; const anyE = e as any; diff --git a/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts b/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts new file mode 100644 index 00000000000..4071e84c6a2 --- /dev/null +++ b/packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19307] THE DUPLICATE-NAME REFUSAL on `sys_permission_set` — its ADR-0112 + * envelope, and the ORDER it stands in relative to the packaged-set lock. + * + * Two halves of one defect, both measured live on `examples/app-showcase` + * before the fix (seeded admin, cookie session): + * + * 1. the refusal was a bare `Error` carrying `.status = 409` and NO `.code`, + * so the flat `{ error, code }` responder — which invents nothing for a + * producer that declared nothing — put prose on the wire: + * `409 {"error":"[Security] permission set 'showcase_manager' already + * exists","object":"sys_permission_set"}`. ADR-0112's 2026-08-17 amendment + * (#9232) closed `error.code` at the flat door too, so a 409 with no code + * is that contract unhonoured, and a dialog that must branch on the + * refusal is pushed to string-matching; + * + * 2. ⭐ it ran BEFORE `assertPermissionSetNotPackageDeclared`. A + * package-declared set HAS a projected row, so its name is duplicate and + * locked at once — and the admin who opens the Clone dialog on a packaged + * set and types the base set's own name (the single most likely thing to + * type) got the duplicate refusal and never reached `NOT_OVERRIDABLE`, + * the refusal that explains the actual situation and names the remedy. + * + * ## What each case is for + * + * 1. CONTROL — the ordinary (non-packaged) duplicate still refuses, and the + * refusal now carries the closed member. Asserting the ENVELOPE (`code` + + * `status`), never a bare "it threw": the unfixed producer threw too. + * 2. ⭐ THE ORDERING — same middleware, same duplicate row, but the name is + * package-declared: the answer is the lock's `NOT_OVERRIDABLE` / 403 + * carrying the lock's OWN message. The code alone would not identify the + * gate — ADR-0005's tier gate inside `saveMetaItem` answers 403 + * `NOT_OVERRIDABLE` for the same row — so the message and `saves.length` + * are what prove the LOCK answered, ahead of any metadata write. + * 3. NEGATIVE CONTROL — the reorder did not make every duplicate answer + * `NOT_OVERRIDABLE`: case 1's refusal is asserted to be NOT that code. + * (Stated as its own case because that is the regression the ordering + * change could plausibly introduce.) + * 4. HAPPY PATH — a free name on an org-verdict kernel still lands. Running + * the lock first must not cost a create. + * 5. FAIL-CLOSED, DECLARED — an ordinary duplicate attempted while NO + * artifact source can answer takes the lock's `unknown` refusal (403) + * instead of the 409. That case MOVED with the reorder; it is pinned so + * the behaviour is declared rather than incidental. Both answers are + * refusals and neither writes. + */ + +import { describe, it, expect } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { createPermissionSetWriteThrough } from './permission-set-projection.js'; +import { + PERMISSION_SET_NAME_CONFLICT_CODE, + PERMISSION_SET_NAME_CONFLICT_STATUS, +} from './errors.js'; + +/** The package-declared body case 2 turns on. */ +const declaredBody = () => ({ + name: 'showcase_manager', + label: 'Showcase Manager', + objects: { showcase_task: { allowRead: true } }, + fields: {}, + systemPermissions: ['showcase.manage'], + rowLevelSecurity: [], + tabPermissions: {}, + _packageId: 'com.example.showcase', +}); + +/** A `sys_permission_set` row that already holds `name`. */ +const rowFor = (name: string, over: Record = {}) => ({ + id: `ps_${name}`, + name, + label: name, + managed_by: 'admin', + object_permissions: JSON.stringify({}), + field_permissions: JSON.stringify({}), + system_permissions: JSON.stringify([]), + row_level_security: JSON.stringify([]), + tab_permissions: JSON.stringify({}), + ...over, +}); + +/** + * Minimal engine double. + * + * `registry.listItems('permission')` is the artifact source the lock reads, so + * seeding it IS seeding "this name is package-declared". It REFUSES query + * shapes it does not implement rather than answering `[]`, which would report + * "no such row" and let every case pass while measuring the double — and it + * holds the caller's `limit` and opens `update` with the producer's own + * dispatch predicate, so a fake looser than `ObjectQL` cannot collect a green + * the real engine would not have given. + */ +function makeQl(rows: any[], declared: any[]) { + const permRows = [...rows]; + return { + permRows, + registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, + async find(object: string, q: any) { + if (object !== 'sys_permission_set') return []; + const where = q?.where ?? {}; + const matched = permRows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + if (v && typeof v === 'object') throw new Error(`fake engine: unsupported operand for ${k}`); + return r[k] === v; + }), + ); + // The caller's bound, applied AFTER the filter and by PRESENCE — the + // duplicate pre-check asks for `limit: 1`, and a limit-blind double + // would be answering a different question than the engine does. + return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; + }, + async findOne(object: string, q: any) { + assertEngineFindOnePredicate(object, q); + return (await this.find(object, q))[0] ?? null; + }, + async insert(_object: string, data: any) { + permRows.push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any, options?: any) { + const dispatch = assertEngineUpdateDispatch(data, options); + const targets = dispatch.kind === 'by-id' + ? permRows.filter((r) => r.id === dispatch.id) + : await this.find(object, options); + for (const r of targets) Object.assign(r, data); + return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; + }, + }; +} + +/** + * Metadata protocol double. The real `PermissionSetSchema` runs on every + * accepted save, exactly as `saveMetaItem` does. + * + * `layeredThrows` models a metadata layer that cannot answer — case 5's + * `unknown` verdict, which is a READ failure and not an empty answer. + */ +function makeProtocol(ql: any, declaredNames: string[], opts: { layeredThrows?: boolean } = {}) { + return { + saves: [] as any[], + projected: [] as string[], + registerMutationProjector(_type: string, _fn: any) { /* not exercised here */ }, + async saveMetaItem(req: { type: string; name: string; item: any }) { + const parsed = PermissionSetSchema.safeParse(req.item); + if (!parsed.success) { + const err: any = new Error( + `[invalid_metadata] permission/${req.name} failed spec validation: ` + + parsed.error.issues.map((i: any) => `${i.path.join('.') || ''}: ${i.message}`).join('; '), + ); + err.code = 'INVALID_METADATA'; + err.status = 422; + throw err; + } + this.saves.push({ ...req }); + ql.permRows.push(rowFor(req.name)); + return { success: true }; + }, + async deleteMetaItem() { return { success: true }; }, + async getMetaItemLayered(req: { type: string; name: string }) { + if (opts.layeredThrows) throw new Error('metadata store unreachable'); + const code = declaredNames.includes(req.name) ? declaredBody() : null; + return { type: 'permission', name: req.name, code, overlay: null, overlayScope: null, effective: code }; + }, + }; +} + +/** Run the middleware, reporting whether the engine's own write (`next`) ran. */ +async function run(mw: any, opCtx: any): Promise { + let nextCalled = false; + await mw(opCtx, async () => { nextCalled = true; }); + return nextCalled; +} + +const insertCtx = (name: string) => ({ + object: 'sys_permission_set', + operation: 'insert', + context: { userId: 'usr_admin' }, + data: { name, label: 'Clone of the base' }, +}); + +/** The thrown value, or a loud failure — `rejects` alone reads a bare throw as a pass. */ +async function refusalOf(fn: () => Promise): Promise { + try { + await fn(); + } catch (e) { + return e; + } + throw new Error('expected the insert to be REFUSED; it was accepted'); +} + +describe('[#19307] duplicate-name refusal on sys_permission_set', () => { + it('1. CONTROL — an ORDINARY duplicate is refused with the closed member on the envelope', async () => { + const ql = makeQl([rowFor('org_owned_set')], []); + const protocol = makeProtocol(ql, []); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); + + const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); + + // The envelope, not the throw: `status` AND the machine-readable `code`, + // which is what the flat door puts on the wire (#9232). + expect(err.code).toBe(PERMISSION_SET_NAME_CONFLICT_CODE); + expect(err.code).toBe('UNIQUE_VIOLATION'); + expect(err.status).toBe(PERMISSION_SET_NAME_CONFLICT_STATUS); + expect(err.status).toBe(409); + // Both spellings: the two transports read different property names. + expect(err.statusCode).toBe(409); + expect(err.name).toBe('PermissionSetNameConflictError'); + expect(err.message).toContain("permission set 'org_owned_set' already exists"); + // Refused BEFORE the write, on both stores. + expect(protocol.saves.length).toBe(0); + expect(ql.permRows.length).toBe(1); + }); + + it('2. ⭐ ORDERING — a duplicate that is ALSO package-declared answers the LOCK, not the conflict', async () => { + // The Clone-dialog case: the base set is declared by a package AND has its + // projected row, so both refusals are true at once. + const ql = makeQl([rowFor('showcase_manager', { managed_by: 'package', package_id: 'com.example.showcase' })], + [declaredBody()]); + const protocol = makeProtocol(ql, ['showcase_manager']); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); + + const err = await refusalOf(() => run(mw, insertCtx('showcase_manager'))); + + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + expect(err.statusCode).toBe(403); + // ⭐ The LOCK answered, not ADR-0005's tier gate inside `saveMetaItem` + // (same code, different message) and not the duplicate check: the lock's + // message is the only one that names the clone path. + expect(err.name).toBe('PackagedPermissionSetLockedError'); + expect(err.message).toContain("declared by package 'com.example.showcase'"); + expect(err.message).toContain('clone'); + expect(err.message).not.toContain('already exists'); + // Nothing was written, and the metadata door was never reached. + expect(protocol.saves.length).toBe(0); + }); + + it('3. NEGATIVE CONTROL — the reorder did NOT turn ordinary duplicates into NOT_OVERRIDABLE', async () => { + const ql = makeQl([rowFor('org_owned_set')], [declaredBody()]); // a packaged set exists, under ANOTHER name + const protocol = makeProtocol(ql, ['showcase_manager']); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); + + const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); + + expect(err.code).not.toBe('NOT_OVERRIDABLE'); + expect(err.code).toBe('UNIQUE_VIOLATION'); + expect(err.status).toBe(409); + }); + + it('4. HAPPY PATH — a free name still lands with the lock consulted first', async () => { + const ql = makeQl([rowFor('org_owned_set')], [declaredBody()]); + const protocol = makeProtocol(ql, ['showcase_manager']); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); + + const nextCalled = await run(mw, insertCtx('my_new_set')); + + expect(nextCalled).toBe(false); // projector-owned record; no driver write + expect(protocol.saves.map((s) => s.name)).toEqual(['my_new_set']); + }); + + it('5. FAIL-CLOSED, DECLARED — a duplicate whose provenance cannot be resolved takes the lock refusal', async () => { + // No registry AND a layered read that throws ⇒ no source answered. + const ql: any = makeQl([rowFor('org_owned_set')], []); + delete ql.registry; + const protocol = makeProtocol(ql, [], { layeredThrows: true }); + const mw = createPermissionSetWriteThrough({ ql, getProtocol: () => protocol }); + + const err = await refusalOf(() => run(mw, insertCtx('org_owned_set'))); + + expect(err.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + expect(err.name).toBe('PackagedPermissionSetProvenanceUnknownError'); + expect(protocol.saves.length).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 293a43b0373..fed1af15857 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -99,6 +99,7 @@ import { assertPermissionSetNotPackageDeclared, type LayeredProbe, } from './packaged-permission-set-lock.js'; +import { PermissionSetNameConflictError } from './errors.js'; export const SYSTEM_CTX = { isSystem: true }; @@ -1206,12 +1207,6 @@ export function createPermissionSetWriteThrough( const results: any[] = []; for (const row of rows) { const name = String(row.name); - const dup = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; - if (dup) { - const err: any = new Error(`[Security] permission set '${name}' already exists`); - err.status = 409; - throw err; - } // [2026-08-24 ruling — lock the base, clone to customize] A name an // installed package DECLARES is not available for an environment // definition: with the `OS_METADATA_WRITABLE=permission` operator hatch @@ -1220,9 +1215,34 @@ export function createPermissionSetWriteThrough( // that overlay onto the record on every boot, unconditionally, forever. // Refused here, before the write, with a message that names the clone // path. Fail-closed: unresolvable provenance refuses too. + // + // [#19307] ⭐ It runs BEFORE the duplicate-name check below, and the + // order is the fix rather than a tidy-up. A package-declared set has a + // PROJECTED ROW, so its name is duplicate AND locked at once — and the + // admin most likely to arrive here is the one who opened the Clone + // dialog on a packaged set and typed the base set's own name, which is + // the single most likely thing to type. Duplicate-first answered that + // caller `already exists`: true, and the less useful of two true + // refusals — it names no remedy, while `NOT_OVERRIDABLE` is the one + // that explains the actual situation and teaches the clone path. So + // the refusal that carries the remedy speaks first, and the ordinary + // duplicate (verdict `org`) still falls through to the check below + // unchanged. + // + // ⚠️ One case moves besides the packaged one: an ordinary duplicate + // attempted while NO artifact source can answer now takes the lock's + // fail-closed `unknown` refusal (403, retry when the metadata layer is + // readable) instead of the 409. Both are refusals and neither writes, + // which is why the ordering is judged on the case that is reachable on + // purpose; pinned so it is declared rather than incidental. assertPermissionSetNotPackageDeclared( name, ql, 'insert', (await probeLayered(protocol, name)).probe, ); + // [#19307] The duplicate-name refusal carries `UNIQUE_VIOLATION` — the + // wire identity this collision already has when the `name` index + // catches it instead. See `PermissionSetNameConflictError`. + const dup = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0]; + if (dup) throw new PermissionSetNameConflictError(name); // The metadata write is the authoritative one; spec validation // (PermissionSetSchema) runs inside saveMetaItem and rejects an // off-contract body with a structured 422. diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index c19c00ba9e6..5765706edb7 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1093,6 +1093,32 @@ export const ERROR_CODE_LEDGER = { 'INVALID_METADATA', '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` + // — `PermissionSetNameConflictError` (`errors.ts`), thrown by the + // ADR-0094 D3 write-through middleware's insert leg + // (`permission-set-projection.ts`) when a set with that machine name + // already exists. `code` / `status` via the package's exported + // `PERMISSION_SET_NAME_CONFLICT_CODE` / `PERMISSION_SET_NAME_CONFLICT_STATUS`. + // + // THIRD EMITTER of a code `@objectstack/rest` (SQL conflict) and + // `@objectstack/driver-memory` (in-memory uniqueness refusal) already + // register, and the wire identity is deliberately the SAME for the reason + // their rows give: this object declares `{ fields: ['name'], unique: + // 'organization' }`, so the very same collision answers `409 + // UNIQUE_VIOLATION` when the index catches it instead of this pre-check + // (the reading recorded on that index, #8554). A second spelling here + // would make one condition answer two envelopes depending only on which + // layer got there first. Per this file's header, a code emitted by + // several packages is listed once per emitting package — provenance, not + // identity; the union, its casing and every other package's rows are + // unchanged. + // + // Wire-reachable by the test the "Retiring a code" section applies + // (#8035), measured live on `examples/app-showcase` over a cookie session: + // `POST /api/v1/data/sys_permission_set {"name":""}` + // answers `409` with this code on the flat `{ error, code }` responder + // (`mapDataError`'s declared-status 4xx arm, `thrownCodeFields`). + 'UNIQUE_VIOLATION', ], '@objectstack/plugin-webhooks': [ // [#13353] The redeliver endpoint's malformed-body refusal — the plugin diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b5dc04640d5..3a63859e226 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2936,6 +2936,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/permission-set-name-collision.test.ts", "verb": "update",