|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Pin: **on a HOST config, `os dev` has exactly ONE registrar for the four |
| 5 | + * ADR-0057 security collections — the artifact door — and only when that door |
| 6 | + * actually composes.** |
| 7 | + * |
| 8 | + * ## The two writers this refuses, as measured |
| 9 | + * |
| 10 | + * `shouldBootWithLibrary()` returns `false` for a host config (one whose |
| 11 | + * `plugins[]` holds instantiated plugins), so `createStandaloneStack` — the |
| 12 | + * composition that already declares `securityMetadataRegistrar: |
| 13 | + * 'artifact-door'` — never runs. Two other writers then reach the metadata |
| 14 | + * service over the SAME stack: |
| 15 | + * |
| 16 | + * 1. `new AppPlugin(config)` wrapping the config MODULE. Under the default |
| 17 | + * `'app-plugin'` registrar its ADR-0057 block registers `positions` / |
| 18 | + * `permissions` / `capabilities` / `sharingRules`. |
| 19 | + * 2. the dev-only HMR `MetadataPlugin`, over `dist/objectstack.json` — the |
| 20 | + * COMPILED TWIN of that same module, which the `os dev` supervisor |
| 21 | + * produced moments earlier. It strict-parses, forward-converts and |
| 22 | + * ADR-0010-stamps, and reaches all four collections too. |
| 23 | + * |
| 24 | + * Both were measured on a real `os dev` boot of a host config (`examples/ |
| 25 | + * app-showcase`, whose `plugins[]` holds four connector plugins and whose |
| 26 | + * stack declares all four collections): |
| 27 | + * |
| 28 | + * → Compiling objectstack.config.ts → dist/objectstack.json... |
| 29 | + * INFO [MetadataPlugin] Loading metadata from local artifact file |
| 30 | + * {"path":".../examples/app-showcase/dist/objectstack.json"} |
| 31 | + * INFO [MetadataPlugin] Artifact metadata loaded {...,"totalRegistered":246} |
| 32 | + * INFO Registered stack-declared security metadata |
| 33 | + * {"appId":"com.example.showcase","count":23} |
| 34 | + * |
| 35 | + * — the door at `21.215`, the wrap at `21.835`. `registerInMemory` is a |
| 36 | + * `Map.set`, so **the wrap's copy wins the cold boot**; and because the door |
| 37 | + * re-ingests on every artifact reload while the module copy never refreshes, |
| 38 | + * **the winner changes mid-run**. Measured on an instrumented host config |
| 39 | + * whose compiled twin carried a distinguishing label: the cold-boot registry |
| 40 | + * held the module's labels and no `_packageVersion`, and 37s later — after one |
| 41 | + * artifact reload, no restart — the same four items held the artifact's labels |
| 42 | + * and `_packageVersion: '1.0.0'`. |
| 43 | + * |
| 44 | + * ⚠️ **The two copies differ by PROVENANCE and FRESHNESS, not by parsing.** On |
| 45 | + * a config boot `defineStack()` is strict by default and runs the same |
| 46 | + * `ObjectStackDefinitionSchema` parse the door runs (`packages/spec/src/ |
| 47 | + * stack.zod.ts`), so the wrap's copy already carries the schema defaults and |
| 48 | + * the ADR-0122 input transforms. What it lacks is the ADR-0010 stamp |
| 49 | + * (`_packageVersion` on all four kinds, `_packageId` / `_provenance` on |
| 50 | + * `position`), and — the half that bites — it never refreshes, while the |
| 51 | + * door's copy reloads on every recompile. A consumer therefore reads one of |
| 52 | + * two copies of an authorization input depending on when it asked. That is |
| 53 | + * why this is `security`-labelled, and why the fix is the ownership one |
| 54 | + * rather than "make the two shapes match". |
| 55 | + * |
| 56 | + * ## What is pinned, and why the guard is on SOURCE |
| 57 | + * |
| 58 | + * The decision lives inside `Serve.run()`, ~900 lines into a method that boots |
| 59 | + * a kernel, a database and an HTTP server; there is no seam to call. The |
| 60 | + * repo's answer for exactly this shape is a source pin |
| 61 | + * (`child-env-source-loader.pin.test.ts`, `serve-settings-ordering.pin.test.ts`) |
| 62 | + * — assert the STRUCTURE that makes the composition correct, and pair it with |
| 63 | + * a behavioural assertion that the words the structure uses still mean |
| 64 | + * something. Both halves are here: without the second, renaming the option on |
| 65 | + * `AppPlugin` would leave this file green over a dead string. |
| 66 | + * |
| 67 | + * The structural invariant has two directions and both matter: |
| 68 | + * |
| 69 | + * • the wrap declares `'artifact-door'` when the door composes, and |
| 70 | + * • it declares NOTHING (so `AppPlugin` defaults to `'app-plugin'`) when the |
| 71 | + * door does not — because a host config with no compiled artifact, and |
| 72 | + * every non-dev host boot, would otherwise lose its ONLY registrar. |
| 73 | + * Measured: `os serve` over the same host config has a metadata service |
| 74 | + * and the wrap is its only writer (`Registered stack-declared security |
| 75 | + * metadata {"appId":"com.probe.hostcfg","count":4}`, no door in the boot). |
| 76 | + * |
| 77 | + * ⛔ The second direction has a trap that RESOLVING hides: under `os dev` the |
| 78 | + * supervisor always writes its channel, and `resolveDefaultArtifactPath` |
| 79 | + * returns an explicitly named path VERBATIM with no existence check |
| 80 | + * (`packages/runtime/src/default-host.ts`) — only the conventional |
| 81 | + * `<cwd>/dist/objectstack.json` fallback is stat'ed. Compose the door over a |
| 82 | + * path that is not on disk (`os dev --artifact ./typo.json`, a stale |
| 83 | + * `OS_ARTIFACT_PATH`) and it starts EMPTY and SILENT: its local-file load is |
| 84 | + * `{ optional: true }` and answers ENOENT with an `info` line, registering |
| 85 | + * nothing (`packages/metadata/src/plugin.ts`). The wrap would have deferred to |
| 86 | + * a writer that never writes, and all four collections would end the boot with |
| 87 | + * ZERO registrars — green and quiet, and strictly worse than the divergence |
| 88 | + * this composition removes. So the gate is EXISTENCE, not resolution. |
| 89 | + * |
| 90 | + * That is why the door instance is constructed next to the wrap and only |
| 91 | + * `kernel.use`d at its ordering-constrained site: ONE value decides both |
| 92 | + * facts. Two independent expressions would be free to drift, and the drift is |
| 93 | + * invisible — a boot with no registrar looks exactly like a boot with one. |
| 94 | + */ |
| 95 | + |
| 96 | +import { describe, it, expect } from 'vitest'; |
| 97 | +import fs from 'node:fs'; |
| 98 | +import path from 'node:path'; |
| 99 | +import { fileURLToPath } from 'node:url'; |
| 100 | +import { AppPlugin } from '@objectstack/runtime'; |
| 101 | + |
| 102 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 103 | +const SERVE_TS = path.join(HERE, 'serve.ts'); |
| 104 | +const source = fs.readFileSync(SERVE_TS, 'utf8'); |
| 105 | + |
| 106 | +/** Occurrences of `needle` in the source, as a plain substring count. */ |
| 107 | +function count(needle: string): number { |
| 108 | + return source.split(needle).length - 1; |
| 109 | +} |
| 110 | + |
| 111 | +describe('#14397 — `os dev` over a HOST config composes ONE registrar for stack-declared security metadata', () => { |
| 112 | + it('the dev artifact door is decided ONCE, before the AppPlugin wrap', () => { |
| 113 | + expect(source, 'the door decision must be a single named value').toContain( |
| 114 | + 'let devArtifactDoor: any;', |
| 115 | + ); |
| 116 | + // The gate is the same one the composition has always used. |
| 117 | + expect(source).toContain( |
| 118 | + "if (isDev && flags.server && !plugins.some((p: any) => p?.constructor?.name === 'MetadataPlugin')) {", |
| 119 | + ); |
| 120 | + // Exactly one MetadataPlugin is constructed in this file, and it is |
| 121 | + // that value — a second construction site is a second decision. |
| 122 | + expect(count('new MetadataPlugin(')).toBe(1); |
| 123 | + expect(source).toContain('devArtifactDoor = new MetadataPlugin({'); |
| 124 | + // The path still comes from the supervisor's own channel, never from |
| 125 | + // `<cwd>/dist/objectstack.json` by accident. |
| 126 | + expect(source).toContain( |
| 127 | + 'const hmrArtifactPath = resolveDefaultArtifactPath(readInternalArtifactPath());', |
| 128 | + ); |
| 129 | + }); |
| 130 | + |
| 131 | + it('the door is composed only when its artifact EXISTS, not merely resolves', () => { |
| 132 | + // The regression this closes: `resolveDefaultArtifactPath` returns a |
| 133 | + // NAMED path verbatim without stat'ing it, and the door tolerates |
| 134 | + // ENOENT by starting empty — so gating on resolution alone hands the |
| 135 | + // four collections to a writer that never writes. |
| 136 | + expect(source, 'the door must be gated on the artifact being on disk').toContain( |
| 137 | + 'if (!fs.existsSync(hmrArtifactPath)) {', |
| 138 | + ); |
| 139 | + // The gate must sit BEFORE the construction, not after it: a door |
| 140 | + // constructed and then discarded would still have set the wrap's |
| 141 | + // option under any future refactor that reads "was one built?". |
| 142 | + const gateAt = source.indexOf('if (!fs.existsSync(hmrArtifactPath)) {'); |
| 143 | + const buildAt = source.indexOf('devArtifactDoor = new MetadataPlugin({'); |
| 144 | + expect(gateAt).toBeGreaterThan(-1); |
| 145 | + expect(buildAt).toBeGreaterThan(-1); |
| 146 | + expect(gateAt).toBeLessThan(buildAt); |
| 147 | + // A missing artifact is not a silent downgrade: the warning names the |
| 148 | + // path, and says the wrap keeps the collections. |
| 149 | + expect(source).toContain( |
| 150 | + '` ⚠ Dev metadata-HMR endpoint not enabled: no compiled artifact at ${hmrArtifactPath}`', |
| 151 | + ); |
| 152 | + expect(source).toContain('Stack-declared security metadata stays with the app wrap'); |
| 153 | + }); |
| 154 | + |
| 155 | + it('the host-config wrap declares `artifact-door` exactly when that door exists', () => { |
| 156 | + expect(source).toContain( |
| 157 | + "devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},", |
| 158 | + ); |
| 159 | + // ⛔ The unconditional shape is the defect: it is what put a SECOND |
| 160 | + // writer on every `os dev` boot of a host config — a copy that lacks |
| 161 | + // the ADR-0010 provenance stamp and never refreshes, alongside the |
| 162 | + // door's, which reloads on every recompile. |
| 163 | + expect( |
| 164 | + source, |
| 165 | + 'the wrap must never be constructed without the registrar decision', |
| 166 | + ).not.toContain('new AppPlugin(config)]'); |
| 167 | + }); |
| 168 | + |
| 169 | + it('the door is `kernel.use`d from that same value, at its ordering-constrained site', () => { |
| 170 | + expect(count('await kernel.use(devArtifactDoor);')).toBe(1); |
| 171 | + expect(source).toContain('if (devArtifactDoor) {\n try {\n await kernel.use(devArtifactDoor);'); |
| 172 | + // The `kernel.use` still sits AFTER the HonoServer composition — |
| 173 | + // MetadataPlugin.start() mounts its route on the `http-server` |
| 174 | + // service. Positions, not line numbers: the file moves. |
| 175 | + expect(source.indexOf('await kernel.use(serverPlugin);')) |
| 176 | + .toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);')); |
| 177 | + // ...and the wrap is constructed BEFORE it, which is the whole reason |
| 178 | + // the decision had to be hoisted. |
| 179 | + expect(source.indexOf("devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {},")) |
| 180 | + .toBeLessThan(source.indexOf('await kernel.use(devArtifactDoor);')); |
| 181 | + }); |
| 182 | + |
| 183 | + it('the `kernel.use` catch does not claim a consequence it cannot have', () => { |
| 184 | + // An earlier draft warned there that the four collections had gone |
| 185 | + // unregistered. `Kernel.use` only validates the plugin and registers |
| 186 | + // it by NAME (packages/core/src/kernel.ts) — `init`/`start` run later, |
| 187 | + // in `bootstrap` — so for a MetadataPlugin already constructed above, |
| 188 | + // on a still-`idle` kernel, that catch does not fire. The real |
| 189 | + // lost-door case is the missing artifact, and it warns where the path |
| 190 | + // can be named; see the existence test above. |
| 191 | + const useAt = source.indexOf('await kernel.use(devArtifactDoor);'); |
| 192 | + expect(useAt).toBeGreaterThan(-1); |
| 193 | + const catchWindow = source.slice(useAt, useAt + 600); |
| 194 | + expect(catchWindow).not.toContain('The app wrap deferred'); |
| 195 | + expect(catchWindow).not.toContain('NOT registered on this boot'); |
| 196 | + }); |
| 197 | + |
| 198 | + it('behavioural: the option the source passes is the one AppPlugin reads', () => { |
| 199 | + const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } }; |
| 200 | + // The exact two literals the composition above can pass. |
| 201 | + expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin'); |
| 202 | + expect( |
| 203 | + new AppPlugin(bundle, undefined, { securityMetadataRegistrar: 'artifact-door' }) |
| 204 | + .securityMetadataRegistrar, |
| 205 | + ).toBe('artifact-door'); |
| 206 | + }); |
| 207 | +}); |
0 commit comments