diff --git a/.changeset/15204-catalog-activation-environment-authored.md b/.changeset/15204-catalog-activation-environment-authored.md new file mode 100644 index 00000000000..461d24c293f --- /dev/null +++ b/.changeset/15204-catalog-activation-environment-authored.md @@ -0,0 +1,17 @@ +--- +"@objectstack/runtime": minor +"@objectstack/core": minor +"@objectstack/platform-objects": minor +"@objectstack/service-automation": minor +--- + +The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + +Clause-②: yes (widening) + +- **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). +- **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. +- **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. +- **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. +- **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. +- **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index eb4ac0fe648..166724dbac2 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -414,7 +414,14 @@ The ledger row is written through grant off, `true` back on). The caller needs `manage_metadata`; under a `group` or `isolated` tenancy posture the caller must also be the platform operator, because the row reaches every organization (ADR-0126 §5). The name must resolve -in the security catalog, or the route answers `404`. It is the same authority +in the security catalog, or the route answers `404`. That covers a position or +permission set the environment authored through the metadata door as well as +one a package ships: the ledger row records the package when there is one and +leaves `package_id` empty when there is none, and the resolver honours both +alike, because it keys the row by type and name. A write the ledger refuses +answers with the status its code means: a record validation failure is `400` +`VALIDATION_FAILED`, a hook's refusal keeps its own pair, and a store failure +is `503` `SERVICE_UNAVAILABLE`. It is the same authority as the flow and action activation switches. The two audience anchors, `everyone` and `guest`, are refused with `400` in either direction: one row would switch the authenticated or anonymous baseline off for every principal. diff --git a/packages/core/src/utils/metadata-activation-store.test.ts b/packages/core/src/utils/metadata-activation-store.test.ts index e7064be6da1..450dc3008df 100644 --- a/packages/core/src/utils/metadata-activation-store.test.ts +++ b/packages/core/src/utils/metadata-activation-store.test.ts @@ -296,6 +296,62 @@ describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics, }); }); +// [#22621 → A] The security catalog's activation door switches a position or +// permission set the environment authored, and no package ships those. The row +// says so with ONE spelling — no package value — and the store refuses the +// other spelling a caller reaches for (`?? ''`), so a reader can never take an +// environment item for a package named ''. +describe('ObjectStoreMetadataActivationStore — an item no package ships', () => { + it('INSERTS a row with no package when packageId is absent', async () => { + const { engine, calls } = makeStoreEngine([]); + + await new ObjectStoreMetadataActivationStore(engine, 'position').setActive({ + name: 'env_position', active: false, + }); + + expect(calls.find((c) => c.op === 'insert')?.data).toEqual({ + metadata_type: 'position', name: 'env_position', package_id: null, active: false, + }); + }); + + it('UPDATES its row with no package on the way back on', async () => { + const { engine, calls } = makeStoreEngine([ + { id: 'r1', metadata_type: 'permission', name: 'env_set', package_id: null, active: false }, + ]); + + await new ObjectStoreMetadataActivationStore(engine, 'permission').setActive({ + name: 'env_set', active: true, + }); + + expect(calls.filter((c) => c.op === 'insert')).toHaveLength(0); + expect(calls.find((c) => c.op === 'update')?.data).toEqual({ id: 'r1', active: true, package_id: null }); + }); + + it('REFUSES an empty-string package before reading or writing anything', async () => { + const { engine, calls } = makeStoreEngine([]); + + await expect(new ObjectStoreMetadataActivationStore(engine, 'position').setActive({ + name: 'env_position', packageId: '', active: false, + })).rejects.toThrow(TypeError); + expect(calls).toEqual([]); + }); + + it('reads a package-less row back with packageId ABSENT, beside a packaged one that keeps its package', async () => { + const { engine } = makeStoreEngine([ + { id: 'r1', metadata_type: 'position', name: 'env_position', package_id: null, active: false }, + { id: 'r2', metadata_type: 'position', name: 'sales_rep', package_id: 'crm', active: false }, + ]); + + const rows = await new ObjectStoreMetadataActivationStore(engine, 'position').list(); + + expect(rows).toEqual([ + { name: 'env_position', active: false }, + { name: 'sales_rep', packageId: 'crm', active: false }, + ]); + expect('packageId' in rows[0]).toBe(false); + }); +}); + describe('InMemoryMetadataActivationStore', () => { it('round-trips a row and reflects the latest flip', async () => { const store = new InMemoryMetadataActivationStore(); diff --git a/packages/core/src/utils/metadata-activation-store.ts b/packages/core/src/utils/metadata-activation-store.ts index 51b1763b92f..3a309ee1d08 100644 --- a/packages/core/src/utils/metadata-activation-store.ts +++ b/packages/core/src/utils/metadata-activation-store.ts @@ -86,18 +86,25 @@ export const METADATA_ACTIVATION_TABLE = 'sys_metadata_activation'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; /** - * [ADR-0126 §4] One packaged artifact's install-level activation row. + * [ADR-0126 §4] One artifact's install-level activation row. * * The ledger's own columns are `metadata_type` / `name` / `package_id` / * `active`; `metadata_type` is fixed by the store, so it never reaches a * consumer's projection. */ export interface MetadataActivationRow { - /** The packaged artifact's declarative machine name (ADR-0126 §4). */ + /** The artifact's declarative machine name (ADR-0126 §4). */ name: string; - /** The package that ships the base artifact. */ - packageId: string; - /** Is the packaged artifact armed for this installation. */ + /** + * The package that ships the artifact — ABSENT when no package does: a + * position or permission set the environment authored, which the security + * catalog's activation door switches as well as a packaged one. Absent is + * the only package-less spelling: the store writes no value for it and + * refuses `''` ({@link ObjectStoreMetadataActivationStore.setActive}), so + * no reader can take an environment item for a package named `''`. + */ + packageId?: string; + /** Is the artifact armed for this installation. */ active: boolean; } @@ -190,7 +197,9 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto if (typeof r.name !== 'string' || !r.name) continue; out.push({ name: r.name, - packageId: typeof r.package_id === 'string' ? r.package_id : '', + // A row with no package — an environment-authored item — reads + // back with `packageId` absent, the spelling it was written in. + ...(typeof r.package_id === 'string' && r.package_id !== '' ? { packageId: r.package_id } : {}), // The column defaults to `true`; only an explicit `false` // disarms. A driver that round-trips booleans as 0/1 // (SQLite/libsql) is read through the same `=== false || === 0` @@ -216,8 +225,22 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto * out of the result, back when the table carried a reserved tenant column; * with no such column the set it was choosing from can no longer hold more * than one member. + * + * `packageId` absent writes NO package (`null`), the ledger's spelling for + * an item no package ships. An empty string is refused before anything is + * read or written: it is the placeholder a caller reaches for with + * `?? ''`, and a stored `''` would be one more spelling of "no package" + * beside the absent one — exactly the ambiguity the field's contract + * rules out. */ async setActive(row: MetadataActivationRow): Promise { + if (row.packageId === '') { + throw new TypeError( + `Activation row for ${this.metadataType} '${row.name}' names the empty string as its package. ` + + `Omit packageId for an item no package ships; an empty package id is not a package.`, + ); + } + const packageId = row.packageId ?? null; const existing = await this.engine.find(METADATA_ACTIVATION_TABLE, { where: { metadata_type: this.metadataType, name: row.name }, context: SYSTEM_CTX, @@ -227,7 +250,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto if (current && (current as { id?: unknown }).id != null) { await this.engine.update( METADATA_ACTIVATION_TABLE, - { id: (current as { id: unknown }).id, active: row.active, package_id: row.packageId }, + { id: (current as { id: unknown }).id, active: row.active, package_id: packageId }, { context: SYSTEM_CTX }, ); return; @@ -238,7 +261,7 @@ export class ObjectStoreMetadataActivationStore implements MetadataActivationSto { metadata_type: this.metadataType, name: row.name, - package_id: row.packageId, + package_id: packageId, active: row.active, }, { context: SYSTEM_CTX }, diff --git a/packages/platform-objects/src/system/sys-metadata-activation.object.ts b/packages/platform-objects/src/system/sys-metadata-activation.object.ts index 724bcfadc1c..616b93dcaa9 100644 --- a/packages/platform-objects/src/system/sys-metadata-activation.object.ts +++ b/packages/platform-objects/src/system/sys-metadata-activation.object.ts @@ -55,6 +55,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Writers: the enable/disable actions (ADR-0126 L2/L3). Readers: each runtime's * own consult point. * + * Packaged and environment-authored: flows and actions reach this ledger only + * when a package ships them, but the security catalog's activation door + * (`POST /security/_activation/:type/:name`) switches every position and + * permission set the catalog holds, the environment's own included — so + * `package_id` is empty for those (see the field). + * * @namespace sys */ export const SysMetadataActivation = ObjectSchema.create({ @@ -65,7 +71,7 @@ export const SysMetadataActivation = ObjectSchema.create({ isSystem: true, managedBy: 'engine-owned', description: - 'Activation ledger for packaged metadata artifacts (ADR-0126 §4): one row per packaged artifact whose armed state has been changed from the packaged default. No row means the packaged default — active.', + 'Activation ledger for metadata artifacts (ADR-0126 §4): one row per artifact whose armed state has been changed from its default. No row means the default — active. Packaged artifacts and positions or permission sets authored in this environment are both recorded here.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) highlightFields: ['metadata_type', 'name', 'package_id', 'active'], @@ -124,22 +130,31 @@ export const SysMetadataActivation = ObjectSchema.create({ required: true, searchable: true, maxLength: 255, - description: "The packaged artifact's machine name.", + description: "The artifact's machine name.", group: 'Identity', }), + // Not part of the row identity (the index below is `(metadata_type, + // name)`), and not read by any consumer to decide anything: every reader + // keys a row by type and name. It records provenance, and so it is empty + // exactly when there is no package to record — a position or permission + // set the environment authored through the metadata door, which the + // security catalog's activation door switches as well (#22621 → A). The + // writer (`ObjectStoreMetadataActivationStore`) stores NO value for such + // an item and refuses an empty string, so a package-less row has one + // spelling and can never be read as a package named ''. package_id: Field.text({ label: 'Package', - required: true, maxLength: 255, - description: 'The package that ships the base artifact.', + description: + 'The package that ships the artifact. Empty when no package ships it: an item authored in this environment.', group: 'Identity', }), active: Field.boolean({ label: 'Active', defaultValue: true, - description: 'Is the packaged artifact armed for this scope.', + description: 'Is the artifact armed for this scope.', group: 'State', }), }, diff --git a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts index 4dbe17939fc..dc54f9843ba 100644 --- a/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts @@ -15,10 +15,14 @@ // really judges switching `admin_full_access` off: refused when no // administrator would be left, landing when one stands; // - the resolver over the booted engine stops granting through a position -// the door switched off, for a holder of that position. +// the door switched off, for a holder of that position; +// - a position and a permission set saved through the metadata door — no +// package ships them — switch the same way (#22621 → A): the real ledger +// object accepts their package-less row, which the test doubles cannot +// measure because they enforce no field contract. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { resolveUserAuthzGrants } from '@objectstack/core'; +import { resolveUserAuthzGrants, securityCatalogReaderOf } from '@objectstack/core'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; @@ -33,6 +37,14 @@ const POSITION_SET = 'showcase_auditor'; const HELD_POSITION = 'exec'; const HELD_POSITION_SET = 'showcase_executive'; const HOLDER_EMAIL = 'exec.holder@example.com'; +/** + * A position and a permission set saved through the metadata door, so no + * package ships either: the environment-authored items the door switches as + * well as packaged ones (#22621 → A), held by a holder of their own. + */ +const ENV_POSITION = 'door_env_position'; +const ENV_SET = 'door_env_set'; +const ENV_HOLDER_EMAIL = 'env.holder@example.com'; interface Engine { find(object: string, options?: unknown): Promise>>; @@ -163,6 +175,76 @@ describe('the catalog activation door, on a booted showcase', () => { expect(restored.every((r) => r.active === true || r.active === 1)).toBe(true); }); + // [#22621 → A] An item the environment authored has no package, and the + // ledger row the door writes for it says so: `package_id` is empty, never a + // placeholder a reader could take for a package. The row is honoured all + // the same, because the resolver keys it by type and name only. + it('an environment-authored position and permission set switch off and back on, and the resolver honours both', async () => { + const savedSet = await stack.apiAs(token, 'PUT', `/meta/permission/${ENV_SET}`, { + name: ENV_SET, label: 'Saved in the environment', objects: {}, + }); + expect(savedSet.status, await savedSet.clone().text()).toBe(200); + const savedPosition = await stack.apiAs(token, 'PUT', `/meta/position/${ENV_POSITION}`, { + name: ENV_POSITION, label: 'Saved in the environment', permissionSets: [ENV_SET], + }); + expect(savedPosition.status, await savedPosition.clone().text()).toBe(200); + + // Anti-vacuity: the catalog the door reads really holds both, and + // neither names a package — the case under test, not a packaged one. + const reader = securityCatalogReaderOf(ql); + expect(reader, 'no security catalog bound to the booted engine').toBeTruthy(); + const position = await reader!.resolve('position', ENV_POSITION); + const set = await reader!.resolve('permission', ENV_SET); + expect(position?.name).toBe(ENV_POSITION); + expect(set?.name).toBe(ENV_SET); + expect(position?.packageId).toBeUndefined(); + expect(set?.packageId).toBeUndefined(); + + await stack.signUp(ENV_HOLDER_EMAIL); + const [user] = await ql.find('sys_user', { where: { email: ENV_HOLDER_EMAIL }, limit: 1, context: SYSTEM_CTX }); + expect(user?.id, `no sys_user row for ${ENV_HOLDER_EMAIL}`).toBeTruthy(); + const holder = String(user.id); + await ql.insert( + 'sys_user_position', + { id: 'usp_catalog_activation_env', user_id: holder, position: ENV_POSITION }, + { context: SYSTEM_CTX }, + ); + + // CONTROL: before any flip the position grants its set. + const before = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(before.positions).toContain(ENV_POSITION); + expect(before.permissions).toContain(ENV_SET); + + const positionOff = await flip('position', ENV_POSITION, { enabled: false }); + expect(positionOff.status, await positionOff.clone().text()).toBe(200); + const positionRows = await rowsOf('position', ENV_POSITION); + expect(positionRows).toHaveLength(1); + expect(positionRows[0].active === false || positionRows[0].active === 0).toBe(true); + expect(positionRows[0].package_id ?? null).toBeNull(); + const positionSwitchedOff = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(positionSwitchedOff.positions).not.toContain(ENV_POSITION); + expect(positionSwitchedOff.permissions).not.toContain(ENV_SET); + + const positionOn = await flip('position', ENV_POSITION, { enabled: true }); + expect(positionOn.status, await positionOn.clone().text()).toBe(200); + expect(await rowsOf('position', ENV_POSITION)).toHaveLength(1); + + const setOff = await flip('permission', ENV_SET, { enabled: false }); + expect(setOff.status, await setOff.clone().text()).toBe(200); + const setRows = await rowsOf('permission', ENV_SET); + expect(setRows).toHaveLength(1); + expect(setRows[0].package_id ?? null).toBeNull(); + const setSwitchedOff = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(setSwitchedOff.positions).toContain(ENV_POSITION); + expect(setSwitchedOff.permissions).not.toContain(ENV_SET); + + const setOn = await flip('permission', ENV_SET, { enabled: true }); + expect(setOn.status, await setOn.clone().text()).toBe(200); + const after = await resolveUserAuthzGrants(ql as never, holder, {}); + expect(after.positions).toContain(ENV_POSITION); + expect(after.permissions).toContain(ENV_SET); + }); + it('a name nobody declares is 404 and writes nothing', async () => { const res = await flip('position', 'no_such_position', { enabled: false }); diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index d4d0bbdb5cf..bf8369f05fa 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -17,7 +17,7 @@ // does not honour would be a switch that switches nothing. import { describe, it, expect } from 'vitest'; -import { resolveUserAuthzGrants } from '@objectstack/core'; +import { resolveUserAuthzGrants, securityCatalogReaderOf } from '@objectstack/core'; import { assertEngineUpdateDispatch } from '@objectstack/objectql'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -29,10 +29,14 @@ const USER = 'u_holder'; const POSITION = 'sales_rep'; const SET = 'sales_access'; const DIRECT_SET = 'report_reader'; +/** A position and a set the ENVIRONMENT authored: no `_packageId` on either (#22621 → A). */ +const ENV_POSITION = 'env_auditor'; +const ENV_SET = 'env_audit_access'; const CATALOG: TestSecurityCatalog = { positions: [ { name: POSITION, permissionSets: [SET], _packageId: 'crm' }, + { name: ENV_POSITION, permissionSets: [ENV_SET] }, // The audience anchors are DECLARED here so a refusal of them cannot be // a 404 in disguise: the lookup would find them. { name: 'everyone', permissionSets: [] }, @@ -41,6 +45,7 @@ const CATALOG: TestSecurityCatalog = { permissions: [ { name: SET, systemPermissions: ['cap_sales'] }, { name: DIRECT_SET, systemPermissions: ['cap_reports'] }, + { name: ENV_SET, systemPermissions: ['cap_audit'] }, { name: 'admin_full_access', systemPermissions: ['manage_metadata'] }, ], }; @@ -61,7 +66,10 @@ function makeEngine(opts: EngineOptions = {}) { const tables: Record = { sys_user: [{ id: USER }], sys_member: [], - sys_user_position: [{ user_id: USER, position: POSITION, organization_id: null }], + sys_user_position: [ + { user_id: USER, position: POSITION, organization_id: null }, + { user_id: USER, position: ENV_POSITION, organization_id: null }, + ], sys_user_permission_set: [ { user_id: USER, permission_set_id: 'ps_direct', permission_set: DIRECT_SET, organization_id: null }, ], @@ -416,6 +424,24 @@ describe('the catalog activation door — contract', () => { expect(h.tables[LEDGER]).toEqual([]); }); + // [ADR-0112] The code and the status say the same thing. The ledger's + // record validation refuses a write with `VALIDATION_FAILED` and no status + // of its own; the door used to keep that code under its 503 default, a pair + // that contradicts itself (a client input refusal served as an outage). + it('a record validation refusal on the ledger is 400 VALIDATION_FAILED — code and status agree', async () => { + const refusal = Object.assign(new Error('Package is required'), { + code: 'VALIDATION_FAILED', + fields: [{ field: 'package_id', code: 'required', message: 'Package is required' }], + }); + const h = boot('single', { writeError: refusal }); + + const res = await flip(h, TENANT_ADMIN(), `/_activation/position/${POSITION}`); + + expect(statusOf(res)).toBe(400); + expect(codeOf(res)).toBe('VALIDATION_FAILED'); + expect(h.tables[LEDGER]).toEqual([]); + }); + it('a store failure is 503 SERVICE_UNAVAILABLE, never a 200', async () => { const h = boot('single', { writeError: new Error('connection reset') }); @@ -436,6 +462,78 @@ describe('the catalog activation door — contract', () => { }); }); +// [#22621 → A] Under `single` the administrator switches every position and +// permission set from the same Setup page, the environment's own included. No +// package ships those, and the row says so with no package value — never a +// placeholder — while the resolver honours it exactly like a packaged one. +describe('the catalog activation door — an environment-authored item', () => { + it('anti-vacuity: the catalog holds both items, and neither names a package', async () => { + const h = boot('single'); + const reader = securityCatalogReaderOf(h.engine); + + const position = await reader!.resolve('position', ENV_POSITION); + const set = await reader!.resolve('permission', ENV_SET); + + expect(position?.name).toBe(ENV_POSITION); + expect(set?.name).toBe(ENV_SET); + expect(position?.packageId).toBeUndefined(); + expect(set?.packageId).toBeUndefined(); + // CONTROL: the packaged position beside them does name its package. + expect((await reader!.resolve('position', POSITION))?.packageId).toBe('crm'); + }); + + for (const [type, name] of [['position', ENV_POSITION], ['permission', ENV_SET]] as const) { + it(`\`${type}\`: switches off and back on, one row with NO package`, async () => { + const h = boot('single'); + + const off = await flip(h, TENANT_ADMIN(), `/_activation/${type}/${name}`, { enabled: false }); + expect(statusOf(off)).toBe(200); + expect(off.response?.body?.data).toEqual({ type, name, enabled: false }); + expect(h.writes[0]).toEqual({ + op: 'insert', + data: { metadata_type: type, name, package_id: null, active: false }, + }); + + const on = await flip(h, TENANT_ADMIN(), `/_activation/${type}/${name}`, { enabled: true }); + expect(statusOf(on)).toBe(200); + expect(h.tables[LEDGER]).toHaveLength(1); + expect(h.tables[LEDGER][0]).toEqual(expect.objectContaining({ package_id: null, active: true })); + }); + } + + it('the resolver honours its row: the position switched off stops granting, back on grants again', async () => { + const h = boot('single'); + + const before = await grantsOf(h); + expect(before.positions).toContain(ENV_POSITION); + expect(before.permissions).toContain(ENV_SET); + expect(before.systemPermissions).toContain('cap_audit'); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/position/${ENV_POSITION}`, { enabled: false }))).toBe(200); + const off = await grantsOf(h); + expect(off.positions).not.toContain(ENV_POSITION); + expect(off.permissions).not.toContain(ENV_SET); + expect(off.systemPermissions).not.toContain('cap_audit'); + // Only that position: the packaged one still grants. + expect(off.positions).toContain(POSITION); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/position/${ENV_POSITION}`, { enabled: true }))).toBe(200); + const on = await grantsOf(h); + expect(on.positions).toContain(ENV_POSITION); + expect(on.permissions).toContain(ENV_SET); + }); + + it('the resolver honours its row: the set switched off stops granting while its position stays held', async () => { + const h = boot('single'); + + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/permission/${ENV_SET}`, { enabled: false }))).toBe(200); + const off = await grantsOf(h); + expect(off.positions).toContain(ENV_POSITION); + expect(off.permissions).not.toContain(ENV_SET); + expect(off.systemPermissions).not.toContain('cap_audit'); + }); +}); + describe('the catalog activation door — the resolver honours what it writes', () => { it('CONTROL: before any flip the holder is granted through the position and the direct set', async () => { const h = boot('single'); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index 9b308dd0765..a1a26242877 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -33,7 +33,9 @@ * * One `sys_metadata_activation` row through the shared * `ObjectStoreMetadataActivationStore` — the same row contract the flow and - * action stores use. ⛔ No definition is touched, ⛔ no catalog row's `active` + * action stores use. Unlike theirs, this door's subjects need not be packaged: + * a position or permission set the environment authored is switched the same + * way (#22621 → A), and its row carries no package. ⛔ No definition is touched, ⛔ no catalog row's `active` * is written (ADR-0131 D3: that column is not the switch any more), ⛔ no clone * is authored. The write goes through the engine, so every hook on the ledger * runs: the last-admin guard refuses switching `admin_full_access` off @@ -127,7 +129,8 @@ function ledgerUnregistered(ql: any): boolean { * the resolver cannot disagree about which names exist. No reader bound, * or a reader that cannot answer ⇒ 503 (an outage is not a verdict); * unknown ⇒ 404. - * 5. **The durable write**, through the engine. + * 5. **The durable write**, through the engine. A packaged item's row names + * its package; an environment-authored item's row names none. */ export async function handleCatalogActivationWrite( deps: DomainHandlerDeps, @@ -249,21 +252,24 @@ export async function handleCatalogActivationWrite( }; } try { + // [#22621 → A] The row carries the definition's package when it names + // one, and NO package when it does not — a position or permission set + // the environment authored through the metadata door. That item is + // switched exactly like a packaged one: the resolver keys the row by + // type and name, never by package. await new ObjectStoreMetadataActivationStore(ql, type).setActive({ name: entry.name, - packageId: entry.packageId ?? '', + ...(entry.packageId !== undefined ? { packageId: entry.packageId } : {}), active: enabled, }); - } catch (err: any) { - // A hook's refusal carries its own status and code (the last-admin - // guard: 403 PERMISSION_DENIED); anything else is a store failure, and - // a failed write must never read as a successful flip. - const status = typeof err?.status === 'number' ? err.status : 503; - const code = typeof err?.code === 'string' ? err.code : 'SERVICE_UNAVAILABLE'; - return { - handled: true, - response: deps.error(err?.message ?? String(err), status, { code }), - }; + } catch (err: unknown) { + // A failed write must never read as a successful flip, and its code and + // status must say the same thing (ADR-0112). The one shared rule + // decides both (`resolveThrownHttpError`): a hook's refusal keeps its + // own (the last-admin guard: 403 PERMISSION_DENIED), a record + // validation refusal is 400 VALIDATION_FAILED, and anything that + // declares no status is a store failure, 503 SERVICE_UNAVAILABLE. + return { handled: true, response: deps.errorFromThrown(err, 503) }; } return { handled: true, response: deps.success({ type, name: entry.name, enabled }) }; } diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 69bb14b848c..b8f8efcca9e 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2233,8 +2233,15 @@ export interface FlowDispatchStore { export interface FlowActivationRow { /** The packaged flow's machine name. */ name: string; - /** The package that ships the base artifact. */ - packageId: string; + /** + * The package that ships the flow. Every flow row names one — the toggle + * refuses a flow no package ships before it writes — but the row type is + * the shared ledger's (`MetadataActivationRow`, `@objectstack/core`), + * where an absent package is the spelling of an environment-authored + * position or permission set, so it is optional here too. Nothing reads + * it back to decide anything. + */ + packageId?: string; /** Is the packaged flow armed for this installation. */ active: boolean; }