@stigmer/server 3.22.1 → 3.23.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 (71) hide show
  1. package/dist/artifactstorage/artifact-storage.d.ts +29 -31
  2. package/dist/artifactstorage/artifact-storage.d.ts.map +1 -1
  3. package/dist/artifactstorage/artifact-storage.js +5 -7
  4. package/dist/artifactstorage/artifact-storage.js.map +1 -1
  5. package/dist/artifactstorage/r2-storage.d.ts +12 -7
  6. package/dist/artifactstorage/r2-storage.d.ts.map +1 -1
  7. package/dist/artifactstorage/r2-storage.js +6 -14
  8. package/dist/artifactstorage/r2-storage.js.map +1 -1
  9. package/dist/boot/compose.d.ts.map +1 -1
  10. package/dist/boot/compose.js +49 -24
  11. package/dist/boot/compose.js.map +1 -1
  12. package/dist/boot/config.d.ts +4 -3
  13. package/dist/boot/config.d.ts.map +1 -1
  14. package/dist/boot/config.js.map +1 -1
  15. package/dist/boot/skill-transfer-origin.d.ts +52 -0
  16. package/dist/boot/skill-transfer-origin.d.ts.map +1 -0
  17. package/dist/boot/skill-transfer-origin.js +54 -0
  18. package/dist/boot/skill-transfer-origin.js.map +1 -0
  19. package/dist/domain/plugin/controller.d.ts +3 -7
  20. package/dist/domain/plugin/controller.d.ts.map +1 -1
  21. package/dist/domain/plugin/controller.js +8 -8
  22. package/dist/domain/plugin/controller.js.map +1 -1
  23. package/dist/domain/skill/constants.d.ts +26 -3
  24. package/dist/domain/skill/constants.d.ts.map +1 -1
  25. package/dist/domain/skill/constants.js +26 -3
  26. package/dist/domain/skill/constants.js.map +1 -1
  27. package/dist/domain/skill/controller.d.ts +6 -11
  28. package/dist/domain/skill/controller.d.ts.map +1 -1
  29. package/dist/domain/skill/controller.js +33 -22
  30. package/dist/domain/skill/controller.js.map +1 -1
  31. package/dist/domain/skill/push.d.ts +2 -2
  32. package/dist/domain/skill/push.d.ts.map +1 -1
  33. package/dist/domain/skill/push.js +2 -2
  34. package/dist/domain/skill/push.js.map +1 -1
  35. package/dist/domain/skill/transfer/handler.d.ts +17 -9
  36. package/dist/domain/skill/transfer/handler.d.ts.map +1 -1
  37. package/dist/domain/skill/transfer/handler.js +19 -8
  38. package/dist/domain/skill/transfer/handler.js.map +1 -1
  39. package/dist/domain/skill/transfer/slots.d.ts +26 -41
  40. package/dist/domain/skill/transfer/slots.d.ts.map +1 -1
  41. package/dist/domain/skill/transfer/slots.js +80 -87
  42. package/dist/domain/skill/transfer/slots.js.map +1 -1
  43. package/dist/domain/skill/transfer/staging.d.ts +48 -0
  44. package/dist/domain/skill/transfer/staging.d.ts.map +1 -0
  45. package/dist/domain/skill/transfer/staging.js +127 -0
  46. package/dist/domain/skill/transfer/staging.js.map +1 -0
  47. package/dist/pipeline/steps/resolve-artifact-source.d.ts +11 -8
  48. package/dist/pipeline/steps/resolve-artifact-source.d.ts.map +1 -1
  49. package/dist/pipeline/steps/resolve-artifact-source.js +8 -4
  50. package/dist/pipeline/steps/resolve-artifact-source.js.map +1 -1
  51. package/package.json +6 -6
  52. package/src/artifactstorage/__tests__/artifact-storage.test.ts +36 -33
  53. package/src/artifactstorage/__tests__/r2-storage.test.ts +11 -13
  54. package/src/artifactstorage/artifact-storage.ts +37 -33
  55. package/src/artifactstorage/r2-storage.ts +6 -15
  56. package/src/boot/__tests__/skill-transfer-origin.test.ts +72 -0
  57. package/src/boot/compose.ts +62 -25
  58. package/src/boot/config.ts +4 -3
  59. package/src/boot/skill-transfer-origin.ts +76 -0
  60. package/src/domain/plugin/controller.ts +15 -17
  61. package/src/domain/skill/__tests__/skill.test.ts +5 -3
  62. package/src/domain/skill/__tests__/slots.test.ts +113 -60
  63. package/src/domain/skill/__tests__/staging.test.ts +304 -0
  64. package/src/domain/skill/constants.ts +28 -3
  65. package/src/domain/skill/controller.ts +44 -35
  66. package/src/domain/skill/push.ts +3 -3
  67. package/src/domain/skill/transfer/handler.ts +33 -15
  68. package/src/domain/skill/transfer/slots.ts +89 -100
  69. package/src/domain/skill/transfer/staging.ts +191 -0
  70. package/src/pipeline/steps/resolve-artifact-source.ts +20 -13
  71. package/src/transport/registry/__tests__/lanes.test.ts +1 -1
