|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #5086 — `allowRuntimeCreate: false` is enforced on EVERY kernel. |
| 5 | + * |
| 6 | + * #4509 set `allowRuntimeCreate: false` on `job` and its changeset promised |
| 7 | + * the refusal without qualification — *no "create job" in Studio or via |
| 8 | + * `PUT /meta`*. ADR-0063 §2 says the same for `agent`. The gate that keeps |
| 9 | + * that promise sat behind `environmentId !== undefined`, so it ran on |
| 10 | + * project-scoped (cloud per-env) kernels only. Every kernel assembled |
| 11 | + * WITHOUT an environmentId — which is what the CLI's lightweight assembler |
| 12 | + * builds for a host config (`isHostConfig` → `shouldBootWithLibrary` false → |
| 13 | + * `new ObjectQLPlugin()`), i.e. the flagship showcase and every self-hosted |
| 14 | + * app server shaped like it — accepted the write and answered |
| 15 | + * `200 {"success":true,"message":"Saved customization overlay (env-wide) …"}` |
| 16 | + * for a `job` whose `handler` names no function in any compiled bundle. |
| 17 | + * |
| 18 | + * So these tests run every case against BOTH kernel shapes. The unscoped one |
| 19 | + * is the regression; the scoped one pins the behaviour that already worked so |
| 20 | + * the two topologies can never drift apart again. |
| 21 | + * |
| 22 | + * The flags are DATA (`DEFAULT_METADATA_TYPE_REGISTRY`), so the suite is |
| 23 | + * data-driven: it derives the code-only set from the registry and fails when |
| 24 | + * a newly-flagged type arrives without a probe payload here. Covering the |
| 25 | + * next flagged type costs one entry in {@link PROBES}. |
| 26 | + * |
| 27 | + * Harness: the real write path over a stub engine (same shape as |
| 28 | + * `protocol.runtime-authoring-gate.test.ts`) — a gate INSIDE `saveMetaItem` |
| 29 | + * cannot be tested against a harness that mocks `saveMetaItem`. |
| 30 | + */ |
| 31 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 32 | +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; |
| 33 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 34 | +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; |
| 35 | + |
| 36 | +interface Row { |
| 37 | + id: string; |
| 38 | + type: string; |
| 39 | + name: string; |
| 40 | + organization_id: string | null; |
| 41 | + state: string; |
| 42 | + metadata: string; |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * A type is code-only when the registry gives it NO runtime write channel at |
| 47 | + * all. Derived, never hardcoded — Prime Directive #8 ("no parallel |
| 48 | + * whitelists"): if someone flags a third type, this set grows by itself and |
| 49 | + * the coverage guard below turns red until it has a probe. |
| 50 | + */ |
| 51 | +const CODE_ONLY_TYPES = DEFAULT_METADATA_TYPE_REGISTRY |
| 52 | + .filter((e) => !e.allowRuntimeCreate && !e.allowOrgOverride) |
| 53 | + .map((e) => e.type); |
| 54 | + |
| 55 | +/** |
| 56 | + * Schema-VALID bodies, straight from the issue's repro. This matters: a |
| 57 | + * minimal payload 422s on spec validation first, which is exactly what made |
| 58 | + * the missing gate easy to miss — only a body the schema accepts proves the |
| 59 | + * refusal came from the registry consult. |
| 60 | + */ |
| 61 | +const PROBES: Record<string, { name: string; item: Record<string, unknown> }> = { |
| 62 | + job: { |
| 63 | + name: 'rc3_runtime_job', |
| 64 | + item: { |
| 65 | + name: 'rc3_runtime_job', |
| 66 | + label: 'J', |
| 67 | + schedule: { type: 'cron', expression: '0 0 * * *' }, |
| 68 | + handler: 'nope', |
| 69 | + }, |
| 70 | + }, |
| 71 | + agent: { |
| 72 | + name: 'rc3_agent_probe', |
| 73 | + item: { |
| 74 | + name: 'rc3_agent_probe', |
| 75 | + label: 'A', |
| 76 | + role: 'assistant', |
| 77 | + instructions: 'be helpful', |
| 78 | + }, |
| 79 | + }, |
| 80 | +}; |
| 81 | + |
| 82 | +function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { |
| 83 | + const rows = new Map<string, Row>(); |
| 84 | + let nextId = 0; |
| 85 | + const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`)); |
| 86 | + const keyOf = (w: Record<string, unknown>) => |
| 87 | + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; |
| 88 | + const engine: any = { |
| 89 | + async findOne(_t: string, opts: { where: Record<string, unknown> }) { |
| 90 | + for (const row of rows.values()) { |
| 91 | + if (opts.where.type !== undefined && row.type !== opts.where.type) continue; |
| 92 | + if (opts.where.name !== undefined && row.name !== opts.where.name) continue; |
| 93 | + return row; |
| 94 | + } |
| 95 | + return null; |
| 96 | + }, |
| 97 | + async find() { return []; }, |
| 98 | + async insert(_t: string, data: Record<string, unknown>) { |
| 99 | + if (_t !== 'sys_metadata') return { id: 'side_effect_skip' }; |
| 100 | + nextId += 1; |
| 101 | + const row = { id: `r_${nextId}`, ...(data as any) } as Row; |
| 102 | + rows.set(keyOf(data), row); |
| 103 | + return { id: row.id }; |
| 104 | + }, |
| 105 | + async update() { return { id: null }; }, |
| 106 | + async delete() { return { deleted: 0 }; }, |
| 107 | + registry: { |
| 108 | + registerItem: () => {}, |
| 109 | + registerObject: () => {}, |
| 110 | + listItems: () => [], |
| 111 | + getItem: () => undefined, |
| 112 | + // `isArtifactBacked` prefers this lookup — a hit here means the |
| 113 | + // name is shipped by a code package (`_packageId` provenance). |
| 114 | + getArtifactItem: (type: string, name: string) => |
| 115 | + artifactKeys.has(`${type}|${name}`) ? { name, _packageId: 'showcase' } : undefined, |
| 116 | + }, |
| 117 | + }; |
| 118 | + return { engine, rows }; |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * The two kernel shapes this issue is about. |
| 123 | + * |
| 124 | + * • `single-kernel` — `environmentId` undefined. The CLI lightweight |
| 125 | + * assembler / showcase topology, where the gate never ran (the bug). |
| 126 | + * • `project-kernel` — `environmentId` set. Cloud per-env kernels, where |
| 127 | + * the gate already ran (the pin). |
| 128 | + */ |
| 129 | +const KERNELS: Array<{ label: string; environmentId?: string }> = [ |
| 130 | + { label: 'single-kernel (no environmentId)' }, |
| 131 | + { label: 'project-kernel (environmentId set)', environmentId: 'env_test' }, |
| 132 | +]; |
| 133 | + |
| 134 | +function makeProtocol(environmentId?: string, artifacts?: Array<{ type: string; name: string }>) { |
| 135 | + const { engine, rows } = makeStubEngine(artifacts); |
| 136 | + const protocol = new ObjectStackProtocolImplementation( |
| 137 | + engine, |
| 138 | + () => new Map(), |
| 139 | + environmentId, |
| 140 | + ) as any; |
| 141 | + return { protocol, rows }; |
| 142 | +} |
| 143 | + |
| 144 | +const metaRows = (rows: Map<string, Row>) => Array.from(rows.values()); |
| 145 | + |
| 146 | +describe('code-only metadata types are refused on every kernel (#5086)', () => { |
| 147 | + beforeEach(() => { |
| 148 | + delete process.env.OS_METADATA_WRITABLE; |
| 149 | + // Two memoised readers of the same env var — the protocol's gate and |
| 150 | + // the repository's `assertAllowed`. Both must be reset or the second |
| 151 | + // one answers from a stale parse. |
| 152 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 153 | + resetEnvWritableMetadataTypes(); |
| 154 | + }); |
| 155 | + afterEach(() => { |
| 156 | + delete process.env.OS_METADATA_WRITABLE; |
| 157 | + // Two memoised readers of the same env var — the protocol's gate and |
| 158 | + // the repository's `assertAllowed`. Both must be reset or the second |
| 159 | + // one answers from a stale parse. |
| 160 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 161 | + resetEnvWritableMetadataTypes(); |
| 162 | + }); |
| 163 | + |
| 164 | + // ── the flags are data: keep the suite honest about new ones ────────── |
| 165 | + |
| 166 | + it('covers every code-only type the registry declares', () => { |
| 167 | + // Today: job (#4509) and agent (ADR-0063 §2). When a third type is |
| 168 | + // flagged, this fails until it has a schema-valid probe above — |
| 169 | + // which is the whole cost of covering it. |
| 170 | + expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); |
| 171 | + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'job']); |
| 172 | + for (const type of CODE_ONLY_TYPES) { |
| 173 | + expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); |
| 174 | + } |
| 175 | + }); |
| 176 | + |
| 177 | + // ── the refusal, per flagged type, on both kernels ──────────────────── |
| 178 | + |
| 179 | + for (const { label, environmentId } of KERNELS) { |
| 180 | + describe(label, () => { |
| 181 | + for (const type of CODE_ONLY_TYPES) { |
| 182 | + const probe = PROBES[type]!; |
| 183 | + |
| 184 | + it(`refuses a schema-valid ${type} create with the catalogued code`, async () => { |
| 185 | + const { protocol, rows } = makeProtocol(environmentId); |
| 186 | + |
| 187 | + const err = await protocol |
| 188 | + .saveMetaItem({ type, name: probe.name, item: probe.item }) |
| 189 | + .then(() => null, (e: any) => e); |
| 190 | + |
| 191 | + expect(err, 'the write was accepted').not.toBeNull(); |
| 192 | + expect(err.code).toBe('NOT_CREATABLE'); |
| 193 | + expect(err.status).toBe(403); |
| 194 | + // Names the type and why it is code-only — a refusal the |
| 195 | + // author can act on without reading the source. |
| 196 | + expect(err.message).toContain(`'${type}'`); |
| 197 | + expect(err.message).toContain('allowRuntimeCreate=false'); |
| 198 | + expect(err.message).toContain('code-only'); |
| 199 | + |
| 200 | + // A gate that refuses AFTER persisting is a log line. |
| 201 | + expect(metaRows(rows)).toEqual([]); |
| 202 | + }); |
| 203 | + |
| 204 | + it(`refuses an org-scoped ${type} create too`, async () => { |
| 205 | + const { protocol, rows } = makeProtocol(environmentId); |
| 206 | + const err = await protocol |
| 207 | + .saveMetaItem({ |
| 208 | + type, |
| 209 | + name: probe.name, |
| 210 | + item: probe.item, |
| 211 | + organizationId: 'org_alpha', |
| 212 | + }) |
| 213 | + .then(() => null, (e: any) => e); |
| 214 | + |
| 215 | + expect(err?.code).toBe('NOT_CREATABLE'); |
| 216 | + expect(metaRows(rows)).toEqual([]); |
| 217 | + }); |
| 218 | + |
| 219 | + it(`refuses a ${type} DRAFT save (Studio's staging door)`, async () => { |
| 220 | + // #4509 is explicit that Studio must not offer "create" at |
| 221 | + // all — staging it as a draft first is the same create. |
| 222 | + const { protocol, rows } = makeProtocol(environmentId); |
| 223 | + const err = await protocol |
| 224 | + .saveMetaItem({ type, name: probe.name, item: probe.item, mode: 'draft' }) |
| 225 | + .then(() => null, (e: any) => e); |
| 226 | + |
| 227 | + expect(err?.code).toBe('NOT_CREATABLE'); |
| 228 | + expect(metaRows(rows)).toEqual([]); |
| 229 | + }); |
| 230 | + |
| 231 | + it(`refuses overlaying an artifact-backed ${type} with not_overridable`, async () => { |
| 232 | + // Same verdict, honest reason: the name IS shipped by a |
| 233 | + // code package, so "you may not overlay it" beats "you may |
| 234 | + // not create it". |
| 235 | + const { protocol, rows } = makeProtocol(environmentId, [ |
| 236 | + { type, name: probe.name }, |
| 237 | + ]); |
| 238 | + const err = await protocol |
| 239 | + .saveMetaItem({ type, name: probe.name, item: probe.item }) |
| 240 | + .then(() => null, (e: any) => e); |
| 241 | + |
| 242 | + expect(err?.code).toBe('NOT_OVERRIDABLE'); |
| 243 | + expect(err.status).toBe(403); |
| 244 | + expect(err.message).toContain(`${type}/${probe.name}`); |
| 245 | + expect(metaRows(rows)).toEqual([]); |
| 246 | + }); |
| 247 | + } |
| 248 | + }); |
| 249 | + } |
| 250 | + |
| 251 | + // ── no over-blocking: types WITHOUT the flags still save ────────────── |
| 252 | + |
| 253 | + describe('types the registry does not declare code-only still save', () => { |
| 254 | + it('view (allowOrgOverride + allowRuntimeCreate) saves on a single kernel', async () => { |
| 255 | + const { protocol, rows } = makeProtocol(undefined); |
| 256 | + const result = await protocol.saveMetaItem({ |
| 257 | + type: 'view', |
| 258 | + name: 'rc3_probe_view', |
| 259 | + item: { |
| 260 | + name: 'rc3_probe_view', |
| 261 | + label: 'Probe', |
| 262 | + object: 'task', |
| 263 | + columns: [{ field: 'name', label: 'Name' }], |
| 264 | + }, |
| 265 | + }); |
| 266 | + expect(result.success).toBe(true); |
| 267 | + expect(metaRows(rows).length).toBe(1); |
| 268 | + }); |
| 269 | + |
| 270 | + it('hook (allowOrgOverride:false, allowRuntimeCreate:true) still saves on both kernels', async () => { |
| 271 | + // The two-tier model (ADR-0005 PR-10d.7): no artifact at this name |
| 272 | + // means only `allowRuntimeCreate` is required. This is the case the |
| 273 | + // #5086 gate must NOT catch — it is the difference between "code-only" |
| 274 | + // and "packaged items are locked". |
| 275 | + for (const { environmentId } of KERNELS) { |
| 276 | + const { protocol, rows } = makeProtocol(environmentId); |
| 277 | + const result = await protocol.saveMetaItem({ |
| 278 | + type: 'hook', |
| 279 | + name: 'rc3_probe_hook', |
| 280 | + item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'] }, |
| 281 | + ...(environmentId ? { organizationId: 'org_alpha' } : {}), |
| 282 | + }); |
| 283 | + expect(result.success).toBe(true); |
| 284 | + expect(metaRows(rows).length).toBe(1); |
| 285 | + } |
| 286 | + }); |
| 287 | + |
| 288 | + it('a plugin-registered type with no static registry entry still saves', async () => { |
| 289 | + // `getMetaTypes()` synthesises those with allowRuntimeCreate:true; |
| 290 | + // the write gate must keep agreeing with what it advertises. |
| 291 | + const { protocol, rows } = makeProtocol(undefined); |
| 292 | + const result = await protocol.saveMetaItem({ |
| 293 | + type: 'theme', |
| 294 | + name: 'rc3_probe_theme', |
| 295 | + item: { name: 'rc3_probe_theme', label: 'Probe', tokens: {} }, |
| 296 | + }); |
| 297 | + expect(result.success).toBe(true); |
| 298 | + expect(metaRows(rows).length).toBe(1); |
| 299 | + }); |
| 300 | + }); |
| 301 | + |
| 302 | + // ── one door, not two: the operator escape hatch still opens ────────── |
| 303 | + |
| 304 | + describe('OS_METADATA_WRITABLE stays the single escape hatch', () => { |
| 305 | + for (const type of CODE_ONLY_TYPES) { |
| 306 | + it(`unlocks ${type} on a single kernel when the operator sets it`, async () => { |
| 307 | + const probe = PROBES[type]!; |
| 308 | + process.env.OS_METADATA_WRITABLE = type; |
| 309 | + ObjectStackProtocolImplementation.resetEnvWritableCache(); |
| 310 | + resetEnvWritableMetadataTypes(); |
| 311 | + |
| 312 | + const { protocol } = makeProtocol(undefined); |
| 313 | + // Only the gate is under test — a later stage may still object |
| 314 | + // to the body, but never with the code-only verdict. |
| 315 | + const err = await protocol |
| 316 | + .saveMetaItem({ type, name: probe.name, item: probe.item }) |
| 317 | + .then(() => null, (e: any) => e); |
| 318 | + |
| 319 | + expect(err?.code).not.toBe('NOT_CREATABLE'); |
| 320 | + expect(err?.code).not.toBe('NOT_OVERRIDABLE'); |
| 321 | + }); |
| 322 | + } |
| 323 | + }); |
| 324 | +}); |
0 commit comments