swarm-deploy 0.1.0 → 0.3.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 (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +780 -50
  3. package/RELEASING.md +2 -2
  4. package/SECURITY.md +2 -3
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +182 -25
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +32 -27
  9. package/dist/client.js +103 -54
  10. package/dist/client.js.map +1 -1
  11. package/dist/errors.d.ts +6 -0
  12. package/dist/errors.js +6 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/files.d.ts +13 -28
  15. package/dist/files.js +13 -78
  16. package/dist/files.js.map +1 -1
  17. package/dist/hooks-module.d.ts +9 -0
  18. package/dist/hooks-module.js +118 -0
  19. package/dist/hooks-module.js.map +1 -0
  20. package/dist/hooks.d.ts +69 -0
  21. package/dist/hooks.js +88 -0
  22. package/dist/hooks.js.map +1 -0
  23. package/dist/index.d.ts +8 -4
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/release.d.ts +26 -0
  27. package/dist/release.js +419 -0
  28. package/dist/release.js.map +1 -0
  29. package/dist/server.d.ts +97 -0
  30. package/dist/server.js +527 -53
  31. package/dist/server.js.map +1 -1
  32. package/dist/storage/commit-journal.d.ts +39 -2
  33. package/dist/storage/commit-journal.js +140 -2
  34. package/dist/storage/commit-journal.js.map +1 -1
  35. package/dist/storage/commit-store.d.ts +69 -8
  36. package/dist/storage/commit-store.js +374 -22
  37. package/dist/storage/commit-store.js.map +1 -1
  38. package/dist/storage/layout.js +7 -1
  39. package/dist/storage/layout.js.map +1 -1
  40. package/dist/storage/link-store.d.ts +88 -0
  41. package/dist/storage/link-store.js +448 -0
  42. package/dist/storage/link-store.js.map +1 -0
  43. package/dist/storage/recovery.d.ts +2 -1
  44. package/dist/storage/recovery.js +8 -1
  45. package/dist/storage/recovery.js.map +1 -1
  46. package/dist/storage/retention.d.ts +41 -21
  47. package/dist/storage/retention.js +162 -8
  48. package/dist/storage/retention.js.map +1 -1
  49. package/dist/storage/tar-session-store.d.ts +36 -5
  50. package/dist/storage/tar-session-store.js +283 -55
  51. package/dist/storage/tar-session-store.js.map +1 -1
  52. package/dist/storage/tree-fs.d.ts +44 -0
  53. package/dist/storage/tree-fs.js +580 -0
  54. package/dist/storage/tree-fs.js.map +1 -0
  55. package/dist/storage/tree-staging.d.ts +9 -0
  56. package/dist/storage/tree-staging.js +64 -0
  57. package/dist/storage/tree-staging.js.map +1 -0
  58. package/dist/storage/types.d.ts +15 -0
  59. package/dist/symlinks.d.ts +44 -0
  60. package/dist/symlinks.js +153 -0
  61. package/dist/symlinks.js.map +1 -0
  62. package/dist/tar-protocol/controls.d.ts +52 -0
  63. package/dist/tar-protocol/controls.js +160 -2
  64. package/dist/tar-protocol/controls.js.map +1 -1
  65. package/dist/tar-protocol/direct-wire.d.ts +8 -3
  66. package/dist/tar-protocol/direct-wire.js +14 -3
  67. package/dist/tar-protocol/direct-wire.js.map +1 -1
  68. package/dist/tar-protocol/manifest.d.ts +13 -1
  69. package/dist/tar-protocol/manifest.js +26 -1
  70. package/dist/tar-protocol/manifest.js.map +1 -1
  71. package/dist/tar-protocol/tree-extract.d.ts +37 -0
  72. package/dist/tar-protocol/tree-extract.js +240 -0
  73. package/dist/tar-protocol/tree-extract.js.map +1 -0
  74. package/dist/tar-protocol/tree-manifest.d.ts +45 -0
  75. package/dist/tar-protocol/tree-manifest.js +277 -0
  76. package/dist/tar-protocol/tree-manifest.js.map +1 -0
  77. package/dist/tar-protocol/tree-path.d.ts +15 -0
  78. package/dist/tar-protocol/tree-path.js +68 -0
  79. package/dist/tar-protocol/tree-path.js.map +1 -0
  80. package/dist/tar-protocol/tree.d.ts +68 -0
  81. package/dist/tar-protocol/tree.js +269 -0
  82. package/dist/tar-protocol/tree.js.map +1 -0
  83. package/dist/tar-protocol/ustar.d.ts +14 -0
  84. package/dist/tar-protocol/ustar.js +81 -1
  85. package/dist/tar-protocol/ustar.js.map +1 -1
  86. package/dist/types.d.ts +3 -0
  87. package/package.json +3 -1
package/dist/server.js CHANGED
@@ -3,25 +3,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.FINGERPRINT_LENGTH = exports.MAX_ACTIVE_UPLOADS = exports.MAX_CONNECTIONS = exports.DEFAULT_MIN_FREE_BYTES = exports.DEFAULT_MAX_ACTIVE_UPLOADS = exports.DEFAULT_MAX_CONNECTIONS = exports.Server = void 0;
6
+ exports.FINGERPRINT_LENGTH = exports.MAX_ACTIVE_UPLOADS = exports.MAX_CONNECTIONS = exports.DEFAULT_MIN_FREE_BYTES = exports.DEFAULT_MAX_ACTIVE_UPLOADS = exports.DEFAULT_MAX_CONNECTIONS = exports.Server = exports.MAX_OWED_POST_COMMIT_RETENTION = void 0;
7
7
  exports.fingerprint = fingerprint;
8
8
  const b4a_1 = __importDefault(require("b4a"));
9
9
  const _events_1 = __importDefault(require("#events"));
10
10
  const _fs_1 = __importDefault(require("#fs"));
11
+ const _path_1 = __importDefault(require("#path"));
11
12
  const sodium_native_1 = __importDefault(require("sodium-native"));
12
13
  const abort_js_1 = require("./abort.js");
13
14
  const direct_dht_js_1 = require("./direct-dht.js");
14
15
  const errors_js_1 = require("./errors.js");
15
16
  const files_js_1 = require("./files.js");
17
+ const hooks_js_1 = require("./hooks.js");
16
18
  const identity_js_1 = require("./identity.js");
19
+ const release_js_1 = require("./release.js");
17
20
  const commit_store_js_1 = require("./storage/commit-store.js");
21
+ const link_store_js_1 = require("./storage/link-store.js");
18
22
  const layout_js_1 = require("./storage/layout.js");
23
+ const root_coordinator_js_1 = require("./storage/root-coordinator.js");
19
24
  const recovery_js_1 = require("./storage/recovery.js");
20
25
  const retention_js_1 = require("./storage/retention.js");
21
26
  const session_store_js_1 = require("./storage/session-store.js");
27
+ const tree_fs_js_1 = require("./storage/tree-fs.js");
28
+ const controls_js_1 = require("./tar-protocol/controls.js");
22
29
  const direct_wire_js_1 = require("./tar-protocol/direct-wire.js");
23
30
  const hash_js_1 = require("./tar-protocol/hash.js");
24
31
  const manifest_js_1 = require("./tar-protocol/manifest.js");
32
+ const tree_manifest_js_1 = require("./tar-protocol/tree-manifest.js");
33
+ const symlinks_js_1 = require("./symlinks.js");
34
+ const commit_journal_js_1 = require("./storage/commit-journal.js");
25
35
  const EventEmitter = _events_1.default.EventEmitter;
26
36
  const DEFAULT_MAX_CONNECTIONS = 64;
27
37
  exports.DEFAULT_MAX_CONNECTIONS = DEFAULT_MAX_CONNECTIONS;
@@ -36,6 +46,15 @@ exports.MAX_ACTIVE_UPLOADS = MAX_ACTIVE_UPLOADS;
36
46
  const FINGERPRINT_LENGTH = 12;
37
47
  exports.FINGERPRINT_LENGTH = FINGERPRINT_LENGTH;
38
48
  const TAR_DURABILITY_BATCH_BYTES = 1024 * 1024;
49
+ /**
50
+ * How many transfers may owe a deferred post-commit retention pass at once.
51
+ *
52
+ * The set grows only when an `afterCommit` hook fails after a durable commit,
53
+ * and entries are removed as soon as a retry succeeds, so reaching the bound
54
+ * means a persistently failing hook. Capping it at the connection ceiling
55
+ * keeps a broken deployment step from growing server memory without limit.
56
+ */
57
+ exports.MAX_OWED_POST_COMMIT_RETENTION = MAX_CONNECTIONS;
39
58
  function fail(code, message, cause = null) {
40
59
  return new errors_js_1.SwarmDeployError(code, message, cause);
41
60
  }
@@ -91,6 +110,11 @@ class Server extends EventEmitter {
91
110
  minFreeBytes;
92
111
  maxAge;
93
112
  maxStorageBytes;
113
+ artifactPatterns;
114
+ maxCount;
115
+ maxVersions;
116
+ versionGranularity;
117
+ releaseMatcher;
94
118
  allowedKeySnapshot;
95
119
  logger;
96
120
  dht;
@@ -98,6 +122,11 @@ class Server extends EventEmitter {
98
122
  storage;
99
123
  scheduler;
100
124
  replaceNames;
125
+ hooks;
126
+ symlinks;
127
+ linkNames;
128
+ managedLinkNamesSnapshot;
129
+ links = null;
101
130
  listening = false;
102
131
  closed = false;
103
132
  keyPair;
@@ -105,6 +134,24 @@ class Server extends EventEmitter {
105
134
  abort = (0, abort_js_1.createAbortController)();
106
135
  active = new Map();
107
136
  activeUploads = new Set();
137
+ /**
138
+ * Transfer IDs this process committed but whose deferred post-commit
139
+ * retention is still owed because `afterCommit` has not yet succeeded. Only
140
+ * these may start a retention pass from an already-committed retry, so an
141
+ * ordinary duplicate offer cannot be used to force repeated full passes.
142
+ * The set is in-memory only and bounded by `MAX_OWED_POST_COMMIT_RETENTION`;
143
+ * a restart or an eviction loses an entry, and the documented fallback is the
144
+ * next startup, scheduled, or commit-triggered pass.
145
+ */
146
+ pendingAfterCommit = new Set();
147
+ /**
148
+ * Authenticated transfer IDs a connection currently owns. One connection
149
+ * holds at most one entry and releases it in a `finally`, so the set is
150
+ * bounded by `maxConnections`. A second connection offering the same
151
+ * transfer is rejected with `FILE_BUSY` rather than running the lifecycle
152
+ * and its hooks concurrently against one staging and commit identity.
153
+ */
154
+ activeTransfers = new Set();
108
155
  receives = new Set();
109
156
  layout = null;
110
157
  sessions = null;
@@ -149,6 +196,38 @@ class Server extends EventEmitter {
149
196
  }
150
197
  this.maxAge = options.maxAge;
151
198
  this.maxStorageBytes = options.maxStorageBytes;
199
+ const patterns = options.artifactPatterns ?? [];
200
+ if (typeof patterns[Symbol.iterator] !== 'function') {
201
+ throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, 'Invalid artifact patterns');
202
+ }
203
+ try {
204
+ this.artifactPatterns = Object.freeze([...patterns]);
205
+ this.releaseMatcher = new release_js_1.ReleaseMatcher(this.artifactPatterns);
206
+ }
207
+ catch (error) {
208
+ throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, 'Invalid artifact patterns', error);
209
+ }
210
+ if (options.maxCount !== undefined) {
211
+ positive(options.maxCount, 'maxCount');
212
+ if (this.releaseMatcher.size === 0) {
213
+ throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, 'maxCount requires artifact patterns');
214
+ }
215
+ }
216
+ if (options.maxVersions !== undefined) {
217
+ positive(options.maxVersions, 'maxVersions');
218
+ if (!this.releaseMatcher.hasVersionPattern) {
219
+ throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, 'maxVersions requires a {version} artifact pattern');
220
+ }
221
+ if (options.versionGranularity !== 'major' && options.versionGranularity !== 'minor') {
222
+ throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, 'Invalid versionGranularity');
223
+ }
224
+ }
225
+ else if (options.versionGranularity !== undefined) {
226
+ throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, 'versionGranularity requires maxVersions');
227
+ }
228
+ this.maxCount = options.maxCount;
229
+ this.maxVersions = options.maxVersions;
230
+ this.versionGranularity = options.versionGranularity;
152
231
  const allow = [];
