swarm-deploy 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/LICENSE.md +201 -0
  3. package/NOTICE.md +13 -0
  4. package/README.md +1283 -0
  5. package/RELEASING.md +71 -0
  6. package/SECURITY.md +40 -0
  7. package/dist/abort.d.ts +16 -0
  8. package/dist/abort.js +52 -0
  9. package/dist/abort.js.map +1 -0
  10. package/dist/allowlist.d.ts +2 -0
  11. package/dist/allowlist.js +21 -0
  12. package/dist/allowlist.js.map +1 -0
  13. package/dist/bin/swarm-deploy.d.ts +2 -0
  14. package/dist/bin/swarm-deploy.js +41 -0
  15. package/dist/bin/swarm-deploy.js.map +1 -0
  16. package/dist/cli.d.ts +27 -0
  17. package/dist/cli.js +751 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/client.d.ts +111 -0
  20. package/dist/client.js +276 -0
  21. package/dist/client.js.map +1 -0
  22. package/dist/direct-dht.d.ts +81 -0
  23. package/dist/direct-dht.js +254 -0
  24. package/dist/direct-dht.js.map +1 -0
  25. package/dist/error-code.d.ts +15 -0
  26. package/dist/error-code.js +30 -0
  27. package/dist/error-code.js.map +1 -0
  28. package/dist/errors.d.ts +31 -0
  29. package/dist/errors.js +37 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/events-node.d.ts +2 -0
  32. package/dist/events-node.js +7 -0
  33. package/dist/events-node.js.map +1 -0
  34. package/dist/files.d.ts +32 -0
  35. package/dist/files.js +85 -0
  36. package/dist/files.js.map +1 -0
  37. package/dist/fs-node.d.ts +2 -0
  38. package/dist/fs-node.js +7 -0
  39. package/dist/fs-node.js.map +1 -0
  40. package/dist/hooks-module.d.ts +9 -0
  41. package/dist/hooks-module.js +118 -0
  42. package/dist/hooks-module.js.map +1 -0
  43. package/dist/hooks.d.ts +69 -0
  44. package/dist/hooks.js +88 -0
  45. package/dist/hooks.js.map +1 -0
  46. package/dist/identity.d.ts +7 -0
  47. package/dist/identity.js +50 -0
  48. package/dist/identity.js.map +1 -0
  49. package/dist/index.d.ts +12 -0
  50. package/dist/index.js +21 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/os-node.d.ts +2 -0
  53. package/dist/os-node.js +7 -0
  54. package/dist/os-node.js.map +1 -0
  55. package/dist/path-node.d.ts +2 -0
  56. package/dist/path-node.js +7 -0
  57. package/dist/path-node.js.map +1 -0
  58. package/dist/process-node.d.ts +2 -0
  59. package/dist/process-node.js +7 -0
  60. package/dist/process-node.js.map +1 -0
  61. package/dist/release.d.ts +26 -0
  62. package/dist/release.js +419 -0
  63. package/dist/release.js.map +1 -0
  64. package/dist/server.d.ts +239 -0
  65. package/dist/server.js +928 -0
  66. package/dist/server.js.map +1 -0
  67. package/dist/storage/atomic-file.d.ts +15 -0
  68. package/dist/storage/atomic-file.js +172 -0
  69. package/dist/storage/atomic-file.js.map +1 -0
  70. package/dist/storage/commit-journal.d.ts +120 -0
  71. package/dist/storage/commit-journal.js +417 -0
  72. package/dist/storage/commit-journal.js.map +1 -0
  73. package/dist/storage/commit-store.d.ts +307 -0
  74. package/dist/storage/commit-store.js +1691 -0
  75. package/dist/storage/commit-store.js.map +1 -0
  76. package/dist/storage/layout.d.ts +16 -0
  77. package/dist/storage/layout.js +409 -0
  78. package/dist/storage/layout.js.map +1 -0
  79. package/dist/storage/link-store.d.ts +70 -0
  80. package/dist/storage/link-store.js +363 -0
  81. package/dist/storage/link-store.js.map +1 -0
  82. package/dist/storage/recovery.d.ts +100 -0
  83. package/dist/storage/recovery.js +260 -0
  84. package/dist/storage/recovery.js.map +1 -0
  85. package/dist/storage/retention.d.ts +183 -0
  86. package/dist/storage/retention.js +619 -0
  87. package/dist/storage/retention.js.map +1 -0
  88. package/dist/storage/root-coordinator.d.ts +7 -0
  89. package/dist/storage/root-coordinator.js +27 -0
  90. package/dist/storage/root-coordinator.js.map +1 -0
  91. package/dist/storage/session-store.d.ts +2 -0
  92. package/dist/storage/session-store.js +7 -0
  93. package/dist/storage/session-store.js.map +1 -0
  94. package/dist/storage/tar-session-store.d.ts +147 -0
  95. package/dist/storage/tar-session-store.js +1044 -0
  96. package/dist/storage/tar-session-store.js.map +1 -0
  97. package/dist/storage/tree-fs.d.ts +44 -0
  98. package/dist/storage/tree-fs.js +580 -0
  99. package/dist/storage/tree-fs.js.map +1 -0
  100. package/dist/storage/tree-staging.d.ts +9 -0
  101. package/dist/storage/tree-staging.js +64 -0
  102. package/dist/storage/tree-staging.js.map +1 -0
  103. package/dist/storage/types.d.ts +77 -0
  104. package/dist/storage/types.js +3 -0
  105. package/dist/storage/types.js.map +1 -0
  106. package/dist/symlinks.d.ts +36 -0
  107. package/dist/symlinks.js +128 -0
  108. package/dist/symlinks.js.map +1 -0
  109. package/dist/tar-protocol/controls.d.ts +73 -0
  110. package/dist/tar-protocol/controls.js +291 -0
  111. package/dist/tar-protocol/controls.js.map +1 -0
  112. package/dist/tar-protocol/direct-wire.d.ts +43 -0
  113. package/dist/tar-protocol/direct-wire.js +194 -0
  114. package/dist/tar-protocol/direct-wire.js.map +1 -0
  115. package/dist/tar-protocol/extract.d.ts +18 -0
  116. package/dist/tar-protocol/extract.js +124 -0
  117. package/dist/tar-protocol/extract.js.map +1 -0
  118. package/dist/tar-protocol/hash.d.ts +10 -0
  119. package/dist/tar-protocol/hash.js +44 -0
  120. package/dist/tar-protocol/hash.js.map +1 -0
  121. package/dist/tar-protocol/lifecycle.d.ts +13 -0
  122. package/dist/tar-protocol/lifecycle.js +90 -0
  123. package/dist/tar-protocol/lifecycle.js.map +1 -0
  124. package/dist/tar-protocol/manifest.d.ts +58 -0
  125. package/dist/tar-protocol/manifest.js +312 -0
  126. package/dist/tar-protocol/manifest.js.map +1 -0
  127. package/dist/tar-protocol/tree-extract.d.ts +37 -0
  128. package/dist/tar-protocol/tree-extract.js +240 -0
  129. package/dist/tar-protocol/tree-extract.js.map +1 -0
  130. package/dist/tar-protocol/tree-manifest.d.ts +45 -0
  131. package/dist/tar-protocol/tree-manifest.js +277 -0
  132. package/dist/tar-protocol/tree-manifest.js.map +1 -0
  133. package/dist/tar-protocol/tree-path.d.ts +15 -0
  134. package/dist/tar-protocol/tree-path.js +68 -0
  135. package/dist/tar-protocol/tree-path.js.map +1 -0
  136. package/dist/tar-protocol/tree.d.ts +68 -0
  137. package/dist/tar-protocol/tree.js +269 -0
  138. package/dist/tar-protocol/tree.js.map +1 -0
  139. package/dist/tar-protocol/ustar.d.ts +36 -0
  140. package/dist/tar-protocol/ustar.js +167 -0
  141. package/dist/tar-protocol/ustar.js.map +1 -0
  142. package/dist/types.d.ts +61 -0
  143. package/dist/types.js +3 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/validation.d.ts +4 -0
  146. package/dist/validation.js +26 -0
  147. package/dist/validation.js.map +1 -0
  148. package/package.json +103 -1
