dsh-mini-utility-dock 0.5.0 → 0.6.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.
@@ -1,38 +1,44 @@
1
1
  // Cross-repo drift check for the shared host fragments.
2
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:
3
+ // Why this exists: every member repository embeds its own copy of the loopback
4
+ // predicates and the request guard, because every package has to stand alone, and
5
+ // hand-copied enforcement code drifts in ways that are always
6
+ // security-relevant — an IPv6 loopback that reads as foreign and locks a
7
+ // legitimate browser out of its own API, Host spellings two copies disagree on,
8
+ // a Host that parses to no hostname skipping the allowlist in one copy while
9
+ // another denies it.
6
10
  //
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.
11
+ // The copies are no longer hand-written: they are embedded blocks generated from
12
+ // dsh-mini-utility-dock by the dock CLI — `dist/loopback.js` (the predicates),
13
+ // `dist/guard.js` (the enforcement policy) and `dist/host-http.js` (the response
14
+ // glue: JSON replies, the POST gate, the browser authorizer). So this checker
15
+ // asserts three things a generator alone cannot:
13
16
  //
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;
17
+ // * every embedded block is byte-identical in every member (a hand edit to
18
+ // one copy, or a repo that never re-ran `loopback:sync` / `guard:sync` /
19
+ // `http:sync`, fails here), and they appear in dependency order — the guard
20
+ // uses the predicates the loopback block declares in the same file;
23
21
  // * 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;
22
+ // is how the drifts above happened, and embedding the blocks is worthless if
23
+ // a repo also branches on its own version;
26
24
  // * the members AGREE ON EVERY DECISION. Error codes and wording deliberately
27
25
  // differ (each plugin's published API vocabulary), so the comparison is on
28
26
  // the allow/deny outcome only. This is the assertion that survives a policy
29
27
  // change: byte equality implies it, but only this proves each consumer
30
28
  // actually routes through the blocks.
31
29
  //
30
+ // The host-HTTP block is in the compared set for the same reason the guard is:
31
+ // what it carries is policy. A response header that keeps live port and session
32
+ // data out of caches, and the reply for "browser authorization is unavailable",
33
+ // are exactly the lines a hand-copied glue function loses on one repo.
34
+ //
32
35
  // Usage:
33
36
  // dsh-plugin-parity --root <dir> --member <repo>:<export> [--member ...]
37
+ // dsh-plugin-parity --root <dir> --member ... --fleet <repo>
38
+ // # `--fleet` names the one member that opts into fleet mode, so the check
39
+ // # that the relaxation stays bounded runs against its guard alone
34
40
  // dsh-plugin-parity --root <dir> # static checks for every member found
35
- // dsh-plugin-parity --self-test # exercises the extractor alone
41
+ // dsh-plugin-parity --self-test # exercises the block extractor and the block-dropping step
36
42
  //
37
43
  // `--member` names one participating repository and the guard factory it
38
44
  // exports. That name is per-repository by design — the shared block is not
@@ -44,11 +50,12 @@
44
50
  // markers is checked statically, and the decision comparison is skipped.
45
51
  //
46
52
  // 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.
53
+ // proves the stronger local property — `loopback:check` / `guard:check` /
54
+ // `http:check` compare each repo's embedded blocks against the `dist/` of the
55
+ // exact dock version it pins, and that version is immutable on npm. Members
56
+ // pinning one version therefore hold byte-identical blocks by construction,
57
+ // which is why this script is redundant as a CI gate and stays a manual
58
+ // diagnostic instead.
52
59
  //
53
60
  // The property it asserts is inherently cross-repository and cannot hold at an
54
61
  // arbitrary moment: on a `dev` push the peer checkouts resolve to their default
@@ -99,25 +106,42 @@ const discovered = () => readdirSync(root, { withFileTypes: true })
99
106
  const repos = members.length ? members : discovered().map((name) => [name, name, null])
100
107
  const tags = repos.map(([, tag]) => tag).join('/')