153
232
  if (!options.allowedKeys ||
154
233
  typeof options.allowedKeys[Symbol.iterator] !== 'function') {
@@ -167,9 +246,24 @@ class Server extends EventEmitter {
167
246
  this.storage = options.storage || _fs_1.default.promises;
168
247
  this.scheduler = options.scheduler || { setTimeout, clearTimeout, setInterval, clearInterval };
169
248
  this.replaceNames = (0, files_js_1.validateReplaceNames)(options.replaceNames);
249
+ this.hooks = (0, hooks_js_1.snapshotHooks)(options.hooks);
250
+ this.symlinks = (0, symlinks_js_1.compileSymlinkRules)(options.symlinks);
251
+ this.linkNames = (0, symlinks_js_1.symlinkRuleNames)(this.symlinks);
252
+ this.managedLinkNamesSnapshot = new Set(this.linkNames);
253
+ if (this.symlinks.length > 0)
254
+ (0, tree_fs_js_1.assertSymlinkCapable)(this.storage);
170
255
  this.logger = safeLogger(options.logger);
171
256
  this.signal = this.abort.signal;
172
257
  }
258
+ artifactKind(metadata) {
259
+ return (0, controls_js_1.isTreeMetadata)(metadata) ? 'directory' : 'file';
260
+ }
261
+ payloadBytes(metadata) {
262
+ return (0, controls_js_1.isTreeMetadata)(metadata) ? metadata.payloadBytes : metadata.fileSize;
263
+ }
264
+ payloadDigest(metadata) {
265
+ return (0, controls_js_1.isTreeMetadata)(metadata) ? metadata.treeSha256 : metadata.fileSha256;
266
+ }
173
267
  emitSafe(type, payload) {
174
268
  try {
175
269
  this.emit(type, payload);
@@ -189,8 +283,220 @@ class Server extends EventEmitter {
189
283
  return {
190
284
  transfer: fingerprint(b4a_1.default.from(metadata.transferId, 'hex')),
191
285
  name: metadata.name,
192
- size: metadata.fileSize
286
+ kind: this.artifactKind(metadata),
287
+ size: this.payloadBytes(metadata)
288
+ };
289
+ }
290
+ async rejectOffer(socket, event, owner, reason) {
291
+ await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'REJECTED', code: reason }, { signal: this.signal, timeout: this.idleTimeout });
292
+ this.emitSafe('offer', {
293
+ ...event,
294
+ fingerprint: fingerprint(owner),
295
+ status: 'rejected',
296
+ reason
297
+ });
298
+ }
299
+ hookArtifact(metadata, release) {
300
+ return Object.freeze({
301
+ name: metadata.name,
302
+ kind: this.artifactKind(metadata),
303
+ size: this.payloadBytes(metadata),
304
+ sha256: this.payloadDigest(metadata),
305
+ transferId: metadata.transferId,
306
+ ...((0, controls_js_1.isTreeMetadata)(metadata) ? { entryCount: metadata.entryCount } : {}),
307
+ ...(metadata.sourceParent === undefined ? {} : { sourceParent: metadata.sourceParent }),
308
+ ...(release === null
309
+ ? {}
310
+ : {
311
+ release: Object.freeze({
312
+ series: release.series,
313
+ ...(release.version === undefined ? {} : { version: release.version })
314
+ })
315
+ })
316
+ });
317
+ }
318
+ /**
319
+ * Computes and converges the desired links from durable commit records.
320
+ * Called with the root lease already held.
321
+ */
322
+ async reconcileLinksUnlocked(records) {
323
+ const desired = (0, symlinks_js_1.selectDesiredLinks)(this.symlinks, records);
324
+ if (!this.links)
325
+ return new Set(desired.map((link) => link.transferId));
326
+ await this.links.reconcile(desired, this.linkNames, {
327
+ managedArtifactNames: new Set(records.map((record) => record.name))
328
+ });
329
+ const ledger = await this.links.list();
330
+ this.setManagedLinkNames(ledger);
331
+ const pins = new Set(desired.map((link) => link.transferId));
332
+ for (const link of ledger) {
333
+ if (link.mode !== 'manual')
334
+ continue;
335
+ const target = records.find((record) => record.name === link.target);
336
+ if (target)
337
+ pins.add(target.transferId);
338
+ else
339
+ pins.add(link.transferId);
340
+ }
341
+ return pins;
342
+ }
343
+ /** Pins link targets when reconciliation fails but retention must continue. */
344
+ async linkPinsFallbackUnlocked(records) {
345
+ const pins = new Set((0, symlinks_js_1.selectDesiredLinks)(this.symlinks, records).map((link) => link.transferId));
346
+ if (!this.links)
347
+ return pins;
348
+ for (const ledger of await this.links.list()) {
349
+ pins.add(ledger.transferId);
350
+ const target = records.find((record) => record.name === ledger.target);
351
+ if (target)
352
+ pins.add(target.transferId);
353
+ }
354
+ return pins;
355
+ }
356
+ /** Reconciles after a durable commit; this is the only caller that leases the root. */
357
+ async reconcileAfterCommit() {
358
+ if (!this.links || !this.commits)
359
+ return;
360
+ try {
361
+ await (0, root_coordinator_js_1.withRootLease)(this.layout.root, async () => this.reconcileLinksUnlocked(await this.commits.list()));
362
+ }
363
+ catch (error) {
364
+ if (error instanceof errors_js_1.SwarmDeployError && error.code === errors_js_1.ERRORS.LINK_CONFLICT)
365
+ throw error;
366
+ throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.LINK_FAILED, 'Unable to reconcile managed symlinks', error);
367
+ }
368
+ }
369
+ setManagedLinkNames(records) {
370
+ this.managedLinkNamesSnapshot = new Set([
371
+ ...this.linkNames,
372
+ ...records.map((record) => record.name)
373
+ ]);
374
+ }
375
+ async refreshManagedLinkNames() {
376
+ if (!this.links) {
377
+ this.managedLinkNamesSnapshot = new Set(this.linkNames);
378
+ return;
379
+ }
380
+ this.setManagedLinkNames(await this.links.list());
381
+ }
382
+ async receiveLink(socket, reader, owner, request) {
383
+ let emitted = false;
384
+ let resultWriteStarted = false;
385
+ const emitTerminal = (status, reason) => {
386
+ if (emitted)
387
+ return;
388
+ emitted = true;
389
+ this.emitSafe('link', {
390
+ fingerprint: fingerprint(owner),
391
+ target: request.target,
392
+ name: request.name,
393
+ status,
394
+ ...(reason === undefined ? {} : { reason })
395
+ });
193
396
  };
397
+ try {
398
+ // A link request has no payload: EOF is the required no-trailing-data boundary.
399
+ await reader.requireEnd(this.signal, this.idleTimeout);
400
+ if (request.target === request.name) {
401
+ throw fail(errors_js_1.ERRORS.LINK_NOT_ALLOWED, 'Manual link cannot target itself');
402
+ }
403
+ if (!(0, symlinks_js_1.isManualSymlinkTargetAllowed)(this.symlinks, request.target)) {
404
+ throw fail(errors_js_1.ERRORS.LINK_NOT_ALLOWED, 'Manual link target is not allowed');
405
+ }
406
+ if (this.linkNames.has(request.name)) {
407
+ throw fail(errors_js_1.ERRORS.LINK_NOT_ALLOWED, 'Manual link name is reserved by an automatic rule');
408
+ }
409
+ const result = await (0, root_coordinator_js_1.withRootLease)(this.layout.root, async () => {
410
+ const records = await this.commits.list();
411
+ const target = records.find((record) => record.name === request.target && !(0, files_js_1.isReservedHistoryName)(record.name));
412
+ if (!target)
413
+ throw fail(errors_js_1.ERRORS.LINK_TARGET_NOT_FOUND, 'Manual link target was not found');
414
+ const artifacts = new Set(records.map((record) => record.name));
415
+ if (artifacts.has(request.name)) {
416
+ throw fail(errors_js_1.ERRORS.LINK_CONFLICT, 'Manual link name conflicts with an artifact');
417
+ }
418
+ if (!this.links)
419
+ throw fail(errors_js_1.ERRORS.LINK_FAILED, 'Managed link storage is unavailable');
420
+ const link = {
421
+ name: request.name,
422
+ target: target.name,
423
+ transferId: target.transferId,
424
+ targetKind: (0, commit_journal_js_1.commitRecordKind)(target)
425
+ };
426
+ const linked = await this.links.linkManual(link, {
427
+ managedArtifactNames: artifacts,
428
+ automaticLinkNames: this.linkNames
429
+ });
430
+ await this.refreshManagedLinkNames();
431
+ return linked;
432
+ });
433
+ resultWriteStarted = true;
434
+ await (0, direct_wire_js_1.writeLinkResult)(socket, { v: 1, status: result.status }, { signal: this.signal, timeout: this.idleTimeout });
435
+ emitTerminal(result.status === 'LINKED' ? 'linked' : 'unchanged');
436
+ }
437
+ catch (error) {
438
+ let reason = error instanceof errors_js_1.SwarmDeployError &&
439
+ (error.code === errors_js_1.ERRORS.LINK_NOT_ALLOWED ||
440
+ error.code === errors_js_1.ERRORS.LINK_TARGET_NOT_FOUND ||
441
+ error.code === errors_js_1.ERRORS.LINK_CONFLICT ||
442
+ error.code === errors_js_1.ERRORS.LINK_FAILED)
443
+ ? error.code
444
+ : errors_js_1.ERRORS.LINK_FAILED;
445
+ const rejected = reason === errors_js_1.ERRORS.LINK_NOT_ALLOWED ||
446
+ reason === errors_js_1.ERRORS.LINK_TARGET_NOT_FOUND ||
447
+ reason === errors_js_1.ERRORS.LINK_CONFLICT;
448
+ if (!resultWriteStarted) {
449
+ resultWriteStarted = true;
450
+ try {
451
+ await (0, direct_wire_js_1.writeLinkResult)(socket, { v: 1, status: 'FAILED', code: reason }, { timeout: this.idleTimeout });
452
+ }
453
+ catch {
454
+ reason = errors_js_1.ERRORS.LINK_FAILED;
455
+ }
456
+ }
457
+ else {
458
+ reason = errors_js_1.ERRORS.LINK_FAILED;
459
+ }
460
+ this.logger.warn('Direct link failed', { fingerprint: fingerprint(owner), reason });
461
+ this.emitSafe('failure', { fingerprint: fingerprint(owner), reason });
462
+ emitTerminal(rejected && reason !== errors_js_1.ERRORS.LINK_FAILED ? 'rejected' : 'failed', reason);
463
+ try {
464
+ socket.destroy();
465
+ }
466
+ catch { }
467
+ }
468
+ }
469
+ /** Runs a gating callback; only callback exceptions become HOOK_FAILED. */
470
+ async runHook(phase, hook, context) {
471
+ if (hook === undefined)
472
+ return;
473
+ (0, abort_js_1.throwIfAborted)(this.signal);
474
+ let outcome;
475
+ try {
476
+ outcome = await (0, hooks_js_1.invokeHook)(hook, context, this.signal);
477
+ }
478
+ catch (cause) {
479
+ throw (0, hooks_js_1.hookError)(phase, cause);
480
+ }
481
+ if (outcome === 'aborted')
482
+ throw (0, abort_js_1.abortError)();
483
+ }
484
+ /** Runs onFailure once; its own failure is logged and never replaces the original. */
485
+ async runFailureHook(context, owner, transfer) {
486
+ const hook = this.hooks.onFailure;
487
+ if (hook === undefined)
488
+ return;
489
+ try {
490
+ await (0, hooks_js_1.invokeHook)(hook, context, this.signal);
491
+ }
492
+ catch {
493
+ this.logger.warn('Failure hook failed', {
494
+ fingerprint: owner ? fingerprint(owner) : 'invalid',
495
+ transfer,
496
+ phase: context.phase,
497
+ reason: errors_js_1.ERRORS.HOOK_FAILED
498
+ });
499
+ }
194
500
  }
