dsh-mini-utility-dock 0.1.1 → 0.1.3

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
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 - 2026-09-14
4
+
5
+ - Add two host-side fragments. `dist/loopback.js` (`dsh-loopback-helpers`) exports `LOOPBACK_HOSTNAMES`, `normalizeHostValue`, `hostHostname`, `isLoopbackName` and `isLoopbackAddress`. `dist/guard.js` (`dsh-host-guard`) exports `portOf`, `GUARD_REASONS`, `DEFAULT_GUARD_POLICY` and an internal `bindGuard()` factory; a consumer passes its own error codes and wording as `policy`, so the enforcement does not fork per plugin.
6
+ - `sync` / `check` now maintain **every** marked fragment in the target file, applied bottom-up in `FRAGMENTS` order, instead of requiring exactly one block. Single-block callers are unaffected.
7
+ - Correct the READMEs and the package description: each host-side fragment's responsibility, the fixed order, and why `bindGuard` is not exported.
8
+ - Add tests for multi-block sync and idempotent `check`, block order, the absence of `import`/`require` in either fragment, and `bindGuard` staying private with no duplicated predicates.
9
+
10
+ ## 0.1.2 - 2026-09-06
11
+
12
+ - Fix the README `npm run dock:embed` examples: add the previously missing `dock:embed` script to `package.json` and switch the usage to `npm run dock:embed -- check|sync path/to/client.js` (npm argument passing needs the `--` separator, and the CLI takes `sync|check` subcommands, not `--check`). A new smoke test extracts the documented commands from both READMEs and runs them, preventing docs/script drift.
13
+
3
14
  ## 0.1.1 - 2026-09-04
4
15
 
5
16
  - Normalize `label` in `register()`: a missing, blank, or non-string label falls back to `id`, so no item renders `aria-label="undefined"`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 - 2026-09-14
4
+
5
+ - 新增两个 host 侧片段:`dist/loopback.js`(`dsh-loopback-helpers`)导出 `LOOPBACK_HOSTNAMES`、`normalizeHostValue`、`hostHostname`、`isLoopbackName`、`isLoopbackAddress`;`dist/guard.js`(`dsh-host-guard`)导出 `portOf`、`GUARD_REASONS`、`DEFAULT_GUARD_POLICY` 与内部的 `bindGuard()` 工厂。调用方以 `policy` 传入自己的错误码与文案,判定逻辑不随之分叉。
6
+ - `sync` / `check` 改为处理目标文件中**所有**已标记的片段,按 `FRAGMENTS` 顺序自下而上应用,不再要求恰好一个块。单块调用方的行为不变。
7
+ - 修正 README 与包描述:说明 host 侧两个片段各自的职责、固定顺序,以及 `bindGuard` 不导出的原因。
8
+ - 新增测试:多块同步与幂等 `check`、块顺序、两个片段均不引入 `import`/`require`、`bindGuard` 不导出且判据不被重复声明。
9
+
10
+ ## 0.1.2 - 2026-09-06
11
+
12
+ - 修正 README 的 `npm run dock:embed` 示例:补上 `package.json` 缺失的 `dock:embed` 脚本,用法改为 `npm run dock:embed -- check|sync path/to/client.js`(npm 传参需要 `--` 分隔符,CLI 接受 `sync|check` 子命令而非 `--check`)。新增从双语 README 提取命令逐条执行的 smoke test,防止文档与脚本漂移。
13
+
3
14
  ## 0.1.1 - 2026-09-04
4
15
 
5
16
  - 归一化 `register()` 的 `label`:缺省、空白或非字符串的 `label` 回退为 `id`,避免渲染出 `aria-label="undefined"`。
package/README.en.md CHANGED
@@ -1,21 +1,53 @@
1
1
  # dsh-mini-utility-dock
2
2
 
3
- The shared DSH utility dock package. It provides a canonical, self-contained classic-script fragment and a build-time CLI for embedding it into a plugin `client.js`.
3
+ Source fragments and an embedding CLI shared by the DSH plugins. Fragments are written into a consumer at build time, so a plugin publishes standalone with no dependency on this package.
4
4
 
5
- Put these markers in the client file:
5
+ ## Fragments
6
6
 