101
108
 
102
- // Dependency order: the guard block uses the predicates the loopback block
103
- // declares, so it must come second.
109
+ // The order the blocks sit in `lib/shared.js`: the guard block uses the
110
+ // predicates the loopback block declares in the same file, so the predicates must
111
+ // come first, and the host-HTTP block lands below the guard, where a consumer's
112
+ // own glue has always lived. `FRAGMENTS` in the dock CLI lists the same order, so
113
+ // `dropBlocks` and the position check below can treat this table as the file's
114
+ // true layout.
104
115
  const BLOCKS = [
105
116
  { name: 'dsh-loopback-helpers', label: 'loopback predicates' },
106
- { name: 'dsh-host-guard', label: 'host guard' }
117
+ { name: 'dsh-host-guard', label: 'host guard' },
118
+ { name: 'dsh-host-http', label: 'host HTTP glue' }
107
119
  ]
108
120
 
109
121
  const marks = (name) => ({ start: `// <${name}>`, end: `// </${name}>` })
110
122
 
111
123
  // 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.
124
+ // the markers is generator output, so it must match once re-indented. A consumer
125
+ // re-indents the whole block to its marker's own indentation, and the dock's
126
+ // `sync` preserves that indentation, so two repos that indent their markers
127
+ // differently still hold the same block. Stripping the block's shared leading
128
+ // whitespace — not a fixed 2 spaces — is what makes them compare equal.
114
129
  const extractBlock = (src, name) => {
115
130
  const { start, end } = marks(name)
116
131
  const lines = src.split(/\r?\n/)
117
132
  const starts = lines.reduce((hits, line, i) => line.trim() === start ? [...hits, i] : hits, [])
118
133
  const ends = lines.reduce((hits, line, i) => line.trim() === end ? [...hits, i] : hits, [])
119
134
  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()
135
+ const body = lines.slice(starts[0] + 1, ends[0])
136
+ // The least-indented non-blank line carries exactly the block's indentation, so
137
+ // removing that many leading whitespace characters from every line restores the
138
+ // fragment's own relative indentation, whether the marker used 2 spaces, 4, or a
139
+ // tab. A fragment always has top-level (column-0) lines, so the minimum is its
140
+ // indent, not a deeper body line.
141
+ const indent = body.reduce((min, line) => line.trim() === '' ? min
142
+ : Math.min(min, (/^[ \t]*/.exec(line) || [''])[0].length), Infinity)
143
+ const strip = Number.isFinite(indent) ? indent : 0
144
+ return body.map((line) => line.trim() === '' ? '' : line.slice(strip)).join('\n').trim()
121
145
  }
122
146
 
123
147
  const read = (repo) => readFileSync(join(root, repo, 'lib', 'shared.js'), 'utf8')