195
501
  async receive(socket) {
196
502
  const owner = socket.remotePublicKey;
@@ -231,30 +537,160 @@ class Server extends EventEmitter {
231
537
  let verificationSucceeded = false;
232
538
  let commitStarted = false;
233
539
  let commitSucceeded = false;
540
+ let artifact = null;
541
+ let phase = 'offer';
542
+ let hookPath = null;
543
+ let resumed = false;
544
+ let alreadyCommitted = false;
545
+ let hooksFinished = false;
546
+ let finalStarted = false;
547
+ let failureReported = false;
548
+ let guardedTransfer = null;
549
+ /**
550
+ * Hands the transfer back before this connection's last await. Clearing
551
+ * `guardedTransfer` makes the `finally` a no-op, so a late release can
552
+ * never take ownership away from the connection that acquired it next.
553
+ */
554
+ const releaseTransfer = () => {
555
+ if (guardedTransfer === null)
556
+ return;
557
+ this.activeTransfers.delete(guardedTransfer);
558
+ guardedTransfer = null;
559
+ };
560
+ // Runs onFailure at most once per connection, after the client was answered.
561
+ const reportFailure = async (error) => {
562
+ if (failureReported || !event || !artifact)
563
+ return;
564
+ failureReported = true;
565
+ // The guard serializes the commit lifecycle, and this connection has
566
+ // finished its own. `onFailure` only observes, so holding the transfer
567
+ // across a slow callback would reject the client's legitimate retry.
568
+ releaseTransfer();
569
+ await this.runFailureHook(Object.freeze({ artifact, path: hookPath, phase, resumed, alreadyCommitted, error }), owner, event.transfer);
570
+ };
571
+ const rejectEarly = async (reason, message) => {
572
+ await this.rejectOffer(socket, event, owner, reason);
573
+ sentAdmission = true;
574
+ await reportFailure(fail(reason, message));
575
+ };
234
576
  try {
235
- const metadata = await reader.control(direct_wire_js_1.decodeDirectMetadata, this.signal, this.idleTimeout);
236
- (0, manifest_js_1.assertMetadataTransferId)(owner, metadata);
577
+ const first = await reader.control(controls_js_1.decodeFirstControlRecord, this.signal, this.idleTimeout);
578
+ if ('kind' in first && first.kind === 'link') {
579
+ await this.receiveLink(socket, reader, owner, first);
580
+ return;
581
+ }
582
+ const metadata = first;
583
+ // The decoded record is shape-validated but not yet authenticated; the
584
+ // artifact context carries only its non-secret descriptive fields.
237
585
  event = this.transfer(metadata);
238
- if (metadata.fileSize > this.maxFileBytes ||
239
- this.activeUploads.size >= this.maxActiveUploads) {
240
- const reason = metadata.fileSize > this.maxFileBytes ? errors_js_1.ERRORS.FILE_TOO_LARGE : errors_js_1.ERRORS.ACTIVE_UPLOAD_LIMIT;
241
- await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'REJECTED', code: reason }, { signal: this.signal, timeout: this.idleTimeout });
242
- sentAdmission = true;
243
- this.emitSafe('offer', {
586
+ artifact = this.hookArtifact(metadata, null);
587
+ if ((0, controls_js_1.isTreeMetadata)(metadata))
588
+ (0, tree_manifest_js_1.assertTreeMetadataTransferId)(owner, metadata);
589
+ else
590
+ (0, manifest_js_1.assertMetadataTransferId)(owner, metadata);
591
+ // The transfer ID is authenticated from here on, so it is safe to key
592
+ // the single-owner guard on it.
593
+ if (this.activeTransfers.has(metadata.transferId)) {
594
+ await rejectEarly(errors_js_1.ERRORS.FILE_BUSY, 'Transfer is already in progress');
595
+ return;
596
+ }
597
+ this.activeTransfers.add(metadata.transferId);
598
+ guardedTransfer = metadata.transferId;
599
+ let release = null;
600
+ if (this.releaseMatcher.size > 0) {
601
+ release = this.releaseMatcher.match(metadata.name, metadata.sourceParent);
602
+ if (release === null) {
603
+ await rejectEarly(errors_js_1.ERRORS.INVALID_FILENAME, 'Artifact name does not match a configured pattern');
604
+ return;
605
+ }
606
+ artifact = this.hookArtifact(metadata, release);
607
+ }
608
+ if (this.managedLinkNamesSnapshot.has(metadata.name)) {
609
+ await rejectEarly(errors_js_1.ERRORS.INVALID_FILENAME, 'Artifact name is a configured symlink name');
610
+ return;
611
+ }
612
+ const hookArtifact = artifact;
613
+ const stagingHookPath = (0, controls_js_1.isTreeMetadata)(metadata)
614
+ ? _path_1.default.join(this.layout.staging, `${metadata.transferId}.tree`)
615
+ : _path_1.default.join(this.layout.staging, `${metadata.transferId}.part`);
616
+ const finish = async (verified) => {
617
+ phase = 'beforeCommit';
618
+ hookPath = stagingHookPath;
619
+ await this.runHook('beforeCommit', this.hooks.beforeCommit, Object.freeze({
620
+ artifact: hookArtifact,
621
+ path: hookPath,
622
+ resumed,
623
+ alreadyCommitted: false
624
+ }));
625
+ phase = 'commit';
626
+ commitStarted = true;
627
+ await this.commits.commit(verified, {
628
+ retentionManager: this.retention,
629
+ signal: this.signal,
630
+ replaceNames: this.replaceNames,
631
+ release,
632
+ deferPostCommitRetention: this.hooks.afterCommit !== undefined
633
+ });
634
+ this.emitSafe('commit', {
244
635
  ...event,
245
636
  fingerprint: fingerprint(owner),
246
- status: 'rejected',
247
- reason
637
+ status: 'succeeded'
248
638
  });
639
+ commitSucceeded = true;
640
+ hookPath = _path_1.default.join(this.layout.root, metadata.name);
641
+ await this.retireQuietly(verified.transferId, owner);
642
+ await this.reconcileAfterCommit();
643
+ phase = 'afterCommit';
644
+ if (this.hooks.afterCommit !== undefined) {
645
+ this.owePostCommitRetention(metadata.transferId);
646
+ }
647
+ await this.runHook('afterCommit', this.hooks.afterCommit, Object.freeze({
648
+ artifact: hookArtifact,
649
+ path: hookPath,
650
+ resumed,
651
+ alreadyCommitted: false
652
+ }));
653
+ hooksFinished = true;
654
+ await this.settleDeferredRetention(metadata.transferId, owner);
655
+ finalStarted = true;
656
+ await (0, direct_wire_js_1.writeFinal)(socket, { v: 1, status: 'COMMITTED' }, { signal: this.signal, timeout: this.idleTimeout });
657
+ };
658
+ const payloadBytes = this.payloadBytes(metadata);
659
+ if (payloadBytes > this.maxFileBytes || this.activeUploads.size >= this.maxActiveUploads) {
660
+ if (payloadBytes > this.maxFileBytes) {
661
+ await rejectEarly(errors_js_1.ERRORS.FILE_TOO_LARGE, 'File exceeds the maximum size');
662
+ }
663
+ else {
664
+ await rejectEarly(errors_js_1.ERRORS.ACTIVE_UPLOAD_LIMIT, 'Active upload capacity exceeded');
665
+ }
249
666
  return;
250
667
  }
251
668
  const inspected = await this.commits.inspect(metadata.name, {
252
669
  name: metadata.name,
253
- size: metadata.fileSize,
254
- digest: b4a_1.default.from(metadata.fileSha256, 'hex'),
255
- transferId: b4a_1.default.from(metadata.transferId, 'hex')
670
+ kind: this.artifactKind(metadata),
671
+ size: payloadBytes,
672
+ digest: b4a_1.default.from(this.payloadDigest(metadata), 'hex'),
673
+ ...((0, controls_js_1.isTreeMetadata)(metadata) ? { entryCount: metadata.entryCount } : {}),
674
+ transferId: b4a_1.default.from(metadata.transferId, 'hex'),
675
+ release
256
676
  }, { replaceNames: this.replaceNames });
257
677
  if (inspected.status === 'ALREADY_COMMITTED') {
678
+ alreadyCommitted = true;
679
+ hookPath = _path_1.default.join(this.layout.root, metadata.name);
680
+ await this.reconcileAfterCommit();
681
+ phase = 'afterCommit';
682
+ await this.runHook('afterCommit', this.hooks.afterCommit, Object.freeze({
683
+ artifact: hookArtifact,
684
+ path: hookPath,
685
+ resumed: false,
686
+ alreadyCommitted: true
687
+ }));
688
+ hooksFinished = true;
689
+ // Only a transfer this process committed can still owe a deferred
690
+ // pass; an ordinary duplicate offer never starts one.
691
+ if (this.pendingAfterCommit.has(metadata.transferId)) {
692
+ await this.settleDeferredRetention(metadata.transferId, owner);
693
+ }
258
694
  await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'ALREADY_COMMITTED' }, { signal: this.signal, timeout: this.idleTimeout });
259
695
  sentAdmission = true;
260
696
  this.emitSafe('offer', {
@@ -265,25 +701,22 @@ class Server extends EventEmitter {
265
701
  return;
266
702
  }
267
703
  if (inspected.status === 'FILE_EXISTS') {
268
- throw fail(errors_js_1.ERRORS.FILE_EXISTS, 'Destination already exists');
704
+ await rejectEarly(errors_js_1.ERRORS.FILE_EXISTS, 'Destination already exists');
705
+ return;
269
706
  }
270
707
  if (this.activeUploads.size >= this.maxActiveUploads) {
271
- await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'REJECTED', code: errors_js_1.ERRORS.ACTIVE_UPLOAD_LIMIT }, { signal: this.signal, timeout: this.idleTimeout });
272
- sentAdmission = true;
273
- this.emitSafe('offer', {
274
- ...event,
275
- fingerprint: fingerprint(owner),
276
- status: 'rejected',
277
- reason: errors_js_1.ERRORS.ACTIVE_UPLOAD_LIMIT
278
- });
708
+ await rejectEarly(errors_js_1.ERRORS.ACTIVE_UPLOAD_LIMIT, 'Active upload capacity exceeded');
279
709
  return;
280
710
  }
281
711
  this.activeUploads.add(socket);
282
712
  const admission = await this.sessions.admit(owner, metadata);
283
713
  current.transfer = metadata.transferId;
714
+ resumed = admission.status !== 'ACCEPT';
284
715
  if (admission.status === 'VERIFIED') {
285
716
  await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'VERIFIED' }, { signal: this.signal, timeout: this.idleTimeout });
286
717
  sentAdmission = true;
718
+ phase = 'verification';
719
+ hookPath = stagingHookPath;
287
720
  this.emitSafe('verification', {
288
721
  ...event,
289
722
  fingerprint: fingerprint(owner),
@@ -297,20 +730,7 @@ class Server extends EventEmitter {
297
730
  status: 'succeeded'
298
731
  });
299
732
  verificationSucceeded = true;
300
- commitStarted = true;
301
- await this.commits.commit(verified, {
302
- retentionManager: this.retention,
303
- signal: this.signal,
304
- replaceNames: this.replaceNames
305
- });
306
- this.emitSafe('commit', {
307
- ...event,
308
- fingerprint: fingerprint(owner),
309
- status: 'succeeded'
310
- });
311
- commitSucceeded = true;
312
- await this.retireQuietly(b4a_1.default.from(metadata.transferId, 'hex'), owner);
313
- await (0, direct_wire_js_1.writeFinal)(socket, { v: 1, status: 'COMMITTED' }, { signal: this.signal, timeout: this.idleTimeout });
733
+ await finish(verified);
314
734
  return;
315
735
  }
316
736
  await (0, direct_wire_js_1.writeAdmission)(socket, admission.status === 'ACCEPT'
@@ -322,6 +742,8 @@ class Server extends EventEmitter {
322
742
  prefixSha256: b4a_1.default.toString(admission.prefixSha256, 'hex')
323
743
  }, { signal: this.signal, timeout: this.idleTimeout });
324
744
  sentAdmission = true;
745
+ phase = 'transfer';
746
+ hookPath = _path_1.default.join(this.layout.staging, `${metadata.transferId}.tar.part`);
325
747
  this.emitSafe('offer', {
326
748
  ...event,
327
749
  fingerprint: fingerprint(owner),
@@ -360,6 +782,7 @@ class Server extends EventEmitter {
360
782
  }, this.signal, this.idleTimeout);
361
783
  await flush();
362
784
  await reader.requireEnd(this.signal, this.idleTimeout);
785
+ phase = 'verification';
363
786
  this.emitSafe('verification', {
364
787
  ...event,
365
788
  fingerprint: fingerprint(owner),
@@ -373,20 +796,7 @@ class Server extends EventEmitter {
373
796
  status: 'succeeded'
374
797
  });
375
798
  verificationSucceeded = true;
376
- commitStarted = true;
377
- await this.commits.commit(verified, {
378
- retentionManager: this.retention,
379
- signal: this.signal,
380
- replaceNames: this.replaceNames
381
- });
382
- this.emitSafe('commit', {
383
- ...event,
384
- fingerprint: fingerprint(owner),
385
- status: 'succeeded'
386
- });
387
- commitSucceeded = true;
388
- await this.retireQuietly(verified.transferId, owner);
389
- await (0, direct_wire_js_1.writeFinal)(socket, { v: 1, status: 'COMMITTED' }, { signal: this.signal, timeout: this.idleTimeout });
799
+ await finish(verified);
390
800
  }
391
801
  catch (error) {
392
802
  const reason = codeOf(error);
@@ -415,7 +825,7 @@ class Server extends EventEmitter {
415
825
  if (!sentAdmission) {
416
826
  await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'REJECTED', code: reason }, { timeout: this.idleTimeout });
417
827
  }
418
- else if (!commitSucceeded) {
828
+ else if (!finalStarted) {
419
829
  await (0, direct_wire_js_1.writeFinal)(socket, { v: 1, status: 'FAILED', code: reason }, { timeout: this.idleTimeout });
420
830
  }
421
831
  }
@@ -424,12 +834,59 @@ class Server extends EventEmitter {
424
834
  socket.destroy();
425
835
  }
426
836
  catch { }
837
+ this.activeUploads.delete(socket);
838
+ if (!hooksFinished)
839
+ await reportFailure((0, hooks_js_1.callbackError)(error));
427
840
  }
428
841
  finally {
429
842
  this.activeUploads.delete(socket);
843
+ releaseTransfer();
430
844
  reader.closeReader();
431
845
  }
432
846
  }
847
+ /**
848
+ * Records that a transfer still owes its deferred post-commit retention
849
+ * pass, keeping at most `MAX_OWED_POST_COMMIT_RETENTION` entries.
850
+ *
851
+ * `Set` preserves insertion order, so the oldest owed transfer is evicted
852
+ * first and eviction is deterministic. An evicted transfer loses nothing
853
+ * durable: its artifact is already committed and its rotation falls back to
854
+ * the next startup, scheduled, or commit-triggered pass.
855
+ */
856
+ owePostCommitRetention(transferId) {
857
+ if (this.pendingAfterCommit.has(transferId))
858
+ return;
859
+ if (this.pendingAfterCommit.size >= exports.MAX_OWED_POST_COMMIT_RETENTION) {
860
+ const oldest = this.pendingAfterCommit.values().next();
861
+ if (!oldest.done)
862
+ this.pendingAfterCommit.delete(oldest.value);
863
+ }
864
+ this.pendingAfterCommit.add(transferId);
865
+ }
866
+ /**
867
+ * Runs the post-commit retention pass that a configured `afterCommit` hook
868
+ * deferred, only after that hook succeeded, and then stops owing it. The
869
+ * retention manager already reports its own failures as non-fatal, so
870
+ * nothing here can fail the upload; the pass is attempted exactly once per
871
+ * successful hook, which is why the transfer is cleared either way.
872
+ */
873
+ async settleDeferredRetention(transferId, owner) {
874
+ if (this.hooks.afterCommit === undefined)
875
+ return;
876
+ try {
877
+ if (this.retention)
878
+ await this.retention.afterCommit();
879
+ }
880
+ catch (error) {
881
+ this.logger.warn('Post-commit retention failed', {
882
+ fingerprint: owner ? fingerprint(owner) : 'invalid',
883
+ reason: codeOf(error)
884
+ });
885
+ }
886
+ finally {
887
+ this.pendingAfterCommit.delete(transferId);
888
+ }
889
+ }
433
890
  /**
434
891
  * Retires a committed session without letting cleanup undo a durable commit.
435
892
  *
@@ -466,6 +923,13 @@ class Server extends EventEmitter {
466
923
  storage: this.storage,
467
924
  logger: this.logger
468
925
  });
926
+ if (this.symlinks.length > 0 ||
927
+ (typeof this.storage.symlink === 'function' && typeof this.storage.readlink === 'function')) {
928
+ this.links = new link_store_js_1.LinkStore({
929
+ layout: this.layout,
930
+ storage: this.storage
931
+ });
932
+ }
469
933
  this.emitSafe('recovery', { status: 'started' });
470
934
  await (0, recovery_js_1.prepareStorageRecovery)({
471
935
  layout: this.layout,
@@ -496,6 +960,9 @@ class Server extends EventEmitter {
496
960
  sessionStore: this.sessions,
497
961
  commitStore: this.commits,
498
962
  maxAge: this.maxAge,
963
+ maxCount: this.maxCount,
964
+ maxVersions: this.maxVersions,
965
+ versionGranularity: this.versionGranularity,
499
966
  maxStorageBytes: this.maxStorageBytes,
500
967
  resumeTtl: this.resumeTtl,
501
968
  cleanupInterval: this.cleanupInterval,
@@ -504,9 +971,13 @@ class Server extends EventEmitter {
504
971
  isSessionActive: (session) => [...this.active.values()].some((active) => active.transfer === session.id),
505
972
  hasActiveUploads: () => this.activeUploads.size > 0,
506
973
  isPinned: (record) => this.replaceNames.has(record.name),
974
+ managedLinkNames: () => this.managedLinkNamesSnapshot,
975
+ reconcileLinks: (records) => this.reconcileLinksUnlocked(records),
976
+ linkPinsFallback: (records) => this.linkPinsFallbackUnlocked(records),
507
977
  logger: this.logger,
508
978
  onEvent: ({ type, ...event }) => this.emitSafe(type, event)
509
979
  });
980
+ await this.refreshManagedLinkNames();
510
981
  await this.retention.start();
511
982
  (0, abort_js_1.throwIfAborted)(this.signal);
512
983
  this.transport = new direct_dht_js_1.DirectDhtServer({
@@ -550,8 +1021,11 @@ class Server extends EventEmitter {
550
1021
  this.transport = null;
551
1022
  await Promise.allSettled([...this.receives]);
552
1023
  this.active.clear();
1024
+ this.activeTransfers.clear();
1025
+ this.pendingAfterCommit.clear();
553
1026
  await this.retention?.stop().catch(() => { });
554
1027
  this.retention = null;
1028
+ this.links = null;
555
1029
  await this.sessions?.close().catch(() => { });
556
1030
  this.sessions = null;
557
1031
  if (this.releaseLock)