7
- ```js
8
- // <dsh-mini-utility-dock>
9
- // </dsh-mini-utility-dock>
7
+ Three fragments, each delimited by markers.
8
+
9
+ | Fragment | Marker | Target file | Exports |
10
+ | --- | --- | --- | --- |
11
+ | dock bootstrap | `dsh-mini-utility-dock` | `lib/client.js` | — |
12
+ | loopback predicates | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`, `normalizeHostValue`, `hostHostname`, `isLoopbackName`, `isLoopbackAddress` |
13
+ | host request guard | `dsh-host-guard` | `lib/shared.js` | `portOf`, `GUARD_REASONS`, `DEFAULT_GUARD_POLICY` |
14
+
15
+ `dsh-loopback-helpers` decides address locality; the Host-header path and the TCP-peer path share one decision. `dsh-host-guard` decides admission, and provides a `bindGuard()` factory internally.
16
+
17
+ ## Usage
18
+
19
+ Write the markers into the target file, then run the CLI.
20
+
21
+ ```sh
22
+ npx dsh-mini-utility-dock sync path/to/client.js
23
+ npx dsh-mini-utility-dock check path/to/client.js
10
24
  ```
11
25
 
12
- Run `npx dsh-mini-utility-dock sync path/to/client.js` to embed the current fragment, or `... check ...` in CI. Marker indentation is preserved. The fragment deduplicates itself through the page-local global protocol v1.
26
+ `sync` preserves the markers and their indentation and writes the fragment; `check` exits non-zero when the content no longer matches it. The CLI maintains every marked fragment in the target file, applied bottom-up in `FRAGMENTS` order. A file containing a single fragment is unaffected.
13
27
 
14
- You can also embed from this repo's own script (it never hard-codes a consumer path; the target is passed by the caller):
28
+ This repository also exposes an equivalent entry point, with the target path passed by the caller:
15
29
 
16
30
  ```sh
17
- npm run dock:embed --check path/to/client.js # verify only, non-zero on drift
18
- npm run dock:embed path/to/client.js # write between the markers
31
+ npm run dock:embed -- check path/to/client.js # verify only, non-zero on drift
32
+ npm run dock:embed -- sync path/to/client.js # write between the markers
19
33
  ```
20
34
 
21
- When registering, a missing, blank, or non-string `label` falls back to `id` as the accessible name, avoiding `aria-label="undefined"`.
35
+ Consumers invoke the same CLI as `loopback:sync` / `guard:sync`, with the target fixed to their own `lib/shared.js`; either command maintains both fragments in that file.
36
+
37
+ ## The two host-side fragments
38
+
39
+ The two fragments in `lib/shared.js` have a fixed order, `dsh-loopback-helpers` first.
40
+
41
+ The order is a functional requirement, not a style choice. Both fragments occupy one file, and `dsh-host-guard` uses the module-scope names the preceding fragment exports. It therefore neither redeclares those predicates nor imports a sibling module: redeclaring collides with the declaration in the same file, and importing would break the standalone-publication constraint. The `guard-parity` check fails when the order is reversed.
42
+
43
+ `bindGuard` is not exported from the fragment. A consumer declares its own guard export in the same file (typically reusing an existing name such as `createGuard`), and exporting the same identifier would collide. Each plugin calls `bindGuard()` and passes its own error codes and wording through `policy`: the enforcement is shared, the error vocabulary stays with each plugin.
44
+
45
+ ## Development
46
+
47
+ `dist/` is the single source for each fragment. Run `npm test` after a change; consumers that have already embedded a fragment must re-run the matching `sync` command.
48
+
49
+ A fragment must not contain `import` or `require`: it shares a file with the consumer's own code, and the consumer must publish standalone. Tests assert this constraint.
50
+
51
+ ## Registration
52
+
53
+ A missing, blank, or non-string `label` falls back to `id`, so no item renders `aria-label="undefined"`.
package/README.md CHANGED
@@ -1,30 +1,53 @@
1
1
  # dsh-mini-utility-dock
2
2
 
3
- DSH 插件共享的 utility dock:提供一个 canonical classic-script 片段,并在构建时嵌入插件的 `client.js`。
3
+ DSH 插件共享的源码片段与嵌入 CLI。片段在构建时写入消费插件,使插件无需依赖本包即可独立发布。
4
4
 
5
- ## 使用
5
+ ## 片段
6
6
 
7
- 在目标文件中放置标记:
7
+ 三个片段,均由标记界定。
8
8
 