@@ -153,12 +177,19 @@ const dockPinOf = (repo) => {
153
177
  if (process.argv.includes('--self-test')) {
154
178
  const good = [
155
179
  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
180
+ marks('dsh-host-guard').start, 'const b = 2', marks('dsh-host-guard').end,
181
+ marks('dsh-host-http').start, 'const c = 3', marks('dsh-host-http').end,
182
+ // The plugin's own code, which `dropBlocks` must leave behind.
183
+ 'export const pluginLine = 7'
157
184
  ].join('\n')
158
185
  const cases = [
159
186
  ['first block extracted', 'dsh-loopback-helpers', good, 'const a = 1'],
160
187
  ['second block extracted', 'dsh-host-guard', good, 'const b = 2'],
188
+ ['third block extracted', 'dsh-host-http', good, 'const c = 3'],
161
189
  ['indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, ' const a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
190
+ ['four-space indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, ' const a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
191
+ ['tab indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, '\tconst a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
192
+ ['relative indent is kept', 'dsh-host-guard', [marks('dsh-host-guard').start, ' const b = 2', ' inner', marks('dsh-host-guard').end].join('\n'), 'const b = 2\n inner'],
162
193
  ['missing end marker returns null', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, 'const a = 1'].join('\n'), null],
163
194
  ['absent block returns null', 'dsh-host-guard', good.replace(/dsh-host-guard/g, 'other'), null]
164
195
  ]
@@ -170,17 +201,19 @@ if (process.argv.includes('--self-test')) {
170
201
  else console.log(`self-test ok ${label}`)
171
202
  if (!ok) bad++
172
203
  }
173
- // Dropping both blocks must leave only the plugin's own code.
204
+ // Dropping every block must remove the generated bodies and leave the plugin's
205
+ // own code intact. A leftover body would read as a private enforcement decision;
206
+ // a dropped plugin line would mean the splices overreached.
174
207
  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)}`)
208
+ const dropOk = !/const a = 1|const b = 2|const c = 3/.test(residual) && /export const pluginLine = 7/.test(residual)
209
+ console.log(dropOk ? 'self-test ok every block dropped, plugin code kept' : `self-test FAIL block dropping: ${JSON.stringify(residual)}`)
177
210
  if (!dropOk) bad++
178
211
  process.exit(bad ? 1 : 0)
179
212
  }
180
213
 
181
214
  let failures = 0
182
215
 
183
- // 1. Both generated blocks must be present, byte-identical everywhere, and in
216
+ // 1. Every generated block must be present, byte-identical everywhere, and in
184
217
  // dependency order.
185
218
  for (const block of BLOCKS) {
186
219
  const copies = repos.map(([repo, tag]) => ({ repo, tag, body: extractBlock(read(repo), block.name) }))
@@ -207,7 +240,7 @@ for (const block of BLOCKS) {
207
240
  // 2. Every repo must embed from the same dock version. A skew here is the one
208
241
  // cross-repo cause of divergence that per-repo checks cannot see: each repo's
209
242
  // 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'.
243
+ // dock stays green while its blocks differ from the other members'.
211
244
  {
212
245
  const pins = repos.map(([repo, tag]) => ({ tag, pin: dockPinOf(repo) }))
213
246
  const unset = pins.filter((p) => p.pin === null)
@@ -219,7 +252,7 @@ for (const block of BLOCKS) {
219
252
  console.log(`FAIL dock pin: versions differ — ${pins.map((p) => `${p.tag}=${p.pin}`).join(' ')}`)
220
253
  failures++
221
254
  } else {
222
- // An exact pin is what makes the sibling repos hold identical blocks: a range
255
+ // An exact pin is what makes every member hold identical blocks: a range
223
256
  // could resolve to different builds under one declared value.
224
257
  const exact = /^\d+\.\d+\.\d+$/.test(distinct[0])
225
258
  if (!exact) {
@@ -232,45 +265,50 @@ for (const block of BLOCKS) {
232
265
  }
233
266
 
234
267
  // The guard block reads what the loopback block declares in the same file, so the
235
- // order is load-bearing, not cosmetic.
268
+ // order is load-bearing, not cosmetic. `FRAGMENTS` in the dock CLI documents one
269
+ // order and every consumer writes that one, so a member whose blocks sit any
270
+ // other way has either hand-moved a block or embedded from a different dock —
271
+ // both of which make its `check` results harder to read than this line makes them.
236
272
  {
237
273
  let orderBad = 0
238
274
  for (const [repo, tag] of repos) {
239
275
  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++
276
+ const placed = BLOCKS
277
+ .map((block) => ({ block, span: spanOf(src, block.name) }))
278
+ .filter((entry) => entry.span !== null)
279
+ for (let i = 1; i < placed.length; i++) {
280
+ if (placed[i - 1].span.from > placed[i].span.from) {
281
+ console.log(`FAIL ${tag}: the ${placed[i].block.label} block precedes the ${placed[i - 1].block.label} block`)
282
+ orderBad++
283
+ }
246
284
  }
247
285
  }
248
286
  if (orderBad) failures += orderBad
249
- else console.log('ok block order: the loopback predicates precede the host guard everywhere')
287
+ else console.log('ok block order: loopback predicates, then host guard, then host HTTP glue, everywhere')
250
288
  }
251
289
 
252
290
  // 3. Embedding the blocks is worthless if a repo also keeps its own enforcement
253
291
  // beside them. Policy is expected outside the blocks; decisions are not.
254
292
  //
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.
293
+ // Every pattern here matches a CODE SHAPE, not a word. These files are scanned
294
+ // as text with the generated blocks dropped, so a bare keyword also matches
295
+ // prose: a doc comment explaining why a private helper is NOT a second guard
296
+ // used to fail the very repository that documented its reasoning. An
297
+ // explanation is not enforcement; reading a header or declaring a function is.
260
298
  const ENFORCEMENT = [
261
- [/LOOPBACK_HOSTNAMES\s*=/, 'redefines LOOPBACK_HOSTNAMES'],
299
+ [/LOOPBACK_HOSTNAMES\s*=(?!=)/, 'redefines LOOPBACK_HOSTNAMES'],
262
300
  [/(?:const|function|let)\s+isLoopbackName\b/, 'defines a private isLoopbackName'],
263
301
  [/(?:const|function|let)\s+isLoopbackAddress\b/, 'defines a private isLoopbackAddress'],
264
302
  [/(?:const|function|let)\s+hostHostname\b/, 'defines a private hostHostname'],
265
303
  [/(?:const|function|let)\s+bindGuard\b/, 'defines a private guard factory'],
266
- [/sec-fetch-site/, 'branches on Fetch Metadata outside the blocks']
304
+ [/\[\s*['"]sec-fetch-site['"]\s*\]/, 'reads Fetch Metadata outside the blocks'],
305
+ [/\{[^}]*['"]sec-fetch-site['"]\s*[,:}]/, 'reads Fetch Metadata outside the blocks']
267
306
  ]
268
307
  for (const [repo, tag] of repos) {
269
308
  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
- }
309
+ // The bracket read and the destructuring read share one reason, so collapse the
310
+ // two patterns' matches into a single report line.
311
+ const problems = [...new Set(ENFORCEMENT.filter(([re]) => re.test(outside)).map(([, why]) => why))]
274
312
  if (problems.length) {
275
313
  console.log(`FAIL ${tag}: ${problems.join('; ')}`)
276
314
  failures++
@@ -398,7 +436,8 @@ else console.log(`ok decisions: ${cases.length} cases agree across ${tags}`)
398
436
  // member named by `--fleet` opts in. Assert the relaxation stays bounded: a
399
437
  // fleet guard still rejects a cross-site request, a foreign Origin, and a
400
438
  // missing peer.
401
- const fleetMember = members.find(([repo]) => repo === flagOf('--fleet')) || null
439
+ const fleetFlag = flagOf('--fleet')
440
+ const fleetMember = fleetFlag ? members.find(([repo]) => repo === fleetFlag) : null
402
441
  const fleetCases = [
403
442
  ['fleet admits a peer host', { host: 'box.lan:3080' }, 'allow'],
404
443
  ['fleet admits a remote peer', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'allow'],
@@ -406,8 +445,14 @@ const fleetCases = [
406
445
  ['fleet still rejects foreign Origin', { host: 'box.lan:3080', origin: 'https://evil.example' }, 'deny'],
407
446
  ['fleet still rejects a missing peer', { host: 'box.lan:3080', peer: null }, 'deny']
408
447
  ]
409
- if (!fleetMember) {
448
+ if (!fleetFlag) {
410
449
  console.log('ok fleet bounds: skipped (pass --fleet <repo> to check the member that opts in)')
450
+ } else if (!fleetMember) {
451
+ // Passing `--fleet` asks for the relaxation boundary to be tested; a name that
452
+ // matches no member means that test never ran, so say FAIL rather than let the
453
+ // "skipped" line read as a pass for a check that was requested but not performed.
454
+ console.log(`FAIL fleet bounds: --fleet ${fleetFlag} is not one of the --member repositories (${members.map(([, tag]) => tag).join(', ')})`)
455
+ failures++
411
456
  } else {
412
457
  const fleetVerdict = (req) => verdict(fleetMember[0], fleetMember[2], { ...req, fleet: true })
413
458
  let fleetBad = 0
package/dist/guard.js CHANGED
@@ -1,13 +1,14 @@
1
- // Host-side request guard shared by the DSH plugins.
1
+ // Host-side same-origin request guard, for a plugin's host half that binds an
2
+ // API to loopback.
2
3
  //
3
4
  // This fragment has ONE source of truth: dsh-mini-utility-dock/dist/guard.js.
4
- // DSH plugin host halves are plain Node ESM that each package ships standalone,
5
- // so the fragment is embedded into `lib/shared.js` at build time by
5
+ // A host half is plain Node ESM that its package ships standalone, so the
6
+ // fragment is embedded into `lib/shared.js` at build time by
6
7
  // npm run guard:sync (write it)
7
8
  // npm run guard:check (fail on drift)
8
9
  // instead of being imported: a bare `import 'dsh-mini-utility-dock/...'` would
9
- // put a runtime dependency on the dock into every plugin, and the whole point of
10
- // the dock is that a plugin ships standalone, with nothing else required.
10
+ // put a runtime dependency on this package into the file that embeds it, and an
11
+ // embedding plugin ships standalone, with nothing else required.
11
12
  //
12
13
  // This file is the POLICY half. What counts as loopback is a separate fragment
13
14
  // (`dist/loopback.js`, embedded under the `dsh-loopback-helpers` marker), and
@@ -21,18 +22,15 @@
21
22
  // file, so an import of another module would both break the standalone promise
22
23
  // and collide with the exports the block above already declares.
23
24
  //
24
- // Why the guard is shared rather than reimplemented per plugin: each consumer
25
- // carried its own `createGuard`, and the copies diverged repeatedly. The
26
- // parts that differed were never the *decisions* — they were the error codes and
27
- // message strings welded into the same function, which forced every repo to keep
28
- // its own copy and made drift possible. Here the enforcement order and every
29
- // decision are fixed, and the wording is supplied as data by the caller
30
- // (`policy`), so a plugin customizes its vocabulary without forking the logic.
25
+ // The enforcement order and every decision are fixed here, and the wording is
26
+ // supplied as data by the caller (`policy`): a plugin customizes the codes and
27
+ // messages its own API publishes without forking the checks. Keeping the
28
+ // vocabulary out of the decisions is what makes one copy of them enough.
31
29
 
32
30
  // Default ports each scheme normalises away, so an Origin carrying no explicit
33
31
  // port (for example `http://127.0.0.1`) compares equal to a server on 80/443.
34
- // `new URL('http://127.0.0.1:80').port` is '', which compared unequal to "80"
35
- // and turned a legitimate same-origin request into a rejection.
32
+ // `new URL('http://127.0.0.1:80').port` is '', which would read as unequal to
33
+ // `80` and reject a legitimate same-origin request.
36
34
  const DEFAULT_PORTS = { 'http:': '80', 'https:': '443' }
37
35
  export const portOf = (url) => url.port || DEFAULT_PORTS[url.protocol] || ''
38
36
 
@@ -41,10 +39,9 @@ export const portOf = (url) => url.port || DEFAULT_PORTS[url.protocol] || ''
41
39
  // plugin's API exposes and the human wording are policy.
42
40
  //
43
41
  // An unidentifiable peer and an off-loopback peer are deliberately distinct
44
- // decisions. Two plugins answer `non_loopback_peer` for both; one distinguishes
45
- // them. Both distinctions are correct for their own API, and a plugin that
46
- // collapses them names the same `code` for each — nothing widens either way,
47
- // because every reason rejects.
42
+ // decisions, because they are distinct facts about the request. A caller may map
43
+ // both to the same `code`: that is a vocabulary choice about its published API,
44
+ // and nothing widens either way, because every reason rejects.
48
45
  export const GUARD_REASONS = Object.freeze([
49
46
  'non_loopback_peer',
50
47
  'cross_site',
@@ -66,9 +63,9 @@ export const DEFAULT_GUARD_POLICY = Object.freeze({
66
63
  * Build the same-origin request guard for a loopback-bound API route.
67
64
  *
68
65
  * Not exported under a plugin-facing name: each plugin publishes its own guard
69
- * bound to its own error vocabulary, so the name it exports — usually
70
- * `createGuard`, matching its previous API — is its own to declare. This is the
71
- * one factory every plugin calls.
66
+ * bound to its own error vocabulary, so the name it exports — `createGuard` is
67
+ * the conventional one — is the plugin's own to declare. This is the one factory
68
+ * every plugin that embeds this block calls.
72
69
  *
73
70
  * Enforces, in order: Fetch Metadata, an unparseable Host, the TCP peer address,
74
71
  * then the Host allowlist, then the Origin. Rejects by calling
@@ -0,0 +1,207 @@
1
+ // Host-side HTTP glue, for a plugin's host half that answers its own API.
2
+ //
3
+ // This fragment has ONE source of truth: dsh-mini-utility-dock/dist/host-http.js.
4
+ // A host half is plain Node ESM that its package ships standalone, so the
5
+ // fragment is embedded into `lib/shared.js` at build time by
6
+ // npm run http:sync (write it)
7
+ // npm run http:check (fail on drift)
8
+ // instead of being imported: a bare `import 'dsh-mini-utility-dock/...'` would
9
+ // put a runtime dependency on this package into the file that embeds it, and an
10
+ // embedding plugin ships standalone, with nothing else required.
11
+ //
12
+ // There is deliberately no `import` here. The block sits BELOW `dsh-host-guard`
13
+ // in the consumer's `lib/shared.js` and declares nothing the blocks above it
14
+ // already declared, so the three host-side blocks coexist in one file and a
15
+ // plugin that needs only this one can embed only this one.
16
+ //
17
+ // These lines are the response policy, so they are stated once here: every JSON
18
+ // reply carries `cache-control: no-store`, because a body naming instance ports,
19
+ // pids or session ids must never be servable from an intermediary cache — that
20
+ // header is a security posture, not cosmetics. The "browser authorization is
21
+ // unavailable" reply and the POST gate each have one definition, so a change to
22
+ // one reaches every file that embeds this block at the same time.
23
+ //
24
+ // What legitimately differs between plugins — a machine-readable `code`, the
25
+ // human wording, whether the rejected `action` is echoed — is supplied as data
26
+ // (`policy`), exactly the way `dist/guard.js` separates enforcement from
27
+ // vocabulary.
28
+
29
+ /**
30
+ * Send a JSON reply and end the response.
31
+ *
32
+ * `cache-control: no-store` is part of this function rather than of each call
33
+ * site: a route that reports live host facts (ports, pids, session data) must
34
+ * not be servable from an intermediary cache, and a two-line helper that each
35
+ * call site restates is where such a header goes missing.
36
+ */
37
+ export function sendJson(res, status, body) {
38
+ const text = JSON.stringify(body)
39
+ res.writeHead(status, {
40
+ 'content-type': 'application/json; charset=utf-8',
41
+ 'cache-control': 'no-store'
42
+ })
43
+ res.end(text)
44
+ }
45
+
46
+ /**
47
+ * The one reply for "browser authorization is unavailable right now". Exported
48
+ * as data so a call site that cannot go through `connectionUnavailable()` — a
49
+ * test, or a route that composes its own body — still states it once.
50
+ */
51
+ export const CONNECTION_UNAVAILABLE = Object.freeze({
52
+ ok: false,
53
+ code: 'connection_unavailable',
54
+ error: 'browser authentication unavailable'
55
+ })
56
+
57
+ /**
58
+ * Answer `res` with 503 and the shared `connection_unavailable` payload, and
59
+ * return false so a caller can hand its own verdict back in one statement.
60
+ * `respond` has the same injectable sink shape as the guard block's `bindGuard`
61
+ * — `(res, status, body)` — and it defaults to this block's `sendJson`, so the
62
+ * no-store policy is not something a call site can drop.
63
+ */
64
+ export const connectionUnavailable = (res, respond = sendJson) => {
65
+ respond(res, 503, { ...CONNECTION_UNAVAILABLE })
66
+ return false
67
+ }
68
+
69
+ /**
70
+ * The reasons the POST gate can reject. One today; the table exists so the
71
+ * wording is overridable by key and a typo in that key is an error rather than a
72
+ * silent no-op — the same contract the guard block's `GUARD_REASONS` offers.
73
+ */
74
+ export const REQUIRE_POST_REASONS = Object.freeze(['method_not_allowed'])
75
+
76
+ /** Default code and wording. `{action}` in `error` is substituted per call. */
77
+ export const DEFAULT_REQUIRE_POST_POLICY = Object.freeze({
78
+ method_not_allowed: { code: 'method', error: 'action "{action}" requires POST' }
79
+ })
80
+
81
+ /**
82
+ * Build the POST-only gate for a mutating action.
83
+ *
84
+ * Behavior is fixed: the method is read case-insensitively (an absent method
85
+ * reads as `GET`, which is not POST), POST passes, anything else is answered
86
+ * 405 and returns false. What a plugin publishes — the `code`, the language of
87
+ * `error`, and whether the rejected `action` is echoed in the body — is policy:
88
+ *
89
+ * createRequirePost() // `{ code: 'method' }`
90
+ * createRequirePost({ policy: { method_not_allowed: { // an established API keeps its shape
91
+ * code: 'need_post', error: '{action} 需要 POST 请求', includeAction: true
92
+ * } } })
93
+ *
94
+ * @param respond - rejection sink, defaults to this block's `sendJson`, so the
95
+ * `no-store` header is not a thing a caller can forget to pass.
96
+ * @param policy - optional per-reason `{ code, error, includeAction }` overrides
97
+ * keyed by `REQUIRE_POST_REASONS`; a partial override merges with the default,
98
+ * and an unknown key throws.
99
+ */
100
+ export const createRequirePost = ({ respond = sendJson, policy } = {}) => {
101
+ const overrides = policy || {}
102
+ for (const key of Object.keys(overrides)) {
103
+ if (!REQUIRE_POST_REASONS.includes(key)) {
104
+ throw new Error(`createRequirePost: unknown policy key ${JSON.stringify(key)}; expected one of ${REQUIRE_POST_REASONS.join(', ')}`)
105
+ }
106
+ }
107
+ const say = Object.fromEntries(REQUIRE_POST_REASONS.map((reason) => [
108
+ reason,
109
+ { ...DEFAULT_REQUIRE_POST_POLICY[reason], ...(overrides[reason] || {}) }
110
+ ]))
111
+ const render = (template, action) => String(template).replace(/\{action\}/g, action == null ? '' : String(action))
112
+
113
+ return function requirePost(req, res, action) {
114
+ if (String((req && req.method) || 'GET').toUpperCase() === 'POST') return true
115
+ const words = say.method_not_allowed
116
+ respond(res, 405, {
117
+ ok: false,
118
+ code: words.code,
119
+ error: render(words.error, action),
120
+ ...(words.includeAction ? { action } : {})
121
+ })
122
+ return false
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build the browser authorizer for a route that is guarded by RC1's Connection
128
+ * when the host provides one, and by the plugin's own same-origin guard when it
129
+ * does not.
130
+ *
131
+ * @param getConnection - `() => connection`, an ACCESSOR, never the value. A host
132
+ * half keeps its Connection in a closure variable that service unload/reload
133
+ * reassigns to `null` and later back to a new instance; a captured value would
134
+ * keep authorizing against a disposed Connection forever.
135
+ * @param getConnectionSeen - `() => connectionSeen`, an accessor for the latch
136
+ * that records "this host has had an RC1 Connection at least once". Same
137
+ * reason, same consequence: read it per request.
138
+ * @param guard - the plugin's bound request guard, used only on a host that has
139
+ * never had a Connection (older hosts, where the guard IS the boundary).
140
+ * @param respond - rejection sink, defaults to this block's `sendJson`.
141
+ * @returns `(req, res) => boolean`, true when the request may proceed.
142
+ *
143
+ * The decision order is the security property, so it is fixed here:
144
+ *
145
+ * 1. A Connection that throws is not a Connection that permits. The request is
146
+ * rejected 503 and must never fall through to the route handler.
147
+ * 2. A rejection code from the Connection is final: the status is that code,
148
+ * 401 reads as `unauthorized`, anything else as `forbidden`.
149
+ * 3. No Connection but a seen one: 503, and deliberately NOT the guard. Once
150
+ * the host has had an RC1 Connection, an unload gap must not reopen the
151
+ * route through the weaker loopback fence — a local socket peer is not the
152
+ * same statement as an authorized browser.
153
+ * 4. No Connection and none ever seen: this is a pre-RC1 host, and the
154
+ * plugin's own guard is the whole boundary, so it decides.
155
+ *
156
+ * These two codes and their wording state the decision itself rather than a
157
+ * plugin's published vocabulary, so they are not policy and are not injectable;
158
+ * only wording that legitimately differs between plugins belongs in a table.
159
+ */
160
+ export const createBrowserAuthorizer = ({ getConnection, getConnectionSeen, guard, respond = sendJson }) => {
161
+ if (typeof getConnection !== 'function') {
162
+ throw new Error('createBrowserAuthorizer: getConnection must be an accessor (`() => connection`); a Connection captured here is stale the first time the service reloads')
163
+ }
164
+ if (typeof getConnectionSeen !== 'function') {
165
+ throw new Error('createBrowserAuthorizer: getConnectionSeen must be an accessor (`() => connectionSeen`); the latch is set once an RC1 Connection exists and must never be read from a captured copy')
166
+ }
167
+ if (typeof guard !== 'function') {
168
+ throw new Error('createBrowserAuthorizer: guard is required; a host without an RC1 Connection falls back to the plugin request guard')
169
+ }
170
+
171
+ return function authorizeBrowser(req, res) {
172
+ const connection = getConnection()
173
+ if (connection) {
174
+ let rejection
175
+ try {
176
+ rejection = connection.requestRejection(req)
177
+ } catch {
178
+ return connectionUnavailable(res, respond)
179
+ }
180
+ if (rejection !== undefined) {
181
+ respond(res, rejection, {
182
+ ok: false,
183
+ code: rejection === 401 ? 'unauthorized' : 'forbidden',
184
+ error: rejection === 401 ? 'browser authentication required' : 'request rejected'
185
+ })
186
+ return false
187
+ }
188
+ return true
189
+ }
190
+ // Once an RC1 Connection has existed, a reload gap answers 503 — it does not
191
+ // downgrade to the guard fence.
192
+ if (getConnectionSeen()) return connectionUnavailable(res, respond)
193
+ return guard(req, res)
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Accept only a bounded, plausible session id from query or body input.
199
+ * Returns the trimmed value or null; `null` is the caller's "missing/invalid"
200
+ * signal, so an empty string never reaches a lookup.
201
+ */
202
+ export function optionalSessionId(value) {
203
+ if (typeof value !== 'string') return null
204
+ const trimmed = value.trim()
205
+ if (!trimmed || trimmed.length > 512) return null
206
+ return trimmed
207
+ }