@@ -121,9 +121,10 @@ export interface ServerConfig {
121
121
  readonly oauthRedirectUri: string;
122
122
  /**
123
123
  * Skill artifact storage root (STORAGE_PATH; Go defaultStoragePath
124
- * ~/.stigmer/storage). Artifacts live at {storagePath}/skills/,
125
- * upload staging at {storagePath}/skills-staging/ — byte-identical to
126
- * Go's layout, so a Go-written directory is served in place at cutover.
124
+ * ~/.stigmer/storage). Artifacts live at {storagePath}/skills/ —
125
+ * byte-identical to Go's layout, so a Go-written directory is served in
126
+ * place at cutover — and upload staging at {storagePath}/skills/staging/,
127
+ * the prefix the domain names (domain/skill/constants.ts).
127
128
  */
128
129
  readonly storagePath: string;
129
130
  /**
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Whether the skill transfer lane's origin can be reached by the callers
3
+ * it is minted for.
4
+ *
5
+ * On the local blob driver the server itself carries artifact bytes over
6
+ * HTTP, and every upload and download capability URL it mints is rendered
7
+ * from `SKILL_TRANSFER_BASE_URL`, which defaults to the server's own
8
+ * loopback. That default is right for a single-operator install reached
9
+ * on the machine it runs on, and silently wrong for a hosted one: the
10
+ * server boots, serves, and passes every in-process check while handing
11
+ * the CLI, the SDKs and the sandbox runner a host none of them can reach —
12
+ * every push above the gRPC cap fails on the PUT and every sandbox mount
13
+ * of a skill with supporting files loses them (stigmer#1219). No test
14
+ * inside the process can see it, so the process says it at boot.
15
+ *
16
+ * The hosted signal is the require-authentication posture (boot/compose.ts
17
+ * combines its two sources): a server that requires credentials serves
18
+ * callers it does not share a machine with. A bucket driver signs URLs
19
+ * straight to its bucket and never renders the base URL, so the verdict
20
+ * is moot there and says so rather than warning about a knob nothing
21
+ * reads.
22
+ *
23
+ * Proven by __tests__/skill-transfer-origin.test.ts.
24
+ */
25
+
26
+ /** Hosts that name the process's own machine and nothing beyond it. */
27
+ const LOOPBACK_HOSTS = new Set([
28
+ "localhost",
29
+ "127.0.0.1",
30
+ "[::1]",
31
+ "0.0.0.0",
32
+ "[::]",
33
+ ]);
34
+
35
+ export type SkillTransferOriginVerdict =
36
+ /** The base URL names a host other than the server's own loopback. */
37
+ | { readonly kind: "reachable" }
38
+ /** Loopback on a single-operator install: the default, and correct. */
39
+ | { readonly kind: "loopback-local" }
40
+ /** Loopback on a server that requires authentication: minted URLs are dead on arrival. */
41
+ | { readonly kind: "loopback-hosted"; readonly baseUrl: string }
42
+ /** The blob driver signs its own URLs; the base URL is never rendered. */
43
+ | { readonly kind: "unused" };
44
+
45
+ export interface SkillTransferOriginInputs {
46
+ /** The configured or defaulted `SKILL_TRANSFER_BASE_URL`. */
47
+ readonly baseUrl: string;
48
+ /** Whether the local driver relays artifact bytes through this server. */
49
+ readonly relaysThroughServer: boolean;
50
+ /** The composed require-authentication posture. */
51
+ readonly requireAuthentication: boolean;
52
+ }
53
+
54
+ export function assessSkillTransferOrigin(
55
+ inputs: SkillTransferOriginInputs,
56
+ ): SkillTransferOriginVerdict {
57
+ if (!inputs.relaysThroughServer) {
58
+ return { kind: "unused" };
59
+ }
60
+ if (!namesLoopback(inputs.baseUrl)) {
61
+ return { kind: "reachable" };
62
+ }
63
+ return inputs.requireAuthentication
64
+ ? { kind: "loopback-hosted", baseUrl: inputs.baseUrl }
65
+ : { kind: "loopback-local" };
66
+ }
67
+
68
+ function namesLoopback(baseUrl: string): boolean {
69
+ try {
70
+ return LOOPBACK_HOSTS.has(new URL(baseUrl).hostname);
71
+ } catch {
72
+ // An unparseable base URL renders into every capability URL as-is; it
73
+ // is not loopback, and the failure it causes is loud on the first mint.
74
+ return false;
75
+ }
76
+ }
@@ -106,8 +106,10 @@ import type { VersionHistoryBinding } from "../../pipeline/steps/version-history
106
106
  import { newAuthorizeVisibilityTransitionStep } from "../../pipeline/steps/visibility-gates.js";
107
107
  import { metadataOf } from "../../pipeline/steps/shapes.js";
108
108
  import type { Store } from "../../store/interface.js";
109
- import { uploadUrl } from "../skill/transfer/handler.js";
110
- import type { UploadSlots } from "../skill/transfer/slots.js";
109
+ import type {
110
+ ArchiveStaging,
111
+ StagedUpload,
112
+ } from "../skill/transfer/staging.js";
111
113
  import { TRANSFER_LANE_NOT_CONFIGURED } from "./constants.js";
112
114
  import type { PluginMaterializerProvider } from "./materialize/ports.js";
113
115
  import { findMembers } from "./members.js";
@@ -140,12 +142,6 @@ import {
140
142
  } from "./push.js";
141
143
  import { pluginSearchExtractor } from "./search-extractor.js";
142
144
 
143
- /** The transfer lane's controller-side pair; the slots are the skill lane's (one upload surface). */
144
- export interface PluginTransferLaneDeps {
145
- readonly slots: UploadSlots;
146
- readonly baseUrl: string;
147
- }
148
-
149
145
  export interface PluginControllerDeps {
150
146
  readonly store: Store;
151
147
  readonly logger: Logger;
@@ -153,7 +149,8 @@ export interface PluginControllerDeps {
153
149
  readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
154
150
  readonly artifactStorage: ContentAddressedArchiveStore;
155
151
  readonly materializerProvider: PluginMaterializerProvider;
156
- readonly transferLane?: PluginTransferLaneDeps;
152
+ /** The skill lane's staging port (one upload surface for every archive); absent, the lane answers FailedPrecondition. */
153
+ readonly staging?: ArchiveStaging;
157
154
  }
158
155
 
159
156
  /** Registers both plugin services on the router (routes stage). */
@@ -208,7 +205,7 @@ async function push(
208
205
  .addStep(newValidateProtoStep())
209
206
  .addStep(
210
207
  newResolveArtifactSourceStep<typeof PushPluginRequestSchema>(
211
- deps.transferLane?.slots,
208
+ deps.staging,
212
209
  {
213
210
  source: (input) => ({
214
211
  artifact: input.artifact,
@@ -274,16 +271,17 @@ async function push(
274
271
  // ─── createArtifactUploadUrl ─────────────────────────────────────────────
275
272
 
276
273
  /**
277
- * Mints a short-lived, single-use upload URL over the shared slots; an
278
- * over-limit declaration is refused with the limit BEFORE any bytes move.
274
+ * Mints a short-lived, single-use upload URL through the shared staging
275
+ * port; an over-limit declaration is refused with the limit BEFORE any
276
+ * bytes move.
279
277
  */
280
278
  async function createArtifactUploadUrl(
281
279
  deps: PluginControllerDeps,
282
280
  req: CreatePluginArtifactUploadUrlRequest,
283
281
  ctx: HandlerContext,
284
282
  ): Promise<PluginArtifactUploadUrl> {
285
- const lane = deps.transferLane;
286
- if (lane === undefined) {
283
+ const staging = deps.staging;
284
+ if (staging === undefined) {
287
285
  throw failedPreconditionError(TRANSFER_LANE_NOT_CONFIGURED);
288
286
  }
289
287
  const reqCtx = new RequestContext(
@@ -310,14 +308,14 @@ async function createArtifactUploadUrl(
310
308
  `plugin archive size ${req.sizeBytes} bytes exceeds the ${MAX_ZIP_SIZE}-byte (100MB) archive limit`,
311
309
  );
312
310
  }
313
- let minted: { ref: string; ttlMs: number };
311
+ let minted: StagedUpload;
314
312
  try {
315
- minted = lane.slots.mint(Number(req.sizeBytes));
313
+ minted = await staging.mint(Number(req.sizeBytes));
316
314
  } catch (error) {
317
315
  throw internalError(error, "failed to mint upload reference");
318
316
  }
319
317
  return create(PluginArtifactUploadUrlSchema, {
320
- url: uploadUrl(lane.baseUrl, minted.ref),
318
+ url: minted.url,
321
319
  artifactUploadRef: minted.ref,
322
320
  ttlSeconds: Math.trunc(minted.ttlMs / 1000),
323
321
  });
@@ -434,7 +434,7 @@ describe("transfer lane (#675) — mint → PUT → push-by-ref → download", (
434
434
  );
435
435
  });
436
436
 
437
- it("a ref whose slot was minted but never uploaded is rejected at push", async () => {
437
+ it("a ref whose slot was minted but never uploaded is rejected at push — the one condition every driver can answer", async () => {
438
438
  const minted = await command.createArtifactUploadUrl({ org: ORG, sizeBytes: 1024n });
439
439
  const err = await expectCode(
440
440
  command.push({ org: ORG, artifactUploadRef: minted.artifactUploadRef }),
@@ -442,7 +442,7 @@ describe("transfer lane (#675) — mint → PUT → push-by-ref → download", (
442
442
  "empty slot",
443
443
  );
444
444
  expect(err.rawMessage).toBe(
445
- "artifact_upload_ref not usable: upload reference has no uploaded bytes — request a new upload URL via createArtifactUploadUrl",
445
+ "artifact_upload_ref not usable: upload reference unknown or expired — request a new upload URL via createArtifactUploadUrl",
446
446
  );
447
447
  });
448
448
 
@@ -499,7 +499,9 @@ describe("transfer lane (#675) — mint → PUT → push-by-ref → download", (
499
499
  const minted = await query.getArtifactDownloadUrl({
500
500
  artifactStorageKey: pushed.status!.artifactStorageKey,
501
501
  });
502
- expect(minted.ttlSeconds).toBe(0);
502
+ // The floor of the URL's validity, the same on every driver; the local
503
+ // lane's content-hash URL simply outlives it.
504
+ expect(minted.ttlSeconds).toBe(3600);
503
505
  expect(minted.sizeBytes).toBe(BigInt(artifact.length));
504
506
 
505
507
  const resp = await fetch(minted.url);
@@ -1,28 +1,39 @@
1
1
  /**
2
- * Pins the upload-slot registry against Go's slots_test.go: mint bounds,
3
- * ref shape, TTL expiry (injected clock), exact-declared-size enforcement
4
- * (short AND long bodies), strict single-use consume, staged-file cleanup,
5
- * expiry sweep on mint, and the boot-time staging wipe.
2
+ * Pins the upload-slot registry against Go's slots_test.go, keyed by the
3
+ * domain's staging key: reservation bounds and key shape, TTL expiry
4
+ * (injected clock), exact-declared-size enforcement (short AND long
5
+ * bodies), single upload per key, staged-file placement under the driver
6
+ * root, expiry sweep on reservation, the boot-time staging wipe, and the
7
+ * staging directory's recreation after the driver's delete pruned it.
6
8
  */
7
- import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import {
10
+ existsSync,
11
+ mkdtempSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
8
18
  import { tmpdir } from "node:os";
9
19
  import path from "node:path";
10
20
  import { Readable } from "node:stream";
11
21
 
12
22
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
23
 
24
+ import { STAGING_KEY_PREFIX } from "../constants.js";
14
25
  import {
15
26
  SizeMismatchError,
16
27
  SlotConsumedError,
17
- SlotEmptyError,
18
28
  SlotUnknownError,
19
29
  UploadSlots,
20
30
  } from "../transfer/slots.js";
31
+ import { newUploadRef, stagingKeyOf } from "../transfer/staging.js";
21
32
 
22
33
  const TTL_MS = 15 * 60 * 1000;
23
34
  const MAX_SIZE = 100 * 1024 * 1024;
24
35
 
25
- let dir: string;
36
+ let root: string;
26
37
  let stagingDir: string;
27
38
  let nowMs: number;
28
39
  let slots: UploadSlots;
@@ -31,101 +42,143 @@ function body(text: string): Readable {
31
42
  return Readable.from([Buffer.from(text)]);
32
43
  }
33
44
 
45
+ /** A fresh, well-formed staging key — what the domain's mint hands the registry. */
46
+ function freshKey(): string {
47
+ const key = stagingKeyOf(newUploadRef());
48
+ if (key === undefined) throw new Error("a minted ref always has a key");
49
+ return key;
50
+ }
51
+
34
52
  beforeEach(() => {
35
- dir = mkdtempSync(path.join(tmpdir(), "skill-slots-test-"));
36
- stagingDir = path.join(dir, "skills-staging");
53
+ root = mkdtempSync(path.join(tmpdir(), "skill-slots-test-"));
54
+ stagingDir = path.join(root, STAGING_KEY_PREFIX);
37
55
  nowMs = 1_000_000;
38
- slots = new UploadSlots(stagingDir, TTL_MS, MAX_SIZE, () => nowMs);
56
+ slots = new UploadSlots(
57
+ root,
58
+ STAGING_KEY_PREFIX,
59
+ TTL_MS,
60
+ MAX_SIZE,
61
+ () => nowMs,
62
+ );
39
63
  });
40
64
 
41
65
  afterEach(() => {
42
- rmSync(dir, { recursive: true, force: true });
66
+ rmSync(root, { recursive: true, force: true });
43
67
  });
44
68
 
45
69
  describe("UploadSlots", () => {
46
- it("mints sau_-prefixed 128-bit hex refs with the registry TTL", () => {
47
- const { ref, ttlMs } = slots.mint(1024);
48
- expect(ref).toMatch(/^sau_[0-9a-f]{32}$/);
49
- expect(ttlMs).toBe(TTL_MS);
70
+ it("reserves a key with the registry TTL", () => {
71
+ expect(slots.reserve(freshKey(), 1024)).toEqual({ ttlMs: TTL_MS });
50
72
  });
51
73
 
52
- it("rejects out-of-bounds declarations at mint", () => {
53
- expect(() => slots.mint(0)).toThrow("declared size 0 outside (0, 104857600]");
54
- expect(() => slots.mint(-1)).toThrow(`declared size -1 outside (0, ${MAX_SIZE}]`);
55
- expect(() => slots.mint(MAX_SIZE + 1)).toThrow(
74
+ it("rejects out-of-bounds declarations at reservation", () => {
75
+ expect(() => slots.reserve(freshKey(), 0)).toThrow(
76
+ "declared size 0 outside (0, 104857600]",
77
+ );
78
+ expect(() => slots.reserve(freshKey(), -1)).toThrow(
79
+ `declared size -1 outside (0, ${MAX_SIZE}]`,
80
+ );
81
+ expect(() => slots.reserve(freshKey(), MAX_SIZE + 1)).toThrow(
56
82
  `declared size ${MAX_SIZE + 1} outside (0, ${MAX_SIZE}]`,
57
83
  );
58
84
  });
59
85
 
60
- it("round-trips: mint → receive → consume returns the exact bytes and retires the slot", async () => {
61
- const { ref } = slots.mint(5);
62
- await slots.receive(ref, body("hello"));
63
- const data = await slots.consume(ref);
64
- expect(Buffer.from(data).toString()).toBe("hello");
65
- // Strictly single-use: the slot AND its staged file are gone.
66
- await expect(slots.consume(ref)).rejects.toThrow(SlotUnknownError);
67
- expect(readdirSync(stagingDir)).toEqual([]);
86
+ it("refuses a key outside the staging prefix or not shaped like a staged archive", () => {
87
+ expect(() => slots.reserve("skills/deadbeef.zip", 1)).toThrow(
88
+ `staging key skills/deadbeef.zip is outside ${STAGING_KEY_PREFIX}`,
89
+ );
90
+ expect(() =>
91
+ slots.reserve(`${STAGING_KEY_PREFIX}../escape.zip`, 1),
92
+ ).toThrow("does not name a staged archive");
93
+ expect(() => slots.reserve(`${STAGING_KEY_PREFIX}notes.txt`, 1)).toThrow(
94
+ "does not name a staged archive",
95
+ );
96
+ });
97
+
98
+ it("refuses to reserve the same key twice", () => {
99
+ const key = freshKey();
100
+ slots.reserve(key, 1);
101
+ expect(() => slots.reserve(key, 1)).toThrow(
102
+ `staging key ${key} is already reserved`,
103
+ );
104
+ });
105
+
106
+ it("stages a received body at the key under the root — where the driver reads it", async () => {
107
+ const key = freshKey();
108
+ slots.reserve(key, 5);
109
+ await slots.receive(key, body("hello"));
110
+ expect(readFileSync(path.join(root, key), "utf8")).toBe("hello");
68
111
  });
69
112
 
70
- it("rejects receive on an unknown ref", async () => {
71
- await expect(slots.receive("sau_nope", body("x"))).rejects.toThrow(SlotUnknownError);
113
+ it("rejects receive on an unreserved key", async () => {
114
+ await expect(slots.receive(freshKey(), body("x"))).rejects.toThrow(
115
+ SlotUnknownError,
116
+ );
72
117
  });
73
118
 
74
- it("rejects receive on an expired ref", async () => {
75
- const { ref } = slots.mint(1);
119
+ it("rejects receive on an expired key", async () => {
120
+ const key = freshKey();
121
+ slots.reserve(key, 1);
76
122
  nowMs += TTL_MS + 1;
77
- await expect(slots.receive(ref, body("x"))).rejects.toThrow(SlotUnknownError);
123
+ await expect(slots.receive(key, body("x"))).rejects.toThrow(
124
+ SlotUnknownError,
125
+ );
78
126
  });
79
127
 
80
- it("rejects a second upload to the same ref", async () => {
81
- const { ref } = slots.mint(1);
82
- await slots.receive(ref, body("x"));
83
- await expect(slots.receive(ref, body("x"))).rejects.toThrow(SlotConsumedError);
128
+ it("rejects a second upload to the same key", async () => {
129
+ const key = freshKey();
130
+ slots.reserve(key, 1);
131
+ await slots.receive(key, body("x"));
132
+ await expect(slots.receive(key, body("x"))).rejects.toThrow(
133
+ SlotConsumedError,
134
+ );
84
135
  });
85
136
 
86
137
  it("rejects a short body, naming both sizes, and stages nothing", async () => {
87
- const { ref } = slots.mint(10);
88
- await expect(slots.receive(ref, body("short"))).rejects.toThrow(
138
+ const key = freshKey();
139
+ slots.reserve(key, 10);
140
+ await expect(slots.receive(key, body("short"))).rejects.toThrow(
89
141
  "upload size mismatch: received 5 bytes, declared 10",
90
142
  );
91
- await expect(slots.receive(ref, body("short"))).rejects.toBeInstanceOf(SizeMismatchError);
143
+ await expect(slots.receive(key, body("short"))).rejects.toBeInstanceOf(
144
+ SizeMismatchError,
145
+ );
92
146
  expect(readdirSync(stagingDir)).toEqual([]);
93
147
  });
94
148
 
95
149
  it("rejects an over-declaration body without buffering it whole (declared+1 proof)", async () => {
96
- const { ref } = slots.mint(3);
97
- await expect(slots.receive(ref, body("longer than three"))).rejects.toThrow(
150
+ const key = freshKey();
151
+ slots.reserve(key, 3);
152
+ await expect(slots.receive(key, body("longer than three"))).rejects.toThrow(
98
153
  "upload size mismatch: received 4 bytes, declared 3",
99
154
  );
100
155
  });
101
156
 
102
- it("rejects consume on a minted-but-never-uploaded ref", async () => {
103
- const { ref } = slots.mint(1);
104
- await expect(slots.consume(ref)).rejects.toThrow(SlotEmptyError);
105
- });
106
-
107
- it("rejects consume on an expired ref", async () => {
108
- const { ref } = slots.mint(1);
109
- await slots.receive(ref, body("x"));
110
- nowMs += TTL_MS + 1;
111
- await expect(slots.consume(ref)).rejects.toThrow(SlotUnknownError);
112
- });
113
-
114
- it("sweeps expired slots and their staged files on mint", async () => {
115
- const { ref } = slots.mint(1);
116
- await slots.receive(ref, body("x"));
157
+ it("sweeps expired slots and their staged files on reservation", async () => {
158
+ const key = freshKey();
159
+ slots.reserve(key, 1);
160
+ await slots.receive(key, body("x"));
117
161
  expect(readdirSync(stagingDir)).toHaveLength(1);
118
162
  nowMs += TTL_MS + 1;
119
- slots.mint(1);
163
+ slots.reserve(freshKey(), 1);
120
164
  expect(readdirSync(stagingDir)).toEqual([]);
121
165
  });
122
166
 
167
+ it("recreates the staging directory when the driver's delete pruned it between uploads", async () => {
168
+ const key = freshKey();
169
+ slots.reserve(key, 1);
170
+ rmSync(stagingDir, { recursive: true, force: true });
171
+ await slots.receive(key, body("x"));
172
+ expect(readFileSync(path.join(root, key), "utf8")).toBe("x");
173
+ });
174
+
123
175
  it("wipes the staging directory at construction (orphans are unreachable)", () => {
124
- const orphanDir = path.join(dir, "staging-with-orphans");
176
+ const orphanRoot = path.join(root, "orphan-root");
177
+ const orphanDir = path.join(orphanRoot, STAGING_KEY_PREFIX);
125
178
  mkdirSync(orphanDir, { recursive: true });
126
- const orphan = path.join(orphanDir, "sau_orphan.zip");
179
+ const orphan = path.join(orphanDir, "deadbeef.zip");
127
180
  writeFileSync(orphan, "stale bytes");
128
- new UploadSlots(orphanDir, TTL_MS, MAX_SIZE);
181
+ new UploadSlots(orphanRoot, STAGING_KEY_PREFIX, TTL_MS, MAX_SIZE);
129
182
  expect(existsSync(orphan)).toBe(false);
130
183
  expect(readdirSync(orphanDir)).toEqual([]);
131
184
  });