dsh-mini-utility-dock 0.1.8 → 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/CHANGELOG.en.md CHANGED
@@ -3,6 +3,11 @@
3
3
  Release notes are generated from the matching version section; newest first.
4
4
  For Chinese, see [CHANGELOG.md](CHANGELOG.md).
5
5
 
6
+ ## 0.2.0 - 2026-09-21
7
+
8
+ - New `dsh-plugin-parity`: a cross-repository drift check, adopted from the copy each plugin repository carried. The member list is caller-supplied via `--member <repo>:<export>`; this package names no repository of its own. Without `--member` it runs the static checks only and discovers every repository that embeds the host-guard block.
9
+ - The copy in each plugin repository is gone with it; the bilingual-structure half of `docs:check` stays with the repositories.
10
+
6
11
  ## 0.1.8 - 2026-09-21
7
12
 
8
13
  - The two host-side fragments no longer say "sibling": the three phrasings now state the design goal itself — a plugin ships standalone, with nothing else required. A fragment is embedded verbatim into each consumer, where the word had no referent.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,11 @@
3
3
  Release Notes 由对应版本段生成;最新版本在前。
4
4
  英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
5
5
 
6
+ ## 0.2.0 - 2026-09-21
7
+
8
+ - 新增 `dsh-plugin-parity`:跨仓漂移诊断,收编自插件仓里各自维护的那份脚本。成员表由调用方通过 `--member <repo>:<export>` 提供,本包不点名任何仓;不带 `--member` 时只跑静态检查,自动发现所有嵌入了宿主守卫块的仓。
9
+ - 插件仓里的该脚本由此删除;`docs:check` 的双语结构校验仍归各仓。
10
+
6
11
  ## 0.1.8 - 2026-09-21
7
12
 
8
13
  - 两个 host 侧片段的注释不再出现 `sibling`:三处改为陈述设计目标本身——插件独立运行,不依赖同级安装。片段会逐字嵌入消费仓,那个词在那里没有可指对象。