package/README.md ADDED
@@ -0,0 +1,1283 @@
1
+ <h1 align="center">Swarm Deploy</h1>
2
+
3
+ <p align="center">
4
+ Authenticated, encrypted, resumable artifact uploads over direct HyperDHT.
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://github.com/tetherto/swarm-deploy/actions/workflows/ci.yml"><img alt="CI status" src="https://github.com/tetherto/swarm-deploy/actions/workflows/ci.yml/badge.svg?branch=main"></a>
9
+ <a href="https://github.com/tetherto/swarm-deploy/blob/main/package.json"><img alt="Package version" src="https://img.shields.io/github/package-json/v/tetherto/swarm-deploy?filename=package.json&amp;label=version&amp;style=flat-square"></a>
10
+ <a href="https://nodejs.org/"><img alt="Node.js 22 and 24" src="https://img.shields.io/badge/node-22%20%7C%2024-339933?logo=nodedotjs&amp;logoColor=white&amp;style=flat-square"></a>
11
+ <a href="https://github.com/holepunchto/bare"><img alt="Bare supported" src="https://img.shields.io/badge/Bare-supported-171717?logo=javascript&amp;logoColor=white&amp;style=flat-square"></a>
12
+ <a href="https://www.typescriptlang.org/"><img alt="Strict TypeScript" src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&amp;logoColor=white&amp;style=flat-square"></a>
13
+ <a href="https://github.com/tetherto/swarm-deploy/blob/main/LICENSE.md"><img alt="Apache-2.0 license" src="https://img.shields.io/github/license/tetherto/swarm-deploy?style=flat-square"></a>
14
+ </p>
15
+
16
+ Swarm Deploy moves build artifacts from authorized clients to one receiving
17
+ server. Each file uses a fresh direct HyperDHT connection authenticated and
18
+ encrypted with Noise. The client pins the server's public key, while the server
19
+ accepts only client public keys in its immutable startup allowlist.
20
+
21
+ The receiver is upload-only. It does not execute, serve, or provide a download
22
+ protocol for stored artifacts.
23
+
24
+ ## Requirements
25
+
26
+ - Node.js 22 or 24, or the current stable Bare runtime.
27
+ - A persistent server seed and at least one independently generated client seed.
28
+ - A dedicated server storage directory.
29
+ - Network access suitable for HyperDHT.
30
+
31
+ ## Install
32
+
33
+ Install the runtime API in an application:
34
+
35
+ ```sh
36
+ npm install swarm-deploy
37
+ ```
38
+
39
+ Install the CLI globally for a receiving server or CI uploader:
40
+
41
+ ```sh
42
+ npm install --global swarm-deploy
43
+ swarm-deploy --help
44
+ ```
45
+
46
+ ## Security and identity model
47
+
48
+ Three values have different roles:
49
+
50
+ - **Seed:** private 32-byte identity material. Prefer a protected seed file or
51
+ environment secret. The CLI also accepts `--seed <64-lower-hex>`, but command
52
+ arguments can be exposed through shell history, process listings, and CI
53
+ tracing. Never put a seed in an allowlist or application log.
54
+ - **Client public key:** derived from a client seed and installed in the
55
+ server's allowlist.
56
+ - **Server public key:** derived from the server seed and pinned by every
57
+ client. It is both the direct HyperDHT destination and the server identity
58
+ commitment.
59
+
60
+ HyperDHT Noise authenticates both peers and encrypts the transport. Application
61
+ SHA-256 checks verify the deterministic TAR and extracted file bytes.
62
+
63
+ There is no topic, swarm discovery, Protomux channel, dynamic allowlist reload,
64
+ or reconnect budget. Changing an allowed client key requires a controlled
65
+ server restart.
66
+
67
+ Keep seeds stable to preserve identity. Copying one client seed to several
68
+ machines intentionally gives all of them the same uploader identity; use
69
+ separate client seeds when independent authorization is required.
70
+
71
+ ## Provision identities
72
+
73
+ Generate separate server and client seed files:
74
+
75
+ ```sh
76
+ swarm-deploy keygen --out server.seed
77
+ swarm-deploy keygen --out client.seed
78
+ ```
79
+
80
+ `keygen` creates an owner-only file, refuses to overwrite an existing path, and
81
+ prints the corresponding public key—not the seed.
82
+
83
+ Recover public keys later:
84
+
85
+ ```sh
86
+ SERVER_KEY=$(swarm-deploy public-key --seed-file server.seed)
87
+ CLIENT_KEY=$(swarm-deploy public-key --seed-file client.seed)
88
+ ```
89
+
90
+ Public keys are lowercase 64-character hexadecimal strings and are safe to use
91
+ as configuration values.
92
+
93
+ ## Quick start
94
+
95
+ Start the receiver:
96
+
97
+ ```sh
98
+ swarm-deploy server --seed-file server.seed --storage /srv/artifacts \
99
+ --allow-key "$CLIENT_KEY" --max-file-bytes 1073741824 --max-staging-bytes 2147483648
100
+ ```
101
+
102
+ The server prints its full public key and then `ready` after storage recovery,
103
+ scrub, retention initialization, and HyperDHT listening complete:
104
+
105
+ ```text
106
+ 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
107
+ ready
108
+ ```
109
+
110
+ Upload a file:
111
+
112
+ ```sh
113
+ swarm-deploy upload --seed-file client.seed --server-key "$SERVER_KEY" \
114
+ --idle-timeout 60000 ./artifact.bin
115
+ ```
116
+
117
+ Successful output is:
118
+
119
+ ```text
120
+ artifact.bin COMMITTED
121
+ ```
122
+
123
+ Uploading the same managed content again returns `ALREADY_COMMITTED` without
124
+ retransmitting its TAR bytes.
125
+
126
+ ## CLI reference
127
+
128
+ ### `keygen`
129
+
130
+ ```sh
131
+ swarm-deploy keygen --out <seed-file>
132
+ ```
133
+
134
+ Creates a new seed file with owner-only permissions and prints its public key.
135
+ The destination must not already exist.
136
+
137
+ ### `public-key`
138
+
139
+ ```sh
140
+ swarm-deploy public-key --seed-file <seed-file>
141
+ swarm-deploy public-key --seed <64-lower-hex>
142
+ ```
143
+
144
+ Reads a seed file or canonical seed string and prints its public key.
145
+
146
+ ### `server`
147
+
148
+ ```sh
149
+ swarm-deploy server \
150
+ --seed-file <seed-file> \
151
+ --storage <directory> \
152
+ --allow-key <64-lower-hex> \
153
+ --max-file-bytes <bytes> \
154
+ --max-staging-bytes <bytes> \
155
+ [--allow-key <64-lower-hex>]... \
156
+ [--max-storage-bytes <bytes>] \
157
+ [--max-age-days <days>] \
158
+ [--replace-name <safe-basename>]... \
159
+ [--artifact-pattern <template>]... \
160
+ [--max-count <count>] \
161
+ [--max-versions <count> --version-granularity <major|minor>] \
162
+ [--symlink <selector> <link-name>]... \
163
+ [--hooks <module>]
164
+ ```
165
+
166
+ Replace `--seed-file <seed-file>` with `--seed <64-lower-hex>` to provide the
167
+ seed inline.
168
+
169
+ Required options:
170
+
171
+ - Exactly one seed source: `--seed-file`, `--seed`, or
172
+ `SWARM_DEPLOY_SERVER_SEED`.
173
+ - `--storage`: dedicated artifact and internal-state root.
174
+ - `--allow-key`: authorized client public key. Repeat for multiple identities;
175
+ duplicates and malformed keys are rejected.
176
+ - `--max-file-bytes`: maximum extracted artifact size.
177
+ - `--max-staging-bytes`: aggregate persistent staging reservation. An admitted
178
+ transfer reserves its deterministic TAR size plus extracted file size, so
179
+ this commonly needs to be at least twice the largest simultaneously staged
180
+ payload.
181
+
182
+ Optional options:
183
+
184
+ - `--max-storage-bytes`: maximum total managed committed storage. Oldest
185
+ eligible artifacts are removed first.
186
+ - `--max-age-days`: remove eligible committed artifacts at or beyond this age.
187
+ - `--replace-name`: permit replacement of this exact basename. Repeat for
188
+ multiple mutable names.
189
+ - `--artifact-pattern <template>`: identify releases and make matching
190
+ mandatory. Repeat for multiple templates; declaration order is match order.
191
+ See [Artifact patterns and rotation](#artifact-patterns-and-rotation).
192
+ - `--max-count <count>`: keep the newest `<count>` releases per series. Requires
193
+ at least one `--artifact-pattern`.
194
+ - `--max-versions <count>`: keep every release in the newest `<count>` distinct
195
+ version groups per series. Requires a pattern containing `{version}` and
196
+ `--version-granularity`.
197
+ - `--version-granularity <major|minor>`: how versions are grouped for
198
+ `--max-versions`. It is rejected without `--max-versions`, and there is no
199
+ default.
200
+ - `--symlink <selector> <link-name>`: declarative managed symlink rule.
201
+ Repeat for multiple links. A selector that begins and ends with `/` is an
202
+ unflagged regular expression matched against managed artifact basenames;
203
+ anything else is an exact managed basename. The link name must be a safe
204
+ single-component basename and must not collide with another rule.
205
+ - `--hooks <module>`: JavaScript module (`.js`, `.mjs`, or `.cjs`) exporting
206
+ trusted lifecycle callbacks. The path is resolved against the working
207
+ directory. See [Deployment hooks](#deployment-hooks).
208
+
209
+ All counts are positive safe integers. Invalid combinations, malformed or
210
+ duplicate templates, and unloadable hook modules are configuration errors that
211
+ exit `2` before the server listens. Each hooks-module error names only the
212
+ module basename with a fixed reason, never module content.
213
+
214
+ The CLI requires at least one `--allow-key`. Its snapshot is immutable for the
215
+ life of the process.
216
+
217
+ ### `upload`
218
+
219
+ ```sh
220
+ swarm-deploy upload \
221
+ --seed-file <seed-file> \
222
+ --server-key <64-lower-hex> \
223
+ [--idle-timeout <milliseconds>] \
224
+ [--no-source-parent] \
225
+ <file-or-directory>
226
+ ```
227
+
228
+ Replace `--seed-file <seed-file>` with `--seed <64-lower-hex>` to provide the
229
+ seed inline.
230
+
231
+ - `--server-key` is the full pinned server public key.
232
+ - `--idle-timeout` defaults to 60 seconds and bounds inactive protocol reads and
233
+ backpressured writes.
234
+ - The input may be any regular binary file; Swarm Deploy creates the canonical
235
+ one-entry USTAR stream automatically. Pre-tarring is not required.
236
+ - A direct file retains its basename.
237
+ - Each upload also carries the immediate local parent directory name as
238
+ optional `sourceParent` metadata, but only when that name is a safe single
239
+ component (see [Source parent](#source-parent)).
240
+ - `--no-source-parent` takes no value and suppresses that metadata for every
241
+ file in the run. The upload then keeps the legacy transfer identity and
242
+ cannot match a server pattern with a parent segment.
243
+ - A directory input commits exactly one recursive managed directory artifact
244
+ named after the input basename. Every member must be a safe regular file or
245
+ directory; symlinks, hard links, devices, sockets, FIFOs, unsafe names,
246
+ over-depth, over-count, or over-length members reject the whole upload.
247
+ Nothing is silently skipped.
248
+ - On success the CLI prints one line: `<name> <kind> <status>` (for example
249
+ `0.18.1 directory COMMITTED`).
250
+
251
+ Accepted names start with an ASCII letter or digit, contain only letters,
252
+ digits, `.`, `_`, and `-`, and occupy at most 100 UTF-8 bytes. `history-` is
253
+ reserved for server-managed replacement history.
254
+
255
+ If a basename is exactly 64 lowercase hexadecimal characters, pass it with a
256
+ directory component such as `./<name>` so the CLI does not treat it as an
257
+ accidentally pasted seed.
258
+
259
+ ### Seed sources
260
+
261
+ Server and upload commands accept exactly one of:
262
+
263
+ - `--seed-file <seed-file>`
264
+ - `--seed <64-lower-hex>`
265
+ - the role-specific `SWARM_DEPLOY_SERVER_SEED` or
266
+ `SWARM_DEPLOY_CLIENT_SEED` environment variable
267
+
268
+ String values must contain exactly 64 lowercase hexadecimal characters.
269
+ Combining seed sources is an error.
270
+
271
+ The `public-key` command accepts `--seed-file` or `--seed`; it does not consume a
272
+ role-specific environment variable.
273
+
274
+ Prefer seed files or protected environment variables in production. Use
275
+ `--seed` only when exposure through command history, process inspection, and
276
+ tooling logs is acceptable.
277
+
278
+ ### CLI exit codes
279
+
280
+ - `0`: the upload committed or was already committed.
281
+ - `1`: upload, network, protocol, storage, cleanup, or runtime failure.
282
+ - `2`: usage or configuration error.
283
+
284
+ ## CI uploader example
285
+
286
+ Store the client seed as a protected CI secret and the server public key as a
287
+ nonsecret variable:
288
+
289
+ ```yaml
290
+ - name: Install uploader
291
+ run: npm install --global swarm-deploy
292
+
293
+ - name: Upload artifact
294
+ env:
295
+ SWARM_DEPLOY_CLIENT_SEED: ${{ secrets.SWARM_DEPLOY_CLIENT_SEED }}
296
+ SWARM_DEPLOY_SERVER_KEY: ${{ vars.SWARM_DEPLOY_SERVER_KEY }}
297
+ run: |
298
+ swarm-deploy upload \
299
+ --server-key "$SWARM_DEPLOY_SERVER_KEY" \
300
+ --idle-timeout 60000 \
301
+ ./dist/artifact-linux-x64.tar.gz
302
+ ```
303
+
304
+ Do not enable shell tracing around commands that read seed environment
305
+ variables or pass `--seed`.
306
+
307
+ ## Transfer and resume behavior
308
+
309
+ Each file follows this lifecycle:
310
+
311
+ 1. The client connects directly to the pinned server public key with its seeded
312
+ HyperDHT identity.
313
+ 2. The server firewall and connection handler verify the authenticated client
314
+ key against the startup allowlist.
315
+ 3. The client sends bounded metadata containing the name, file size and digest,
316
+ deterministic TAR size and digest, transfer ID, and optional `sourceParent`.
317
+ 4. The server responds with `ACCEPT`, `RESUME`, `VERIFIED`,
318
+ `ALREADY_COMMITTED`, or a stable rejection.
319
+ 5. The client sends exactly the required deterministic one-entry USTAR bytes.
320
+ 6. The server validates the canonical archive, extracted size, and both
321
+ SHA-256 values before durable commit.
322
+ 7. Success requires an explicit terminal `COMMITTED` result. EOF or socket
323
+ closure is never success.
324
+
325
+ Incomplete uploads retain only durable TAR progress. On reconnect, the server
326
+ returns a TAR-byte offset and SHA-256 of that prefix. The client regenerates and
327
+ compares the prefix before sending the suffix. A mismatch closes that connection
328
+ and retries once from zero with explicit reset intent.
329
+
330
+ The server coalesces network fragments into 1 MiB durability batches plus the
331
+ final remainder. A disconnect can require retransmitting only the uncheckpointed
332
+ in-memory tail; it never advertises bytes that were not durably published.
333
+
334
+ Inactive sessions expire after seven days by default. Active receives and
335
+ verification are protected from expiry.
336
+
337
+ ## Storage, replacement, and retention
338
+
339
+ The storage root contains visible current and historical artifacts plus the
340
+ reserved `.swarm-deploy/` internal directory. Do not modify that directory
341
+ while the server is running.
342
+
343
+ Names are create-only by default:
344
+
345
+ - New content for an unused name is committed atomically.
346
+ - Identical managed content returns `ALREADY_COMMITTED`.
347
+ - Different content for an occupied create-only name returns `FILE_EXISTS`.
348
+ - Unmanaged files and paths are never overwritten or deleted.
349
+
350
+ Names configured with `--replace-name` or `ServerOptions.replaceNames` are
351
+ mutable:
352
+
353
+ - Different verified content atomically becomes current.
354
+ - The prior managed inode and record are preserved as
355
+ `history-<full-old-transfer-id>`.
356
+ - The current mutable name is pinned against age and quota retention.
357
+ - Historical versions remain eligible for retention.
358
+
359
+ Commit journals and inode checks recover interrupted create and replacement
360
+ operations. Recovery rolls back mutations before the durable new current
361
+ sidecar and rolls forward operations after that linearization point.
362
+
363
+ Optional retention applies to managed artifacts only:
364
+
365
+ - `maxAge`/`--max-age-days` removes eligible artifacts by age.
366
+ - `maxStorageBytes`/`--max-storage-bytes` removes the oldest eligible artifacts
367
+ until under quota.
368
+ - `maxCount`/`--max-count` and `maxVersions`/`--max-versions` rotate released
369
+ artifacts by series; see
370
+ [Artifact patterns and rotation](#artifact-patterns-and-rotation).
371
+ - Directory artifacts are create-only. A directory offer for a configured
372
+ `replaceNames` entry, a file-to-directory kind change, or a directory-to-file
373
+ kind change is rejected. Directory replacement and history are deferred.
374
+ - Committed artifacts, including directories, are immutable on disk. Do not write
375
+ into a committed directory after publish: hooks must not run `npm ci`, create
376
+ `.cache`, dotfiles, or extra symlinks inside the tree. If the tree changes,
377
+ startup hash scrub drops the sidecar while leaving the bytes in place, the
378
+ name stays occupied (`FILE_EXISTS`), the tree falls outside quota and rotation,
379
+ and a managed `latest` link can move to an older release.
380
+ - Managed directories are deleted by renaming into `.swarm-deploy/trash` before
381
+ recursive removal. Startup sweeps proven trash residue.
382
+ - Startup recovery re-hashes managed files and recursively verifies directory
383
+ tree digests. Scheduled and pre-commit passes validate type, sidecar, and root
384
+ identity without re-hashing entire trees on every tick.
385
+
386
+ Runtime defaults:
387
+
388
+ - 64 authenticated connections.
389
+ - 8 active uploads.
390
+ - 60-second upload inactivity timeout.
391
+ - 1 GiB minimum free-disk reserve.
392
+ - 15-minute cleanup interval.
393
+ - 7-day resumable-session lifetime.
394
+
395
+ The advanced runtime API can override these values; the server CLI intentionally
396
+ exposes only its required limits, committed retention and rotation, replacement
397
+ policy, artifact patterns, and hooks module.
398
+
399
+ ## Artifact patterns and rotation
400
+
401
+ Artifact patterns teach the server which uploads belong to which release
402
+ series and, optionally, which SemVer version each one carries. They drive count
403
+ and version rotation and are configured only on the server:
404
+ `ServerOptions.artifactPatterns` or repeatable `--artifact-pattern`.
405
+
406
+ ### Pattern templates
407
+
408
+ A template is literal text plus the placeholders `{series}` and `{version}`:
409
+
410
+ - At least one placeholder is required; each may appear at most once.
411
+ - Two placeholders in one path segment must be separated by literal text.
412
+ - `{` and `}` are reserved. A template whose literal text still contains a
413
+ brace after the exact `{series}` and `{version}` placeholders are removed
414
+ (`{serie}-{version}.tar.gz`, `{{series}.zip`) is rejected at startup rather
415
+ than matched literally.
416
+ - A template has one segment (matched against the basename) or two segments
417
+ separated by one `/` (`<source-parent>/<basename>`). Empty segments, further
418
+ `/` characters, and duplicate templates are rejected at startup.
419
+ - `{series}` captures one safe component. A template without `{series}` is a
420
+ fixed series whose key is `fixed-` followed by the lowercase hex SHA-256 of
421
+ the exact template text. `{version}/payments.tar.gz` therefore has the series
422
+ `fixed-594ef13d9808171190f7827f7d2fb1dc33f000f531b69d55c1bd485a70ff8c4b`. The
423
+ key is a 70-character safe basename, so it persists in commit records and
424
+ stays identical across restarts and between Node.js and Bare. The root export
425
+ `fixedSeriesKey(template)` derives the same value, so an operator can map a
426
+ series in a sidecar or log line back to the template that produced it.
427
+ - That derived shape is a reserved namespace: a captured `{series}` of exactly
428
+ `fixed-` plus 64 lowercase hex characters never matches, so an uploaded
429
+ filename cannot place itself in a version-only pattern's rotation group. The
430
+ offer is simply unmatched, which with mandatory matching means
431
+ `INVALID_FILENAME`. Look-alikes (different length, uppercase, non-hex) are
432
+ unaffected.
433
+ - `{version}` must be strict SemVer 2.0.0: no `v` prefix, no leading zeros, no
434
+ padding. The stored version is the normalized `major.minor.patch[-prerelease]`;
435
+ build metadata is accepted in a folder name but dropped, and it never
436
+ influences precedence.
437
+ - Basenames never contain `+`, so build metadata can only appear in a
438
+ source-parent segment.
439
+ - A malformed version is a non-match, not a partial match.
440
+
441
+ Examples:
442
+
443
+ | Template | Offer (`sourceParent`, basename) | Series | Version |
444
+ | --------------------------- | ----------------------------------- | ------------- | ------------ |
445
+ | `{series}-{version}.tar.gz` | `payments-2.4.1.tar.gz` | `payments` | `2.4.1` |
446
+ | `{series}-{version}.tar.gz` | `payments-3.0.0-rc.1.tar.gz` | `payments` | `3.0.0-rc.1` |
447
+ | `{version}/{series}.tar.gz` | `2.4.1+build.7` / `payments.tar.gz` | `payments` | `2.4.1` |
448
+ | `releases/{series}.zip` | `releases` / `payments.zip` | `payments` | none |
449
+ | `{version}/payments.tar.gz` | `1.8.0` / `payments.tar.gz` | `fixed-594e…` | `1.8.0` |
450
+ | `{series}.tar.gz` | `payments.tar.gz` | `payments` | none |
451
+
452
+ With a series-first template such as `{series}-{version}.tar.gz`, the split
453
+ chosen is the right-most `-` that yields a valid SemVer, so hyphenated series
454
+ names and prereleases both work. A version-first template such as
455
+ `{version}-{series}.tar.gz` splits at the left-most valid position and cannot
456
+ tell a prerelease hyphen from the series separator; prefer series-first or a
457
+ folder-based version for prereleases.
458
+
459
+ Patterns are tried in declaration order and the first template that produces
460
+ coordinates wins. A template with a parent segment never matches an offer that
461
+ carries no `sourceParent`.
462
+
463
+ ### Source parent
464
+
465
+ The client sends the immediate parent directory name of the resolved input as
466
+ optional authenticated metadata, never an absolute path or a higher component.
467
+ Uploading `/ci/2.4.1+build.7/payments.tar.gz` sends `sourceParent`
468
+ `2.4.1+build.7`; uploading a directory sends that directory's name for each
469
+ child.
470
+
471
+ The parent is sent only when it is a safe single component
472
+ (`[A-Za-z0-9][A-Za-z0-9._+-]*`, at most 100 UTF-8 bytes). A parent with a space,
473
+ a leading dot, a non-ASCII character, an overlong name, or the filesystem root
474
+ is omitted, and the upload keeps the pre-existing metadata shape. Consequently a
475
+ folder pattern rejects uploads whose parent is unsafe or omitted: stage release
476
+ files in a conforming directory. `sourceParent` is part of the transfer ID, so
477
+ it cannot change between resume attempts.
478
+
479
+ A client can also suppress it deliberately with `includeSourceParent: false`
480
+ (CLI `--no-source-parent`) where the staging folder name itself is sensitive.
481
+ The upload then sends no parent and derives the same transfer ID as a client
482
+ that predates the field, so a server pattern needing a parent segment rejects
483
+ it with `INVALID_FILENAME`.
484
+
485
+ ### Mandatory matching
486
+
487
+ When `artifactPatterns` is non-empty, matching is mandatory for new uploads. An
488
+ offer that matches no pattern is rejected with `INVALID_FILENAME` before
489
+ session admission, staging, verification, commit, `beforeCommit`, and
490
+ `afterCommit`; the server then calls `onFailure` once with phase `offer`, a
491
+ `null` path, and no `release`. A server with no patterns accepts every
492
+ otherwise-valid name, and records get no release identity.
493
+
494
+ Release coordinates (`series` and, if the pattern has one, `version`) are
495
+ decided at commit time and persisted in the commit record, replacement history
496
+ inherits them, and they survive restarts and later pattern changes. A commit
497
+ record written before this feature, or by a server without patterns, has no
498
+ release and is never deleted by count or version rotation; it remains subject
499
+ to age and quota retention.
500
+
501
+ A new upload whose matched release differs from the persisted release of the
502
+ same transfer fails closed instead of reporting `ALREADY_COMMITTED`.
503
+
504
+ ### Selection rules
505
+
506
+ Retention runs under the root lease at startup, on schedule, around every
507
+ commit, and manually. After session expiry and a committed-state scrub, its
508
+ stages run in this order, each working from the records the previous stage
509
+ left:
510
+
511
+ 1. age (`maxAge`);
512
+ 2. count (`maxCount`);
513
+ 3. version (`maxVersions` with `versionGranularity`);
514
+ 4. storage quota (`maxStorageBytes`).
515
+
516
+ **Count.** Records are grouped by series and ordered by commit order: newest
517
+ `committedAt` first, with transfer ID and then name as deterministic
518
+ tie-breakers. The newest `maxCount` records in each series are kept. Replacement
519
+ history records count like any other release.
520
+
521
+ **Version.** Records with a version are grouped by series and ordered by SemVer
522
+ precedence, not by commit time. With `major`, every record in the newest
523
+ `maxVersions` distinct majors is kept; with `minor`, in the newest
524
+ `maxVersions` distinct `major.minor` groups. Prereleases and history records in
525
+ a retained group are kept, so `2.0.0-rc.1` and `2.0.0` share group `2`. Series-only
526
+ records (no `{version}`) are never selected by version rotation.
527
+
528
+ **Combined limits.** A record may be removed by either configured bound, so
529
+ `maxCount` plus `maxVersions` retains the intersection of the two keep sets, and
530
+ age and quota then apply to what remains.
531
+
532
+ **Pinned mutable names.** The current artifact of a `--replace-name` name is
533
+ never deleted by any stage, but it still takes part in the accounting. If it
534
+ occupies a slot or falls outside a keep set, the limit is best-effort: the
535
+ series can hold more than the configured number until a later upload moves the
536
+ name on.
537
+
538
+ **Immediate effect.** Rotation also runs right after each commit, so an upload
539
+ that is older than the retained window (for example version `1.0.0` when
540
+ `maxVersions` is `2` and `3.x` and `2.x` exist) is committed and then removed in
541
+ that same pass. The exact ordering depends on hooks:
542
+
543
+ - Without an `afterCommit` hook, the post-commit pass runs immediately after the
544
+ commit becomes durable, before `COMMITTED` is sent.
545
+ - With an `afterCommit` hook, the post-commit pass is deferred. Order:
546
+ commit (the pre-commit quota and age checks still run first), `afterCommit`
547
+ with the final path and sidecar present, and only after the callback
548
+ succeeds the post-commit pass, then `COMMITTED` (or `ALREADY_COMMITTED`).
549
+ Because the deferred pass runs before the terminal reply, a slow retention
550
+ pass delays `COMMITTED` or `ALREADY_COMMITTED`. `beforeCommit`-only and
551
+ `onFailure`-only servers do not defer.
552
+ - The final path exists when `afterCommit` begins in the sequential server
553
+ flow, including for an out-of-window release, and for replacement commits
554
+ (a replaced artifact's history record is also present). It is not a lock:
555
+ a concurrent commit's retention pass, a scheduled retention pass, or a manual
556
+ retention pass can remove the file while the callback runs. A hook that needs stable bytes should open or
557
+ copy the file promptly at the start of the callback.
558
+ - If `afterCommit` throws, no post-commit pass runs for that connection. The
559
+ artifact and its record stay, and the server remembers in memory that this
560
+ transfer still owes a pass. An immediate retry normally reaches
561
+ `ALREADY_COMMITTED`, runs `afterCommit` again with the existing path, and runs
562
+ the owed pass only after that call succeeds. A failing already-committed
563
+ `afterCommit` likewise skips the pass and keeps the transfer owed. See the
564
+ retry caveat below.
565
+ - An ordinary duplicate offer — one whose `afterCommit` already succeeded, or
566
+ that this process never committed — still calls `afterCommit` with
567
+ `alreadyCommitted: true`, but starts **no** retention pass. Only a connection
568
+ that committed in this process, or a retry of a transfer whose hook failed
569
+ here, can trigger the deferred pass, so repeated duplicate offers cannot be
570
+ used to force repeated full retention scans.
571
+ - The owed-transfer set lives only in memory. A server restart forgets it, and
572
+ closing the server clears it; the artifact is then rotated by the next
573
+ startup, scheduled, or commit-triggered pass instead.
574
+ - Post-commit retention failures stay non-fatal: they are logged and reported as
575
+ `retention` events, and never fail the upload.
576
+
577
+ **Retry caveat.** There is no persistent "hook pending" marker. After
578
+ `afterCommit` fails for an out-of-window artifact, any intervening retention
579
+ pass can remove it before the client retries: a startup pass after a restart, a
580
+ scheduled or manual cleanup, or the retention pass of another concurrent commit.
581
+ The retry is then a normal fresh upload (transfer, verification, `beforeCommit`,
582
+ commit, `afterCommit` with `alreadyCommitted: false`), not `ALREADY_COMMITTED`.
583
+ Every statement below that a failed hook is retried as `ALREADY_COMMITTED`
584
+ assumes the artifact was not removed in between.
585
+
586
+ `retention` events and results report `ageDeleted`, `countDeleted`,
587
+ `versionDeleted`, and `storageDeleted` next to `expiredSessions` and `scrubbed`.
588
+ Logged deletions use the reasons `MAX_AGE`, `MAX_COUNT`, `MAX_VERSIONS`, and
589
+ `MAX_STORAGE`.
590
+
591
+ ### Configuration and CLI examples
592
+
593
+ Filename versions, keeping the newest 5 builds per product and the newest two
594
+ minor lines:
595
+
596
+ ```sh
597
+ swarm-deploy server --seed-file server.seed --storage /srv/artifacts \
598
+ --allow-key "$CLIENT_KEY" --max-file-bytes 1073741824 --max-staging-bytes 2147483648 \
599
+ --artifact-pattern '{series}-{version}.tar.gz' \
600
+ --max-count 5 --max-versions 2 --version-granularity minor
601
+ ```
602
+
603
+ Folder versions (the source parent carries the version, build metadata
604
+ included), keeping the two newest majors:
605
+
606
+ ```sh
607
+ swarm-deploy server --seed-file server.seed --storage /srv/artifacts \
608
+ --allow-key "$CLIENT_KEY" --max-file-bytes 1073741824 --max-staging-bytes 2147483648 \
609
+ --artifact-pattern '{version}/{series}.tar.gz' \
610
+ --max-versions 2 --version-granularity major
611
+
612
+ # client: /ci/out/2.4.1+build.7/payments.tar.gz is offered as
613
+ # sourceParent "2.4.1+build.7", name "payments.tar.gz"
614
+ swarm-deploy upload --seed-file client.seed --server-key "$SERVER_KEY" \
615
+ /ci/out/2.4.1+build.7/payments.tar.gz
616
+ ```
617
+
618
+ The runtime API takes the same values:
619
+
620
+ ```ts
621
+ new Server({
622
+ // ...required options
623
+ artifactPatterns: ['{series}-{version}.tar.gz', 'releases/{series}.zip'],
624
+ maxCount: 5,
625
+ maxVersions: 2,
626
+ versionGranularity: 'minor'
627
+ })
628
+ ```
629
+
630
+ `maxCount` requires at least one pattern. `maxVersions` requires at least one
631
+ pattern containing `{version}` and a `versionGranularity`; a granularity without
632
+ `maxVersions` is invalid. These errors are raised at construction or CLI startup
633
+ (exit `2`). The pattern list is snapshotted at construction.
634
+
635
+ ### Rollout and compatibility
636
+
637
+ Upgrade every server before any client. An older server decodes upload metadata
638
+ with an exact key set and rejects the new `sourceParent` field, so a new client
639
+ that sends a parent to an old server fails.
640
+
641
+ A new server remains compatible with older clients: they never send
642
+ `sourceParent`, no-pattern servers are unaffected, filename-only patterns still
643
+ match, and patterns that need a parent reject their offers as unmatched. Older
644
+ sessions and commit records stay readable, and older commit records are not
645
+ count or version rotated.
646
+
647
+ Roll out the server, then the pattern configuration, then clients that stage
648
+ into conforming folders.
649
+
650
+ #### Overlapping retries now get `FILE_BUSY`
651
+
652
+ **Behavior change.** A transfer ID may have only one in-flight commit
653
+ lifecycle. If a client (or a job runner that fans the same artifact out to
654
+ parallel workers) opens a second connection for a transfer that is still
655
+ verifying or committing, that second connection is now rejected with
656
+ `FILE_BUSY` instead of being admitted alongside the first. Sequential retries,
657
+ resumes, and verified reconnects are unaffected, and so is a retry issued after
658
+ the previous attempt failed — the server releases the transfer before running
659
+ its observational `onFailure`, so a slow callback never blocks the retry.
660
+
661
+ Audit any automation that uploads the same artifact from more than one worker
662
+ at a time, and treat `FILE_BUSY` on an upload as "retry after the in-flight
663
+ attempt finishes" rather than a permanent failure.
664
+
665
+ #### Enabling or changing patterns
666
+
667
+ A commit record's release identity is durable and is never rewritten in place.
668
+ Enabling patterns, or editing one that an in-flight upload already matched,
669
+ therefore changes the identity a retry computes for bytes that are already
670
+ committed, and the server fails closed rather than relabelling them:
671
+
672
+ - A create-only name whose record carries a different release (or none) is no
673
+ longer recognised as the same commit, so the retry is rejected with
674
+ `FILE_EXISTS`.
675
+ - A replaceable name retried under the same transfer ID with a different
676
+ release is rejected as a release-identity conflict.
677
+
678
+ Drain or complete in-flight uploads before enabling or changing
679
+ `artifactPatterns`. Uploads started after the change are unaffected, and a
680
+ rejected legacy retry is resolved by re-uploading under a new transfer (new
681
+ content or a new staged name), not by editing records on disk.
682
+
683
+ #### Session records and rollback
684
+
685
+ A resumable session is written at on-disk version 3 only when it carries a
686
+ `sourceParent`; a session without one is written at version 2, exactly as an
687
+ older server would. The new server reads both. An older server cannot read
688
+ version 3, so only parent-bearing resumable sessions are lost to a downgrade:
689
+ they cannot resume and must be re-uploaded. Draining in-flight uploads before
690
+ rolling a server back avoids this entirely.
691
+
692
+ Commit-record compatibility with older code is covered only by what this
693
+ version's tests assert: an older record without release coordinates is read by
694
+ this version and is not count or version rotated. Whether older code tolerates
695
+ the release coordinates this version writes is untested.
696
+
697
+ ## Managed symlinks
698
+
699
+ Configure repeatable managed symlinks so the server keeps versioned artifacts
700
+ and maintains declarative links in the storage root:
701
+
702
+ ```sh
703
+ swarm-deploy server \
704
+ --seed-file ./server.seed \
705
+ --storage /srv/artifacts \
706
+ --allow-key <64-lower-hex> \
707
+ --max-file-bytes 1073741824 \
708
+ --max-staging-bytes 4294967296 \
709
+ --symlink '/^\d+\.\d+\.\d+$/' latest \
710
+ --symlink release.tar.gz current.tar.gz
711
+ ```
712
+
713
+ Resulting layout:
714
+
715
+ ```text
716
+ /srv/artifacts/
717
+ ├── 0.18.0/
718
+ ├── 0.18.1/
719
+ └── latest -> 0.18.1
720
+ ```
721
+
722
+ Runtime equivalent:
723
+
724
+ ```js
725
+ const server = new Server({
726
+ seed,
727
+ storageDir: '/srv/artifacts',
728
+ allowedKeys,
729
+ maxFileBytes: 1024 ** 3,
730
+ maxStagingBytes: 4 * 1024 ** 3,
731
+ symlinks: [
732
+ { selector: '/^\\d+\\.\\d+\\.\\d+$/', name: 'latest' },
733
+ { selector: 'release.tar.gz', name: 'current.tar.gz' }
734
+ ]
735
+ })
736
+ ```
737
+
738
+ Selection uses only validated managed commit records. The newest `committedAt`
739
+ wins, tie-broken by transfer ID then name. Replacement history names never
740
+ match. A rule is dormant until its target exists.
741
+
742
+ Safety: an unmanaged file, directory, unrecorded symlink, changed symlink, or
743
+ foreign ownership record is never replaced, moved, or deleted and causes a
744
+ fail-closed error. An upload whose name equals a configured link name is
745
+ rejected before admission.
746
+
747
+ Retention pins every selected symlink target against age, count, SemVer, and
748
+ quota deletion. Pinned targets still count toward the quota. The desired-link
749
+ set is recomputed under the root lease before any deletion, so a repoint always
750
+ precedes the old target becoming eligible. Removing a rule removes its ownership
751
+ record and visible link only while ownership is still proven.
752
+
753
+ `symlinks` requires `StorageAdapter.symlink` and `readlink`. The Node and Bare
754
+ default adapters provide them; a custom adapter without symlink rules stays
755
+ source-compatible.
756
+
757
+ ## Deployment hooks
758
+
759
+ Hooks let a trusted server operator run code around the commit. They are
760
+ server-side only, run in the server process with its privileges, and receive no
761
+ seeds, secret keys, TAR bytes, or session material.
762
+
763
+ ### Lifecycle points
764
+
765
+ - `beforeCommit(context)`: after verification, before any commit mutation. For a
766
+ file, `path` is the verified `.part` staging file; for a directory, `path` is
767
+ the verified `.tree` staging directory. Its failure aborts the commit.
768
+ - `afterCommit(context)`: after the artifact is durably committed and configured
769
+ links are reconciled, before the terminal success reply. For a directory,
770
+ `path` is the committed directory tree. Its failure fails the upload even
771
+ though the artifact is already stored. When it is configured, post-commit
772
+ rotation is deferred until it succeeds (see "Immediate effect" above).
773
+ - `onFailure(context)`: once per failed connection whose metadata was decoded,
774
+ after the client has been answered.
775
+
776
+ Callbacks may return `void` or a promise, are called with no receiver, and
777
+ receive a frozen context. The artifact is
778
+ `{ name, kind, size, sha256, transferId, entryCount?, sourceParent?, release? }`,
779
+ where `entryCount` is present only for directories and `release` is
780
+ `{ series, version? }` when patterns are configured. These descriptive fields
781
+ come from decoded offer metadata, not yet from verified content.
782
+
783
+ | Callback | Extra context |
784
+ | -------------- | ---------------------------------------------------------------------------- |
785
+ | `beforeCommit` | `path` (verified staging file or tree), `resumed`, `alreadyCommitted: false` |
786
+ | `afterCommit` | `path` (committed artifact), `resumed`, `alreadyCommitted` |
787
+ | `onFailure` | `path` (`string \| null`), `phase`, `resumed`, `alreadyCommitted`, `error` |
788
+
789
+ `resumed` is `true` when the server admitted the connection as `RESUME` or
790
+ `VERIFIED`. Closing the server aborts the wait; a callback that outlives the
791
+ abort continues detached and its later result is ignored.
792
+
793
+ There is no hook timeout, and a hung callback holds more than its own
794
+ connection. On a fresh or resumed upload it also holds a `maxActiveUploads`
795
+ slot and the session's staging reservation for as long as it runs, so enough
796
+ simultaneously hung callbacks stop the server from admitting new uploads
797
+ (`ACTIVE_UPLOAD_LIMIT`) until the server is closed and the waits are aborted.
798
+ Only the already-committed path holds no upload slot. Write callbacks that
799
+ finish or throw; bounded hook timeout and anti-spam controls are tracked in
800
+ issue #8.
801
+
802
+ The **lifecycle** hooks `beforeCommit` and `afterCommit` never run concurrently
803
+ for one transfer. Once a transfer ID authenticates, that connection owns it for
804
+ the whole verify/commit sequence; a second connection offering the same transfer
805
+ ID — fresh, resumed, verified, or already committed — is rejected with
806
+ `FILE_BUSY` before any lifecycle hook runs, and that rejection is reported to
807
+ `onFailure` once with `phase: 'offer'`.
808
+
809
+ `onFailure` is observational and is **not** covered by that guarantee. A failed
810
+ connection hands the transfer back before awaiting its `onFailure`, because the
811
+ callback only reports an outcome and the connection has already finished
812
+ mutating state. A slow callback must not reject the client's legitimate retry,
813
+ so a retry of the same transfer can be admitted — and can run its own
814
+ `beforeCommit`/`afterCommit` — while the previous `onFailure` is still running.
815
+ Each connection still calls `onFailure` at most once, so write it to tolerate
816
+ overlapping with the next attempt and key any external effect on
817
+ `artifact.transferId`.
818
+
819
+ Hooks for **different** transfers always run concurrently.
820
+
821
+ ### Invocation sequences
822
+
823
+ Fresh upload:
824
+
825
+ 1. Offer inspected and admitted (`ACCEPT`), TAR received.
826
+ 2. Verification.
827
+ 3. `beforeCommit` (`resumed: false`).
828
+ 4. Commit.
829
+ 5. `afterCommit` (`resumed: false`, `alreadyCommitted: false`).
830
+ 6. `COMMITTED`.
831
+
832
+ Partial resume (`RESUME`): the same steps, with `resumed: true` in both hooks.
833
+
834
+ Verified reconnect (`VERIFIED`): the staged data was already verified, so the
835
+ server re-reads it, calls `beforeCommit` again with `resumed: true`, commits, and
836
+ calls `afterCommit` with `resumed: true`. A previous connection may have ended
837
+ after `beforeCommit` returned but before the commit finished.
838
+
839
+ Already committed: identical content is detected during offer inspection, so
840
+ there is no transfer, verification, or `beforeCommit`. The server calls
841
+ `afterCommit` with `resumed: false` and `alreadyCommitted: true`, then replies
842
+ `ALREADY_COMMITTED`. A deferred retention pass follows only when this process
843
+ committed that transfer and its `afterCommit` has not yet succeeded. This path
844
+ is reached only while the artifact still exists; see the
845
+ [retry caveat](#artifact-patterns-and-rotation).
846
+
847
+ Failure sequences:
848
+
849
+ - `beforeCommit` throws: nothing is committed and the verified session remains.
850
+ The client receives `HOOK_FAILED`; `onFailure` runs (`phase: 'beforeCommit'`).
851
+ A retry reconnects as `VERIFIED` and calls `beforeCommit` again.
852
+ - `afterCommit` throws on a fresh or resumed upload: the artifact **stays
853
+ durably committed** and its session is retired. The client receives
854
+ `HOOK_FAILED`; `onFailure` runs (`phase: 'afterCommit'`, final path). Unless
855
+ retention removed the artifact first (see the
856
+ [retry caveat](#artifact-patterns-and-rotation)), a retry takes the
857
+ already-committed path and calls `afterCommit` with `alreadyCommitted: true`;
858
+ otherwise it is a fresh upload.
859
+ - `afterCommit` throws on an already-committed retry: the offer is rejected with
860
+ `HOOK_FAILED` and a later retry repeats `afterCommit`, subject to the same
861
+ retry caveat.
862
+ - A failure after both hooks succeeded (for example the terminal reply cannot be
863
+ written) does not call `onFailure`.
864
+
865
+ Retries therefore can call hooks more than once for one transfer. **Make hooks
866
+ idempotent and key external side effects on `artifact.transferId`.** Treat
867
+ `afterCommit` as at-least-once: record the transfer ID when the deployment step
868
+ finishes and skip repeated calls.
869
+
870
+ ### onFailure
871
+
872
+ `phase` is one of `offer`, `transfer`, `verification`, `beforeCommit`, `commit`,
873
+ or `afterCommit`. `offer` covers every failure before a staging path exists:
874
+ metadata or transfer-ID problems, unmatched patterns, file-size, capacity and
875
+ destination rejections, and inspection errors.
876
+
877
+ An `offer`-phase context is built from decoded metadata **before** the transfer
878
+ ID is authenticated, and it is also reported when that authentication is exactly
879
+ what failed. Its `transferId`, `name`, `size`, `sha256`, and `sourceParent` are
880
+ therefore attacker-chosen values from an allowlisted key, not verified identity.
881
+ Use them for logging and alerting only; never as audit records or idempotency
882
+ keys. Every later phase runs after authentication, so only `offer` carries this
883
+ caveat.
884
+
885
+ `error` is the original failure. For an exception thrown by `beforeCommit` or
886
+ `afterCommit`, hooks receive the **raw thrown value** (not a wrapper) because
887
+ hooks are trusted. The client and server events see only the stable wire code
888
+ `HOOK_FAILED` with a fixed message, so callback text never reaches the wire. Other
889
+ failures arrive as `SwarmDeployError` (or raw storage errors).
890
+ A failing `onFailure` is logged as a secondary warning and never replaces the
891
+ original failure.
892
+
893
+ `path` by phase:
894
+
895
+ | Situation | `path` |
896
+ | ------------------------------------------------------------------ | ------------------------------------------------------- |
897
+ | `offer` | `null` |
898
+ | `transfer`; fresh-upload `verification` | `<storage>/.swarm-deploy/staging/<transferId>.tar.part` |
899
+ | `beforeCommit`, `commit`; `verification` of a `VERIFIED` reconnect | `<storage>/.swarm-deploy/staging/<transferId>.part` |
900
+ | `afterCommit` (including already committed) | `<storage>/<name>` |
901
+
902
+ ### Hook modules
903
+
904
+ `--hooks <module>` accepts `.js`, `.mjs`, and `.cjs`; any other extension is
905
+ rejected at startup with exit code `2` before the module is loaded. ESM and
906
+ CommonJS examples:
907
+
908
+ ```js
909
+ // hooks.mjs
910
+ export async function afterCommit({ artifact, path, alreadyCommitted }) {
911
+ // Idempotent: keyed by artifact.transferId.
912
+ await deploy(artifact.transferId, path)
913
+ }
914
+
915
+ export function onFailure({ phase, artifact, error }) {
916
+ console.error('deploy failed', phase, artifact.transferId, error)
917
+ }
918
+ ```
919
+
920
+ ```js
921
+ // hooks.cjs
922
+ module.exports = {
923
+ beforeCommit({ artifact, path }) {
924
+ // Return or throw; a throw rejects the upload with HOOK_FAILED.
925
+ },
926
+ async afterCommit({ artifact, path }) {
927
+ await deploy(artifact.transferId, path)
928
+ }
929
+ }
930
+ ```
931
+
932
+ Loader rules:
933
+
934
+ - Only the own properties `beforeCommit`, `afterCommit`, and `onFailure` are
935
+ read. Other exports (helpers, configuration) are ignored, including a
936
+ harmless non-object default export.
937
+ - Named exports take precedence over callbacks on a default export object; a
938
+ named export set to `undefined` does not override it.
939
+ - A hook name that is present must be a function, the callbacks must be plain
940
+ own properties, and at least one must exist; otherwise startup fails.
941
+ - One level of compiled-CommonJS interop (`__esModule` with `default`) is
942
+ unwrapped; deeper nesting is ignored.
943
+ - Modules are loaded once with `import()` and cached by the runtime. Top-level
944
+ code runs with the server's privileges during startup, so the module is fully
945
+ trusted.
946
+
947
+ `ServerOptions.hooks` is stricter: it must be a hook object whose own keys are
948
+ only the three callbacks, every callback a function. Unknown keys and
949
+ non-function values throw `PROTOCOL_INVALID`. The object is snapshotted at
950
+ construction, so later mutation has no effect.
951
+
952
+ ## Runtime API
953
+
954
+ The package is strict TypeScript and exposes the same root API to ESM and
955
+ CommonJS consumers.
956
+
957
+ ### Identity helpers
958
+
959
+ ```ts
960
+ import {
961
+ generateSeed,
962
+ keyPairFromSeed,
963
+ parsePublicKey,
964
+ parseSeed,
965
+ publicKeyFromSeed
966
+ } from 'swarm-deploy'
967
+
968
+ const seed = generateSeed() // 32 random bytes
969
+ const restored = parseSeed(process.env.SEED!) // strict lowercase hex
970
+ const keyPair = keyPairFromSeed(restored)
971
+ const publicKey = publicKeyFromSeed(restored)
972
+ const peer = parsePublicKey(process.env.PEER_PUBLIC_KEY!)
973
+ ```
974
+
975
+ `parseSeed` and `parsePublicKey` require exactly 64 lowercase hexadecimal
976
+ characters. `keyPairFromSeed` is deterministic.
977
+
978
+ `parseAllowlist(text)` parses lowercase client public keys separated by
979
+ newlines. Blank lines and lines beginning with `#` are ignored; malformed or
980
+ duplicate keys throw. It returns a `Set<string>` suitable for
981
+ `ServerOptions.allowedKeys`.
982
+
983
+ `fixedSeriesKey(template)` returns the series a template without `{series}`
984
+ persists, so an operator reading `fixed-…` in a commit sidecar, retention log,
985
+ or hook context can identify which configured pattern owns it:
986
+
987
+ ```ts
988
+ import { fixedSeriesKey } from 'swarm-deploy'
989
+
990
+ const owner = patterns.find((template) => fixedSeriesKey(template) === record.release?.series)
991
+ ```
992
+
993
+ ### Server
994
+
995
+ ```ts
996
+ import { Server, parsePublicKey } from 'swarm-deploy'
997
+
998
+ const server = new Server({
999
+ seed: process.env.SWARM_DEPLOY_SERVER_SEED!,
1000
+ storageDir: '/srv/artifacts',
1001
+ allowedKeys: [
1002
+ parsePublicKey(process.env.CLIENT_A_PUBLIC_KEY!),
1003
+ parsePublicKey(process.env.CLIENT_B_PUBLIC_KEY!)
1004
+ ],
1005
+ maxFileBytes: 1024 ** 3,
1006
+ maxStagingBytes: 4 * 1024 ** 3,
1007
+ maxConnections: 64,
1008
+ maxActiveUploads: 8,
1009
+ idleTimeout: 60_000,
1010
+ cleanupInterval: 15 * 60_000,
1011
+ resumeTtl: 7 * 24 * 60 * 60_000,
1012
+ minFreeBytes: 1024 ** 3,
1013
+ maxAge: 15 * 24 * 60 * 60_000,
1014
+ maxStorageBytes: 100 * 1024 ** 3,
1015
+ replaceNames: ['release.tar.gz', 'latest.json']
1016
+ })
1017
+
1018
+ await server.listen()
1019
+ console.log(server.publicKey.toString('hex'))
1020
+
1021
+ // Later, after draining or on process shutdown:
1022
+ await server.close()
1023
+ ```
1024
+
1025
+ Required `ServerOptions`:
1026
+
1027
+ - `seed: Buffer | string`
1028
+ - `storageDir: string`
1029
+ - `allowedKeys: Iterable<Buffer | string>`
1030
+ - `maxFileBytes: number`
1031
+ - `maxStagingBytes: number`
1032
+
1033
+ Optional operational limits and policies:
1034
+
1035
+ - `maxConnections`, `maxActiveUploads`, `idleTimeout`
1036
+ - `cleanupInterval`, `resumeTtl`, `minFreeBytes`
1037
+ - `maxAge`, `maxStorageBytes`, `replaceNames`
1038
+ - `artifactPatterns?: Iterable<string>`, `maxCount?: number`,
1039
+ `maxVersions?: number`, and `versionGranularity?: 'major' | 'minor'`; see
1040
+ [Artifact patterns and rotation](#artifact-patterns-and-rotation).
1041
+ - `hooks?: ServerHooks | null`; see [Deployment hooks](#deployment-hooks).
1042
+
1043
+ The root package exports the `ServerHooks`, `BeforeCommitContext`,
1044
+ `AfterCommitContext`, `HookFailureContext`, `HookFailurePhase`, `HookArtifact`,
1045
+ `ReleaseCoordinates`, and `VersionGranularity` types:
1046
+
1047
+ ```ts
1048
+ import type { ServerHooks } from 'swarm-deploy'
1049
+
1050
+ const hooks: ServerHooks = {
1051
+ async afterCommit({ artifact, path, alreadyCommitted }) {
1052
+ // Idempotent: skip work already recorded for artifact.transferId.
1053
+ },
1054
+ onFailure({ phase, path, error }) {
1055
+ console.error(phase, path, error)
1056
+ }
1057
+ }
1058
+ ```
1059
+
1060
+ Advanced integration and test seams:
1061
+
1062
+ - `dht` or `dhtFactory` for an injected HyperDHT node.
1063
+ - `storage` for a compatible filesystem adapter.
1064
+ - `scheduler` for timeout and interval control.
1065
+ - `logger` with optional `info`, `warn`, and `error` methods.
1066
+
1067
+ ### Client
1068
+
1069
+ ```ts
1070
+ import { Client, parsePublicKey } from 'swarm-deploy'
1071
+
1072
+ const client = new Client({
1073
+ seed: process.env.SWARM_DEPLOY_CLIENT_SEED!,
1074
+ serverPublicKey: parsePublicKey(process.env.SWARM_DEPLOY_SERVER_KEY!),
1075
+ connectTimeout: 30_000,
1076
+ idleTimeout: 60_000
1077
+ })
1078
+
1079
+ const result = await client.upload('./dist/release.tar.gz')
1080
+ console.log(result.status, result.name, result.size)
1081
+
1082
+ await client.close()
1083
+ ```
1084
+
1085
+ `ClientOptions.seed` accepts a Buffer or canonical lowercase 64-character hex
1086
+ string. `serverPublicKey` is a Buffer produced by `parsePublicKey`. Optional
1087
+ values are `connectTimeout`, `idleTimeout`, `includeSourceParent`, `dht`,
1088
+ `dhtFactory`, and `logger`.
1089
+
1090
+ `connectTimeout` defaults to 30 seconds. `idleTimeout` defaults to 60 seconds.
1091
+ `includeSourceParent` defaults to `true`; set it to `false` to suppress the
1092
+ [source parent](#source-parent) for every upload the client makes.
1093
+ Calling `close()` aborts pending work, closes active sockets, and is idempotent.
1094
+
1095
+ ### Upload results
1096
+
1097
+ A direct file resolves to:
1098
+
1099
+ ```ts
1100
+ interface UploadResult {
1101
+ status: 'COMMITTED' | 'ALREADY_COMMITTED'
1102
+ kind: 'file' | 'directory'
1103
+ name: string
1104
+ size: number
1105
+ digest: Buffer
1106
+ transferId: Buffer
1107
+ entryCount?: number
1108
+ }
1109
+ ```
1110
+
1111
+ Files and directories share the same result shape. For a directory, `size` is
1112
+ aggregate payload bytes and `entryCount` counts stored entries (files and
1113
+ directories).
1114
+
1115
+ ### Events
1116
+
1117
+ `Server` and `Client` are event emitters. Listener and logger exceptions are
1118
+ contained and cannot change protocol correctness.
1119
+
1120
+ Public peer and transfer correlation fields use 12-character SHA-256
1121
+ fingerprints. Events never expose seeds, secret keys, full remote public keys,
1122
+ TAR contents, or resumable session material. Internal storage warnings may
1123
+ include a full SHA-256 uploader fingerprint, but never the uploader key itself.
1124
+
1125
+ Server events:
1126
+
1127
+ - `authentication`: accepted client fingerprint.
1128
+ - `connection`, `connection-open`, `connection-close`: authenticated connection
1129
+ lifecycle and current count.
1130
+ - `offer`: accepted, resumed, reset, rejected, or already-committed state.
1131
+ Includes artifact `kind`.
1132
+ - `progress`: durable TAR bytes received and total TAR bytes. Includes artifact
1133
+ `kind`.
1134
+ - `verification`: started, succeeded, or failed.
1135
+ - `commit`: succeeded or failed.
1136
+ - `recovery`: startup, per-journal, corruption, resumable, and completion
1137
+ outcomes.
1138
+ - `retention`: startup, scheduled, manual, commit, or post-commit outcomes,
1139
+ with `expiredSessions`, `scrubbed`, `ageDeleted`, `countDeleted`,
1140
+ `versionDeleted`, and `storageDeleted` counters.
1141
+ - `failure`: stable failure code and peer fingerprint.
1142
+ - `listening`: local server-key fingerprint.
1143
+ - `close`: closed or failed outcome.
1144
+
1145
+ Client events:
1146
+
1147
+ - `connection`, `connection-open`, `connection-close`
1148
+ - `offer`: offered, accepted, resumed, reset, rejected, or already committed.
1149
+ - `progress`: cumulative TAR `bytesSent` and full `totalBytes`, including a
1150
+ durable resume offset.
1151
+ - `verification`, `commit`
1152
+ - `result`: terminal upload result with artifact `kind`.
1153
+ - `failure`, `close`
1154
+
1155
+ Use the exported `ServerEventMap`, `ClientEventMap`, `ServerEventName`, and
1156
+ `ClientEventName` types for event-name-specific payload narrowing:
1157
+
1158
+ ```ts
1159
+ client.on('progress', ({ name, bytesSent, totalBytes }) => {
1160
+ console.log(name, `${bytesSent}/${totalBytes}`)
1161
+ })
1162
+
1163
+ server.on('recovery', (event) => {
1164
+ if (event.status === 'failed') console.error(event.phase, event.reason)
1165
+ })
1166
+ ```
1167
+
1168
+ ### Logging
1169
+
1170
+ Both constructors accept:
1171
+
1172
+ ```ts
1173
+ interface Logger {
1174
+ info?(message: string, details?: Record<string, unknown>): void
1175
+ warn?(message: string, details?: Record<string, unknown>): void
1176
+ error?(message: string, details?: Record<string, unknown>): void
1177
+ }
1178
+ ```
1179
+
1180
+ Logger exceptions are ignored. Logger detail objects are diagnostic rather than
1181
+ a stable ingestion schema; use typed event payloads or `SwarmDeployError.code`
1182
+ for automation. Do not use fingerprints as credentials.
1183
+
1184
+ ### Errors
1185
+
1186
+ Configuration, authentication, protocol, transfer, and managed-storage failures
1187
+ generally reject with `SwarmDeployError`. Raw operating-system or adapter errors
1188
+ may propagate while selecting or opening a local input, initializing or locking
1189
+ the server storage root, or performing filesystem operations:
1190
+
1191
+ ```ts
1192
+ import { ERRORS, SwarmDeployError } from 'swarm-deploy'
1193
+
1194
+ try {
1195
+ await client.upload('./artifact.tgz')
1196
+ } catch (error) {
1197
+ if (error instanceof SwarmDeployError) {
1198
+ console.error(error.code, error.message)
1199
+ if (error.code === ERRORS.CONNECT_TIMEOUT) {
1200
+ // Server could not be reached and authenticated before the deadline.
1201
+ }
1202
+ } else {
1203
+ console.error(error)
1204
+ }
1205
+ }
1206
+ ```
1207
+
1208
+ `HOOK_FAILED` is the stable code for a failed `beforeCommit` or `afterCommit`
1209
+ callback. Its message is fixed and the original exception is available only as
1210
+ `cause` and to `onFailure`.
1211
+
1212
+ Stable codes include authentication and server-key rejection, invalid
1213
+ configuration and protocol records, file and staging limits, disk reserve,
1214
+ filename and replacement conflicts, checksum failures, connection or upload
1215
+ timeouts, aborts, commit failures, cleanup failures, and managed-link failures
1216
+ (`LINK_CONFLICT`, `LINK_FAILED`, `UNSUPPORTED_STORAGE`). A link reconciliation
1217
+ failure after a durable commit behaves like other post-commit deployment
1218
+ failures: the artifact stays committed, the client receives a stable failure,
1219
+ and an already-committed retry reruns reconciliation before succeeding. Import
1220
+ `ERRORS` rather than matching exception messages.
1221
+
1222
+ ## Production operations
1223
+
1224
+ - Run the server under a dedicated non-root account.
1225
+ - Restrict the server seed and storage root to that account.
1226
+ - Supervise the process and wait for the final `ready` line before marking it
1227
+ healthy.
1228
+ - Upgrade servers before clients; see
1229
+ [Rollout and compatibility](#rollout-and-compatibility).
1230
+ - Restart with the same seed, allowlist, limits, replacement names, artifact
1231
+ patterns, rotation limits, hooks module, and storage root so interrupted sessions and commit journals can recover.
1232
+ - Alert on nonzero CLI exits and failed authentication, recovery, verification,
1233
+ commit, retention, and cleanup events.
1234
+ - Never edit `.swarm-deploy/` while the server is running.
1235
+ - Stop the server cleanly before backing up or restoring the complete storage
1236
+ root.
1237
+ - Publish stored artifacts through a separately configured artifact service or
1238
+ web server.
1239
+ - Roll out an exact package version to a canary before wider deployment.
1240
+
1241
+ See [SECURITY.md](SECURITY.md) for vulnerability reporting and operator
1242
+ precautions.
1243
+
1244
+ ## Contributor development
1245
+
1246
+ Production and tests are strict TypeScript. Build output is generated under
1247
+ untracked `dist/` and `.test-dist/`. Tests generate binary payloads in temporary
1248
+ directories and use an isolated local HyperDHT testnet, not the public DHT.
1249
+
1250
+ ```sh
1251
+ npm install
1252
+ npm run build
1253
+ npm run build:test
1254
+ npm run test:types
1255
+ npm run format:check
1256
+ npm run lint
1257
+ npm run test:node
1258
+ npm run test:bare
1259
+ npm run test:property
1260
+ npm run test:package
1261
+ npm run test:release-tag
1262
+ ```
1263
+
1264
+ Exercise the built CLI directly:
1265
+
1266
+ ```sh
1267
+ node dist/bin/swarm-deploy.js --help
1268
+ bare dist/bin/swarm-deploy.js --help
1269
+ ```
1270
+
1271
+ ## Release
1272
+
1273
+ Version tags use `v<package-version>`. The tag workflow validates the version,
1274
+ builds the untracked distribution, checks package contents and types, and
1275
+ publishes through the configured npm environment with provenance.
1276
+
1277
+ See [RELEASING.md](RELEASING.md) for the release and rollback procedure.
1278
+
1279
+ ## Protocol specification
1280
+
1281
+ See [docs/spec/swarm-deploy.md](docs/spec/swarm-deploy.md) for the complete
1282
+ transport, deterministic TAR, resumability, storage, replacement, recovery,
1283
+ retention, threat-model, and package requirements.