@karoc/dsh-proxy 0.1.4 → 0.1.18

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.md CHANGED
@@ -1,5 +1,421 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.18] - 2026-11-07
4
+
5
+ ### Added
6
+
7
+ - **A gate for what `npm publish` actually ships** (G30): the tarball's member set
8
+ must equal the documented file list, the `lib/*.js` inside it must be
9
+ byte-identical to a FRESH BUILD of the current source (so a stale bundle cannot
10
+ ship), and the packed host bundle is
11
+ loaded **from a profile-shaped install path**, where it must start its proxy,
12
+ serve `/proxy/api` and resolve + self-install the harness egress package
13
+ (through a stub, so the gate stays offline). Written while preparing the first
14
+ publish: every other spec exercises `src/*.ts` or the repo's own `lib/`, so a
15
+ `files` mistake or a stale bundle could only have surfaced in someone else's
16
+ install. Two negative controls keep it load-bearing (an extra shipped directory;
17
+ a stale in-tarball bundle).
18
+
19
+ ### Fixed
20
+
21
+ - **The proxy's own outbound HTTP is no longer captured by the launch
22
+ environment's proxy** (G31). Node ≥ 24.22 makes `http.request` honor
23
+ `NODE_USE_ENV_PROXY`/`HTTP_PROXY`; the deployment this plugin documents
24
+ (systemd `Environment=…` so dsh's own fetch rides the proxy) therefore routed
25
+ the proxy's plain-HTTP forwarding — and its HTTP upstream leg — back into
26
+ itself. Those requests now go through an explicit `Agent` with no `proxyEnv`,
27
+ restoring the documented guarantee that the proxy never proxies itself.
28
+
29
+ - **Absolute-form requests use the target authority as `Host`.** A client that
30
+ sent its own address (or anything else) in `Host` had it forwarded to the
31
+ origin; on Node ≥ 24.22 the resulting `Host`/authority disagreement threw
32
+ synchronously inside the request handler, which takes down the host process.
33
+ The header is now replaced per RFC 9112 §3.2.2, and a synchronous client
34
+ construction error answers `502` instead of escaping the handler.
35
+
36
+ The shipped `lib/*.js` changed accordingly; the bundles still do not embed the
37
+ version.
38
+
39
+ ## [0.1.17] - 2026-10-06
40
+
41
+ ### Security
42
+
43
+ - **`/proxy/api` now sits behind the same operator-session gate as the harness's
44
+ own `/api`** (401/403 without the browser cookie). The route hands out the
45
+ upstream address and credentials and can rewrite the routing, and on a shared
46
+ machine any local process can reach 127.0.0.1 — the app root was gated, this
47
+ route was not. G27 pins it; compositions without the connection service keep
48
+ the previous behaviour (there is no credential model to consult there) and the
49
+ README says so. CLI checks need the cookie, and the recipe is in the README.
50
+
51
+ ### Fixed
52
+
53
+ - **A broken or half-written `proxy.json` silently disabled the upstream.** Every
54
+ parse failure fell back to the default config, which has the upstream switched
55
+ off and no hosts listed — so one accidentally broken edit (or a read landing
56
+ inside a non-atomic write) sent the traffic that was meant to be proxied
57
+ straight out. Reads now keep the last document that parsed (G28), and writes go
58
+ through a sibling temp file plus `rename`, so a concurrent read can never
59
+ observe a torn file.
60
+
61
+ - The settings page matched hosts by exact string, so an IPv6 host listed as
62
+ `[2001:db8::1]` (provider baseURL) and observed as `2001:db8::1` (CONNECT
63
+ authority) could read *direct* while traffic actually went upstream. Both halves
64
+ now share one matcher (`src/host-match.ts`), which also removes the chance of
65
+ the two copies drifting apart again.
66
+
67
+ - **The loopback-only listener is now pinned by a gate** (G29): the assertion
68
+ reads the socket address the OS actually bound and, when the machine has a
69
+ non-loopback interface, proves the proxy is unreachable there. Dropping the host
70
+ argument from `listen` would have exposed the proxy — and the upstream
71
+ credentials it injects — to the whole network without any test noticing.
72
+
73
+ - A failed LOAD of the settings data is no longer reported as "save failed": the
74
+ panel had one error slot, so an expired session (now that the route is gated)
75
+ pointed the reader at the Save button. A load failure has its own line, and a
76
+ 401/403 says the session expired and to refresh.
77
+
78
+ ### Docs
79
+
80
+ - README (both languages): the `/proxy/api` contract lists `op: 'forget'` and the
81
+ authentication requirement, with a cookie recipe for command-line checks. The
82
+ scenario counts are gone from README/CONTRIBUTING (they drifted twice in one
83
+ session); each spec prints its own count when it runs.
84
+
85
+ ## [0.1.16] - 2026-10-06
86
+
87
+ ### Added
88
+
89
+ - **The observed-host list can be cleared** (*Clear* next to the heading, or
90
+ `POST /proxy/api {"op":"forget"}`). Until now the list only ever grew — it
91
+ collects everything dsh has contacted, including hosts from experiments — and
92
+ hand-editing `proxy.json` did not stick: the running proxy merges its own
93
+ session set into that file on the next persist, so removed entries came back.
94
+ Clearing therefore drops the session set *and* the persisted list (G26).
95
+
96
+ - README: the two readings of the checkbox list now have a table and a worked
97
+ example each, in both languages.
98
+
99
+ ## [0.1.15] - 2026-10-06
100
+
101
+ ### Fixed
102
+
103
+ - **A SOCKS5 upstream was sent IPv6 targets as DOMAIN names.** The handshake chose
104
+ between `ATYP=1` (IPv4) and `ATYP=3` (domain) only, so `2001:db8::1` went out as a
105
+ hostname no resolver can answer. It now sends `ATYP=4` with the 16 address bytes:
106
+ `ipv6ToBytes()` expands the compressed form, the full eight-group form, an
107
+ embedded IPv4 tail (`::ffff:1.2.3.4`) and drops a zone id (which has no wire
108
+ representation). An address that cannot be encoded fails loudly instead of being
109
+ sent as a name. G25 pins it, with a negative control that re-injects the domain
110
+ branch.
111
+
112
+ ### Notes
113
+
114
+ - The dsh-desktop shell carries a copy of this engine and had the same
115
+ empty-provider defect plus the older loopback rule (literal `127.0.0.1` only,
116
+ bare `::1` only). Both are fixed in that repository — its provider list now
117
+ reads the active profile's document, and its routing/observation/provider code
118
+ uses the same shared classifier and CONNECT-authority parsing as here. Changes
119
+ are committed there on a branch (commit `6bb8316`), per that repo's PR-only rule.
120
+
121
+ ## [0.1.14] - 2026-10-06
122
+
123
+ ### Fixed
124
+
125
+ - **IPv6 loopback is loopback in every spelling it arrives in.** The guard only
126
+ matched the bare `::1`, while a URL parser hands over `[::1]`, Node rewrites an
127
+ IPv4-mapped literal to `::ffff:7f00:1`, and a CONNECT authority is
128
+ `[::1]:443` — so a bracketed loopback target could be sent to the upstream
129
+ proxy, against the guarantee that loopback is always direct. One shared
130
+ classifier now normalizes all of them (brackets, dotted and hex IPv4-mapped
131
+ forms) and is used by routing, the self-loop guard, the observed-host list and
132
+ the provider list.
133
+
134
+ - **CONNECT authorities are parsed, not split on `:`.** `[::1]:443` used to
135
+ become host `[`, which both defeated the loopback test and wrote a junk `[`
136
+ entry into the observed-host list a user could then tick. The upstream CONNECT
137
+ line keeps IPv6 addresses bracketed, and `proxiedHosts` matching is normalized
138
+ so a listed `[2001:db8::1]` also matches the `2001:db8::1` form a CONNECT
139
+ produces.
140
+
141
+ - **`llm-deepseek` no longer vanishes from the provider list.** Without a
142
+ configured `baseURL` the document carries no host — the harness resolves that
143
+ endpoint from a runtime constant — so the provider carrying real traffic was
144
+ absent. It now falls back to the public endpoint (`api.deepseek.com`); a
145
+ configured `baseURL` still wins.
146
+
147
+ ### Added
148
+
149
+ - README: a deployment matrix (terminal/unit, dsh-desktop, launcher-declared
150
+ proxy, headless, unusual builds) stating what routes dsh's own requests and
151
+ what the page shows for each.
152
+
153
+ ### Verified
154
+
155
+ - **npm-installed layout, for real**: `npm install @deepseek-ai/dsh@0.2.1-alpha.1`
156
+ in a scratch tree, then the plugin's own resolver anchored at the installed
157
+ entry (`node_modules/@deepseek-ai/dsh/lib/bin.js`) — it resolves
158
+ `installProxyFromEnvironment` from the installed `dsh-http-proxy`, and a real
159
+ in-process `fetch` issued a `CONNECT example.org:443` through the installed
160
+ policy. The earlier "[assumed]" note about this layout is settled.
161
+ - **dsh-desktop carries the same empty-provider defect** (its `proxy.mjs` reads
162
+ the removed `settings.yaml`). Fixed in that repository: a profile-document
163
+ reader (`profiles/<name>/cordis.patch.yml`), loopback filtering, and the same
164
+ DeepSeek public-endpoint rule; its `test-proxy` (12 scenarios, incl. a new
165
+ profile-document case) and `test-copy-consistency` gates pass.
166
+
167
+ ## [0.1.13] - 2026-10-06
168
+
169
+ ### Added
170
+
171
+ - **Routing rule: "only checked hosts" (default) or "everything except
172
+ checked".** The second reading exists for setups whose local proxy already
173
+ does its own filtering — there, ticking every host is busywork. The rule lives
174
+ in `proxy.json` as `routeMode` (`only-listed` | `except-listed`), flips the
175
+ meaning of the SAME checkbox list, and changes the list title ("hosts going
176
+ through the proxy" ↔ "hosts staying direct") so the screen never contradicts
177
+ the rule. An unreadable stored value falls back to the default — the safe
178
+ direction, never "send everything upstream" (G22).
179
+
180
+ ### Changed
181
+
182
+ - The page copy was rewritten for plainness and length: the egress block is now
183
+ one short sentence per state instead of a paragraph of mechanism, protocol
184
+ hints and empty-state texts were trimmed, and four strings nothing rendered
185
+ were deleted. Two real i18n defects fell out of that pass: the per-host route
186
+ labels ("via upstream" / "direct") and the protocol labels were hard-coded
187
+ English, so Chinese users saw English in the middle of a Chinese page; both now
188
+ come from the dictionary.
189
+ - Pointing the upstream at this plugin's own port now says so on the page
190
+ (previously only the routing guard silently ignored it).
191
+
192
+ ## [0.1.12] - 2026-10-06
193
+
194
+ ### Changed
195
+
196
+ - **The listen-port field now says what it is not.** A user reading the panel
197
+ reasonably asked whether the upstream's port belongs there (their upstream is
198
+ on loopback, so the two ports look interchangeable). It is the address this
199
+ plugin LISTENS on, not a destination: the label now reads "plugin listen port
200
+ (optional — NOT the upstream address)", the hint points at the upstream card,
201
+ and typing the configured upstream's own loopback port produces a specific
202
+ warning instead of leaving the bind failure as the only feedback. The same
203
+ clarification is in the README's `proxy.json` section.
204
+
205
+ What actually happens if you do it anyway: the bind fails (the upstream owns
206
+ that port), the plugin falls back to an ephemeral port and reports it, and —
207
+ because the upstream card is off in that scenario — every host goes DIRECT, so
208
+ the local upstream is bypassed entirely.
209
+
210
+ ## [0.1.11] - 2026-10-06
211
+
212
+ ### Changed
213
+
214
+ - **The listen port is no longer presented as a setting.** It never was one:
215
+ `listenPort` defaults to `0`, which means the plugin asks the OS for a free
216
+ port, and a port that cannot be bound already falls back to an ephemeral one
217
+ and reports it. Only a deployment whose LAUNCHER must name the address in
218
+ advance needs a fixed value — and even then an unconfigured plugin adopts the
219
+ loopback port that launcher declares. The settings page now reflects that:
220
+ the primary line states the state ("in-process traffic rides this proxy"), the
221
+ port and the `Environment=` line live in a **collapsed diagnostics panel** that
222
+ opens by itself only when something needs attention (a launcher proxy owning
223
+ egress, a failed self-install, an unbindable port, a port awaiting a
224
+ restart), and the port field is labelled optional with the reason spelled out.
225
+
226
+ - A page served by a newer client bundle against an older host half (the window
227
+ before a restart) now says so instead of rendering `undefined` into the status
228
+ text.
229
+
230
+ ## [0.1.10] - 2026-10-06
231
+
232
+ ### Fixed
233
+
234
+ - **A launcher-declared port is adopted instead of fought.** If your service
235
+ environment says `HTTP_PROXY=http://127.0.0.1:45999` (the 0.1.7 contract) while
236
+ `listenPort` is unset, the plugin now LISTENS on 45999 instead of binding an
237
+ ephemeral port and leaving dsh's already-installed policy pointed at a dead
238
+ address. A port that something else already serves is the exception: binding
239
+ fails, the plugin falls back to an ephemeral port and reports it — which is
240
+ also the right outcome when the launch proxy is a *different* local proxy,
241
+ because that one keeps owning egress.
242
+
243
+ Non-loopback and malformed launch proxies are never adopted (G20).
244
+
245
+ ## [0.1.9] - 2026-10-06
246
+
247
+ ### Fixed
248
+
249
+ - **The plugin no longer needs the user to touch their service definition.**
250
+ 0.1.7 shipped the honest but demanding answer: dsh resolves its egress policy
251
+ from the LAUNCH environment before plugins mount, so model traffic could only
252
+ be routed by editing the service unit. That is not the whole truth — the
253
+ harness's own policy installer is reachable from a plugin, and layering a
254
+ policy is a behaviour its suite covers. `src/egress.ts` now resolves
255
+ `@deepseek-ai/dsh-http-proxy` (anchored at the active profile, so the import
256
+ lands on the same module instance the harness uses) and installs a policy
257
+ pointing http/https at this plugin's listener **when, and only when, the launch
258
+ environment declared no proxy at all**. Model calls, web fetch and web search
259
+ then ride this proxy with per-host routing intact — zero configuration.
260
+
261
+ The condition is a correctness rule, not politeness: this plugin's proxy sends
262
+ un-ticked hosts DIRECT, so layering it over a launcher's proxy would silently
263
+ bypass that launcher's egress policy. A launcher that already decides egress
264
+ keeps deciding it, and the page says so. An unresolvable policy package is
265
+ reported as `unavailable` (the page then prints the environment-variable
266
+ fallback) rather than being papered over.
267
+
268
+ - **`listenPort` defaults to `0` (ephemeral) again.** With self-installed egress
269
+ a fixed port is no longer needed; it remains the opt-in for deployments where
270
+ the launcher declares the proxy itself (`HTTP_PROXY=http://127.0.0.1:<listenPort>`).
271
+ 0.1.7's fixed default (45999) is withdrawn before publication — it would have
272
+ made two dsh instances on one machine warn about a port nobody needs.
273
+
274
+ - The settings page now distinguishes four egress states (self-installed /
275
+ launch environment / another launcher proxy owns egress / unavailable) instead
276
+ of assuming the launch environment is the only way.
277
+
278
+ ### Added
279
+
280
+ - `scripts/egress.spec.mjs` (decision + resolution + failure reporting) wired
281
+ into `npm test` and `verify:all`; guarantees G18–G19 pin "never displace a
282
+ launcher proxy" and "a failed self-install is reported"; negative controls
283
+ re-inject both defects and must redden the suite.
284
+
285
+ ## [0.1.8] - 2026-10-06
286
+
287
+ ### Fixed
288
+
289
+ - **`scripts/verify-tooltip.mjs` could report "no tooltip bubble" for reasons
290
+ that have nothing to do with the tooltip.** Two of them: (1) the target row can
291
+ sit below the fold — the host list is a scrollable box inside a panel that grew
292
+ (the new egress block sits above it), so the synthetic mouse moved to a
293
+ coordinate nothing occupied; (2) the bubble wrapper only exists after React has
294
+ seen the injected narrowing (ResizeObserver → setState → re-render), so a hover
295
+ that lands too early finds nothing. The script now scrolls the target into
296
+ view and fails loudly when it is not on screen (with the measured `y`), and it
297
+ retries the hover a bounded three times; the assertion itself is unchanged, so
298
+ it can still only pass when the bubble really appears.
299
+
300
+ ## [0.1.7] - 2026-10-06
301
+
302
+ ### Fixed
303
+
304
+ - **Model requests did not go through the proxy at all — only child processes
305
+ did.** dsh installs its egress policy from the environment it was **launched**
306
+ with, before any plugin mounts (`apps/cli/src/profile-boot.ts` →
307
+ `@deepseek-ai/dsh-http-proxy`'s `installProxyFromEnvironment`, which owns
308
+ undici's global dispatcher and answers its own callers through
309
+ `proxyRouteFor()`). Setting `HTTP_PROXY`/`NODE_USE_ENV_PROXY` later, from a
310
+ plugin's `apply()`, reaches spawned children only: on a systemd-run `dsh web`
311
+ the harness's own `fetch` — model calls, web fetch, web search — stayed direct.
312
+ Measured on the affected machine in one process: a child `curl https://example.org`
313
+ and `npm` (registry.npmjs.org) appeared in the proxy's observed hosts, while an
314
+ in-process `web_fetch` (successful, HTTP 200) and dozens of model calls never
315
+ did. The dsh-desktop shell avoids this by setting the variables before spawning
316
+ `dsh` as a child; a directly-started `dsh web` has nobody to do that.
317
+
318
+ The proxy now listens on a **fixed port** (`listenPort` in `proxy.json`,
319
+ default `45999`) instead of an ephemeral one, so a launch environment can name
320
+ it: `Environment="HTTP_PROXY=http://127.0.0.1:45999" …` in the service unit,
321
+ then one restart, and dsh's own requests ride this proxy with per-host routing
322
+ intact (README → "Putting dsh's own requests behind the proxy"). An existing
323
+ `proxy.json` without the field adopts the default; an explicit `0` still means
324
+ "ephemeral".
325
+
326
+ - **The page never said which traffic it was actually handling.** Settings →
327
+ Smoothly Proxy now reports the bound port, the configured port, whether the
328
+ requested port was honored, and whether dsh's own requests are routed here —
329
+ with the exact `Environment=` line for the current port when they are not. The
330
+ old header comment's "a dsh restart is the documented remedy" was **wrong**: a
331
+ restart alone never fixed in-process routing, because the ordering is the same
332
+ on every boot.
333
+
334
+ - A requested fixed port that is taken now falls back to an ephemeral one **and
335
+ says so** (log + `portHonored: false`) instead of leaving a launcher pointed at
336
+ a port nothing listens on.
337
+
338
+ ### Added
339
+
340
+ - Negative controls for both new rules: `portHonored` forced true, and the
341
+ launch-proxy matcher forced true, each redden `proxy-core.spec.mjs` /
342
+ `host-route.spec.mjs`. Guarantees G16–G17 pin them.
343
+
344
+ ## [0.1.6] - 2026-10-06
345
+
346
+ ### Fixed
347
+
348
+ - **`npm run accept` reported a false red on a healthy GUI.** The boot manifest
349
+ can hand out a ROOT-RELATIVE bundle URL with no leading slash
350
+ (`plugins/??@karoc/dsh-proxy/client.js&rev=…`); `scripts/accept-gui.mjs`
351
+ concatenated it onto the origin and asked for
352
+ `http://127.0.0.1:3080plugins/…`, so "plugin client bundle served" failed while
353
+ the GUI was rendering the plugin correctly. The URL is now resolved the way the
354
+ browser resolves it (`new URL(bundleUrl, ORIGIN)`), and the check prints the
355
+ path it actually fetched.
356
+
357
+ - **`scripts/verify-tooltip.mjs` skipped its own assertions.** Whether any host
358
+ label truncates depends on the content (longest provider/host label vs the
359
+ panel width), so on a page whose labels all fit the script failed its
360
+ precondition and silently skipped the hover checks — a red that explained
361
+ nothing and verified nothing. It now measures the natural layout first and, when
362
+ nothing truncates, forces a narrow label box so the behavior under test
363
+ (truncated label → bubble carrying the FULL text) runs on every invocation; the
364
+ detail line names which mode produced the evidence.
365
+
366
+ ### Note
367
+
368
+ - Acceptance tooling only: `scripts/` is outside the published `files` list, so
369
+ no runtime behavior changed. Both fixes make the acceptance entry points
370
+ truthful on dsh 0.2.
371
+
372
+ ## [0.1.5] - 2026-10-06
373
+
374
+ ### Fixed
375
+
376
+ - **The model-provider list went empty (`0`) on dsh ≥ 0.2.** The host half
377
+ derived provider hosts by parsing `<DSH_HOME>/settings.yaml`. dsh 0.2 removed
378
+ that document: at boot `SettingsForms.importLegacyDocument()` renames it to
379
+ `settings.yaml.imported` and imports every section into the **active profile's**
380
+ config document (`~/.dsh/profiles/<name>/cordis.patch.yml`, a top-level YAML
381
+ array of `{id, name, config}` entries; upstream commit `601d6761e4`,
382
+ `feat(settings): project volatile Config through profile-backed forms`). The
383
+ reader therefore found no file, returned `[]`, and Settings → Smoothly Proxy
384
+ rendered an empty list — while the built-in model pages kept showing the same
385
+ providers normally.
386
+
387
+ Evidence on the affected machine: `GET /proxy/api` answered `providers: []`,
388
+ `~/.dsh/settings.yaml` was gone (only `settings.yaml.imported` remained), and
389
+ the GUI's own settings read (`POST /api/settings/describe`) answered
390
+ `llm-pi-ai.value.providers.<key>.{displayName, baseURL}` for all seven
391
+ configured routes.
392
+
393
+ The host half now reads the **live settings document**
394
+ (`ctx.get('settings').describe()` — the same call dsh's own settings pages
395
+ read) and keeps the legacy `<DSH_HOME>/settings.yaml` only as the fallback for
396
+ a composition without a settings service (dsh < 0.2, or a read that throws). A
397
+ readable live document always wins, so a leftover legacy file can never
398
+ resurrect stale hosts. The provider list is read per request, so adding or
399
+ renaming a provider route reaches the page on the next load — no dsh restart.
400
+
401
+ - **Loopback provider hosts were offered as proxy targets.** A provider whose
402
+ `baseURL` is loopback (`127.0.0.1`, `localhost`, `::1`, `0.0.0.0`) appeared in
403
+ the model-provider list with a checkbox that could never take effect — routing
404
+ sends loopback direct no matter what. Such hosts are now dropped from the list
405
+ on both the live and the legacy path, matching the observed-host list, and the
406
+ page copy now says the list is read live from your dsh settings (the old text
407
+ named `settings.yaml`, a file dsh 0.2 no longer has).
408
+
409
+ ### Added
410
+
411
+ - Negative controls for the new behavior, re-proved on every `npm test`: the
412
+ reported defect re-injected (host half stops reading the live document) must
413
+ redden `host-route.spec.mjs`; a resolver that prefers the legacy file, and a
414
+ provider list that offers loopback hosts again, must redden
415
+ `proxy-core.spec.mjs`. Guarantees G13–G15 (`docs/guarantees.md`) pin the
416
+ loopback rule, the live-over-legacy order, and the "a settings read that
417
+ throws still answers 200" fallback.
418
+
3
419
  ## [0.1.4] - 2026-09-25
4
420
 
5
421
  ### Fixed
package/CONTRIBUTING.md CHANGED
@@ -8,7 +8,8 @@ model-provider proxying.
8
8
  ```sh
9
9
  pnpm install # dev deps (tsdown, typescript, react types)
10
10
  pnpm typecheck # tsc --noEmit
11
- pnpm test # node --test: proxy-core (13 scenarios) + host-route /
11
+ pnpm test # node --test: proxy-core + host-route / client-boot / cordis-mount
12
+ # (each spec prints its own scenario count)
12
13
  # client-boot / cordis-mount specs (no external network)
13
14
  pnpm bundle # emit lib/index.js + lib/client.js
14
15
  ```
@@ -70,10 +71,18 @@ Release steps:
70
71
  # 1. write code + update README.md/README.zh.md + CHANGELOG.md together
71
72
  # 2. bump version in package.json
72
73
  git add -A && git commit -m "dsh-proxy v<version>"
73
- git tag v<version> && git push origin v<version>
74
+ git tag v<version>
74
75
  pnpm release:check
75
76
  ```
76
77
 
78
+ **Keep the tag local until the upload lands.** An unpublished version is allowed
79
+ to move: if the release still needs commits (a gate fix, a re-run of
80
+ verification), re-point the tag with `git tag -f v<version>` at the new HEAD and
81
+ re-run `pnpm verify:all` — the report and the tag must name the same commit.
82
+ Push the tag only after the registry confirms the version
83
+ (`git push origin v<version>`). Pushing it earlier freezes it: a later fix then
84
+ needs a force-push, and the remote advertises a release that is not on npm.
85
+
77
86
  ### Publishing is a manual step (2FA)
78
87
 
79
88
  The npm account has two-factor authentication enabled, so **the agent cannot
@@ -83,13 +92,16 @@ passing, `npm pack --dry-run` content confirmed), then the human runs:
83
92
 
84
93
  ```sh
85
94
  npm login # 2FA
86
- npm publish # runs the gate (prepack/prepublishOnly), then postpublish verifies
95
+ npm run publish:release # gate → build → pack → verify the tarball → publish → advisory verify
87
96
  ```
88
97
 
89
- `npm publish --ignore-scripts` bypasses all gates and post-publish verification
90
- — treat it as a process violation. After a successful publish, `postpublish`
91
- polls the registry until the version is visible and verifies `dist-tags.latest`
92
- plus the tarball contents.
98
+ `npm publish` is still gated (prepack/prepublishOnly), but it rebuilds after the
99
+ gate and forwards a failing `postpublish` exit code — `publish:release` is the
100
+ path that publishes the bytes it verified. `npm publish --ignore-scripts`
101
+ bypasses all gates and post-publish verification — treat it as a process
102
+ violation. After a successful publish, the post-publish check polls the registry
103
+ until the version is visible and verifies `dist-tags.latest` plus the tarball
104
+ contents; then push the tag (`git push origin v<version>`).
93
105
 
94
106
  ### Known release-check "failures" that are actually fine
95
107
 
@@ -104,3 +116,22 @@ plus the tarball contents.
104
116
  ## License
105
117
 
106
118
  MIT
119
+
120
+ ## Publish verification contract (2026-11-01)
121
+
122
+ `npm publish` forwards lifecycle-hook exit codes: a nonzero `postpublish` makes a
123
+ **landed** upload look failed. Therefore:
124
+
125
+ - `postpublish` runs `node scripts/post-publish-check.mjs --advisory`: it reports
126
+ the verdict (`CONFIRMED` / `PENDING_PROPAGATION` / `UNVERIFIED` /
127
+ `CONTENT_MISMATCH` / `NOT_PUBLISHED`) but always exits 0.
128
+ - `npm run verify:publish` is the strict gate (`--strict`; PENDING exits 3).
129
+ - Preferred release path: `npm run publish:release` — gates, builds once, packs,
130
+ verifies the packed bytes (sha512 + `files` whitelist → `.verify/pack.json`),
131
+ publishes the tarball (npm runs no lifecycle scripts for a tarball argument),
132
+ then verifies advisory.
133
+ - If verification reports `PENDING_PROPAGATION` after a publish: do **not**
134
+ re-publish. Wait 10–30 minutes and re-run `npm run verify:publish` (a retry
135
+ that returns 409 means the earlier upload landed — that is success).
136
+ - Env seams: `DSH_POSTPUBLISH_{ATTEMPTS,INTERVAL_MS,TARBALL_ATTEMPTS,TARBALL_INTERVAL_MS,TARBALL_TIMEOUT_MS,BUDGET_MS}`,
137
+ `DSH_POSTPUBLISH_ALLOW_CROSS_ORIGIN_TARBALL=1` for CDN-fronted mirrors.