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 +11 -0
- package/CHANGELOG.md +11 -0
- package/README.en.md +42 -10
- package/README.md +36 -13
- package/bin/dsh-mini-utility-dock.js +88 -27
- package/dist/guard.js +167 -0
- package/dist/loopback.js +99 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
5
|
+
## Fragments
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
npm run dock:embed path/to/client.js
|
|
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
|
-
|
|
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
|
|
3
|
+
DSH 插件共享的源码片段与嵌入 CLI。片段在构建时写入消费插件,使插件无需依赖本包即可独立发布。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 片段
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
三个片段,均由标记界定。
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
41
|
+
const marker = (name, edge) => `// <${edge}${name}>`
|
|
10
42
|
|
|
11
43
|
function usage() {
|
|
12
|
-
|
|
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('
|
|
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(`
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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,
|
|
91
|
+
return { lines, blocks: candidates.map(({ fragment, start, end }) => ({ fragment, start: start[0], end: end[0] })) }
|
|
41
92
|
}
|
|
42
93
|
|
|
43
|
-
function rendered(
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
58
|
-
|
|
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 (
|
|
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 ===
|
|
65
|
-
console.log(`unchanged: ${target}`)
|
|
125
|
+
if (source === output) {
|
|
126
|
+
console.log(`unchanged: ${target} (${names})`)
|
|
66
127
|
return
|
|
67
128
|
}
|
|
68
|
-
await writeFile(target,
|
|
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
|
+
}
|
package/dist/loopback.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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": {
|