@geoly-ai/skills-hub 0.3.6 → 0.3.8

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.
@@ -0,0 +1,400 @@
1
+ // `skills-hub publish` —— 把「fork、建目录、算摘要、开 PR」这一串变成一条命令。
2
+ //
3
+ // ── 🔴 授权模型(用户 2026-09-05 拍板,不要重新设计)─────────────────────
4
+ //
5
+ // **不注册 OAuth App / GitHub App,直接用用户已有的 GitHub token,
6
+ // 不做 `login` / `logout`。** 决策记录见 `docs/m0/10-open-questions.md` Q4
7
+ // 与 `docs/m0/06-submission.md` §9 的偏离说明。
8
+ // 理由:注册应用是一次性且不可逆的公开面。
9
+ //
10
+ // 这个决定的代价必须**说出来**,而不是藏起来:用户已有的 token 很可能是
11
+ // classic `repo` PAT,权限面**比 `public_repo` 还大**,而 CLI **无法收窄它**。
12
+ // 所以本命令在任何写操作之前,把权限面摆到用户脸上并要求确认(§6 `disclose`)。
13
+ //
14
+ // ── 🔴 两条不可打折的实现约束 ────────────────────────────────────────────
15
+ //
16
+ // ① **全走 GitHub API,一次 `git` 都不用。** 带 token 的 `git push` 会让本地
17
+ // `pre-push` hook 拿到 token —— 那是任意代码执行 + 凭据泄漏。
18
+ // ② **Git Data API,不是 Contents API。** Contents API 没有设置 mode 的字段,
19
+ // `0755` 会被静默写成 `0644`;而 mode 进 `tree_digest`(同字节不同 mode 是
20
+ // 两个不同摘要),也进 capability 语义。用 Contents API 投稿 =
21
+ // 静默改掉制品身份。
22
+ //
23
+ // ── 📌 本地校验只是 UX,不是信任门 ───────────────────────────────────────
24
+ // 真正的判定在服务端的 PR gate(`validate-pr.yml` 从 base 那棵树跑校验器)。
25
+ // 这里跑的是**同一批**校验器(`scripts/submission/*`),目的只是让投稿者在
26
+ // 开 PR 之前就看到会红的地方。`--yes` 跳过的**只有**权限风险确认那一问,
27
+ // 不跳过这里任何一道校验。
28
+
29
+ import { rmSync, readFileSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { resolve as resolvePath } from 'node:path';
32
+
33
+ import { EXIT, UsageError } from '../exit-codes.mjs';
34
+ import { resolveToken } from '../publish/token.mjs';
35
+ import {
36
+ createClient, UPSTREAM_FULL, UPSTREAM_REPO, BASE_BRANCH, API_ORIGIN,
37
+ } from '../publish/github.mjs';
38
+ import { readIdentity, stagePayload, runLocalGates, PayloadError } from '../publish/payload.mjs';
39
+ import {
40
+ getViewer, getBaseline, ensureFork, findOpenDuplicates, branchName,
41
+ assertBranchFree, pushSubmission, verifyBranch, openPullRequest, RemoteError,
42
+ } from '../publish/remote.mjs';
43
+
44
+ /**
45
+ * 06-submission.md §9 里仍然成立的一条:**以 `npx github:` 运行时 publish 拒绝执行**。
46
+ *
47
+ * 判据:npm 从 git 安装一个包时,会往安装下来的 `package.json` 里写
48
+ * `gitHead`(commit sha)或把 `_resolved` 写成 `git+https://…`。
49
+ * 从 registry 装的包两者都没有。
50
+ *
51
+ * ⚠️ **诚实边界:这是尽力而为,不是完备判据。**
52
+ * · npm 的哪些版本写哪个字段没有正式契约,改了不会有任何东西变红;
53
+ * · 直接 `node /path/to/checkout/bin/skills-hub.mjs publish` 从**源码树**跑,
54
+ * 两个字段都没有 —— 本函数放行,而那其实也是"未经 registry 分发的代码"。
55
+ * 这道门挡的是最常见的那一种(`npx github:geoly-ai/skills-hub publish`),
56
+ * 不要把它当成"只有签过名的 CLI 才能投稿"的保证。真正的保证在服务端。
57
+ */
58
+ export function looksLikeGitInstall(pkgJsonText) {
59
+ let doc;
60
+ try { doc = JSON.parse(pkgJsonText); } catch { return false; }
61
+ if (typeof doc.gitHead === 'string' && doc.gitHead !== '') return true;
62
+ if (typeof doc._resolved === 'string' && /^git[+:]/.test(doc._resolved)) return true;
63
+ return false;
64
+ }
65
+
66
+ function ownPackageJsonPath() {
67
+ return fileURLToPath(new URL('../../package.json', import.meta.url));
68
+ }
69
+
70
+ /**
71
+ * publish 自己的 flag —— **不进 `commands/context.mjs`**(那里只放全局 flag,
72
+ * 且 `test/flag-doc-parity.test.mjs` 只扫那个文件)。命令面按
73
+ * `09-cli.md` §1 的既有规格:`publish [path] [--pack]`,另加 `--dry-run`。
74
+ *
75
+ * 🔴 `--pack` 是一条**断言**,不是提示:它说「我期望这是一个 pack」。
76
+ * 与实际 manifest 不符即失败。写成"只是个提示、以 manifest 为准"的话,
77
+ * 它就是一个用户以为自己设了、实际什么都没设的开关。
78
+ */
79
+ function parseArgs(args) {
80
+ const o = { path: null, pack: false, dryRun: false };
81
+ for (const a of args) {
82
+ if (a === '--dry-run') { o.dryRun = true; continue; }
83
+ if (a === '--pack') { o.pack = true; continue; }
84
+ if (a.startsWith('-')) {
85
+ throw new UsageError(
86
+ `publish 不认得 ${a}。可用:publish [path] [--pack] [--dry-run]`
87
+ + '(全局 --yes / --json 照常可用)',
88
+ );
89
+ }
90
+ if (o.path !== null) throw new UsageError('publish 只接受一个载荷目录');
91
+ o.path = a;
92
+ }
93
+ return o;
94
+ }
95
+
96
+ /**
97
+ * 权限披露。**在任何写请求之前**,且**不受 `--yes` 影响** ——
98
+ * `--yes` 只是免掉那一问,不是免掉这一屏。
99
+ *
100
+ * 🔴 为什么这一屏必须存在:我们**无法收窄**用户的 token。
101
+ * 一个 classic `repo` PAT 能读写他**所有**私有仓库;`public_repo` 能写他
102
+ * 所有公开仓库。用户交给我们的权限,远大于「给 skills-hub 投一个 skill」
103
+ * 所需 —— 那是这个授权模型的真实代价,藏起来就是在替他做决定。
104
+ */
105
+ export function disclosureLines({ tokenInfo, viewer, plan }) {
106
+ const L = [];
107
+ L.push('');
108
+ L.push('🔴 权限面披露 —— 请在继续之前读完');
109
+ L.push('─'.repeat(64));
110
+ L.push(` token 来源 ${tokenInfo.sourceLabel}`);
111
+ if (tokenInfo.others.length > 0) {
112
+ L.push(` ⚠️ 同时存在 ${tokenInfo.others.join('、')}(值相同,本次未使用它们)`);
113
+ }
114
+ L.push(` 认证身份 ${viewer.login}(node_id ${viewer.nodeId},id ${viewer.id})`);
115
+ L.push(` 可见 scope ${viewer.scopes === null
116
+ ? '看不见(fine-grained token 通常不回 x-oauth-scopes 头)'
117
+ : (viewer.scopes.length === 0 ? '(空 —— 多半是 fine-grained token)' : viewer.scopes.join(', '))}`);
118
+ L.push('');
119
+ L.push(' 这个 token 能做什么(CLI **无法**收窄它):');
120
+ L.push(' · classic `repo` PAT → 可读写你**所有**仓库,含**私有**仓库');
121
+ L.push(' · classic `public_repo` → 可写你**所有**公开仓库');
122
+ L.push(' · fine-grained → 以你建它时授予的仓库与权限为准(这里看不见)');
123
+ L.push(' 🔴 本命令不持有、不存储、不上传你的 token —— 它只活在这个进程里。');
124
+ L.push(' 但「我们不持有」**不等于**「它权限很小」。要收窄,请另建一个');
125
+ L.push(` fine-grained token,只授权你的 fork(${viewer.login}/${UPSTREAM_REPO})`);
126
+ L.push(' 与上游的 Contents(读) + Pull requests(写)。');
127
+ L.push('');
128
+ L.push(' 本次将要执行的**写**操作:');
129
+ L.push(` 1. 若无 fork → 在 ${API_ORIGIN} 建 ${viewer.login}/${UPSTREAM_REPO}(fork 自 ${UPSTREAM_FULL})`);
130
+ L.push(` 2. 在你的 fork 上建 ${plan.files.length} 个 blob + 1 棵 tree + 1 个 commit`);
131
+ L.push(` 3. 建分支 ${plan.branch}(**最后一步**才建,中途失败仓库上看不到任何东西)`);
132
+ L.push(` 4. 向 ${UPSTREAM_FULL} 的 ${BASE_BRANCH} 开一个 PR,并置`);
133
+ L.push(' `maintainer_can_modify: true` —— ⚠️ 这**额外授予上游维护者**修改');
134
+ L.push(` 你 fork 上这个分支的权利(只限这个分支)。GitHub 的 PR 就是这么协作的,`);
135
+ L.push(' 但它是一项你正在给出的权限,不该藏在默认值里。');
136
+ L.push(' 🔴 不 force-push、不 amend、不改写任何已存在的分支或 PR。');
137
+ L.push('');
138
+ L.push(` 写入路径(只有这些,全部在 ${plan.prefix} 之下):`);
139
+ for (const f of plan.files) L.push(` ${f.mode === 0o755 ? '0755' : '0644'} ${f.repoPath}`);
140
+ L.push('');
141
+ L.push(` tree_digest ${plan.treeDigest}`);
142
+ L.push('─'.repeat(64));
143
+ return L;
144
+ }
145
+
146
+ function readLine(stdin) {
147
+ return new Promise((resolve) => {
148
+ if (stdin === null || stdin === undefined) { resolve(''); return; }
149
+ if (stdin.readableEnded === true || stdin.destroyed === true) { resolve(''); return; }
150
+ let buf = '';
151
+ const done = () => { stdin.off?.('data', onData); resolve(buf); };
152
+ const onData = (c) => {
153
+ buf += String(c);
154
+ if (buf.includes('\n')) done();
155
+ };
156
+ stdin.on('data', onData);
157
+ stdin.once('end', done);
158
+ stdin.once('close', done);
159
+ stdin.once('error', done);
160
+ stdin.resume?.();
161
+ });
162
+ }
163
+
164
+ /**
165
+ * 🔴 `--yes` **只**代表「我读过权限披露、接受这个风险」。
166
+ * 它不跳过本地结构门、不跳过重复投稿检测、不跳过 fork/PR 的事后核对。
167
+ * 这一点写在这里,也写在 `--help` 里 —— 因为「一个 flag 到底关掉了什么」
168
+ * 是用户唯一没法从行为上观察到的东西(他只会看到"过了")。
169
+ */
170
+ async function confirm(ctx, out) {
171
+ const tty = ctx.stdin?.isTTY === true;
172
+ if (ctx.yes) {
173
+ out.note('已给 --yes:视为**确认了上面的权限风险**。⚠️ 它不跳过任何一道校验。');
174
+ return;
175
+ }
176
+ if (!tty) {
177
+ throw new UsageError(
178
+ '非交互环境下必须显式给 --yes 才能继续 —— 它表示你读过上面的权限披露并接受风险。\n'
179
+ + ' 🔴 --yes **不跳过**任何一道校验,也不跳过 fork / PR 的事后核对。',
180
+ );
181
+ }
182
+ out.line('');
183
+ out.line('请输入 publish 以确认(回车不算确认):');
184
+ const answer = (await readLine(ctx.stdin)).trim();
185
+ if (answer !== 'publish') {
186
+ throw new UsageError(
187
+ `未确认:需要输入 publish,实际得到 ${JSON.stringify(answer)}。什么都没做。`,
188
+ { telemetryReason: 'user-abort' },
189
+ );
190
+ }
191
+ }
192
+
193
+ export async function cmdPublish(ctx, args, out) {
194
+ const o = parseArgs(args);
195
+
196
+ // ── 前置守卫(都在任何网络请求之前)────────────────────────────────────
197
+ if (ctx.offline) {
198
+ throw new UsageError(
199
+ 'publish 需要网络(它要读上游 base、建 fork、开 PR),与 --offline 互斥。\n'
200
+ + ' 🔴 --offline 承诺的是「整个 CLI 不得有任何网络出口」,不是「尽量少出网」。',
201
+ );
202
+ }
203
+ let ownPkg = '';
204
+ try { ownPkg = readFileSync(ownPackageJsonPath(), 'utf8'); } catch { ownPkg = ''; }
205
+ if (looksLikeGitInstall(ownPkg)) {
206
+ throw new UsageError(
207
+ '拒绝以 `npx github:…`(从 git 安装的副本)运行 publish —— 06-submission.md §9。\n'
208
+ + ' 🔴 这条命令会拿你的 GitHub token 去做写操作;那份代码没有经过 npm 的\n'
209
+ + ' 发布链(provenance / 签名 / 版本不可变),我们不该请你把凭据交给它。\n'
210
+ + ' 请用 `npx @geoly-ai/skills-hub publish`。',
211
+ );
212
+ }
213
+
214
+ // ── 载荷:读一次盘,之后全用这份字节 ──────────────────────────────────
215
+ const payloadDir = resolvePath(ctx.cwd, o.path ?? '.');
216
+ const identity = readIdentity(payloadDir);
217
+ // 🔴 `--pack` 是断言:不符就失败,不是"以 manifest 为准"地静默忽略。
218
+ const wantKind = o.pack ? 'pack' : 'skill';
219
+ if (identity.kind !== wantKind) {
220
+ throw new UsageError(
221
+ `载荷是一个 ${identity.kind}(根目录里是 ${identity.kind}.json),`
222
+ + `但${o.pack ? '你给了 --pack' : '你没给 --pack'} —— 期望的是 ${wantKind}。\n`
223
+ + ` ${o.pack ? '去掉 --pack' : '加上 --pack'} 再重跑,或者检查载荷目录是不是指错了。`,
224
+ );
225
+ }
226
+ const staged = stagePayload(payloadDir, identity);
227
+
228
+ try {
229
+ // ── token(不落盘、不进 argv、不进子进程 env)────────────────────────
230
+ // 🔴 **不包 catch-all。** `resolveToken` 只抛 `TokenError`;用
231
+ // `catch { throw new TokenError(...) }` 兜住的话,一个 CLI 自身的 bug
232
+ // 会被伪装成「你的认证有问题」,而用户会去查他的 token —— 查不出任何东西。
233
+ const tokenInfo = resolveToken({ env: ctx.env });
234
+
235
+ const client = createClient({
236
+ token: tokenInfo.token,
237
+ // 🔴 显式传:`createClient` 不做隐式回退。于是"用了真 fetch"是一个
238
+ // 写得出来的动作,测试忘了注入不会静默出网。
239
+ fetchImpl: ctx.fetchImpl ?? globalThis.fetch,
240
+ userAgent: `skills-hub/${ctx.cliVersion} (+https://github.com/${UPSTREAM_FULL})`,
241
+ });
242
+
243
+ // ── 基线(只读)──────────────────────────────────────────────────────
244
+ // 🔴 顺序:基线 → 本地门 → `/user` → 重复检测 → 披露 → 确认 → 写。
245
+ // `/user` 排在本地门**之后**是有意的:一个连结构门都过不了的投稿,
246
+ // 不该先让我们去问「你是谁」——那次查询会把用户的身份连同 token 发出去,
247
+ // 而它对这次失败没有任何用处。
248
+ // ⚠️ 401/403 在这一路上**不换下一个 token 来源重试**:
249
+ // 那会让用户看到的身份与实际投稿身份不同。
250
+ const segments = staged.prefix.slice(0, -1).split('/'); // ['submissions', ns, 'name@ver']
251
+ const baseline = await getBaseline(client, { submissionSegments: segments });
252
+
253
+ // ── 本地门(跑的是服务端那一批,不是另写的一套)──────────────────────
254
+ const gates = runLocalGates({ staging: staged.staging, baseline });
255
+ const problems = [...gates.problems];
256
+
257
+ // base 里已经有这条路径 → 拒(禁止覆盖 base 中已存在的路径)
258
+ if (baseline.existingSubmission !== null) {
259
+ problems.push(
260
+ `${staged.prefix} 在上游 ${BASE_BRANCH} 上已经存在 —— publish 只新增,绝不覆盖已有路径。`,
261
+ );
262
+ }
263
+ if (gates.scanErrors > 0) {
264
+ problems.push(`载荷里有 ${gates.scanErrors} 处**服务端会拒**的字符问题(见下面的扫描结果)`);
265
+ }
266
+ for (const l of gates.scanLines) out.warn({ kind: 'scan-text', message: l });
267
+
268
+ if (problems.length > 0) {
269
+ throw new PayloadError(
270
+ // ⚠️ 措辞要准:到这里为止已经发过若干**只读** GET(取基线要用),
271
+ // 但**一个写请求都没有**。说成「没有发出任何请求」是不实的。
272
+ `${problems.length} 处不合规,**没有**发出任何写请求(fork / 分支 / PR 都没建):\n`
273
+ + problems.map((p) => ` · ${p}`).join('\n')
274
+ + '\n 📌 这批门与服务端 PR gate 跑的是同一批实现,所以本地过了 CI 大概率也过。',
275
+ { violations: problems.map((m) => ({ code: 'E_PUBLISH_GATE', message: m })) },
276
+ );
277
+ }
278
+
279
+ // 🔴 provenance 是**可选**的(8b9741c 起)。用户自己写了不是错,但值得提醒。
280
+ if (identity.kind === 'skill' && identity.manifest.provenance !== undefined) {
281
+ out.warn({
282
+ kind: 'provenance-declared',
283
+ message:
284
+ 'skill.json 里写了 provenance —— 它会被 promote **逐字核对**,'
285
+ + '而其中 `submitted_by_pr` / `author_github_id` 在 PR 开出来之前根本不存在。\n'
286
+ + ' 建议直接删掉 provenance:它现在是可选的,promote 会按 PR 事实自动填,'
287
+ + '省掉一轮回填与重新审批。',
288
+ });
289
+ }
290
+
291
+ const branch = branchName({ ...identity, treeDigest: staged.treeDigest });
292
+ const plan = { files: staged.files, prefix: staged.prefix, treeDigest: staged.treeDigest, branch };
293
+
294
+ // ── 身份(披露与 fork 都要它;排在本地门之后,见上面那段说明)────────
295
+ const viewer = await getViewer(client);
296
+
297
+ // ── 重复投稿竞争(读,且**在 fork 之前**)────────────────────────────
298
+ // 🔴 必须早于 fork:撞上重复时不该在用户账号下留一个多余的 fork,
299
+ // `--dry-run` 的「一个写请求都不发」也才是真的。
300
+ const dup = await findOpenDuplicates(client, { submissionSegments: segments });
301
+ if (dup.matches.length > 0) {
302
+ throw new RemoteError(
303
+ `${staged.prefix} 已经在 ${dup.matches.length} 个**开着的** PR 里了:\n`
304
+ + dup.matches.map((m) => ` · #${m.number} by ${m.author} —— ${m.url}`).join('\n')
305
+ + '\n 🔴 只读取、核对、报告 —— 本命令不会再开一个,也不改写已有的那个。\n'
306
+ + ' ⚠️ 只看 artifacts/ 是发现不了这种竞争的:同一个版本可能正躺在一个'
307
+ + '尚未合并的 PR 里。',
308
+ { exitCode: 3 },
309
+ );
310
+ }
311
+
312
+ // ── 披露 + 确认(🔴 在任何写请求之前)────────────────────────────────
313
+ for (const l of disclosureLines({ tokenInfo, viewer, plan })) out.line(l);
314
+
315
+ if (o.dryRun) {
316
+ out.line('');
317
+ out.line('--dry-run:到此为止,**一个写请求都没有发**。');
318
+ return out.emit('publish', {
319
+ dry_run: true,
320
+ artifact_id: identity.id,
321
+ tree_digest: staged.treeDigest,
322
+ branch,
323
+ prefix: staged.prefix,
324
+ files: staged.files.map((f) => ({ path: f.repoPath, mode: f.mode === 0o755 ? '100755' : '100644' })),
325
+ token_source: tokenInfo.source,
326
+ viewer: { login: viewer.login, node_id: viewer.nodeId, scopes: viewer.scopes },
327
+ }, EXIT.OK);
328
+ }
329
+
330
+ await confirm(ctx, out);
331
+
332
+ // ── 写 ───────────────────────────────────────────────────────────────
333
+ const { repo: fork, created } = await ensureFork(client, {
334
+ viewer, upstreamId: baseline.upstreamId, baseCommitSha: baseline.baseCommitSha,
335
+ });
336
+ if (created) out.note(`已建 fork ${viewer.login}/${UPSTREAM_REPO}`);
337
+
338
+ await assertBranchFree(client, { login: viewer.login, branch });
339
+
340
+ const pushed = await pushSubmission(client, {
341
+ login: viewer.login,
342
+ branch,
343
+ files: staged.files,
344
+ baseTreeSha: baseline.baseTreeSha,
345
+ baseCommitSha: baseline.baseCommitSha,
346
+ message: `投稿 ${identity.id}\n\ntree_digest: ${staged.treeDigest}\n`,
347
+ });
348
+ out.note(`已建 commit ${pushed.commitSha},分支 ${branch}`);
349
+
350
+ // 🔴 写完用**独立的读**证明结果,而不是相信 201。
351
+ await verifyBranch(client, {
352
+ login: viewer.login, branch, segments, ...pushed,
353
+ baseCommitSha: baseline.baseCommitSha, baseTreeSha: baseline.baseTreeSha,
354
+ });
355
+
356
+ const pr = await openPullRequest(client, {
357
+ login: viewer.login,
358
+ branch,
359
+ title: `投稿 ${identity.id}`,
360
+ body: prBody({ identity, treeDigest: staged.treeDigest, files: staged.files }),
361
+ viewer,
362
+ forkId: fork.id,
363
+ upstreamId: baseline.upstreamId,
364
+ commitSha: pushed.commitSha,
365
+ });
366
+
367
+ out.line('');
368
+ out.line(`✔ PR #${pr.number} 已开:${pr.url}`);
369
+ out.line(' 接下来由服务端的 PR gate 判定 —— 本地那几道门只是提前告诉你会不会红。');
370
+ return out.emit('publish', {
371
+ dry_run: false,
372
+ artifact_id: identity.id,
373
+ tree_digest: staged.treeDigest,
374
+ branch,
375
+ prefix: staged.prefix,
376
+ commit_sha: pushed.commitSha,
377
+ pr: { number: pr.number, url: pr.url, state: pr.state },
378
+ token_source: tokenInfo.source,
379
+ viewer: { login: viewer.login, node_id: viewer.nodeId, scopes: viewer.scopes },
380
+ }, EXIT.OK);
381
+ } finally {
382
+ // 暂存目录里是投稿载荷(公开内容),但它是我们建的,就该我们收。
383
+ try { rmSync(staged.staging, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
384
+ }
385
+ }
386
+
387
+ function prBody({ identity, treeDigest, files }) {
388
+ return [
389
+ `## ${identity.id}`,
390
+ '',
391
+ `- kind: \`${identity.kind}\``,
392
+ `- tree_digest: \`${treeDigest}\``,
393
+ `- 文件数: ${files.length}`,
394
+ '',
395
+ '由 `skills-hub publish` 生成。载荷只写在',
396
+ `\`submissions/${identity.namespace}/${identity.name}@${identity.version}/\` 之下。`,
397
+ '',
398
+ '> ⚠️ `provenance` 由 promote 按 PR 事实填 —— 本 PR 未自行声明它。',
399
+ ].join('\n');
400
+ }
@@ -0,0 +1,86 @@
1
+ // 删除通道的签名协议 —— **客户端与服务端共用的唯一定义**。
2
+ //
3
+ // 🔴 放在 src/ 而不是 server/:客户端要照着它签,服务端要照着它验。
4
+ // 两边各写一份,迟早一边改了另一边没改 —— 结果不是报错,是「所有删除请求
5
+ // 都验不过」或者更糟的「验得过一个不该验过的形状」。与 assertValidEvent 只有一份
6
+ // 是同一条纪律。server/delete.mjs 从这里 re-export。
7
+ //
8
+ // 🔴 **零依赖**:只用 node:buffer。服务端的路由模块会 import 它,而仓库根的测试
9
+ // 只装根依赖(server/delete.mjs 文件头那段教训)。
10
+
11
+ /**
12
+ * 协议版本。**改了它就是换了一套签名语义**,旧签名一律失效 —— 这正是要的。
13
+ * v1 → v2(2026-09-13):把服务端的 audience 纳入签名(Codex 指出的中继重放,见 signedMessage)。
14
+ */
15
+ export const DELETE_PROTOCOL = 'geoly.skills.telemetry-delete/2';
16
+
17
+ // 三个定长量。**定长不是「不超过」**:可变长度会让塞了别的东西的值混进来。
18
+ export const RE_PUBKEY = /^[A-Za-z0-9_-]{43}$/; // 32 字节 Ed25519 公钥
19
+ export const RE_NONCE = /^[A-Za-z0-9_-]{43}$/; // 32 字节
20
+ export const RE_SIGNATURE = /^[A-Za-z0-9_-]{86}$/; // 64 字节签名
21
+
22
+ /** audience 的长度上界 —— 它进签名消息,长度前缀是两个字节。 */
23
+ export const MAX_AUDIENCE_BYTES = 2048;
24
+
25
+ /**
26
+ * 是不是**规范**的 base64url,且恰好解出 `bytes` 个字节。
27
+ *
28
+ * 🔴 光过正则不够:43 个字符解出 32 字节时,最后一个字符的低 4 位是填充位,
29
+ * `…A` 与 `…B` 可能解出同一串字节。同一把公钥有多个文本表示,
30
+ * tag(按文本算 HMAC)就会不同 —— 同一个人被算成两个,删一个漏一个。
31
+ * 判据:解码再编码,必须逐字相等(Codex 2026-09-13)。
32
+ */
33
+ export function isCanonicalB64u(s, bytes) {
34
+ if (typeof s !== 'string') return false;
35
+ const buf = Buffer.from(s, 'base64url');
36
+ return buf.length === bytes && buf.toString('base64url') === s;
37
+ }
38
+
39
+ /**
40
+ * 规范化 audience:它是**删除端点的完整 URL**,客户端与服务端各自独立算出,逐字比对。
41
+ *
42
+ * 只收 http/https、不许带凭据、query 与 fragment —— 这三样都能让「同一个端点」
43
+ * 有多种写法,而 audience 比的是字符串。
44
+ * @returns {string|null} 不合法返回 null
45
+ */
46
+ export function normalizeAudience(raw) {
47
+ if (typeof raw !== 'string' || raw === '') return null;
48
+ let u;
49
+ try { u = new URL(raw); } catch { return null; }
50
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
51
+ if (u.username || u.password || u.search || u.hash) return null;
52
+ const s = u.toString();
53
+ return Buffer.byteLength(s, 'utf8') <= MAX_AUDIENCE_BYTES ? s : null;
54
+ }
55
+
56
+ /**
57
+ * 被签名的消息:`协议名 || u16be(len(audience)) || audience || 公钥32 || nonce32`。
58
+ *
59
+ * 🔴 **不是「签 nonce 原文」。** 裸签一个随机串意味着这个签名在**任何**
60
+ * 也用 Ed25519 的协议里都可能被复用(跨协议重放)。所以:
61
+ * · 协议名做**域分离** —— 换了协议就是另一段消息
62
+ * · 把公钥包进消息 —— 签名与「谁在签」绑死,不能挪给别的公钥用
63
+ * 🔴 **audience 必须进签名**(v2,Codex 2026-09-13 的 P0):客户端允许自定义端点,
64
+ * 一个恶意端点可以拿受害者的公钥去正式服务取 nonce、原样转给 CLI 签、
65
+ * 再把签名转发回正式服务 —— 禁止重定向挡不住这种代理式中继。
66
+ * 签名里写死「我签给的是哪个端点」,转发出去就对不上。
67
+ * 🔴 audience 是唯一的变长段,所以带**长度前缀**:没有前缀的拼接存在歧义
68
+ * (audience 末尾挪几个字节给公钥,拼出来一样)。
69
+ */
70
+ export function signedMessage({ audience, pubkey, nonce }) {
71
+ const aud = normalizeAudience(audience);
72
+ if (aud === null || aud !== audience) throw new Error('delete: audience 不是规范形式');
73
+ if (!RE_PUBKEY.test(pubkey) || !isCanonicalB64u(pubkey, 32)) throw new Error('delete: pubkey 形状不合法');
74
+ if (!RE_NONCE.test(nonce) || !isCanonicalB64u(nonce, 32)) throw new Error('delete: nonce 形状不合法');
75
+ const audBytes = Buffer.from(aud, 'utf8');
76
+ const len = Buffer.alloc(2);
77
+ len.writeUInt16BE(audBytes.length);
78
+ return Buffer.concat([
79
+ Buffer.from(DELETE_PROTOCOL, 'utf8'), len, audBytes,
80
+ Buffer.from(pubkey, 'base64url'), Buffer.from(nonce, 'base64url'),
81
+ ]);
82
+ }
83
+
84
+ /** 请求与响应信封的 schema。两边共用,理由同上。 */
85
+ export const CHALLENGE_SCHEMA = 'geoly.skills.telemetry-delete-challenge/1';
86
+ export const DELETE_SCHEMA = 'geoly.skills.telemetry-delete/1';