@bespokeagentics/microdots-host 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ import { Schema as S } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import { registry as embeddingRegistry } from '../../../apps/embedding-demo/src/registry.ts'
5
+ import { registry as pitchDeckRegistry } from '../../../apps/pitch-deck-website/host/src/registry.ts'
6
+ import { registry as platformRegistry } from '../../../apps/platform/src/registry.ts'
7
+ import {
8
+ type MicroDotRegistry,
9
+ TopologyRegistryEntry,
10
+ bundleAlreadyDefined,
11
+ findEntry,
12
+ isAllowedBundle,
13
+ refusedRegistryRows,
14
+ resolveRegistry,
15
+ } from './registry.ts'
16
+ import { HostTopology } from './wire.ts'
17
+
18
+ const compiled: MicroDotRegistry = [
19
+ {
20
+ tag: 'readout-view',
21
+ bundle: '/microdots/readout.js',
22
+ localPort: 3101,
23
+ deployedApiUrl: 'https://readout.example',
24
+ attributes: {},
25
+ },
26
+ {
27
+ tag: 'intake-form',
28
+ bundle: '/microdots/intake.js',
29
+ localPort: 3103,
30
+ attributes: {},
31
+ },
32
+ ]
33
+
34
+ describe('resolveRegistry', () => {
35
+ test('a topology with no registry leaves the compiled one exactly as it was', () => {
36
+ // Every topology written before this key existed. The compiled array is
37
+ // the floor and must stay byte-identical, or shipping this would change
38
+ // what every existing host mounts.
39
+ expect(resolveRegistry(compiled, undefined)).toBe(compiled)
40
+ expect(resolveRegistry(compiled, [])).toBe(compiled)
41
+ })
42
+
43
+ test('a fetched entry for an UNKNOWN tag becomes mountable — the whole gap', () => {
44
+ // Until this existed, a fetched placement naming a tag the build did not
45
+ // carry reached `findEntry`, got `undefined`, and the mount threw. Placing
46
+ // a MicroDot on a deployed host meant editing registry.ts and rebuilding.
47
+ const resolved = resolveRegistry(compiled, [
48
+ {
49
+ tag: 'dossier-detail',
50
+ bundle: 'https://cdn.microdot.cloud/dossier/1.2.0/dossier.js',
51
+ localPort: 3116,
52
+ deployedApiUrl: 'https://dossier.example',
53
+ },
54
+ ], ['https://cdn.microdot.cloud'])
55
+
56
+ expect(findEntry(compiled, 'dossier-detail')).toBeUndefined()
57
+ expect(findEntry(resolved, 'dossier-detail')).toEqual({
58
+ tag: 'dossier-detail',
59
+ bundle: 'https://cdn.microdot.cloud/dossier/1.2.0/dossier.js',
60
+ localPort: 3116,
61
+ deployedApiUrl: 'https://dossier.example',
62
+ attributes: {},
63
+ })
64
+ // The compiled entries survive alongside it.
65
+ expect(findEntry(resolved, 'readout-view')?.localPort).toBe(3101)
66
+ })
67
+
68
+ test('a fetched entry REPLACES a compiled one whole, never field by field', () => {
69
+ // Replacing wholesale is what stops a stale compiled `deployedApiUrl`
70
+ // surviving underneath a fresh bundle — a mixed row nobody could reason
71
+ // about, and one that would send RPCs to the previous deployment.
72
+ // (Whether a re-point takes effect in a LIVE session is a separate
73
+ // question with a different answer — see `bundleAlreadyDefined` below.)
74
+ const resolved = resolveRegistry(compiled, [
75
+ {
76
+ tag: 'readout-view',
77
+ bundle: 'https://cdn.microdot.cloud/readout/2.0.0/readout.js',
78
+ localPort: 3101,
79
+ },
80
+ ], ['https://cdn.microdot.cloud'])
81
+
82
+ expect(findEntry(resolved, 'readout-view')).toEqual({
83
+ tag: 'readout-view',
84
+ bundle: 'https://cdn.microdot.cloud/readout/2.0.0/readout.js',
85
+ localPort: 3101,
86
+ attributes: {},
87
+ })
88
+ expect(findEntry(resolved, 'readout-view')?.deployedApiUrl).toBeUndefined()
89
+ })
90
+
91
+ test('an absent deployedApiUrl stays ABSENT, not undefined', () => {
92
+ // `exactOptionalPropertyTypes`, and `apiUrlFor` fails closed on a
93
+ // non-local page by checking for `undefined` — a key present with an
94
+ // undefined value would serialise back into a topology as `null`.
95
+ const resolved = resolveRegistry([], [
96
+ { tag: 'workbench-board', bundle: '/microdots/workbench.js', localPort: 3106 },
97
+ ])
98
+ expect(Object.hasOwn(resolved[0] ?? {}, 'deployedApiUrl')).toBe(false)
99
+ })
100
+
101
+ test('the last fetched entry for a tag wins, so a document cannot half-apply', () => {
102
+ const resolved = resolveRegistry([], [
103
+ { tag: 'dupe', bundle: '/first.js', localPort: 1 },
104
+ { tag: 'dupe', bundle: '/second.js', localPort: 2 },
105
+ ])
106
+ expect(resolved).toHaveLength(1)
107
+ expect(findEntry(resolved, 'dupe')?.bundle).toBe('/second.js')
108
+ })
109
+ })
110
+
111
+ describe('the bundle-origin allowlist', () => {
112
+ const CDN = 'https://cdn.microdot.cloud'
113
+
114
+ test('a relative bundle is same-origin and always allowed', () => {
115
+ expect(isAllowedBundle('/microdots/readout.js', [])).toBe(true)
116
+ // Protocol-relative is NOT same-origin — `//evil.example/x.js` inherits the
117
+ // scheme and loads from another host, which is the case a naive
118
+ // startsWith('/') check waves straight through.
119
+ expect(isAllowedBundle('//evil.example/x.js', [])).toBe(false)
120
+ })
121
+
122
+ test('a cross-origin bundle needs its origin named', () => {
123
+ expect(isAllowedBundle(`${CDN}/readout/abc/readout.js`, [])).toBe(false)
124
+ expect(isAllowedBundle(`${CDN}/readout/abc/readout.js`, [CDN])).toBe(true)
125
+ // Exact origin, not a prefix: a lookalike host must not pass.
126
+ expect(
127
+ isAllowedBundle('https://cdn.microdot.cloud.evil.example/x.js', [CDN]),
128
+ ).toBe(false)
129
+ })
130
+
131
+ test('an unparseable specifier is refused, not waved through', () => {
132
+ expect(isAllowedBundle('not a url', [])).toBe(false)
133
+ expect(isAllowedBundle('javascript:alert(1)', [])).toBe(false)
134
+ })
135
+
136
+ test('resolveRegistry DROPS a disallowed row and reports it', () => {
137
+ // Silently dropping it would present later as "not registered", naming
138
+ // the wrong cause — the row exists and was refused.
139
+ const fetched = [
140
+ { tag: 'evil', bundle: 'https://evil.example/x.js', localPort: 1 },
141
+ { tag: 'fine', bundle: '/microdots/fine.js', localPort: 2 },
142
+ ]
143
+ const resolved = resolveRegistry(compiled, fetched, [])
144
+
145
+ expect(findEntry(resolved, 'evil')).toBeUndefined()
146
+ expect(findEntry(resolved, 'fine')?.bundle).toBe('/microdots/fine.js')
147
+ expect(refusedRegistryRows(fetched, []).map(row => row.tag)).toEqual(['evil'])
148
+ })
149
+
150
+ test('naming the origin admits the row', () => {
151
+ const fetched = [
152
+ { tag: 'dossier-detail', bundle: `${CDN}/dossier/abc/dossier.js`, localPort: 3116 },
153
+ ]
154
+ expect(refusedRegistryRows(fetched, [CDN])).toEqual([])
155
+ expect(findEntry(resolveRegistry(compiled, fetched, [CDN]), 'dossier-detail')?.bundle)
156
+ .toBe(`${CDN}/dossier/abc/dossier.js`)
157
+ })
158
+
159
+ test('a refused row never displaces the compiled entry it shadows', () => {
160
+ // The dangerous case: an attacker re-points an EXISTING tag. The compiled
161
+ // row must survive untouched rather than being replaced or dropped.
162
+ const resolved = resolveRegistry(
163
+ compiled,
164
+ [{ tag: 'readout-view', bundle: 'https://evil.example/x.js', localPort: 3101 }],
165
+ [],
166
+ )
167
+ expect(findEntry(resolved, 'readout-view')?.bundle).toBe('/microdots/readout.js')
168
+ })
169
+ })
170
+
171
+ describe('bundleAlreadyDefined', () => {
172
+ test('names a tag whose re-point this session cannot honour', () => {
173
+ // `loader.ts` short-circuits on `customElements.get(tag)` and a custom
174
+ // element cannot be un-defined, so a polling page keeps running the bundle
175
+ // it already loaded. Saying so beats implying the swap took effect.
176
+ const resolved = resolveRegistry(compiled, [
177
+ { tag: 'readout-view', bundle: '/v2/readout.js', localPort: 3101 },
178
+ ])
179
+ const loaded = new Map([['readout-view', '/microdots/readout.js']])
180
+
181
+ expect(bundleAlreadyDefined(resolved, loaded)).toEqual(['readout-view'])
182
+ // Unchanged bundle, and a tag never loaded, are both silent.
183
+ expect(
184
+ bundleAlreadyDefined(resolved, new Map([['readout-view', '/v2/readout.js']])),
185
+ ).toEqual([])
186
+ expect(bundleAlreadyDefined(resolved, new Map())).toEqual([])
187
+ })
188
+ })
189
+
190
+ describe('the premise this key rests on', () => {
191
+ test('every compiled registry entry still has EMPTY attributes', () => {
192
+ // THE LOAD-BEARING FACT, pinned so it cannot rot silently.
193
+ //
194
+ // The programme ruled that `HostTopology` carries no registry because
195
+ // "registries hold computed values (`JSON.stringify(...)` attribute
196
+ // payloads) and stay TypeScript". Phase 5 moved attribute data onto
197
+ // `placement.values` and emptied every entry, which is what made the
198
+ // registry records-only and therefore safe to send as data.
199
+ //
200
+ // If someone puts a computed attribute back into a registry, this fails —
201
+ // and the right response is to move it to `placement.values`, not to
202
+ // loosen this test.
203
+ for (const [name, entries] of [
204
+ ['pitch-deck', pitchDeckRegistry],
205
+ ['platform', platformRegistry],
206
+ ['embedding-demo', embeddingRegistry],
207
+ ] as const) {
208
+ for (const entry of entries) {
209
+ expect(
210
+ Object.keys(entry.attributes),
211
+ `${name}: <${entry.tag}> carries attributes`,
212
+ ).toEqual([])
213
+ }
214
+ }
215
+ })
216
+
217
+ test('every compiled registry entry decodes as a topology registry entry', () => {
218
+ // The wire shape and the runtime shape are separate types; this is what
219
+ // keeps them from drifting apart.
220
+ const decode = S.decodeUnknownSync(TopologyRegistryEntry)
221
+ for (const entries of [
222
+ pitchDeckRegistry,
223
+ platformRegistry,
224
+ embeddingRegistry,
225
+ ]) {
226
+ for (const entry of entries) {
227
+ expect(() => decode(entry)).not.toThrow()
228
+ }
229
+ }
230
+ })
231
+ })
232
+
233
+ describe('HostTopology', () => {
234
+ const base = {
235
+ host: { id: 'h', label: 'H', ownedInputs: [] },
236
+ routes: [
237
+ { path: '/a', label: 'A', title: 'A', sectionIds: ['s'], mounts: [] },
238
+ ],
239
+ wires: [],
240
+ watch: [],
241
+ }
242
+
243
+ test('decodes with no registry key — every topology written before today', () => {
244
+ const decoded = S.decodeUnknownSync(HostTopology)(base)
245
+ expect(decoded.registry).toBeUndefined()
246
+ })
247
+
248
+ test('decodes a carried registry and hands it to resolveRegistry', () => {
249
+ const decoded = S.decodeUnknownSync(HostTopology)({
250
+ ...base,
251
+ registry: [
252
+ { tag: 'late-arrival', bundle: '/late.js', localPort: 3199 },
253
+ ],
254
+ })
255
+ expect(findEntry(resolveRegistry([], decoded.registry), 'late-arrival')).toMatchObject(
256
+ { bundle: '/late.js' },
257
+ )
258
+ })
259
+
260
+ test('refuses an EMPTY bundle as loudly as a missing one', () => {
261
+ // The quiet case, found by audit. A missing key was already refused; `''`
262
+ // decoded cleanly and then `import('')` resolves against the page and
263
+ // imports the page — a blank mount with a clean console.
264
+ expect(() =>
265
+ S.decodeUnknownSync(HostTopology)({
266
+ ...base,
267
+ registry: [{ tag: 'broken', bundle: '', localPort: 3199 }],
268
+ }),
269
+ ).toThrow()
270
+ expect(() =>
271
+ S.decodeUnknownSync(HostTopology)({
272
+ ...base,
273
+ registry: [{ tag: '', bundle: '/x.js', localPort: 3199 }],
274
+ }),
275
+ ).toThrow()
276
+ })
277
+
278
+ test('refuses a registry entry missing its bundle', () => {
279
+ // A row with no bundle is a row that cannot mount; failing the decode is
280
+ // the loud form, and `acquireTopology` renders it as a visible error
281
+ // rather than a blank page.
282
+ expect(() =>
283
+ S.decodeUnknownSync(HostTopology)({
284
+ ...base,
285
+ registry: [{ tag: 'broken', localPort: 3199 }],
286
+ }),
287
+ ).toThrow()
288
+ })
289
+ })
package/src/registry.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Schema as S } from 'effect'
2
+
1
3
  /**
2
4
  * What a host knows about one registered ELEMENT.
3
5
  *
@@ -29,6 +31,162 @@ export type MicroDotEntry = {
29
31
  /** A host's whole registry: one entry per element it may mount. */