9
- ```js
10
- // <dsh-mini-utility-dock>
11
- // </dsh-mini-utility-dock>
12
- ```
9
+ | 片段 | 标记 | 目标文件 | 导出 |
10
+ | --- | --- | --- | --- |
11
+ | dock 引导 | `dsh-mini-utility-dock` | `lib/client.js` | — |
12
+ | loopback 判定 | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`、`normalizeHostValue`、`hostHostname`、`isLoopbackName`、`isLoopbackAddress` |
13
+ | host 请求守卫 | `dsh-host-guard` | `lib/shared.js` | `portOf`、`GUARD_REASONS`、`DEFAULT_GUARD_POLICY` |
13
14
 
14
- 然后运行:
15
+ `dsh-loopback-helpers` 定义地址的归属:Host 头与 TCP 对端两条路径共用同一判定。`dsh-host-guard` 定义准入策略,并在内部提供 `bindGuard()` 工厂。
16
+
17
+ ## 使用
18
+
19
+ 在目标文件中写入标记,然后运行 CLI。
15
20
 
16
21
  ```sh
17
22
  npx dsh-mini-utility-dock sync path/to/client.js
18
23
  npx dsh-mini-utility-dock check path/to/client.js
19
24
  ```
20
25
 
21
- `sync` 保留标记及其缩进;`check` 在内容漂移时以非零状态退出。内嵌脚本通过 global protocol v1 在页面内去重,插件仍可单独运行。
26
+ `sync` 保留标记及其缩进并写入片段;`check` 在内容与片段不一致时以非零状态退出。CLI 处理目标文件中所有已标记的片段,按 `FRAGMENTS` 顺序自下而上应用。仅含单个片段的文件行为不变。
22
27
 
23
- 也可用本仓库脚本同步(不硬编码任何消费仓路径,目标由调用方传入):
28
+ 本仓库另提供等价入口,目标路径由调用方传入:
24
29
 
25
30
  ```sh
26
- npm run dock:embed --check path/to/client.js # 仅校验,漂移时非零退出
27
- npm run dock:embed path/to/client.js # 写入标记之间
31
+ npm run dock:embed -- check path/to/client.js # 仅校验,漂移时非零退出
32
+ npm run dock:embed -- sync path/to/client.js # 写入标记之间
28
33
  ```
29
34
 
30
- 注册时若 `label` 缺省、空白或非字符串,会回退为 `id` 作为可访问名称,避免 `aria-label="undefined"`。
35
+ 消费插件以 `loopback:sync` / `guard:sync` 调用同一 CLI,目标固定为自身的 `lib/shared.js`;任一命令都会同步该文件中的两个片段。
36
+
37
+ ## host 侧两个片段的约定
38
+
39
+ `lib/shared.js` 中的两个片段顺序固定,`dsh-loopback-helpers` 在前。
40
+
41
+ 顺序是功能要求,不是风格约定。两者位于同一文件,`dsh-host-guard` 直接使用前一片段导出的模块级名字,因此它既不重新声明这些判据,也不 import 兄弟模块:重新声明与 import 都会与同文件内的声明冲突,且 import 会破坏插件独立发布的约束。`guard-parity` 检查在该顺序被颠倒时失败。
42
+
43
+ `bindGuard` 不从片段导出。消费插件在同一文件中声明自己的 guard 导出(通常沿用 `createGuard` 等既有名字),导出同名标识符会冲突。每个插件调用 `bindGuard()`,通过 `policy` 传入自身的错误码与文案;判定逻辑共用,错误词汇由各插件持有。
44
+
45
+ ## 开发
46
+
47
+ 片段的唯一来源是 `dist/`。修改后运行 `npm test`;已嵌入片段的消费插件需重新运行对应的 `sync` 命令。
48
+
49
+ 片段不得包含 `import` 或 `require`:它们与消费插件自身的代码位于同一文件,且消费插件必须独立发布。测试对此约束进行断言。
50
+
51
+ ## 注册
52
+
53
+ `label` 缺省、空白或非字符串时回退为 `id`,避免渲染出 `aria-label="undefined"`。
@@ -1,15 +1,53 @@
1
1
  #!/usr/bin/env node
2
+ // Embed a canonical fragment from this package into a consumer file.
3
+ //
4
+ // Two fragments are maintained the same way, because both have the same shape
5
+ // of problem: the consumer must ship the code standalone (a browser classic
6
+ // script cannot `import`, and a host half must not depend on the dock being
7
+ // installed), so the code is embedded at build time and drift is a build error.
8
+ //
9
+ // dsh-mini-utility-dock -> <dsh-mini-utility-dock> ... </dsh-mini-utility-dock>
10
+ // the Mini Utility Dock bootstrap, embedded into a plugin's lib/client.js
11
+ //
12
+ // dsh-loopback-helpers -> <dsh-loopback-helpers> ... </dsh-loopback-helpers>
13
+ // the loopback predicates, embedded into a plugin's lib/shared.js
14
+ //
15
+ // dsh-host-guard -> <dsh-host-guard> ... </dsh-host-guard>
16
+ // the same-origin request guard, embedded into the same file
17
+ //
18
+ // The host half of a plugin embeds BOTH shared.js fragments, in the order above,
19
+ // because the guard imports the predicates. Each is an independent block with its
20
+ // own markers and its own check, so a plugin that only needs the predicates can
21
+ // embed just those.
22
+ //
23
+ // The fragment is selected by the marker already present in the target file, so
24
+ // one command serves either target. A consumer calls this through its own
25
+ // `dock:sync` / `guard:sync` script; the paired `check` mode is what CI runs.
26
+
2
27
  import { readFile, writeFile, stat } from 'node:fs/promises'
3
28
  import { resolve } from 'node:path'
4
29
  import { fileURLToPath } from 'node:url'
5
30
 
6
- const START = '// <dsh-mini-utility-dock>'
7
- const END = '// </dsh-mini-utility-dock>'
31
+ // Listed in dependency order: the guard fragment reads the predicates the
32
+ // loopback fragment declares, so a consumer that wants the guard syncs both, in
33
+ // this order, into the same file.
34
+ const FRAGMENTS = [
35
+ { name: 'dsh-mini-utility-dock', source: 'bootstrap.js' },
36
+ { name: 'dsh-loopback-helpers', source: 'loopback.js' },
37
+ { name: 'dsh-host-guard', source: 'guard.js' }
38
+ ]
39
+
8
40
  const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
9
- const bootstrapPath = resolve(packageRoot, 'dist', 'bootstrap.js')
41
+ const marker = (name, edge) => `// <${edge}${name}>`
10
42
 