@@ -0,0 +1,498 @@
1
+ // Cross-repo drift check for the shared host fragments.
2
+ //
3
+ // Why this exists: each member repository ships its own copy of the loopback
4
+ // predicates and the request guard, because each package has to stand alone.
5
+ // Those copies drifted repeatedly, and every drift was security-relevant:
6
+ //
7
+ // 1. every copy rejected IPv6 loopback (`::1`) — a legitimate browser was
8
+ // locked out of its own API;
9
+ // 2. the copies disagreed on which Host spellings count as loopback, leaving
10
+ // one of them with an allowlist entry its Origin path could never reach;
11
+ // 3. a Host that parsed to no hostname silently skipped the allowlist in one
12
+ // copy and was denied in the others.
13
+ //
14
+ // The copies are no longer hand-written: they are two embedded blocks generated
15
+ // from dsh-mini-utility-dock by the dock CLI — `dist/loopback.js` (the predicates)
16
+ // and `dist/guard.js` (the enforcement policy). So this checker asserts three
17
+ // things a generator alone cannot:
18
+ //
19
+ // * both embedded blocks are byte-identical in every member (a hand edit to
20
+ // one copy, or a repo that never re-ran `loopback:sync` / `guard:sync`, fails
21
+ // here), and they appear in dependency order — the guard uses the predicates
22
+ // the loopback block declares in the same file;
23
+ // * no repo keeps a private copy of an enforcement decision beside them — that
24
+ // is how drifts 1-3 happened, and embedding the blocks is worthless if a repo
25
+ // also branches on its own version;
26
+ // * the members AGREE ON EVERY DECISION. Error codes and wording deliberately
27
+ // differ (each plugin's published API vocabulary), so the comparison is on
28
+ // the allow/deny outcome only. This is the assertion that survives a policy
29
+ // change: byte equality implies it, but only this proves each consumer
30
+ // actually routes through the blocks.
31
+ //
32
+ // Usage:
33
+ // dsh-plugin-parity --root <dir> --member <repo>:<export> [--member ...]
34
+ // dsh-plugin-parity --root <dir> # static checks for every member found
35
+ // dsh-plugin-parity --self-test # exercises the extractor alone
36
+ //
37
+ // `--member` names one participating repository and the guard factory it
38
+ // exports. That name is per-repository by design — the shared block is not
39
+ // exported under a plugin-facing name, so each repository publishes its own —
40
+ // which is why the member list is caller-supplied: this tool names no
41
+ // repository of its own.
42
+ //
43
+ // With no `--member`, every directory under `--root` that carries the guard
44
+ // markers is checked statically, and the decision comparison is skipped.
45
+ //
46
+ // This is a LOCAL DIAGNOSTIC, deliberately not a CI gate. Per-repo CI already
47
+ // proves the stronger local property — `loopback:check` / `guard:check` compare
48
+ // each repo's embedded blocks against the `dist/` of the exact dock version it
49
+ // pins, and that version is immutable on npm. Members pinning one version
50
+ // therefore hold byte-identical blocks by construction, which is why this script
51
+ // is redundant as a gate and was removed from the compat workflow.
52
+ //
53
+ // The property it asserts is inherently cross-repository and cannot hold at an
54
+ // arbitrary moment: on a `dev` push the peer checkouts resolve to their default
55
+ // branch, so a difference reported here may mean only that the peers are on
56
+ // `main`. Run it when every checkout is on the same branch — before a
57
+ // release, or after one — and read a failure as a real signal then.
58
+ //
59
+ // Exit code 0 = identical blocks, one shared dock pin, no private copies, and
60
+ // every decision agrees.
61
+
62
+ import { readFileSync, readdirSync } from 'node:fs'
63
+ import { pathToFileURL } from 'node:url'
64
+ import { join } from 'node:path'
65
+
66
+ const argv = process.argv.slice(2)
67
+ const flagOf = (name) => {
68
+ const at = argv.indexOf(name)
69
+ return at >= 0 ? argv[at + 1] : null
70
+ }
71
+
72
+ const root = flagOf('--root') || process.env.DSH_PLUGINS_ROOT || process.cwd()
73
+
74
+ // `--member <repo>:<export>` names one participating repository and the guard
75
+ // factory it exports. Repeated once per participant.
76
+ const members = []
77
+ for (let i = 0; i < argv.length; i++) {
78
+ if (argv[i] !== '--member') continue
79
+ const [repo, factory] = String(argv[i + 1] || '').split(':')
80
+ if (!repo || !factory) {
81
+ console.error('dsh-plugin-parity: --member expects <repo>:<export>')
82
+ process.exit(2)
83
+ }
84
+ members.push([repo, repo, factory])
85
+ }
86
+
87
+ // With no member list, take every directory under the root that embeds the guard
88
+ // block. Those members take part in the static checks only, which is all a
89
+ // discovered member can support: the decision comparison needs a factory name.
90
+ const discovered = () => readdirSync(root, { withFileTypes: true })
91
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
92
+ .map((entry) => entry.name)
93
+ .filter((name) => {
94
+ try { return readFileSync(join(root, name, 'lib', 'shared.js'), 'utf8').includes('// <dsh-host-guard>') }
95
+ catch { return false }
96
+ })
97
+ .sort()
98
+
99
+ const repos = members.length ? members : discovered().map((name) => [name, name, null])
100
+ const tags = repos.map(([, tag]) => tag).join('/')
101
+
102
+ // Dependency order: the guard block uses the predicates the loopback block
103
+ // declares, so it must come second.
104
+ const BLOCKS = [
105
+ { name: 'dsh-loopback-helpers', label: 'loopback predicates' },
106
+ { name: 'dsh-host-guard', label: 'host guard' }
107
+ ]
108
+
109
+ const marks = (name) => ({ start: `// <${name}>`, end: `// </${name}>` })
110
+
111
+ // Pull a generated block out of a consumer's lib/shared.js. Everything between
112
+ // the markers is generator output, so it must match byte for byte; the consumer
113
+ // indents it, which is stripped before comparing.
114
+ const extractBlock = (src, name) => {
115
+ const { start, end } = marks(name)
116
+ const lines = src.split(/\r?\n/)
117
+ const starts = lines.reduce((hits, line, i) => line.trim() === start ? [...hits, i] : hits, [])
118
+ const ends = lines.reduce((hits, line, i) => line.trim() === end ? [...hits, i] : hits, [])
119
+ if (starts.length !== 1 || ends.length !== 1 || ends[0] <= starts[0]) return null
120
+ return lines.slice(starts[0] + 1, ends[0]).map((line) => line.replace(/^\s{2}/, '')).join('\n').trim()
121
+ }
122
+
123
+ const read = (repo) => readFileSync(join(root, repo, 'lib', 'shared.js'), 'utf8')
124
+
125
+ const spanOf = (src, name) => {
126
+ const { start, end } = marks(name)
127
+ const lines = src.split(/\r?\n/)
128
+ const from = lines.findIndex((line) => line.trim() === start)
129
+ const to = lines.findIndex((line) => line.trim() === end)
130
+ return from >= 0 && to > from ? { from, to } : null
131
+ }
132
+
133
+ // Everything that is neither block: the plugin's own code, which is where a
134
+ // private copy of an enforcement decision would hide.
135
+ const dropBlocks = (src) => {
136
+ const spans = BLOCKS.map((b) => spanOf(src, b.name)).filter(Boolean).sort((a, b) => b.from - a.from)
137
+ const lines = src.split(/\r?\n/)
138
+ for (const { from, to } of spans) lines.splice(from, to - from + 1)
139
+ return lines.join('\n')
140
+ }
141
+
142
+ // The dock version a repo embeds its blocks from. This is the one fact that is
143
+ // genuinely cross-repository: the blocks themselves are proven locally against
144
+ // whatever version is pinned here, and a version is immutable on npm, so equal
145
+ // pins are what make the blocks equal — not the other way round.
146
+ const dockPinOf = (repo) => {
147
+ const pkg = JSON.parse(readFileSync(join(root, repo, 'package.json'), 'utf8'))
148
+ const dev = (pkg.devDependencies || {})['dsh-mini-utility-dock']
149
+ const any = (pkg.dependencies || {})['dsh-mini-utility-dock']
150
+ return dev || any || null
151
+ }
152
+
153
+ if (process.argv.includes('--self-test')) {
154
+ const good = [
155
+ marks('dsh-loopback-helpers').start, 'const a = 1', marks('dsh-loopback-helpers').end,
156
+ marks('dsh-host-guard').start, 'const b = 2', marks('dsh-host-guard').end
157
+ ].join('\n')
158
+ const cases = [
159
+ ['first block extracted', 'dsh-loopback-helpers', good, 'const a = 1'],
160
+ ['second block extracted', 'dsh-host-guard', good, 'const b = 2'],
161
+ ['indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, ' const a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
162
+ ['missing end marker returns null', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, 'const a = 1'].join('\n'), null],
163
+ ['absent block returns null', 'dsh-host-guard', good.replace(/dsh-host-guard/g, 'other'), null]
164
+ ]
165
+ let bad = 0
166
+ for (const [label, name, src, expected] of cases) {
167
+ const got = extractBlock(src, name)
168
+ const ok = got === expected
169
+ if (!ok) console.log(`self-test FAIL ${label}\n expected: ${JSON.stringify(expected)}\n got: ${JSON.stringify(got)}`)
170
+ else console.log(`self-test ok ${label}`)
171
+ if (!ok) bad++
172
+ }
173
+ // Dropping both blocks must leave only the plugin's own code.
174
+ const residual = dropBlocks(good)
175
+ const dropOk = !/const a = 1|const b = 2/.test(residual)
176
+ console.log(dropOk ? 'self-test ok both blocks dropped' : `self-test FAIL both blocks dropped: ${JSON.stringify(residual)}`)
177
+ if (!dropOk) bad++
178
+ process.exit(bad ? 1 : 0)
179
+ }
180
+
181
+ let failures = 0
182
+
183
+ // 1. Both generated blocks must be present, byte-identical everywhere, and in
184
+ // dependency order.
185
+ for (const block of BLOCKS) {
186
+ const copies = repos.map(([repo, tag]) => ({ repo, tag, body: extractBlock(read(repo), block.name) }))
187
+ const missing = copies.filter((c) => c.body === null)
188
+ if (missing.length) {
189
+ console.log(`FAIL ${block.label}: missing or malformed markers in ${missing.map((m) => m.tag).join(', ')}`)
190
+ failures++
191
+ continue
192
+ }
193
+ const base = copies[0]
194
+ const drifted = copies.slice(1).filter((c) => c.body !== base.body)
195
+ if (drifted.length) {
196
+ console.log(`FAIL ${block.label}: ${drifted.map((d) => d.tag).join(', ')} differ from ${base.tag}`)
197
+ for (const d of drifted) {
198
+ console.log(`\n--- ${base.tag} (${base.repo}/lib/shared.js) ---\n${base.body}`)
199
+ console.log(`\n--- ${d.tag} (${d.repo}/lib/shared.js) ---\n${d.body}`)
200
+ }
201
+ failures++
202
+ } else {
203
+ console.log(`ok ${block.label}: identical across ${tags} (${base.body.length} chars)`)
204
+ }
205
+ }
206
+
207
+ // 2. Every repo must embed from the same dock version. A skew here is the one
208
+ // cross-repo cause of divergence that per-repo checks cannot see: each repo's
209
+ // own `guard:check` passes against whatever it pins, so a repo left on an older
210
+ // dock stays green while its blocks differ from its siblings'.
211
+ {
212
+ const pins = repos.map(([repo, tag]) => ({ tag, pin: dockPinOf(repo) }))
213
+ const unset = pins.filter((p) => p.pin === null)
214
+ const distinct = [...new Set(pins.map((p) => p.pin).filter((p) => p !== null))]
215
+ if (unset.length) {
216
+ console.log(`FAIL dock pin: not declared by ${unset.map((u) => u.tag).join(', ')}`)
217
+ failures++
218
+ } else if (distinct.length !== 1) {
219
+ console.log(`FAIL dock pin: versions differ — ${pins.map((p) => `${p.tag}=${p.pin}`).join(' ')}`)
220
+ failures++
221
+ } else {
222
+ // An exact pin is what makes the sibling repos hold identical blocks: a range
223
+ // could resolve to different builds under one declared value.
224
+ const exact = /^\d+\.\d+\.\d+$/.test(distinct[0])
225
+ if (!exact) {
226
+ console.log(`FAIL dock pin: ${JSON.stringify(distinct[0])} is not an exact version, so one declared value can resolve to different blocks`)
227
+ failures++
228
+ } else {
229
+ console.log(`ok dock pin: every member embeds dsh-mini-utility-dock ${distinct[0]}`)
230
+ }
231
+ }
232
+ }
233
+
234
+ // The guard block reads what the loopback block declares in the same file, so the
235
+ // order is load-bearing, not cosmetic.
236
+ {
237
+ let orderBad = 0
238
+ for (const [repo, tag] of repos) {
239
+ const src = read(repo)
240
+ const loop = spanOf(src, 'dsh-loopback-helpers')
241
+ const guard = spanOf(src, 'dsh-host-guard')
242
+ if (!loop || !guard) continue
243
+ if (loop.from > guard.from) {
244
+ console.log(`FAIL ${tag}: the host guard block precedes the loopback predicates`)
245
+ orderBad++
246
+ }
247
+ }
248
+ if (orderBad) failures += orderBad
249
+ else console.log('ok block order: the loopback predicates precede the host guard everywhere')
250
+ }
251
+
252
+ // 3. Embedding the blocks is worthless if a repo also keeps its own enforcement
253
+ // beside them. Policy is expected outside the blocks; decisions are not.
254
+ //
255
+ // `remoteAddress` is deliberately NOT listed on its own: a plugin may keep a
256
+ // second helper that applies the same criterion for a different gate
257
+ // (dsh-instance-manager's `requestNeedsBearer` does exactly that, so the API
258
+ // route and the guard cannot drift apart). A private criterion only matters
259
+ // when the repo also owns a private guard, which the factory check catches.
260
+ const ENFORCEMENT = [
261
+ [/LOOPBACK_HOSTNAMES\s*=/, 'redefines LOOPBACK_HOSTNAMES'],
262
+ [/(?:const|function|let)\s+isLoopbackName\b/, 'defines a private isLoopbackName'],
263
+ [/(?:const|function|let)\s+isLoopbackAddress\b/, 'defines a private isLoopbackAddress'],
264
+ [/(?:const|function|let)\s+hostHostname\b/, 'defines a private hostHostname'],
265
+ [/(?:const|function|let)\s+bindGuard\b/, 'defines a private guard factory'],
266
+ [/sec-fetch-site/, 'branches on Fetch Metadata outside the blocks']
267
+ ]
268
+ for (const [repo, tag] of repos) {
269
+ const outside = dropBlocks(read(repo))
270
+ const problems = ENFORCEMENT.filter(([re]) => re.test(outside)).map(([, why]) => why)
271
+ if (/remoteAddress/.test(outside) && /function\s+guard\s*\(|=>\s*\{\s*$/.test(outside)) {
272
+ problems.push('looks like a private guard reading the socket peer')
273
+ }
274
+ if (problems.length) {
275
+ console.log(`FAIL ${tag}: ${problems.join('; ')}`)
276
+ failures++
277
+ } else {
278
+ console.log(`ok ${tag}: no enforcement outside the generated blocks`)
279
+ }
280
+ }
281
+
282
+ // Everything above is static, so it covers discovered members too. The decision
283
+ // comparison needs a factory name per member, which only `--member` supplies.
284
+ if (!members.length) {
285
+ console.log(failures
286
+ ? `\n${failures} drift problem(s)`
287
+ : '\nno drift (static checks only; pass --member <repo>:<export> to compare decisions)')
288
+ process.exit(failures ? 1 : 0)
289
+ }
290
+
291
+ // 4. Every decision must agree across the members. Codes differ by design, so
292
+ // only the verdict is compared.
293
+ //
294
+ // First confirm each repo exports the guard it is expected to. A repo whose
295
+ // blocks failed to parse is already reported above; without this check the
296
+ // decision cases below would throw `mods[repo][factory] is not a function` and
297
+ // bury the real diagnosis under a stack trace.
298
+ const makeRes = () => ({
299
+ statusCode: null,
300
+ body: null,
301
+ writeHead(code) { this.statusCode = code; return this },
302
+ setHeader() {},
303
+ end() {}
304
+ })
305
+
306
+ const mods = {}
307
+ for (const [repo] of repos) {
308
+ mods[repo] = await import(pathToFileURL(join(root, repo, 'lib', 'shared.js')).href)
309
+ }
310
+
311
+ const unusable = repos.filter(([repo, , factory]) => typeof mods[repo][factory] !== 'function')
312
+ if (unusable.length) {
313
+ for (const [repo, tag, factory] of unusable) {
314
+ console.log(`FAIL ${tag}: lib/shared.js does not export a usable ${factory}() — the embedded blocks are stale or absent`)
315
+ }
316
+ failures += unusable.length
317
+ console.log('\ncannot compare decisions while a repo exports no guard')
318
+ console.log(failures ? `\n${failures} drift problem(s)` : '\nno drift')
319
+ process.exit(1)
320
+ }
321
+
322
+ // Each plugin names its own guard, so resolve it per repo. The guard is built
323
+ // with `allowRemoteHost` only when the case asks for fleet mode, because a plugin
324
+ // that never opts in must behave strictly.
325
+ const verdict = (repo, factory, { host, origin, peer = '127.0.0.1', site = 'same-origin', fleet = false }) => {
326
+ const req = {
327
+ method: 'GET',
328
+ headers: { ...(host === undefined ? {} : { host }), ...(origin ? { origin } : {}), ...(site === null ? {} : { 'sec-fetch-site': site }) },
329
+ socket: peer === null ? {} : { remoteAddress: peer }
330
+ }
331
+ const res = makeRes()
332
+ const guard = mods[repo][factory]({
333
+ currentPort: () => 3080,
334
+ // Every plugin routes rejections through its own sink; a fake one keeps the
335
+ // comparison independent of each plugin's response shape.
336
+ respond: (target, code, body) => { target.statusCode = code; target.body = body },
337
+ ...(fleet ? { allowRemoteHost: () => true } : {})
338
+ })
339
+ return guard(req, res) ? 'allow' : 'deny'
340
+ }
341
+
342
+ const cases = [
343
+ // Host allowlist, including both spellings of the mapped IPv6 loopback.
344
+ ['loopback ipv4 host', { host: '127.0.0.1:3080' }, 'allow'],
345
+ ['loopback name host', { host: 'localhost:3080' }, 'allow'],
346
+ ['loopback ipv6 host', { host: '[::1]:3080' }, 'allow'],
347
+ ['mapped loopback host (dotted)', { host: '[::ffff:127.0.0.1]:3080' }, 'allow'],
348
+ ['mapped loopback host (hex)', { host: '[::ffff:7f00:1]:3080' }, 'allow'],
349
+ ['case-insensitive name', { host: 'LOCALHOST:3080' }, 'allow'],
350
+ ['absent host (host-side caller)', { host: undefined }, 'allow'],
351
+ ['foreign host', { host: 'rebound.example' }, 'deny'],
352
+ ['localhost subdomain', { host: 'api.localhost' }, 'deny'],
353
+ ['evil localhost subdomain', { host: 'evil.localhost:3080' }, 'deny'],
354
+ ['rebinding suffix', { host: '127.0.0.1.evil.example:3080' }, 'deny'],
355
+ ['public ip host', { host: '203.0.113.5:3080' }, 'deny'],
356
+ // An unparseable Host must fail closed rather than skip the allowlist.
357
+ ['unbracketed ipv6 host', { host: '::1:3080' }, 'deny'],
358
+ ['unbracketed mapped host', { host: '::ffff:127.0.0.1:3080' }, 'deny'],
359
+ // Fetch Metadata.
360
+ ['cross-site metadata', { host: '127.0.0.1:3080', site: 'cross-site' }, 'deny'],
361
+ ['same-origin metadata', { host: '127.0.0.1:3080', site: 'same-origin' }, 'allow'],
362
+ ['no metadata (non-browser)', { host: '127.0.0.1:3080', site: null }, 'allow'],
363
+ // Origin.
364
+ ['matching origin', { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' }, 'allow'],
365
+ ['ipv6 origin', { host: '[::1]:3080', origin: 'http://[::1]:3080' }, 'allow'],
366
+ ['mapped origin (hex, as URL normalises)', { host: '127.0.0.1:3080', origin: 'http://[::ffff:7f00:1]:3080' }, 'allow'],
367
+ ['origin on another port', { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3081' }, 'deny'],
368
+ ['foreign origin', { host: '127.0.0.1:3080', origin: 'https://evil.example' }, 'deny'],
369
+ ['rebinding origin', { host: '127.0.0.1:3080', origin: 'http://127.0.0.1.evil.example:3080' }, 'deny'],
370
+ // Peer address.
371
+ ['ipv6 loopback peer', { host: '127.0.0.1:3080', peer: '::1' }, 'allow'],
372
+ ['mapped loopback peer', { host: '127.0.0.1:3080', peer: '::ffff:127.0.0.1' }, 'allow'],
373
+ ['remote peer', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'deny'],
374
+ ['private-range peer', { host: '127.0.0.1:3080', peer: '10.0.0.5' }, 'deny'],
375
+ ['missing peer', { host: '127.0.0.1:3080', peer: null }, 'deny'],
376
+ ['blank peer', { host: '127.0.0.1:3080', peer: '' }, 'deny'],
377
+ // A remote caller that also spoofs a loopback Host is still remote.
378
+ ['remote peer, loopback host', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'deny']
379
+ ]
380
+
381
+ let bad = 0
382
+ for (const [label, req, expected] of cases) {
383
+ const verdicts = repos.map(([repo, , factory]) => verdict(repo, factory, req))
384
+ const agrees = new Set(verdicts).size === 1
385
+ const got = verdicts[0]
386
+ if (!agrees) {
387
+ console.log(`FAIL ${label}: ${repos.map(([, tag], i) => `${tag}=${verdicts[i]}`).join(' ')}`)
388
+ bad++
389
+ } else if (got !== expected) {
390
+ console.log(`FAIL ${label}: every member answered ${got}, expected ${expected}`)
391
+ bad++
392
+ }
393
+ }
394
+ if (bad) failures += bad
395
+ else console.log(`ok decisions: ${cases.length} cases agree across ${tags}`)
396
+
397
+ // 5. Fleet mode is the one place the guard deliberately relaxes, and only the
398
+ // member named by `--fleet` opts in. Assert the relaxation stays bounded: a
399
+ // fleet guard still rejects a cross-site request, a foreign Origin, and a
400
+ // missing peer.
401
+ const fleetMember = members.find(([repo]) => repo === flagOf('--fleet')) || null
402
+ const fleetCases = [
403
+ ['fleet admits a peer host', { host: 'box.lan:3080' }, 'allow'],
404
+ ['fleet admits a remote peer', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'allow'],
405
+ ['fleet still rejects cross-site', { host: 'box.lan:3080', site: 'cross-site' }, 'deny'],
406
+ ['fleet still rejects foreign Origin', { host: 'box.lan:3080', origin: 'https://evil.example' }, 'deny'],
407
+ ['fleet still rejects a missing peer', { host: 'box.lan:3080', peer: null }, 'deny']
408
+ ]
409
+ if (!fleetMember) {
410
+ console.log('ok fleet bounds: skipped (pass --fleet <repo> to check the member that opts in)')
411
+ } else {
412
+ const fleetVerdict = (req) => verdict(fleetMember[0], fleetMember[2], { ...req, fleet: true })
413
+ let fleetBad = 0
414
+ for (const [label, req, expected] of fleetCases) {
415
+ const got = fleetVerdict(req)
416
+ if (got !== expected) {
417
+ console.log(`FAIL ${label}: fleet guard answered ${got}, expected ${expected}`)
418
+ fleetBad++
419
+ }
420
+ }
421
+ if (fleetBad) failures += fleetBad
422
+ else console.log(`ok fleet bounds: ${fleetCases.length} cases hold`)
423
+ }
424
+
425
+ // 6. The predicates themselves, called directly, across every repo.
426
+ const predicateCases = [
427
+ ['isLoopbackName', ['127.0.0.1', true], ['localhost', true], ['::1', true],
428
+ ['LOCALHOST', true], ['[::1]', false], ['::ffff:127.0.0.1', true],
429
+ ['::ffff:7f00:1', true], ['::ffff:8.8.8.8', false], ['::ffff:127.0.0.2', false],
430
+ ['127.0.0.2', false], ['127.0.0.1.evil.example', false], ['api.localhost', false],
431
+ ['', false], [null, false]],
432
+ ['isLoopbackAddress', ['127.0.0.1', true], ['127.255.0.1', true], ['::1', true],
433
+ ['::ffff:127.0.0.1', true], ['::ffff:7f00:1', true], ['::ffff:127.0.0.2', true],
434
+ ['::ffff:8.8.8.8', false], ['10.0.0.1', false], ['::2', false], ['localhost', false],
435
+ ['127.0.0.1.evil.example', false], ['::ffff:999.1.1.1', false], ['', false],
436
+ [null, false], [' ', false]],
437
+ ['hostHostname', ['127.0.0.1:3080', '127.0.0.1'], ['[::1]:3080', '::1'],
438
+ ['[::ffff:127.0.0.1]:80', '::ffff:127.0.0.1'], ['REBOUND.EXAMPLE', 'rebound.example'],
439
+ [undefined, ''], ['localhost', 'localhost']]
440
+ ]
441
+ for (const [fn, ...fnCases] of predicateCases) {
442
+ let fnBad = 0
443
+ for (const [input, expected] of fnCases) {
444
+ const answers = repos.map(([repo]) => mods[repo][fn](input))
445
+ if (new Set(answers).size !== 1) {
446
+ console.log(`FAIL ${fn}(${JSON.stringify(input)}): ${repos.map(([, tag], i) => `${tag}=${JSON.stringify(answers[i])}`).join(' ')}`)
447
+ fnBad++
448
+ } else if (answers[0] !== expected) {
449
+ console.log(`FAIL ${fn}(${JSON.stringify(input)}): every member answered ${JSON.stringify(answers[0])}, expected ${JSON.stringify(expected)}`)
450
+ fnBad++
451
+ }
452
+ }
453
+ if (fnBad) failures += fnBad
454
+ else console.log(`ok ${fn}: ${fnCases.length} cases agree across ${tags}`)
455
+ }
456
+
457
+ // 7. portOf normalises the default ports, which is what stopped a same-origin
458
+ // request on 80/443 from reading as a foreign Origin.
459
+ {
460
+ let portBad = 0
461
+ const portCases = [
462
+ ['http://127.0.0.1', '80'], ['https://127.0.0.1', '443'],
463
+ ['http://127.0.0.1:3080', '3080'], ['https://[::1]:443', '443']
464
+ ]
465
+ for (const [url, expected] of portCases) {
466
+ const answers = repos.map(([repo]) => mods[repo].portOf(new URL(url)))
467
+ if (new Set(answers).size !== 1 || answers[0] !== expected) {
468
+ console.log(`FAIL portOf(${url}): ${repos.map(([, tag], i) => `${tag}=${JSON.stringify(answers[i])}`).join(' ')} (expected ${expected})`)
469
+ portBad++
470
+ }
471
+ }
472
+ if (portBad) failures += portBad
473
+ else console.log(`ok portOf: ${portCases.length} cases agree across ${tags}`)
474
+ }
475
+
476
+ // 8. An unknown policy key is a typo, and a typo would silently leave the default
477
+ // in place, so the shared factory throws. A plugin that names its own policy
478
+ // must not swallow that — it should reach the caller.
479
+ {
480
+ let policyBad = 0
481
+ for (const [repo, tag, factory] of repos) {
482
+ let threw = false
483
+ try {
484
+ mods[repo][factory]({ currentPort: () => 3080, respond: () => {}, policy: { not_a_reason: { code: 'x' } } })
485
+ } catch {
486
+ threw = true
487
+ }
488
+ if (!threw) {
489
+ console.log(`FAIL ${tag}: an unknown policy key was accepted instead of throwing`)
490
+ policyBad++
491
+ }
492
+ }
493
+ if (policyBad) failures += policyBad
494
+ else console.log('ok policy validation: an unknown key is rejected by every plugin')
495
+ }
496
+
497
+ console.log(failures ? `\n${failures} drift problem(s)` : '\nno drift')
498
+ process.exit(failures ? 1 : 0)
package/dist/loopback.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // Why it is shared at all: these predicates were copy-pasted per repo and
13
13
  // drifted twice. The first drift rejected IPv6 loopback everywhere; the second
14
14
  // is that consumers disagreed on which Host spellings count as loopback
15
- // (see scripts/guard-parity.mjs, which compares this block across repos).
15
+ // (see the parity bin this package ships, which compares this block across repositories).
16
16
  //
17
17
  // "Loopback" is decided in exactly one place — LOOPBACK_HOSTNAMES plus the
18
18
  // IPv4-mapped IPv6 form of each entry — and both the name and the address
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "dsh-mini-utility-dock",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Canonical fragments and a build-time embedding CLI shared by DSH plugins: the Mini Utility Dock bootstrap for client halves, and the loopback predicates plus the host request guard for host halves.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dsh-mini-utility-dock": "bin/dsh-mini-utility-dock.js"
7
+ "dsh-mini-utility-dock": "bin/dsh-mini-utility-dock.js",
8
+ "dsh-plugin-parity": "bin/dsh-plugin-parity.js"
8
9
  },
9
10
  "files": [
10
11
  "bin",