30
32
  export type MicroDotRegistry = ReadonlyArray<MicroDotEntry>
31
33
 
34
+ /**
35
+ * The same entry, as it travels IN A TOPOLOGY DOCUMENT.
36
+ *
37
+ * Why this exists. A host acquires its topology at runtime and polls it
38
+ * (`topologySource.ts`, `entry.ts`'s `pollTopology`), so it can already mount
39
+ * a placement whose route, section and slot its build never knew about. The
40
+ * one thing it could not learn at runtime was where to FETCH the bundle: that
41
+ * lived only in the compiled `registry` array, so the moment a fetched
42
+ * placement named an unregistered tag, `findEntry` returned `undefined` and
43
+ * the mount threw. Placing a MicroDot on a deployed host therefore meant
44
+ * editing `registry.ts` by hand and shipping a host build — the last
45
+ * build-time half of `gap-placement-is-source-code-not-data`.
46
+ *
47
+ * Why it is allowed to be data NOW. The programme ruled that `HostTopology`
48
+ * carries no registry because "registries hold computed values
49
+ * (`JSON.stringify(...)` attribute payloads) and stay TypeScript". That
50
+ * premise expired: Phase 5 moved attribute data onto `placement.values`, and
51
+ * every one of the 48 entries across all three hosts now reads
52
+ * `attributes: {}` — pinned by a test, so this reasoning cannot rot silently.
53
+ * What is left is a record of four scalars.
54
+ *
55
+ * Why it is a SEPARATE type from `MicroDotEntry`. On the wire `attributes` may
56
+ * be absent; at runtime it is always present, because every consumer spreads
57
+ * it. `resolveRegistry` is the one conversion, so no existing call site has to
58
+ * learn about `undefined`.
59
+ */
60
+ export const TopologyRegistryEntry = S.Struct({
61
+ tag: S.NonEmptyString,
62
+ /**
63
+ * Where to fetch the bundle. NON-EMPTY, because `''` decoded cleanly and
64
+ * then produced a silent mount failure — `import('')` resolves against the
65
+ * page and imports the page, which is this repo's signature blank-with-a-
66
+ * clean-console. A missing key is refused loudly; so is an empty one.
67
+ *
68
+ * DELIBERATELY UNCONSTRAINED BEYOND THAT, and it is a real trust boundary:
69
+ * this string reaches `import(/* @vite-ignore *\/ bundleUrl)` in
70
+ * `loader.ts`, so whoever may write a host's topology may execute code in
71
+ * every page polling it. That was already true of `WIRING_WRITE_KEY` for
72
+ * placements; carrying the registry widens it from "may rearrange the page"
73
+ * to "may run JS on it", and `microdots/pages` now holds that key too.
74
+ * Accepted for now — no shell ships a CSP either — and recorded on
75
+ * `gap-placement-is-source-code-not-data` rather than left implicit.
76
+ */
77
+ bundle: S.NonEmptyString,
78
+ localPort: S.Number,
79
+ deployedApiUrl: S.optionalKey(S.String),
80
+ attributes: S.optionalKey(S.Record(S.String, S.String)),
81
+ })
82
+ export type TopologyRegistryEntry = typeof TopologyRegistryEntry.Type
83
+
84
+ /**
85
+ * The registry a host actually mounts from: what it was built with, plus
86
+ * whatever its topology carried.
87
+ *
88
+ * FETCHED WINS, deliberately and per tag. The compiled array is the floor —
89
+ * it keeps a host working when its topology carries no registry at all, which
90
+ * is every topology written before this shipped — and a fetched entry for the
91
+ * same tag replaces it whole. Merging FIELDS instead would let a stale
92
+ * compiled `deployedApiUrl` survive underneath a fresh bundle, which is the
93
+ * mixed state nobody could reason about.
94
+ *
95
+ * WHAT THIS DOES AND DOES NOT BUY. A tag the host has never mounted is loaded
96
+ * from the fetched row, which is the whole point. **Re-pointing a tag that is
97
+ * ALREADY defined needs a reload**: `loader.ts` short-circuits on
98
+ * `customElements.get(tag)` and a custom element cannot be un-defined, so a
99
+ * live page keeps running the bundle it already has. `deployedApiUrl` is the
100
+ * same — `api-url` is deliberately not an observed attribute. Callers that
101
+ * poll should say so out loud rather than imply the swap took effect; see
102
+ * `bundleAlreadyDefined` below.
103
+ */
104
+ /**
105
+ * Whether a FETCHED row's bundle may be loaded by this host.
106
+ *
107
+ * THE ALLOWLIST BELONGS TO THE HOST, NOT THE TOPOLOGY. A `bundle` string
108
+ * reaches `import()` in `loader.ts`, so whoever writes a host's topology could
109
+ * otherwise run arbitrary JavaScript in every page polling it — and an
110
+ * allowlist carried IN the topology would be edited by that same writer, which
111
+ * bounds nothing. The host states where it is willing to load code from, the
112
+ * way it states its own topology URL: as an attribute it reads at boot
113
+ * (`configuration-arrives-as-attributes`).
114
+ *
115
+ * FAIL CLOSED. An empty allowlist means same-origin only, which is what every
116
+ * host ships today (all 48 compiled entries are `/microdots/<name>.js`), so a
117
+ * host that says nothing is not weakened by this key existing. A cross-origin
118
+ * bundle — the CDN — is loaded only once someone names that origin.
119
+ *
120
+ * A relative specifier is same-origin by construction. Anything else must
121
+ * parse AND match an allowed origin exactly; a parse failure is a refusal, not
122
+ * a pass, because an unparseable specifier is not a same-origin path either.
123
+ */
124
+ export const isAllowedBundle = (
125
+ bundle: string,
126
+ allowedOrigins: ReadonlyArray<string>,
127
+ ): boolean => {
128
+ if (bundle.startsWith('/') && !bundle.startsWith('//')) return true
129
+ try {
130
+ return allowedOrigins.includes(new URL(bundle).origin)
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ /** Fetched rows this host refuses to load code from, for reporting. Silently
137
+ * dropping them would present as "not registered", naming the wrong cause. */
138
+ export const refusedRegistryRows = (
139
+ fetched: ReadonlyArray<TopologyRegistryEntry> | undefined,
140
+ allowedOrigins: ReadonlyArray<string>,
141
+ ): ReadonlyArray<TopologyRegistryEntry> =>
142
+ (fetched ?? []).filter(entry => !isAllowedBundle(entry.bundle, allowedOrigins))
143
+
144
+ /**
145
+ * Tags whose resolved bundle differs from the one already defined on this
146
+ * page — a re-point that this session will NOT honour, because the element is
147
+ * defined and cannot be redefined.
148
+ *
149
+ * Reported rather than fixed: silently serving the old bundle while the
150
+ * topology says otherwise is the disagreement that costs an afternoon.
151
+ */
152
+ export const bundleAlreadyDefined = (
153
+ resolved: MicroDotRegistry,
154
+ loadedBundles: ReadonlyMap<string, string>,
155
+ ): ReadonlyArray<string> =>
156
+ resolved
157
+ .filter(entry => {
158
+ const already = loadedBundles.get(entry.tag)
159
+ return already !== undefined && already !== entry.bundle
160
+ })
161
+ .map(entry => entry.tag)
162
+
163
+ export const resolveRegistry = (
164
+ compiled: MicroDotRegistry,
165
+ fetched: ReadonlyArray<TopologyRegistryEntry> | undefined,
166
+ /** Origins this host will load code from. Empty = same-origin only. */
167
+ allowedOrigins: ReadonlyArray<string> = [],
168
+ ): MicroDotRegistry => {
169
+ const admitted = (fetched ?? []).filter(entry =>
170
+ isAllowedBundle(entry.bundle, allowedOrigins),
171
+ )
172
+ if (admitted.length === 0) return compiled
173
+ const byTag = new Map<string, MicroDotEntry>(
174
+ compiled.map(entry => [entry.tag, entry]),
175
+ )
176
+ for (const entry of admitted) {
177
+ byTag.set(entry.tag, {
178
+ tag: entry.tag,
179
+ bundle: entry.bundle,
180
+ localPort: entry.localPort,
181
+ ...(entry.deployedApiUrl === undefined
182
+ ? {}
183
+ : { deployedApiUrl: entry.deployedApiUrl }),
184
+ attributes: entry.attributes ?? {},
185
+ })
186
+ }
187
+ return [...byTag.values()]
188
+ }
189
+
32
190
  const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1']
33
191
 
34
192
  /**
@@ -0,0 +1,126 @@
1
+ import type { MicroDotManifest } from '@bespokeagentics/microdots-element'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import { isAllowedBundle } from './registry.ts'
5
+ import {
6
+ DEFAULT_CDN_BASE_URL,
7
+ missingRegistryRows,
8
+ registryEntryFromManifest,
9
+ } from './registryFromManifest.ts'
10
+
11
+ const manifest = (over: Partial<MicroDotManifest> = {}): MicroDotManifest => ({
12
+ name: 'dossier',
13
+ build: 'c7269628',
14
+ bundle: {
15
+ file: 'dossier.js',
16
+ hashedFile: 'dossier.c7269628.js',
17
+ bytes: 100,
18
+ gzipBytes: 40,
19
+ },
20
+ service: { localPort: 3116, deployedApiUrl: 'https://dossier.example' },
21
+ tags: [{ tag: 'dossier-detail', attributes: [], events: [] }],
22
+ ...over,
23
+ })
24
+
25
+ describe('registryEntryFromManifest', () => {
26
+ test('composes the IMMUTABLE build-id URL, not the moving semver one', () => {
27
+ // `cdnPublish` writes two keys for one set of bytes: `{dot}/{version}/…`
28
+ // for humans and `{dot}/{buildId}/…` as the machine alias. `manifest.build`
29
+ // is the content hash of exactly the bundle this manifest describes, so a
30
+ // row built from it can never drift onto different bytes the way a moving
31
+ // alias can.
32
+ expect(registryEntryFromManifest({ manifest: manifest(), tag: 'dossier-detail' })).toEqual({
33
+ tag: 'dossier-detail',
34
+ bundle: `${DEFAULT_CDN_BASE_URL}/dossier/c7269628/dossier.js`,
35
+ localPort: 3116,
36
+ deployedApiUrl: 'https://dossier.example',
37
+ })
38
+ })
39
+
40
+ test('the filename does not repeat the hash', () => {
41
+ // The hash is already in the path. Getting this wrong puts it in every
42
+ // embed snippet twice, and it is invisible until somebody copies one.
43
+ const entry = registryEntryFromManifest({
44
+ manifest: manifest(),
45
+ tag: 'dossier-detail',
46
+ })
47
+ expect(entry.bundle).not.toContain('dossier.c7269628.js')
48
+ expect(entry.bundle.endsWith('/dossier.js')).toBe(true)
49
+ })
50
+
51
+ test('a composed row satisfies the host allowlist once the CDN is named', () => {
52
+ // The two halves have to agree or the join is useless: a row this composes
53
+ // must be one a host will actually load.
54
+ const entry = registryEntryFromManifest({
55
+ manifest: manifest(),
56
+ tag: 'dossier-detail',
57
+ })
58
+ expect(isAllowedBundle(entry.bundle, [])).toBe(false)
59
+ expect(isAllowedBundle(entry.bundle, [DEFAULT_CDN_BASE_URL])).toBe(true)
60
+ })
61
+
62
+ test('an absent deployedApiUrl stays ABSENT', () => {
63
+ const entry = registryEntryFromManifest({
64
+ manifest: manifest({ service: { localPort: 3116 } }),
65
+ tag: 'dossier-detail',
66
+ })
67
+ expect(Object.hasOwn(entry, 'deployedApiUrl')).toBe(false)
68
+ })
69
+
70
+ test('a trailing slash on the base never doubles up', () => {
71
+ expect(
72
+ registryEntryFromManifest({
73
+ manifest: manifest(),
74
+ tag: 'dossier-detail',
75
+ cdnBaseUrl: 'https://cdn.example/',
76
+ }).bundle,
77
+ ).toBe('https://cdn.example/dossier/c7269628/dossier.js')
78
+ })
79
+ })
80
+
81
+ describe('missingRegistryRows', () => {
82
+ const byTag = new Map([['dossier-detail', manifest()]])
83
+
84
+ test('composes only for tags the topology has no row for', () => {
85
+ const result = missingRegistryRows({
86
+ placedTags: ['dossier-detail'],
87
+ existing: [{ tag: 'dossier-detail', bundle: '/pinned.js', localPort: 3116 }],
88
+ manifestsByTag: byTag,
89
+ })
90
+ // An existing row is never overwritten — it may be pinned deliberately,
91
+ // and re-pointing a live host's bundle as a side effect of an unrelated
92
+ // placement is the worst kind of surprise.
93
+ expect(result.rows).toEqual([])
94
+ expect(result.unknown).toEqual([])
95
+ })
96
+
97
+ test('composes for a tag with no row', () => {
98
+ const result = missingRegistryRows({
99
+ placedTags: ['dossier-detail'],
100
+ existing: undefined,
101
+ manifestsByTag: byTag,
102
+ })
103
+ expect(result.rows.map(row => row.tag)).toEqual(['dossier-detail'])
104
+ })
105
+
106
+ test('a tag the catalog cannot describe is REPORTED, never skipped', () => {
107
+ // A placement whose bundle nothing can name will not mount. Dropping it
108
+ // silently is how that becomes a blank slot with a clean console.
109
+ const result = missingRegistryRows({
110
+ placedTags: ['dossier-detail', 'ghost-tag'],
111
+ existing: undefined,
112
+ manifestsByTag: byTag,
113
+ })
114
+ expect(result.rows.map(row => row.tag)).toEqual(['dossier-detail'])
115
+ expect(result.unknown).toEqual(['ghost-tag'])
116
+ })
117
+
118
+ test('a tag placed twice composes one row', () => {
119
+ const result = missingRegistryRows({
120
+ placedTags: ['dossier-detail', 'dossier-detail'],
121
+ existing: undefined,
122
+ manifestsByTag: byTag,
123
+ })
124
+ expect(result.rows).toHaveLength(1)
125
+ })
126
+ })
@@ -0,0 +1,92 @@
1
+ import type { MicroDotManifest } from '@bespokeagentics/microdots-element'
2
+
3
+ import type { TopologyRegistryEntry } from './registry.ts'
4
+
5
+ /**
6
+ * The documented CDN root. Publishing writes here
7
+ * (`microdots/deploy/service/cdnPublish.ts`), and nothing else in the repo
8
+ * reads it — which is exactly the join this module closes.
9
+ */
10
+ export const DEFAULT_CDN_BASE_URL = 'https://cdn.microdot.cloud'
11
+
12
+ /**
13
+ * Compose a topology registry row from a catalog manifest.
14
+ *
15
+ * THE JOIN THAT WAS MISSING. Everything a host needs to load a dot has been
16
+ * stored per version in `microdots/catalog` all along — the tag, the bundle
17
+ * filename, the local port, the deployed API origin — and the bytes have been
18
+ * published to the CDN under a derivable key. Nothing composed the two, so
19
+ * placing a dot on a deployed host still meant a human writing the row by
20
+ * hand. This is that composition, and it is deliberately pure: no fetch, no
21
+ * catalog client, so both services and any test can call it.
22
+ *
23
+ * THE BUILD ID, NOT THE VERSION. `cdnPublish` writes two keys for one set of
24
+ * bytes — `{dot}/{version}/{file}` for humans and `{dot}/{buildId}/{file}` as
25
+ * the immutable machine alias. This composes the machine alias, because
26
+ * `manifest.build` is the 8-hex content hash of exactly the bundle this
27
+ * manifest describes: a row built from it can never drift onto different bytes
28
+ * the way a moving semver alias can. The filename does NOT repeat the hash —
29
+ * the hash is already in the path.
30
+ *
31
+ * WHAT THIS DOES NOT KNOW. Whether those bytes were ever published. That lives
32
+ * in the deploy service's `cdn_bundle_versions` ledger, which the catalog
33
+ * cannot see. A row for an unpublished build produces a 404 at mount — loud,
34
+ * in the console, naming the URL — rather than a silent nothing, which is the
35
+ * honest failure for a fact this side genuinely does not hold.
36
+ */
37
+ export const registryEntryFromManifest = (input: {
38
+ readonly manifest: MicroDotManifest
39
+ readonly tag: string
40
+ /** Defaults to the documented CDN root. */
41
+ readonly cdnBaseUrl?: string
42
+ }): TopologyRegistryEntry => {
43
+ const base = input.cdnBaseUrl ?? DEFAULT_CDN_BASE_URL
44
+ const deployed = input.manifest.service.deployedApiUrl
45
+ return {
46
+ tag: input.tag,
47
+ bundle: `${base.replace(/\/+$/, '')}/${input.manifest.name}/${input.manifest.build}/${input.manifest.bundle.file}`,
48
+ localPort: input.manifest.service.localPort,
49
+ // ABSENT, not undefined — `apiUrlFor` fails closed on `undefined` for a
50
+ // non-local page, and a key present-but-undefined serialises as `null`.
51
+ ...(deployed === undefined || deployed === '' ? {} : { deployedApiUrl: deployed }),
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The rows a topology is MISSING for the tags it places.
57
+ *
58
+ * A writer calls this after mutating placements: any tag now placed that the
59
+ * topology carries no registry row for, and that the catalog can describe,
60
+ * gets a row composed for it. Tags the catalog does not know are returned as
61
+ * `unknown` rather than skipped, because a placement whose bundle nothing can
62
+ * name will not mount and the writer must be able to say so.
63
+ */
64
+ export const missingRegistryRows = (input: {
65
+ readonly placedTags: ReadonlyArray<string>
66
+ readonly existing: ReadonlyArray<TopologyRegistryEntry> | undefined
67
+ readonly manifestsByTag: ReadonlyMap<string, MicroDotManifest>
68
+ readonly cdnBaseUrl?: string
69
+ }): {
70
+ readonly rows: ReadonlyArray<TopologyRegistryEntry>
71
+ readonly unknown: ReadonlyArray<string>
72
+ } => {
73
+ const known = new Set((input.existing ?? []).map(entry => entry.tag))
74
+ const rows: Array<TopologyRegistryEntry> = []
75
+ const unknown: Array<string> = []
76
+ for (const tag of new Set(input.placedTags)) {
77
+ if (known.has(tag)) continue
78
+ const manifest = input.manifestsByTag.get(tag)
79
+ if (manifest === undefined) {
80
+ unknown.push(tag)
81
+ continue
82
+ }
83
+ rows.push(
84
+ registryEntryFromManifest({
85
+ manifest,
86
+ tag,
87
+ ...(input.cdnBaseUrl === undefined ? {} : { cdnBaseUrl: input.cdnBaseUrl }),
88
+ }),
89
+ )
90
+ }
91
+ return { rows, unknown }
92
+ }