11
43
  function usage() {
12
- return 'Usage: dsh-mini-utility-dock <sync|check> <client-file>'
44
+ const fragments = FRAGMENTS.map((f) => ` ${marker(f.name, '')} -> dist/${f.source}`).join('\n')
45
+ return [
46
+ 'Usage: dsh-mini-utility-dock <sync|check> <consumer-file>',
47
+ '',
48
+ 'The fragment is chosen by the marker already present in <consumer-file>:',
49
+ fragments
50
+ ].join('\n')
13
51
  }
14
52
 
15
53
  function error(message) {
@@ -18,11 +56,11 @@ function error(message) {
18
56
  }
19
57
 
20
58
  async function loadTarget(fileName) {
21
- if (!fileName || fileName.startsWith('-')) throw new Error('client-file is required')
59
+ if (!fileName || fileName.startsWith('-')) throw new Error('consumer-file is required')
22
60
  const target = resolve(fileName)
23
61
  const info = await stat(target).catch(() => null)
24
62
  if (!info) throw new Error(`file does not exist: ${target}`)
25
- if (!info.isFile()) throw new Error(`client-file is not a regular file: ${target}`)
63
+ if (!info.isFile()) throw new Error(`consumer-file is not a regular file: ${target}`)
26
64
  return { target, source: await readFile(target, 'utf8') }
27
65
  }
28
66
 
@@ -30,43 +68,66 @@ function indentation(line) {
30
68
  return (/^[ \t]*/.exec(line) || [''])[0]
31
69
  }
32
70
 
71
+ // Locate every marked block in the file, in fragment order. A host half embeds
72
+ // both shared.js fragments, so one command must maintain both; processing them in
73
+ // FRAGMENTS order also keeps the dependency order (the guard reads the predicates
74
+ // declared by the loopback block above it).
33
75
  function locate(source) {
34
76
  const lines = source.split(/\r?\n/)
35
- const starts = lines.reduce((found, line, index) => line.trim() === START ? [...found, index] : found, [])
36
- const ends = lines.reduce((found, line, index) => line.trim() === END ? [...found, index] : found, [])
37
- if (starts.length !== 1 || ends.length !== 1 || ends[0] <= starts[0]) {
38
- throw new Error(`expected exactly one marked block (${START} ... ${END})`)
77
+ const candidates = FRAGMENTS.map((fragment) => {
78
+ const start = lines.reduce((hits, line, i) => line.trim() === marker(fragment.name, '') ? [...hits, i] : hits, [])
79
+ const end = lines.reduce((hits, line, i) => line.trim() === marker(fragment.name, '/') ? [...hits, i] : hits, [])
80
+ return { fragment, start, end }
81
+ }).filter((candidate) => candidate.start.length || candidate.end.length)
82
+
83
+ if (candidates.length === 0) {
84
+ throw new Error(`no fragment marker found; expected at least one marked block (${FRAGMENTS.map((f) => marker(f.name, '')).join(' or ')})`)
85
+ }
86
+ for (const { fragment, start, end } of candidates) {
87
+ if (start.length !== 1 || end.length !== 1 || end[0] <= start[0]) {
88
+ throw new Error(`expected exactly one marked block for ${fragment.name} (${marker(fragment.name, '')} ... ${marker(fragment.name, '/')})`)
89
+ }
39
90
  }
40
- return { lines, start: starts[0], end: ends[0], indent: indentation(lines[starts[0]]) }
91
+ return { lines, blocks: candidates.map(({ fragment, start, end }) => ({ fragment, start: start[0], end: end[0] })) }
41
92
  }
42
93
 
43
- function rendered(source, bootstrap) {
44
- const newline = source.includes('\r\n') ? '\r\n' : '\n'
45
- const block = locate(source)
46
- const body = bootstrap.replace(/\r?\n$/, '').split(/\r?\n/)
47
- .map((line) => line ? block.indent + line : '')
48
- const output = [...block.lines.slice(0, block.start + 1), ...body, ...block.lines.slice(block.end)]
49
- .join(newline)
50
- return { output, block }
94
+ function rendered(lines, fragmentSource, block) {
95
+ const indent = indentation(lines[block.start])
96
+ const body = fragmentSource.replace(/\r?\n$/, '').split(/\r?\n/)
97
+ .map((line) => line ? indent + line : '')
98
+ return [...lines.slice(0, block.start + 1), ...body, ...lines.slice(block.end)]
51
99
  }
52
100
 
101
+ // Applied from the LAST block to the first. Each pass replaces a line range, so
102
+ // doing it bottom-up leaves every earlier block's line indices valid; going
103
+ // top-down would shift them and a later pass would splice the wrong lines.
53
104
  async function main() {
54
105
  const [command, fileName, ...rest] = process.argv.slice(2)
55
106
  if (!['sync', 'check'].includes(command) || !fileName || rest.length) throw new Error(usage())
56
107
  const { target, source } = await loadTarget(fileName)
57
- const bootstrap = await readFile(bootstrapPath, 'utf8')
58
- const result = rendered(source, bootstrap)
108
+ const located = locate(source)
109
+
110
+ let lines = located.lines
111
+ const applied = []
112
+ for (const block of [...located.blocks].reverse()) {
113
+ const fragmentSource = await readFile(resolve(packageRoot, 'dist', block.fragment.source), 'utf8')
114
+ lines = rendered(lines, fragmentSource, block)
115
+ applied.unshift(block.fragment.name)
116
+ }
117
+ const output = lines.join(source.includes('\r\n') ? '\r\n' : '\n')
118
+
119
+ const names = applied.join(', ')
59
120
  if (command === 'check') {
60
- if (source !== result.output) throw new Error(`marked block is out of date: ${target}`)
61
- console.log(`ok: ${target}`)
121
+ if (output !== source) throw new Error(`marked block is out of date: ${target} (${names})`)
122
+ console.log(`ok: ${target} (${names})`)
62
123
  return
63
124
  }
64
- if (source === result.output) {
65
- console.log(`unchanged: ${target}`)
125
+ if (source === output) {
126
+ console.log(`unchanged: ${target} (${names})`)
66
127
  return
67
128
  }
68
- await writeFile(target, result.output, 'utf8')
69
- console.log(`synced: ${target}`)
129
+ await writeFile(target, output, 'utf8')
130
+ console.log(`synced: ${target} (${names})`)
70
131
  }
71
132
 
72
133
  main().catch((cause) => error(cause instanceof Error ? cause.message : String(cause)))
package/dist/guard.js ADDED
@@ -0,0 +1,167 @@
1
+ // Host-side request guard shared by the DSH plugins.
2
+ //
3
+ // 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
6
+ // npm run guard:sync (write it)
7
+ // npm run guard:check (fail on drift)
8
+ // 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 works with no sibling installed.
11
+ //
12
+ // This file is the POLICY half. What counts as loopback is a separate fragment
13
+ // (`dist/loopback.js`, embedded under the `dsh-loopback-helpers` marker), and
14
+ // this module uses the predicates that block exports in the same file rather than
15
+ // restating them: `hostHostname`, `isLoopbackName` and `isLoopbackAddress` are
16
+ // module-scope names here, declared by the block above. That keeps one copy of
17
+ // them in a consumer, and makes the dependency one-way and visible — `guard:sync`
18
+ // needs `loopback:sync` to have produced a `lib/shared.js` that declares them.
19
+ //
20
+ // There is deliberately no `import` here. A consumer embeds both blocks into one
21
+ // file, so an import of a sibling module would both break the standalone promise
22
+ // and collide with the exports the block above already declares.
23
+ //
24
+ // Why the guard is shared rather than reimplemented per plugin: the three plugins
25
+ // each carried their own `createGuard`, and the copies diverged three times. 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.
31
+
32
+ // Default ports each scheme normalises away, so an Origin carrying no explicit
33
+ // 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.
36
+ const DEFAULT_PORTS = { 'http:': '80', 'https:': '443' }
37
+ export const portOf = (url) => url.port || DEFAULT_PORTS[url.protocol] || ''
38
+
39
+ // The reasons this guard can reject. Each is a stable, guard-owned name for one
40
+ // decision; the *reason* is fixed here, while the machine-readable `code` a
41
+ // plugin's API exposes and the human wording are policy.
42
+ //
43
+ // 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.
48
+ export const GUARD_REASONS = Object.freeze([
49
+ 'non_loopback_peer',
50
+ 'cross_site',
51
+ 'unknown_peer',
52
+ 'foreign_origin',
53
+ 'non_loopback_host'
54
+ ])
55
+
56
+ /** Default machine-readable codes and wording, in English, per reason. */
57
+ export const DEFAULT_GUARD_POLICY = Object.freeze({
58
+ non_loopback_peer: { code: 'non_loopback_peer', error: 'non-loopback peer rejected' },
59
+ cross_site: { code: 'cross_site', error: 'cross-site request rejected' },
60
+ unknown_peer: { code: 'unknown_peer', error: 'peer address is not identifiable' },
61
+ foreign_origin: { code: 'foreign_origin', error: 'foreign origin rejected' },
62
+ non_loopback_host: { code: 'non_loopback_host', error: 'non-loopback host rejected' }
63
+ })
64
+
65
+ /**
66
+ * Build the same-origin request guard for a loopback-bound API route.
67
+ *
68
+ * 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.
72
+ *
73
+ * Enforces, in order: Fetch Metadata, an unparseable Host, the TCP peer address,
74
+ * then the Host allowlist, then the Origin. Rejects by calling
75
+ * `respond(res, 403, { ok: false, code, error })` and returning false; returns
76
+ * true when the request may proceed.
77
+ *
78
+ * @param currentPort - the port this server listens on. A function is called per
79
+ * request so an Origin check follows a server whose port changes; a plain
80
+ * value is accepted for a fixed server.
81
+ * @param respond - rejection sink, normally the plugin's `sendJson`. Kept
82
+ * injectable so tests can capture the rejection code instead of standing up a
83
+ * real ServerResponse.
84
+ * @param allowRemoteHost - optional predicate. When it returns true, an
85
+ * off-loopback peer AND an off-loopback Host are admitted, because the caller
86
+ * has opted into verifying its own credential per request; the guard
87
+ * deliberately knows nothing about tokens. The Origin check still applies, so
88
+ * the exemption never widens the browser-facing boundary. A plugin that omits
89
+ * the predicate keeps absolute peer and Host criteria.
90
+ * @param policy - optional per-reason `{ code, error }` overrides, keyed by
91
+ * `GUARD_REASONS`. A partial override merges with the default, so a plugin
92
+ * names only the reasons whose vocabulary differs. An unknown key throws: a
93
+ * typo would otherwise silently leave the default in place, and the plugin
94
+ * would expose a code no test expects.
95
+ */
96
+ const bindGuard = ({ currentPort, respond, allowRemoteHost, policy } = {}) => {
97
+ const port = typeof currentPort === 'function' ? currentPort : () => currentPort
98
+ const overrides = policy || {}
99
+ for (const key of Object.keys(overrides)) {
100
+ if (!GUARD_REASONS.includes(key)) {
101
+ throw new Error(`bindGuard: unknown policy key ${JSON.stringify(key)}; expected one of ${GUARD_REASONS.join(', ')}`)
102
+ }
103
+ }
104
+ const say = Object.fromEntries(GUARD_REASONS.map((reason) => [
105
+ reason,
106
+ { ...DEFAULT_GUARD_POLICY[reason], ...(overrides[reason] || {}) }
107
+ ]))
108
+ const deny = (res, reason) => {
109
+ respond(res, 403, { ok: false, code: say[reason].code, error: say[reason].error })
110
+ return false
111
+ }
112
+ const fleetAllowed = () => typeof allowRemoteHost === 'function' && allowRemoteHost()
113
+
114
+ return function guard(req, res) {
115
+ const headers = (req && req.headers) || {}
116
+
117
+ const site = headers['sec-fetch-site']
118
+ if (site !== undefined && site !== 'same-origin' && site !== 'none') {
119
+ return deny(res, 'cross_site')
120
+ }
121
+
122
+ const host = headers.host || ''
123
+ const parsedHost = host ? hostHostname(host) : ''
124
+ // An empty parse is not permission. A Host header that carries no usable
125
+ // hostname — an unbracketed IPv6 literal such as `::1:3080`, which RFC 7230
126
+ // forbids but a client can still send — parses to '', and reading that as a
127
+ // pass would skip the allowlist. It is a Host problem, so it is reported as
128
+ // one. An ABSENT Host stays loopback so host-side callers keep working.
129
+ if (host && parsedHost === '') {
130
+ return deny(res, 'non_loopback_host')
131
+ }
132
+ const peerAddress = req.socket ? req.socket.remoteAddress : undefined
133
+ if (peerAddress == null || String(peerAddress).trim() === '') {
134
+ return deny(res, 'unknown_peer')
135
+ }
136
+ // `allowRemoteHost` buys exactly one thing: an off-loopback peer AND an
137
+ // off-loopback Host stop being admitted by this guard, because the caller has
138
+ // opted into verifying its own credential per request. Everything else still
139
+ // applies — the Origin check below rejects cross-site traffic in both modes,
140
+ // so the exemption never widens the browser-facing boundary. A plugin that
141
+ // does not pass the predicate never enters this mode, so for it the peer and
142
+ // Host criteria are absolute.
143
+ const remote = fleetAllowed()
144
+ if (!remote && !isLoopbackAddress(peerAddress)) {
145
+ return deny(res, 'non_loopback_peer')
146
+ }
147
+ const hostLoopback = host ? (parsedHost !== '' && isLoopbackName(parsedHost)) : true
148
+ if (!remote && !hostLoopback) {
149
+ return deny(res, 'non_loopback_host')
150
+ }
151
+
152
+ const origin = headers.origin
153
+ if (origin) {
154
+ let same = false
155
+ try {
156
+ const parsed = new URL(origin)
157
+ same = isLoopbackName(hostHostname(parsed.hostname)) &&
158
+ portOf(parsed) === String(port() || '')
159
+ } catch {
160
+ same = false
161
+ }
162
+ if (!same) return deny(res, 'foreign_origin')
163
+ }
164
+
165
+ return true
166
+ }
167
+ }
@@ -0,0 +1,99 @@
1
+ // Loopback predicates shared by the host halves of the DSH plugins.
2
+ //
3
+ // This fragment has ONE source of truth: dsh-mini-utility-dock/dist/loopback.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
6
+ // npm run loopback:sync (write it)
7
+ // npm run loopback:check (fail on drift)
8
+ // 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 works with no sibling installed.
11
+ //
12
+ // Why it is shared at all: these predicates were copy-pasted per repo and
13
+ // drifted twice. The first drift rejected IPv6 loopback in all three plugins;
14
+ // the second made the three disagree on which Host spellings count as loopback
15
+ // (see scripts/guard-parity.mjs, which compares this block across repos).
16
+ //
17
+ // "Loopback" is decided in exactly one place — LOOPBACK_HOSTNAMES plus the
18
+ // IPv4-mapped IPv6 form of each entry — and both the name and the address
19
+ // predicate route through it, so the Host path and the peer path cannot drift
20
+ // apart again.
21
+ //
22
+ // Kept a separate fragment from the host guard on purpose: the predicates are
23
+ // stable facts about what an address is, while the guard is a policy about who
24
+ // may call an API. `dist/guard.js` imports this module, so the guard depends on
25
+ // this block and never the other way round.
26
+
27
+ // Hostnames a request to a loopback-bound API may legitimately arrive with.
28
+ // Exact spellings only: `api.localhost` and `127.0.0.1.evil.example` must stay
29
+ // rejected, which is what keeps DNS rebinding out of the API surface.
30
+ export const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '::1']
31
+
32
+ // Canonicalize a Host-like value. Trims both ends and lowercases, so the
33
+ // allowlist match is case-insensitive and tolerates surrounding whitespace.
34
+ export const normalizeHostValue = (value) => String(value == null ? '' : value).trim().toLowerCase()
35
+
36
+ // Pull the hostname out of a Host header: "127.0.0.1:3080" -> "127.0.0.1",
37
+ // "[::1]:3080" -> "::1". A bracketed IPv6 literal carries its colons inside the
38
+ // brackets, so the brackets decide where the host ends, not the first colon.
39
+ export const hostHostname = (host) => {
40
+ const value = normalizeHostValue(host)
41
+ const bracketed = /^\[([^\]]+)\]/.exec(value)
42
+ return bracketed ? bracketed[1] : value.split(':')[0]
43
+ }
44
+
45
+ // The IPv4 address inside an IPv4-mapped IPv6 literal, or null. Node reports a
46
+ // v4 peer on a dual-stack socket in the mapped form, so this is a routine input,
47
+ // not an exotic one. Two spellings reach us and both must work:
48
+ //
49
+ // ::ffff:127.0.0.1 what Node puts in req.socket.remoteAddress, and what a
50
+ // client may legally write in a Host header
51
+ // ::ffff:7f00:1 what the WHATWG URL parser normalises the above to, so
52
+ // this is the shape a browser's Origin header produces
53
+ //
54
+ // The v4 part is validated as four decimal octets, so `::ffff:1.2.3` and
55
+ // `::ffff:999.1.1.1` are not addresses and fail closed.
56
+ const mappedIpv4 = (value) => {
57
+ if (!value.startsWith('::ffff:')) return null
58
+ const rest = value.slice(7)
59
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(rest)) {
60
+ return rest.split('.').every((octet) => Number(octet) <= 255) ? rest : null
61
+ }
62
+ // Hex form: ::ffff:7f00:1 -> 127.0.0.1. Exactly two groups, four hex digits
63
+ // each, as the URL parser emits.
64
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest)
65
+ if (!hex) return null
66
+ const high = parseInt(hex[1], 16)
67
+ const low = parseInt(hex[2], 16)
68
+ return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
69
+ }
70
+
71
+ /**
72
+ * True when `name` is a loopback hostname — the Host-header side of the guard.
73
+ * Accepts the documented spellings, the IPv4-mapped IPv6 form of 127.0.0.1, and
74
+ * folds case. Fails closed on everything else, including a missing name.
75
+ */
76
+ export const isLoopbackName = (name) => {
77
+ const value = normalizeHostValue(name)
78
+ if (!value) return false
79
+ if (LOOPBACK_HOSTNAMES.indexOf(value) !== -1) return true
80
+ const ipv4 = mappedIpv4(value)
81
+ return ipv4 !== null && LOOPBACK_HOSTNAMES.indexOf(ipv4) !== -1
82
+ }
83
+
84
+ /**
85
+ * True when `address` is a real loopback TCP peer address.
86
+ * Headers cannot identify the network peer — a client sets `Host` freely — so
87
+ * the socket address is the only trustworthy signal. Fail closed on anything
88
+ * unrecognised, including a missing address.
89
+ */
90
+ export const isLoopbackAddress = (address) => {
91
+ const value = normalizeHostValue(address)
92
+ if (!value) return false
93
+ if (value === '::1') return true
94
+ // IPv4-mapped IPv6 (`::ffff:127.0.0.1`) is how Node reports a v4 peer on a
95
+ // dual-stack socket; fold it back before the 127/8 test.
96
+ const mapped = mappedIpv4(value)
97
+ if (mapped !== null) return /^127\./.test(mapped)
98
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)
99
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mini-utility-dock",
3
- "version": "0.1.1",
4
- "description": "Shared classic-script utility dock protocol for DSH plugins.",
3
+ "version": "0.1.3",
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
7
  "dsh-mini-utility-dock": "bin/dsh-mini-utility-dock.js"
@@ -16,6 +16,7 @@
16
16
  "CHANGELOG.en.md"
17
17
  ],
18
18
  "scripts": {
19
+ "dock:embed": "node bin/dsh-mini-utility-dock.js",
19
20
  "test": "node --test test/*.test.js"
20
21
  },
21
22
  "engines": {