diff --git a/.changeset/22411-hatch-legacy-spelling.md b/.changeset/22411-hatch-legacy-spelling.md new file mode 100644 index 00000000000..417ee98d0af --- /dev/null +++ b/.changeset/22411-hatch-legacy-spelling.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: one reader of `OS_METADATA_WRITABLE` — the legacy `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, no longer opens the hatch at the type listing either + +Clause-②: no (narrowing) + + + +**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + +This finishes 11.0's removal at the reader that kept it. The 11.0 release removed ObjectStack's own legacy environment-variable names, `OBJECTSTACK_METADATA_WRITABLE` among them. That change edited one of the two readers of `OS_METADATA_WRITABLE` (the metadata repository's write gate) and missed the other (the protocol's, which feeds `GET /api/v1/meta/types` and the protocol's write gates). So with only the legacy spelling set, the type listing reported a named type as writable (`allowOrgOverride: true`, `overrideSource: 'env'`), while creating a new item of that type answered `403 NOT_CREATABLE`. One deployment gave two answers to one question. + +**What changes.** The protocol now reads the setting through the repository's reader, so there is one reader and it reads `OS_METADATA_WRITABLE` only. With only `OBJECTSTACK_METADATA_WRITABLE` set, the hatch is shut everywhere: the listing reports no env override, and every write the hatch would open is refused as it is with nothing set. No deprecation warning is printed for the legacy name any more, because nothing reads it. + +**What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before. + +**Operator action.** A deployment that still sets `OBJECTSTACK_METADATA_WRITABLE` sets `OS_METADATA_WRITABLE` in its place, with the same comma-separated type list. This is the rename 11.0 already published; nothing else changes. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index d1a6eb96b00..8c68fc8f0b0 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -374,7 +374,7 @@ the hosted ObjectOS Cloud control plane. Some env vars accept a legacy alias for compatibility. **Ecosystem-standard names** (e.g. `DATABASE_URL`, `AUTH_SECRET`, `BETTER_AUTH_*`, `PORT`, `CORS_*`, `MCP_SERVER_*`) are permanently accepted and no longer warn. ObjectStack's own former names are deprecated — prefer the canonical `OS_*`. -> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`. +> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `OBJECTSTACK_METADATA_WRITABLE` → `OS_METADATA_WRITABLE`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`. | Canonical | Legacy | |:---|:---| @@ -391,6 +391,5 @@ Some env vars accept a legacy alias for compatibility. **Ecosystem-standard name | `OS_MCP_SERVER_ENABLED` | `MCP_SERVER_ENABLED` | | `OS_MCP_SERVER_NAME` | `MCP_SERVER_NAME` | | `OS_MCP_SERVER_TRANSPORT` | `MCP_SERVER_TRANSPORT` | -| `OS_METADATA_WRITABLE` | `OBJECTSTACK_METADATA_WRITABLE` | | `OS_HOME` | `OBJECTSTACK_HOME` | | `OS_DEV_CRYPTO_KEY` | `OBJECTSTACK_DEV_CRYPTO_KEY` | diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts index fdeb18ce990..1bba1f2c3f9 100644 --- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts +++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts @@ -15,11 +15,13 @@ * deliberately `null` — a name no package ships, a Regime O overlay type, * and the #6960 delete carve-out. [ADR-0131 D6] The operator hatch is no * longer one of them: managed content is sealed, so the verdict on an item - * a managed package ships is the same with `OS_METADATA_WRITABLE` (either - * spelling) set or not. + * a managed package ships is the same with `OS_METADATA_WRITABLE` set or + * not. [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE`, which + * 11.0 removed, is not read at all: the hatch is shut under it. * - * The registry double serves only `getArtifactItem`, which is all the verdict - * reads; what it returns is what the real `SchemaRegistry` returns for an + * The registry double serves `getArtifactItem`, which is all the verdict + * reads, and `getRegisteredTypes`, which the type listing reads to show the + * hatch shut under the removed spelling; what `getArtifactItem` returns is what the real `SchemaRegistry` returns for an * artifact a code package registered (`_packageId` stamped, package * provenance). `@objectstack/objectql` cannot be imported here: it depends on * this package. @@ -46,7 +48,10 @@ const ARTIFACTS = new Map>([ ]); function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation { - const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) }; + const registry = { + getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name), + getRegisteredTypes: () => Array.from(ARTIFACTS.keys()), + }; return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId); } @@ -120,37 +125,78 @@ describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed .toThrow('registry unreadable'); }); - // [ADR-0131 D6] Managed content is sealed: the hatch, under either spelling - // its reader honours, opens neither verb on an item a managed package - // ships — the verdict is the one it gives with the hatch shut, sentence - // included. Every door that asks this verdict (the `/automation` doors, the - // read envelope) inherits that. - for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { - for (const environmentId of [undefined, 'env_1']) { - it(`${variable}=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => { - const shut = protocolOn(environmentId); - const sealed = { - save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, - delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, - page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, - }; - process.env[variable] = 'flow,page'; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - const p = protocolOn(environmentId); - const open = { - save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, - delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, - page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, - }; - for (const verb of ['save', 'delete', 'page'] as const) { - expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); - expect(open[verb].message, verb).toBe(sealed[verb].message); - } - // The #6960 carve-out and the regime-O overlay keep their `null`. - expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull(); - expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull(); - }); - } + // [ADR-0131 D6] Managed content is sealed: the hatch opens neither verb on + // an item a managed package ships — the verdict is the one it gives with + // the hatch shut, sentence included. Every door that asks this verdict (the + // `/automation` doors, the read envelope) inherits that. + for (const environmentId of [undefined, 'env_1']) { + it(`OS_METADATA_WRITABLE=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => { + const shut = protocolOn(environmentId); + const sealed = { + save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, + delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, + page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, + }; + process.env.OS_METADATA_WRITABLE = 'flow,page'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const p = protocolOn(environmentId); + const open = { + save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, + delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, + page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, + }; + for (const verb of ['save', 'delete', 'page'] as const) { + expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(open[verb].message, verb).toBe(sealed[verb].message); + } + // The #6960 carve-out and the regime-O overlay keep their `null`. + expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull(); + expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull(); + }); + } + + // [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE` was removed in + // 11.0, and the protocol now reads the setting through the repository's one + // reader, which reads the preferred spelling only. Under the legacy + // spelling alone the hatch is SHUT: the type listing reports no env + // override, and every verdict is the one the shut hatch gives. Control: the + // preferred spelling still opens the listing. + for (const environmentId of [undefined, 'env_1']) { + it(`OBJECTSTACK_METADATA_WRITABLE=flow,page is not read — the hatch is shut (${environmentId ? 'environment' : 'host-config'} kernel)`, async () => { + const shut = protocolOn(environmentId); + const sealed = { + save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any, + delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any, + page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any, + }; + const hatchOf = async (p: ObjectStackProtocolImplementation) => { + const { entries } = await (p as any).getMetaTypes(); + return Object.fromEntries(['flow', 'page'].map((t) => { + const e = entries.find((x: any) => x.type === t); + return [t, { allowOrgOverride: e?.allowOrgOverride, overrideSource: e?.overrideSource }]; + })); + }; + const SHUT = { allowOrgOverride: false, overrideSource: 'registry' }; + expect(await hatchOf(shut)).toEqual({ flow: SHUT, page: SHUT }); + + process.env.OBJECTSTACK_METADATA_WRITABLE = 'flow,page'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const legacy = protocolOn(environmentId); + expect(await hatchOf(legacy)).toEqual({ flow: SHUT, page: SHUT }); + for (const verb of ['save', 'delete', 'page'] as const) { + const verdict: any = verb === 'page' + ? legacy.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) + : legacy.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: verb }); + expect(shape(verdict), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(verdict.message, verb).toBe(sealed[verb].message); + } + + // Control: the preferred spelling is read, so the same names open the listing. + process.env.OS_METADATA_WRITABLE = 'flow,page'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + const OPEN = { allowOrgOverride: true, overrideSource: 'env' }; + expect(await hatchOf(protocolOn(environmentId))).toEqual({ flow: OPEN, page: OPEN }); + }); } }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6df9a994bc3..0cf53ab1b32 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4,7 +4,7 @@ import type { DataProtocol, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core'; -import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; +import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering // this question with it is a bug (cloud#1020, #5233) — so the posture, and only @@ -43,7 +43,14 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js'; // ADR-0120 D4 reporting that replaced this file's empty `catch` blocks. import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js'; import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js'; -import { DraftConflictError, SysMetadataRepository, packageScopedRowWhere, type SysMetadataEngine } from './sys-metadata-repository.js'; +import { + DraftConflictError, + SysMetadataRepository, + envWritableMetadataTypes, + packageScopedRowWhere, + resetEnvWritableMetadataTypes, + type SysMetadataEngine, +} from './sys-metadata-repository.js'; import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js'; import { resolveArtifactLockLayer, @@ -15860,29 +15867,24 @@ export class ObjectStackProtocolImplementation implements * use to enable Studio-side editing of types whose protocol-level flag * is still false (object, field, permission, …). * - * Memoised at first call. Tests can override by clearing the cache via + * [#22411] Delegates to the repository's {@link envWritableMetadataTypes}, + * the ONE reader of the setting, so the listing and the save door read the + * same set. This used to be a second copy of the parse. The 11.0 removal of + * the legacy `OBJECTSTACK_METADATA_WRITABLE` spelling edited the other + * copy and missed this one, so the listing advertised a hatch that the + * repository then refused. ⛔ Never parse the variable here again. + * + * Memoised at first call (by the shared reader). Tests can override by + * clearing the cache via * {@link ObjectStackProtocolImplementation.resetEnvWritableCache}. */ - private static _envWritableTypes: Set | null = null; private static envWritableTypes(): ReadonlySet { - if (this._envWritableTypes !== null) return this._envWritableTypes; - const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE') || ''; - const set = new Set(); - for (const tok of raw.split(',')) { - const t = tok.trim(); - if (!t) continue; - const singular = PLURAL_TO_SINGULAR[t] ?? t; - set.add(singular); - const plural = SINGULAR_TO_PLURAL[singular]; - if (plural) set.add(plural); - } - this._envWritableTypes = set; - return set; - } - - /** Test hook — clear the memoised env-writable cache. */ + return envWritableMetadataTypes(); + } + + /** Test hook — clear the memoised env-writable cache (the shared reader's one cache). */ static resetEnvWritableCache(): void { - this._envWritableTypes = null; + resetEnvWritableMetadataTypes(); } /** diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 690b710cc41..c0d9fb29a2a 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -359,13 +359,22 @@ const OVERLAY_CAPABLE_TYPES: ReadonlySet = new Set( /** * Phase 3a-env-writable: parse `OS_METADATA_WRITABLE` (comma- * separated singular type names). Memoised; tests can reset via - * {@link resetEnvWritableMetadataTypes}. Mirrors the same helper in - * ObjectStackProtocolImplementation — both gates must consult the same - * elevated set so the env-var escape hatch is applied consistently - * regardless of which write path a caller takes. + * {@link resetEnvWritableMetadataTypes}. + * + * [#22411] The ONE reader of the setting. The repository's write gate and + * every protocol consumer of the hatch — `getMetaTypes()`'s listing, + * `isOverlayAllowed()` — ask this function, so the listing cannot advertise a + * hatch the save door then refuses. There used to be a second, hand-copied + * reader in `ObjectStackProtocolImplementation`. The 11.0 removal of + * ObjectStack's own legacy env names (`OBJECTSTACK_METADATA_WRITABLE` → + * `OS_METADATA_WRITABLE`, published as a breaking change) edited this copy and + * missed that one, which went on honouring the removed spelling: the listing + * said "writable", the save answered `403 NOT_CREATABLE`. ⛔ Do not add a + * second reader, and do not give this one a legacy alias back — the spelling + * is removed, and `[]` is that decision. */ let _envWritableMetadataTypes: Set | null = null; -function envWritableMetadataTypes(): ReadonlySet { +export function envWritableMetadataTypes(): ReadonlySet { if (_envWritableMetadataTypes !== null) return _envWritableMetadataTypes; const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', []) || ''; const set = new Set(); @@ -381,7 +390,11 @@ function envWritableMetadataTypes(): ReadonlySet { return set; } -/** Test hook — clear the memoised env-writable cache. */ +/** + * Test hook — clear the memoised env-writable cache. The one cache + * {@link envWritableMetadataTypes} keeps, so this and + * `ObjectStackProtocolImplementation.resetEnvWritableCache()` clear the same set. + */ export function resetEnvWritableMetadataTypes(): void { _envWritableMetadataTypes = null; } diff --git a/packages/rest/src/rest-meta-managed-seal-hatch.test.ts b/packages/rest/src/rest-meta-managed-seal-hatch.test.ts index 33c758d2aad..4f460fe93ad 100644 --- a/packages/rest/src/rest-meta-managed-seal-hatch.test.ts +++ b/packages/rest/src/rest-meta-managed-seal-hatch.test.ts @@ -9,8 +9,15 @@ * `sys-metadata-repository.package-writability.test.ts` in * `@objectstack/metadata-protocol`. This file answers what they cannot: the * REST door relays the seal — `403` and `NOT_OVERRIDABLE` — for every type the - * hatch used to open, under both spellings of the variable, on both kernel - * shapes, and the sentence that reaches the client names the managed package. + * hatch used to open, on both kernel shapes, and the sentence that reaches the + * client names the managed package. + * + * [#22411] Only `OS_METADATA_WRITABLE` opens the hatch. The legacy spelling + * `OBJECTSTACK_METADATA_WRITABLE` was removed in 11.0, and the protocol's own + * copy of the reader kept honouring it until the protocol read the setting + * through the repository's one reader. Under the legacy spelling alone the + * hatch is shut at this door: the type listing reports no env override and + * every PUT and DELETE answers what it answers with nothing set. * * Same harness as `rest-meta-packaged-flow-refusal.test.ts`: a REAL * `ObjectStackProtocolImplementation` over a REAL `SchemaRegistry`, the @@ -76,10 +83,22 @@ function boot(environmentId: string | undefined) { await route(method).handler({ method, params: { type, name }, query: {}, headers: {}, body } as any, res); return { status: res._status, code: res._json?.code, message: String(res._json?.error ?? '') }; }; - return { call }; + /** `GET /api/v1/meta/types` — each named type's hatch reading, as the listing reports it. */ + const listing = async (types: readonly string[]) => { + const found = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/types'); + if (!found) throw new Error('GET /api/v1/meta/types is not registered'); + const res = makeRes(); + await found.handler({ method: 'GET', params: {}, query: {}, headers: {} } as any, res); + const entries: any[] = res._json?.entries ?? []; + return Object.fromEntries(types.map((t) => { + const e = entries.find((x) => x.type === t); + return [t, e ? { allowOrgOverride: e.allowOrgOverride, overrideSource: e.overrideSource } : 'absent']; + })); + }; + return { call, listing }; } -function open(variable: 'OS_METADATA_WRITABLE' | 'OBJECTSTACK_METADATA_WRITABLE') { +function open(variable: 'OS_METADATA_WRITABLE' | 'OBJECTSTACK_METADATA_WRITABLE' = 'OS_METADATA_WRITABLE') { process.env[variable] = HATCH; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); @@ -102,35 +121,69 @@ const EDITS: ReadonlyArray]> = ]; describe('[ADR-0131 D6] the hatch opens no PUT onto a managed item at the REST door', () => { - for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { - for (const environmentId of [undefined, 'env_1']) { - const kernel = environmentId ? 'environment' : 'host-config'; - it(`${variable}=${HATCH}: PUT of each managed item answers 403 NOT_OVERRIDABLE, as with the hatch shut (${kernel} kernel)`, async () => { - const shut = boot(environmentId); - const sealed = await Promise.all(EDITS.map(([type, name, body]) => shut.call('PUT', type, name, body))); - open(variable); - const { call } = boot(environmentId); - for (const [i, [type, name, body]] of EDITS.entries()) { - const r = await call('PUT', type, name, body); - expect({ status: r.status, code: r.code }, `${type}/${name}`).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - expect(r.message, `${type}/${name}`).toBe(sealed[i].message); - expect(r.message, `${type}/${name}`).toContain('managed package'); - } - }); - } + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + it(`OS_METADATA_WRITABLE=${HATCH}: PUT of each managed item answers 403 NOT_OVERRIDABLE, as with the hatch shut (${kernel} kernel)`, async () => { + const shut = boot(environmentId); + const sealed = await Promise.all(EDITS.map(([type, name, body]) => shut.call('PUT', type, name, body))); + open(); + const { call } = boot(environmentId); + for (const [i, [type, name, body]] of EDITS.entries()) { + const r = await call('PUT', type, name, body); + expect({ status: r.status, code: r.code }, `${type}/${name}`).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(r.message, `${type}/${name}`).toBe(sealed[i].message); + expect(r.message, `${type}/${name}`).toContain('managed package'); + } + }); } }); describe('[ADR-0131 D6] the hatch opens no removal of a managed item at the REST door', () => { - for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { - it(`${variable}=${HATCH}: DELETE of a managed flow and a managed object answers 403 NOT_OVERRIDABLE (environment kernel)`, async () => { - open(variable); - const { call } = boot('env_1'); - for (const [type, name] of [['flow', 'pkg_flow'], ['object', 'pkg_invoice']] as const) { - const r = await call('DELETE', type, name); - expect({ status: r.status, code: r.code }, `${type}/${name}`).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); - expect(r.message, `${type}/${name}`).toContain('is provided by a managed package and is sealed against removal'); + it(`OS_METADATA_WRITABLE=${HATCH}: DELETE of a managed flow and a managed object answers 403 NOT_OVERRIDABLE (environment kernel)`, async () => { + open(); + const { call } = boot('env_1'); + for (const [type, name] of [['flow', 'pkg_flow'], ['object', 'pkg_invoice']] as const) { + const r = await call('DELETE', type, name); + expect({ status: r.status, code: r.code }, `${type}/${name}`).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(r.message, `${type}/${name}`).toContain('is provided by a managed package and is sealed against removal'); + } + }); +}); + +describe('[#22411] the removed spelling OBJECTSTACK_METADATA_WRITABLE is not read: the hatch is shut at the REST door', () => { + // The types this registry lists (`field` is named in the variable but is no registered type here: + // its items live inside an object, so the listing carries no entry for it). + const HATCH_TYPES = ['flow', 'object', 'permission', 'position'] as const; + const SHUT = { allowOrgOverride: false, overrideSource: 'registry' }; + + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + it(`OBJECTSTACK_METADATA_WRITABLE=${HATCH}: the listing reports no env override, and every ${environmentId ? 'PUT and DELETE' : 'PUT'} answers as with nothing set (${kernel} kernel)`, async () => { + const shut = boot(environmentId); + const shutPuts = await Promise.all(EDITS.map(([type, name, body]) => shut.call('PUT', type, name, body))); + // Removals only on an environment kernel, where the removal door is asked before the store + // (this harness boots none) — the same scope as the removal pins above. + const REMOVALS = environmentId ? EDITS : []; + const shutDeletes = await Promise.all(REMOVALS.map(([type, name]) => shut.call('DELETE', type, name))); + expect(await shut.listing(HATCH_TYPES)).toEqual(Object.fromEntries(HATCH_TYPES.map((t) => [t, SHUT]))); + + open('OBJECTSTACK_METADATA_WRITABLE'); + const legacy = boot(environmentId); + expect(await legacy.listing(HATCH_TYPES)).toEqual(Object.fromEntries(HATCH_TYPES.map((t) => [t, SHUT]))); + for (const [i, [type, name, body]] of EDITS.entries()) { + expect(await legacy.call('PUT', type, name, body), `PUT ${type}/${name}`).toEqual(shutPuts[i]); + } + for (const [i, [type, name]] of REMOVALS.entries()) { + expect(await legacy.call('DELETE', type, name), `DELETE ${type}/${name}`).toEqual(shutDeletes[i]); } }); + + it(`control — OS_METADATA_WRITABLE=${HATCH}: the listing reports the env override for the same types (${kernel} kernel)`, async () => { + open(); + const { listing } = boot(environmentId); + expect(await listing(HATCH_TYPES)).toEqual( + Object.fromEntries(HATCH_TYPES.map((t) => [t, { allowOrgOverride: true, overrideSource: 'env' }])), + ); + }); } }); diff --git a/packages/runtime/src/meta-managed-content-seal.test.ts b/packages/runtime/src/meta-managed-content-seal.test.ts index 5f78dd5d3fe..34a51504419 100644 --- a/packages/runtime/src/meta-managed-content-seal.test.ts +++ b/packages/runtime/src/meta-managed-content-seal.test.ts @@ -10,8 +10,17 @@ * package ships for every type it named: a flow, an object (and through it a * field), a permission set, a position. With the hatch open the dispatcher now * answers what it answers with the hatch shut — `403` `NOT_OVERRIDABLE`, no - * row — under both spellings the protocol's reader honours, on both kernel - * shapes; and the read envelope stops promising an edit the door refuses. + * row — on both kernel shapes; and the read envelope stops promising an edit + * the door refuses. + * + * [#22411] Only the preferred spelling opens the hatch. The legacy spelling + * `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, used to be honoured by the + * protocol's own copy of the reader and not by the repository's, so the type + * listing advertised a hatch the save door then refused. Both now read through + * the repository's one reader. The last block pins the card's two readings at + * this door: under the legacy spelling alone, the listing and the save door + * agree by both refusing, for a type the hatch would name and for one it + * would not; the preferred spelling is the control. * * What stays open, pinned beside it: * - a regime-O overlay: a packaged VIEW is overlaid (`200`, a row), because @@ -127,6 +136,8 @@ function makeEngine() { getItem: (type: string, name: string) => runtimeItems.get(type)?.get(name) ?? artifacts.get(type)?.get(name), getArtifactItem: (type: string, name: string) => artifacts.get(type)?.get(name), getObject: (name: string) => artifacts.get('object')?.get(name) ?? runtimeItems.get('object')?.get(name), + // The type listing (`GET /meta/types`) reads the live types from here. + getRegisteredTypes: () => ['flow', 'object', 'permission', 'position', 'view', 'job', 'picklist'], getPackage: () => undefined, isPackageDisabled: () => false, applyNavContributions: (app: unknown) => app, @@ -234,62 +245,123 @@ afterEach(() => { }); describe('[ADR-0131 D6] the dispatcher /meta door: the hatch opens no write onto a managed item', () => { - for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) { - for (const environmentId of [undefined, 'env_1']) { - const kernel = environmentId ? 'environment' : 'host-config'; + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; - it(`${variable}=${HATCH}: PUT of a managed flow, object, field, permission set and position — 403 NOT_OVERRIDABLE, no row (${kernel})`, async () => { - const shut = makeStack(environmentId); - const sealed: Array> = []; - for (const [type, name, body] of EDITS) { - sealed.push(responseOf(await shut.dispatcher.handleMetadata(`/${type}/${name}`, ctx(), 'PUT', body))); - } + it(`OS_METADATA_WRITABLE=${HATCH}: PUT of a managed flow, object, field, permission set and position — 403 NOT_OVERRIDABLE, no row (${kernel})`, async () => { + const shut = makeStack(environmentId); + const sealed: Array> = []; + for (const [type, name, body] of EDITS) { + sealed.push(responseOf(await shut.dispatcher.handleMetadata(`/${type}/${name}`, ctx(), 'PUT', body))); + } - process.env[variable] = HATCH; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - resetEnvWritableMetadataTypes(); - const { engine, dispatcher } = makeStack(environmentId); - for (const [i, [type, name, body]] of EDITS.entries()) { - const res = responseOf(await dispatcher.handleMetadata(`/${type}/${name}`, ctx(), 'PUT', body)); - expect(res.status, `${type}/${name}`).toBe(403); - expect(res.body?.error?.code, `${type}/${name}`).toBe('NOT_OVERRIDABLE'); - // The same refusal the shut hatch gets — sentence included. - expect(res.body?.error?.message, `${type}/${name}`).toBe(sealed[i].body?.error?.message); - expect(activeRow(engine, type, name), `${type}/${name}`).toBeUndefined(); - } - expect(engine.metaRows()).toEqual([]); - }); + process.env.OS_METADATA_WRITABLE = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { engine, dispatcher } = makeStack(environmentId); + for (const [i, [type, name, body]] of EDITS.entries()) { + const res = responseOf(await dispatcher.handleMetadata(`/${type}/${name}`, ctx(), 'PUT', body)); + expect(res.status, `${type}/${name}`).toBe(403); + expect(res.body?.error?.code, `${type}/${name}`).toBe('NOT_OVERRIDABLE'); + // The same refusal the shut hatch gets — sentence included. + expect(res.body?.error?.message, `${type}/${name}`).toBe(sealed[i].body?.error?.message); + expect(activeRow(engine, type, name), `${type}/${name}`).toBeUndefined(); + } + expect(engine.metaRows()).toEqual([]); + }); - it(`${variable}=${HATCH}: the read envelope promises no edit and no removal of a managed flow (${kernel})`, async () => { - process.env[variable] = HATCH; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - resetEnvWritableMetadataTypes(); - const { dispatcher } = makeStack(environmentId); - const res = responseOf(await dispatcher.handleMetadata(`/flow/${PACKAGED_FLOW.name}`, ctx(), 'GET', undefined)); - expect(res.status).toBe(200); - const envelope = (res.body?.data ?? res.body) as { editable?: unknown; deletable?: unknown }; - expect({ editable: envelope?.editable, deletable: envelope?.deletable }).toEqual({ editable: false, deletable: false }); - }); + it(`OS_METADATA_WRITABLE=${HATCH}: the read envelope promises no edit and no removal of a managed flow (${kernel})`, async () => { + process.env.OS_METADATA_WRITABLE = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { dispatcher } = makeStack(environmentId); + const res = responseOf(await dispatcher.handleMetadata(`/flow/${PACKAGED_FLOW.name}`, ctx(), 'GET', undefined)); + expect(res.status).toBe(200); + const envelope = (res.body?.data ?? res.body) as { editable?: unknown; deletable?: unknown }; + expect({ editable: envelope?.editable, deletable: envelope?.deletable }).toEqual({ editable: false, deletable: false }); + }); + + it(`OS_METADATA_WRITABLE=${HATCH} — controls: a packaged VIEW is overlaid, and a NEW flow is created (${kernel})`, async () => { + process.env.OS_METADATA_WRITABLE = HATCH; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const { engine, dispatcher } = makeStack(environmentId); + + const view = responseOf(await dispatcher.handleMetadata( + `/view/${PACKAGED_VIEW.name}`, ctx(), 'PUT', { ...strip(PACKAGED_VIEW), label: 'My Leads' }, + )); + expect(view.status, JSON.stringify(view.body)).toBe(200); + expect(activeRow(engine, 'view', PACKAGED_VIEW.name)).toBeDefined(); + + const created = responseOf(await dispatcher.handleMetadata( + '/flow/crm_new_alert', ctx(), 'PUT', + { name: 'crm_new_alert', label: 'New Alert', type: 'autolaunched', nodes: [], edges: [] }, + )); + expect(created.status, JSON.stringify(created.body)).toBe(200); + expect(activeRow(engine, 'flow', 'crm_new_alert')).toBeDefined(); + }); + } +}); - it(`${variable}=${HATCH} — controls: a packaged VIEW is overlaid, and a NEW flow is created (${kernel})`, async () => { - process.env[variable] = HATCH; - ObjectStackProtocolImplementation.resetEnvWritableCache(); - resetEnvWritableMetadataTypes(); - const { engine, dispatcher } = makeStack(environmentId); +/** + * [#22411] The type listing and the save door read ONE setting, so they give + * one answer. The legacy spelling `OBJECTSTACK_METADATA_WRITABLE` was removed in + * 11.0; the protocol's own copy of the reader kept honouring it, so under that + * spelling alone `GET /meta/types` advertised the hatch for a type and a create + * of a new item of that type answered `403 NOT_CREATABLE`. + * + * `job` and `picklist` are both code-only (no create channel, no overlay), so + * the hatch is the only thing that could admit a create of either: `job` is + * the type the variable names, `picklist` the type it does not. + */ +describe('[#22411] the dispatcher /meta door: the type listing and the save door agree on the hatch', () => { + const JOB = { name: 'hatch_probe_job', label: 'J', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'nope' }; + const PICKLIST = { name: 'hatch_probe_picklist', label: 'P', options: [{ label: 'One', value: 'one' }] }; - const view = responseOf(await dispatcher.handleMetadata( - `/view/${PACKAGED_VIEW.name}`, ctx(), 'PUT', { ...strip(PACKAGED_VIEW), label: 'My Leads' }, - )); - expect(view.status, JSON.stringify(view.body)).toBe(200); - expect(activeRow(engine, 'view', PACKAGED_VIEW.name)).toBeDefined(); + async function readBoth(environmentId: string | undefined) { + const { engine, dispatcher } = makeStack(environmentId); + const listing = responseOf(await dispatcher.handleMetadata('/types', ctx(), 'GET', undefined)); + expect(listing.status).toBe(200); + const entries = ((listing.body?.data ?? listing.body) as { entries?: any[] })?.entries ?? []; + const listed = (type: string) => { + const entry = entries.find((e) => e.type === type); + expect(entry, `${type} is listed`).toBeDefined(); + return { allowOrgOverride: entry.allowOrgOverride, overrideSource: entry.overrideSource }; + }; + const save = async (type: string, body: { name: string }) => { + const res = responseOf(await dispatcher.handleMetadata(`/${type}/${body.name}`, ctx(), 'PUT', body)); + return { status: res.status, code: res.body?.error?.code, row: activeRow(engine, type, body.name) !== undefined }; + }; + return { + job: { listed: listed('job'), saved: await save('job', JOB) }, + picklist: { listed: listed('picklist'), saved: await save('picklist', PICKLIST) }, + }; + } - const created = responseOf(await dispatcher.handleMetadata( - '/flow/crm_new_alert', ctx(), 'PUT', - { name: 'crm_new_alert', label: 'New Alert', type: 'autolaunched', nodes: [], edges: [] }, - )); - expect(created.status, JSON.stringify(created.body)).toBe(200); - expect(activeRow(engine, 'flow', 'crm_new_alert')).toBeDefined(); + const SHUT = { allowOrgOverride: false, overrideSource: 'registry' }; + const REFUSED = { status: 403, code: 'NOT_CREATABLE', row: false }; + + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + + it(`OBJECTSTACK_METADATA_WRITABLE=job alone: both doors refuse, for the named type and for one it does not name (${kernel})`, async () => { + process.env.OBJECTSTACK_METADATA_WRITABLE = 'job'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + expect(await readBoth(environmentId)).toEqual({ + job: { listed: SHUT, saved: REFUSED }, + picklist: { listed: SHUT, saved: REFUSED }, }); - } + }); + + it(`control — OS_METADATA_WRITABLE=job: both doors admit the named type and refuse the other (${kernel})`, async () => { + process.env.OS_METADATA_WRITABLE = 'job'; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + const both = await readBoth(environmentId); + expect(both.job.listed).toEqual({ allowOrgOverride: true, overrideSource: 'env' }); + expect({ status: both.job.saved.status, row: both.job.saved.row }).toEqual({ status: 200, row: true }); + expect(both.picklist).toEqual({ listed: SHUT, saved: REFUSED }); + }); } });