@bespokeagentics/microdots-host 0.1.3 → 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.
- package/package.json +6 -6
- package/src/index.ts +11 -0
- package/src/registry.test.ts +289 -0
- package/src/registry.ts +158 -0
- package/src/registryFromManifest.test.ts +126 -0
- package/src/registryFromManifest.ts +92 -0
- package/src/wire.ts +12 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bespokeagentics/microdots-host",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MicroDots host shell
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MicroDots host shell \u2014 registry, loader, slot manifest and placement checks for mounting MicroDot custom elements. Raw TypeScript source; consume with Bun or Vite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"directory": "packages/microdots-host"
|
|
11
11
|
},
|
|
12
12
|
"publishConfig": {
|
|
13
|
-
"access": "
|
|
13
|
+
"access": "public"
|
|
14
14
|
},
|
|
15
15
|
"main": "./src/index.ts",
|
|
16
16
|
"exports": {
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
"./composition": "./src/compositionSpec.ts"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@bespokeagentics/microdots-authoring": "0.
|
|
22
|
-
"@bespokeagentics/microdots-element": "0.
|
|
21
|
+
"@bespokeagentics/microdots-authoring": "0.2.0",
|
|
22
|
+
"@bespokeagentics/microdots-element": "0.2.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"effect": "4.0.0-rc.
|
|
25
|
+
"effect": "4.0.0-rc.112"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
export {
|
|
23
23
|
type MicroDotEntry,
|
|
24
24
|
type MicroDotRegistry,
|
|
25
|
+
TopologyRegistryEntry,
|
|
26
|
+
bundleAlreadyDefined,
|
|
27
|
+
isAllowedBundle,
|
|
28
|
+
refusedRegistryRows,
|
|
29
|
+
resolveRegistry,
|
|
25
30
|
apiUrlFor,
|
|
26
31
|
findEntry,
|
|
27
32
|
} from './registry.ts'
|
|
@@ -322,3 +327,9 @@ export {
|
|
|
322
327
|
postWireTraces,
|
|
323
328
|
startTracePublisher,
|
|
324
329
|
} from './tracePublisher.ts'
|
|
330
|
+
|
|
331
|
+
export {
|
|
332
|
+
DEFAULT_CDN_BASE_URL,
|
|
333
|
+
missingRegistryRows,
|
|
334
|
+
registryEntryFromManifest,
|
|
335
|
+
} from './registryFromManifest.ts'
|
|
@@ -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
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ManifestTag,
|
|
7
7
|
} from '@bespokeagentics/microdots-element'
|
|
8
8
|
|
|
9
|
+
import { TopologyRegistryEntry } from './registry.ts'
|
|
9
10
|
import { type RouteTable, makeRouteTable } from './routes.ts'
|
|
10
11
|
// Runtime import of the matcher only — `rules.ts` imports this module
|
|
11
12
|
// type-only, so the emitted module graph stays acyclic.
|
|
@@ -302,6 +303,16 @@ export const layoutModeOf = (topology: {
|
|
|
302
303
|
* - `rules` — route rules resolved by `./rules.ts`. Absent means no rules,
|
|
303
304
|
* and `resolvePlacements` degrades to the identity: exactly the route's own
|
|
304
305
|
* mounts, byte-identical to Phase 4 behaviour.
|
|
306
|
+
*
|
|
307
|
+
* 2026-09-03 adds a third, on the same terms:
|
|
308
|
+
*
|
|
309
|
+
* - `registry` — where to FETCH the bundle for a tag (`./registry.ts`).
|
|
310
|
+
* Absent means the host mounts from its compiled registry alone, exactly as
|
|
311
|
+
* before. Present, it wins per tag, which is what lets a placement for a dot
|
|
312
|
+
* the host was never built with actually mount: until this key existed, a
|
|
313
|
+
* fetched placement naming an unregistered tag reached `findEntry`, got
|
|
314
|
+
* `undefined`, and threw — so placing a MicroDot on a DEPLOYED host meant a
|
|
315
|
+
* human editing `registry.ts` and shipping a host build.
|
|
305
316
|
*/
|
|
306
317
|
export const HostTopology = S.Struct({
|
|
307
318
|
host: TopologyHost,
|
|
@@ -309,6 +320,7 @@ export const HostTopology = S.Struct({
|
|
|
309
320
|
overview: S.optionalKey(TopologyOverview),
|
|
310
321
|
slotManifest: S.optionalKey(HostSlotManifest),
|
|
311
322
|
rules: S.optionalKey(S.Array(RouteRule)),
|
|
323
|
+
registry: S.optionalKey(S.Array(TopologyRegistryEntry)),
|
|
312
324
|
wires: S.Array(Wire),
|
|
313
325
|
watch: S.Array(S.Struct({ event: S.String })),
|
|
314
326
|
})
|