@indigoai-us/hq-cloud 5.45.0 → 5.47.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 (64) hide show
  1. package/dist/bin/sync-runner.d.ts +12 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +78 -12
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +27 -1
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +17 -2
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +2 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync-scope.test.js +1 -0
  13. package/dist/cli/sync-scope.test.js.map +1 -1
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +11 -1
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/sync.test.js +1 -0
  18. package/dist/cli/sync.test.js.map +1 -1
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/object-io.d.ts +218 -0
  24. package/dist/object-io.d.ts.map +1 -0
  25. package/dist/object-io.js +588 -0
  26. package/dist/object-io.js.map +1 -0
  27. package/dist/object-io.test.d.ts +11 -0
  28. package/dist/object-io.test.d.ts.map +1 -0
  29. package/dist/object-io.test.js +568 -0
  30. package/dist/object-io.test.js.map +1 -0
  31. package/dist/s3.d.ts +37 -0
  32. package/dist/s3.d.ts.map +1 -1
  33. package/dist/s3.js +207 -198
  34. package/dist/s3.js.map +1 -1
  35. package/dist/skill-telemetry.d.ts +107 -0
  36. package/dist/skill-telemetry.d.ts.map +1 -0
  37. package/dist/skill-telemetry.js +395 -0
  38. package/dist/skill-telemetry.js.map +1 -0
  39. package/dist/skill-telemetry.test.d.ts +2 -0
  40. package/dist/skill-telemetry.test.d.ts.map +1 -0
  41. package/dist/skill-telemetry.test.js +219 -0
  42. package/dist/skill-telemetry.test.js.map +1 -0
  43. package/dist/vault-client.d.ts +91 -0
  44. package/dist/vault-client.d.ts.map +1 -1
  45. package/dist/vault-client.js +45 -0
  46. package/dist/vault-client.js.map +1 -1
  47. package/package.json +1 -1
  48. package/scripts/presign-transport-e2e.mjs +203 -0
  49. package/scripts/vault-rebaseline.sh +275 -0
  50. package/scripts/vault-rescue.sh +291 -0
  51. package/src/bin/sync-runner.test.ts +41 -0
  52. package/src/bin/sync-runner.ts +91 -13
  53. package/src/cli/share.test.ts +2 -0
  54. package/src/cli/share.ts +29 -2
  55. package/src/cli/sync-scope.test.ts +1 -0
  56. package/src/cli/sync.test.ts +1 -0
  57. package/src/cli/sync.ts +22 -1
  58. package/src/index.ts +16 -0
  59. package/src/object-io.test.ts +663 -0
  60. package/src/object-io.ts +782 -0
  61. package/src/s3.ts +259 -233
  62. package/src/skill-telemetry.test.ts +279 -0
  63. package/src/skill-telemetry.ts +499 -0
  64. package/src/vault-client.ts +135 -0
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Unit tests for the ObjectIO transport seam.
3
+ *
4
+ * Focus is the PresignObjectIO path (the new transport): it talks to a
5
+ * VaultClient-shaped stub for URL minting and a mocked global `fetch` for the
6
+ * byte movement. The S3SdkObjectIO path is already covered transitively by
7
+ * s3.test.ts (which exercises s3.ts through the default factory), plus a couple
8
+ * of factory-selection tests here.
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
12
+ import {
13
+ PresignObjectIO,
14
+ S3SdkObjectIO,
15
+ setObjectIOFactory,
16
+ resolveObjectIO,
17
+ presignObjectIOFactory,
18
+ type PresignTransportClient,
19
+ } from "./object-io.js";
20
+ import type { EntityContext } from "./types.js";
21
+ import type { PresignResultRow, VaultListedObject } from "./vault-client.js";
22
+
23
+ const COMPANY = "cmp_test";
24
+
25
+ function ctx(): EntityContext {
26
+ return {
27
+ uid: COMPANY,
28
+ slug: "test",
29
+ bucketName: "bucket-test",
30
+ region: "us-east-1",
31
+ credentials: {
32
+ accessKeyId: "AKIA",
33
+ secretAccessKey: "secret",
34
+ sessionToken: "token",
35
+ },
36
+ expiresAt: "2099-01-01T00:00:00.000Z",
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Minimal VaultClient stub. Each method returns whatever the per-test config
42
+ * sets; presign records the inputs it was called with so tests can assert the
43
+ * op/metadata/key shape the seam sends.
44
+ */
45
+ function makeVault() {
46
+ const presignCalls: Array<{
47
+ companyUid: string;
48
+ op?: string;
49
+ keys: unknown[];
50
+ }> = [];
51
+ let nextPresign: PresignResultRow[] = [];
52
+ let nextList: {
53
+ objects: VaultListedObject[];
54
+ cursor: string | null;
55
+ truncated: boolean;
56
+ } = { objects: [], cursor: null, truncated: false };
57
+
58
+ const vault: PresignTransportClient = {
59
+ presign: async (input) => {
60
+ presignCalls.push({
61
+ companyUid: input.companyUid,
62
+ op: input.op,
63
+ keys: input.keys,
64
+ });
65
+ return { results: nextPresign, expiresAt: "2099-01-01T00:00:00.000Z" };
66
+ },
67
+ listFiles: async () => nextList,
68
+ };
69
+
70
+ return {
71
+ vault,
72
+ presignCalls,
73
+ setPresign: (rows: PresignResultRow[]) => {
74
+ nextPresign = rows;
75
+ },
76
+ setList: (v: typeof nextList) => {
77
+ nextList = v;
78
+ },
79
+ };
80
+ }
81
+
82
+ describe("factory selection", () => {
83
+ afterEach(() => setObjectIOFactory(null));
84
+
85
+ it("defaults to the S3 SDK transport", () => {
86
+ const io = resolveObjectIO(ctx());
87
+ expect(io).toBeInstanceOf(S3SdkObjectIO);
88
+ });
89
+
90
+ it("setObjectIOFactory(null) resets to the default", () => {
91
+ const { vault } = makeVault();
92
+ setObjectIOFactory(presignObjectIOFactory(vault));
93
+ expect(resolveObjectIO(ctx())).toBeInstanceOf(PresignObjectIO);
94
+ setObjectIOFactory(null);
95
+ expect(resolveObjectIO(ctx())).toBeInstanceOf(S3SdkObjectIO);
96
+ });
97
+
98
+ it("presign factory derives companyUid from ctx.uid", async () => {
99
+ const { vault, presignCalls, setPresign } = makeVault();
100
+ setPresign([{ key: "k", op: "get", url: "https://x/get" }]);
101
+ vi.stubGlobal(
102
+ "fetch",
103
+ vi.fn(async () => new Response(Buffer.from("body"), { status: 200 })),
104
+ );
105
+ const io = presignObjectIOFactory(vault)(ctx());
106
+ await io.getObject("k");
107
+ expect(presignCalls[0].companyUid).toBe(COMPANY);
108
+ vi.unstubAllGlobals();
109
+ });
110
+ });
111
+
112
+ describe("PresignObjectIO.putObject", () => {
113
+ let fetchMock: ReturnType<typeof vi.fn>;
114
+ beforeEach(() => {
115
+ fetchMock = vi.fn();
116
+ vi.stubGlobal("fetch", fetchMock);
117
+ });
118
+ afterEach(() => vi.unstubAllGlobals());
119
+
120
+ it("sends op:put with metadata and replays the signed headers verbatim", async () => {
121
+ const { vault, presignCalls, setPresign } = makeVault();
122
+ setPresign([
123
+ {
124
+ key: "shared/a.md",
125
+ op: "put",
126
+ url: "https://s3/put-url",
127
+ headers: {
128
+ "content-type": "text/markdown",
129
+ "x-amz-server-side-encryption": "aws:kms",
130
+ "x-amz-meta-created-by": "me@getindigo.ai",
131
+ },
132
+ },
133
+ ]);
134
+ fetchMock.mockResolvedValue(
135
+ new Response(null, { status: 200, headers: { etag: '"abc123"' } }),
136
+ );
137
+
138
+ const io = new PresignObjectIO(vault, COMPANY);
139
+ const res = await io.putObject({
140
+ key: "shared/a.md",
141
+ body: Buffer.from("hello"),
142
+ contentType: "text/markdown",
143
+ metadata: { "created-by": "me@getindigo.ai" },
144
+ });
145
+
146
+ // Presign request carried op + per-key metadata.
147
+ expect(presignCalls[0].op).toBe("put");
148
+ expect(presignCalls[0].keys[0]).toMatchObject({
149
+ key: "shared/a.md",
150
+ op: "put",
151
+ contentType: "text/markdown",
152
+ metadata: { "created-by": "me@getindigo.ai" },
153
+ });
154
+
155
+ // PUT replays the exact signed headers.
156
+ const [url, init] = fetchMock.mock.calls[0];
157
+ expect(url).toBe("https://s3/put-url");
158
+ expect(init.method).toBe("PUT");
159
+ expect(init.headers).toEqual({
160
+ "content-type": "text/markdown",
161
+ "x-amz-server-side-encryption": "aws:kms",
162
+ "x-amz-meta-created-by": "me@getindigo.ai",
163
+ });
164
+ // ETag returned with quotes stripped.
165
+ expect(res.etag).toBe("abc123");
166
+ });
167
+
168
+ it("throws on a per-key denial (error row)", async () => {
169
+ const { vault, setPresign } = makeVault();
170
+ setPresign([
171
+ { key: "secret/x", op: "put", error: "forbidden", code: "ACL_DENY" },
172
+ ]);
173
+ const io = new PresignObjectIO(vault, COMPANY);
174
+ await expect(
175
+ io.putObject({
176
+ key: "secret/x",
177
+ body: Buffer.from("z"),
178
+ contentType: "text/plain",
179
+ }),
180
+ ).rejects.toThrow(/denied for secret\/x.*forbidden.*ACL_DENY/);
181
+ expect(fetchMock).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it("throws when the PUT itself fails", async () => {
185
+ const { vault, setPresign } = makeVault();
186
+ setPresign([{ key: "k", op: "put", url: "https://s3/put" }]);
187
+ fetchMock.mockResolvedValue(new Response("AccessDenied", { status: 403 }));
188
+ const io = new PresignObjectIO(vault, COMPANY);
189
+ await expect(
190
+ io.putObject({ key: "k", body: Buffer.from("z"), contentType: "x" }),
191
+ ).rejects.toThrow(/presigned PUT failed for k: 403/);
192
+ });
193
+ });
194
+
195
+ describe("PresignObjectIO.getObject", () => {
196
+ let fetchMock: ReturnType<typeof vi.fn>;
197
+ beforeEach(() => {
198
+ fetchMock = vi.fn();
199
+ vi.stubGlobal("fetch", fetchMock);
200
+ });
201
+ afterEach(() => vi.unstubAllGlobals());
202
+
203
+ it("returns body bytes + x-amz-meta-* metadata", async () => {
204
+ const { vault, presignCalls, setPresign } = makeVault();
205
+ setPresign([{ key: "shared/a.md", op: "get", url: "https://s3/get" }]);
206
+ fetchMock.mockResolvedValue(
207
+ new Response(Buffer.from("file-bytes"), {
208
+ status: 200,
209
+ headers: {
210
+ "x-amz-meta-created-by": "me@getindigo.ai",
211
+ "x-amz-meta-hq-mode": "640",
212
+ "content-type": "text/markdown",
213
+ },
214
+ }),
215
+ );
216
+
217
+ const io = new PresignObjectIO(vault, COMPANY);
218
+ const res = await io.getObject("shared/a.md");
219
+
220
+ expect(presignCalls[0].op).toBe("get");
221
+ expect(res.body.toString("utf-8")).toBe("file-bytes");
222
+ expect(res.metadata).toEqual({
223
+ "created-by": "me@getindigo.ai",
224
+ "hq-mode": "640",
225
+ });
226
+ });
227
+
228
+ it("throws a NotFound-named error on 404 (so s3.ts catch sites match)", async () => {
229
+ const { vault, setPresign } = makeVault();
230
+ setPresign([{ key: "gone", op: "get", url: "https://s3/get" }]);
231
+ fetchMock.mockResolvedValue(new Response("", { status: 404 }));
232
+ const io = new PresignObjectIO(vault, COMPANY);
233
+ await expect(io.getObject("gone")).rejects.toMatchObject({
234
+ name: "NotFound",
235
+ });
236
+ });
237
+ });
238
+
239
+ describe("PresignObjectIO.listObjects", () => {
240
+ it("maps listFiles rows into the RemoteObject shape (etag, Date)", async () => {
241
+ const { vault, setList } = makeVault();
242
+ setList({
243
+ objects: [
244
+ {
245
+ key: "shared/a.md",
246
+ size: 12,
247
+ lastModified: "2026-01-01T00:00:00.000Z",
248
+ etag: "deadbeef",
249
+ permission: "read",
250
+ },
251
+ {
252
+ key: "shared/b.md",
253
+ size: 0,
254
+ lastModified: null,
255
+ etag: null,
256
+ permission: "write",
257
+ },
258
+ ],
259
+ cursor: "next-token",
260
+ truncated: true,
261
+ });
262
+
263
+ const io = new PresignObjectIO(vault, COMPANY);
264
+ const res = await io.listObjects({ prefix: "shared/" });
265
+
266
+ expect(res.nextContinuationToken).toBe("next-token");
267
+ expect(res.objects[0]).toEqual({
268
+ key: "shared/a.md",
269
+ size: 12,
270
+ lastModified: new Date("2026-01-01T00:00:00.000Z"),
271
+ etag: "deadbeef",
272
+ });
273
+ // null etag → "" ; null lastModified → a Date (not crash).
274
+ expect(res.objects[1].etag).toBe("");
275
+ expect(res.objects[1].lastModified).toBeInstanceOf(Date);
276
+ });
277
+
278
+ it("exhausted listing yields undefined nextContinuationToken", async () => {
279
+ const { vault, setList } = makeVault();
280
+ setList({ objects: [], cursor: null, truncated: false });
281
+ const io = new PresignObjectIO(vault, COMPANY);
282
+ const res = await io.listObjects({});
283
+ expect(res.nextContinuationToken).toBeUndefined();
284
+ });
285
+ });
286
+
287
+ describe("PresignObjectIO.deleteObject", () => {
288
+ let fetchMock: ReturnType<typeof vi.fn>;
289
+ beforeEach(() => {
290
+ fetchMock = vi.fn();
291
+ vi.stubGlobal("fetch", fetchMock);
292
+ });
293
+ afterEach(() => vi.unstubAllGlobals());
294
+
295
+ it("sends op:delete and DELETEs the signed URL", async () => {
296
+ const { vault, presignCalls, setPresign } = makeVault();
297
+ setPresign([{ key: "shared/a.md", op: "delete", url: "https://s3/del" }]);
298
+ fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
299
+ const io = new PresignObjectIO(vault, COMPANY);
300
+ await io.deleteObject("shared/a.md");
301
+ expect(presignCalls[0].op).toBe("delete");
302
+ expect(fetchMock.mock.calls[0][1].method).toBe("DELETE");
303
+ });
304
+
305
+ it("treats a 404 DELETE as success (idempotent)", async () => {
306
+ const { vault, setPresign } = makeVault();
307
+ setPresign([{ key: "gone", op: "delete", url: "https://s3/del" }]);
308
+ fetchMock.mockResolvedValue(new Response("", { status: 404 }));
309
+ const io = new PresignObjectIO(vault, COMPANY);
310
+ await expect(io.deleteObject("gone")).resolves.toBeUndefined();
311
+ });
312
+ });
313
+
314
+ describe("PresignObjectIO.headObject", () => {
315
+ let fetchMock: ReturnType<typeof vi.fn>;
316
+ beforeEach(() => {
317
+ fetchMock = vi.fn();
318
+ vi.stubGlobal("fetch", fetchMock);
319
+ });
320
+ afterEach(() => vi.unstubAllGlobals());
321
+
322
+ it("reads headers (etag/size/lastModified/metadata) via a presigned GET", async () => {
323
+ const { vault, setPresign } = makeVault();
324
+ setPresign([{ key: "shared/a.md", op: "get", url: "https://s3/get" }]);
325
+ fetchMock.mockResolvedValue(
326
+ new Response(Buffer.from("ignored-body"), {
327
+ status: 200,
328
+ headers: {
329
+ etag: '"feedface"',
330
+ "content-length": "123",
331
+ "last-modified": "Wed, 01 Jan 2026 00:00:00 GMT",
332
+ "x-amz-meta-created-at": "2026-01-01T00:00:00.000Z",
333
+ },
334
+ }),
335
+ );
336
+ const io = new PresignObjectIO(vault, COMPANY);
337
+ const head = await io.headObject("shared/a.md");
338
+ expect(head).not.toBeNull();
339
+ expect(head!.etag).toBe("feedface");
340
+ expect(head!.size).toBe(123);
341
+ expect(head!.metadata).toEqual({
342
+ "created-at": "2026-01-01T00:00:00.000Z",
343
+ });
344
+ expect(head!.lastModified.getTime()).toBe(
345
+ Date.parse("Wed, 01 Jan 2026 00:00:00 GMT"),
346
+ );
347
+ });
348
+
349
+ it("returns null on 404", async () => {
350
+ const { vault, setPresign } = makeVault();
351
+ setPresign([{ key: "gone", op: "get", url: "https://s3/get" }]);
352
+ fetchMock.mockResolvedValue(new Response("", { status: 404 }));
353
+ const io = new PresignObjectIO(vault, COMPANY);
354
+ expect(await io.headObject("gone")).toBeNull();
355
+ });
356
+
357
+ it("returns null when presign denies the key (no usable head)", async () => {
358
+ const { vault, setPresign } = makeVault();
359
+ setPresign([{ key: "secret/x", op: "get", error: "forbidden" }]);
360
+ const io = new PresignObjectIO(vault, COMPANY);
361
+ expect(await io.headObject("secret/x")).toBeNull();
362
+ expect(fetchMock).not.toHaveBeenCalled();
363
+ });
364
+ });
365
+
366
+ /**
367
+ * Echo vault: presign returns one signed row per requested key (url derived
368
+ * from op+key) and records call count + total keys, so prime/cache tests can
369
+ * assert "N files → ceil(N/100) presign calls, then zero".
370
+ */
371
+ function makeEchoVault() {
372
+ let calls = 0;
373
+ let totalKeys = 0;
374
+ const vault: PresignTransportClient = {
375
+ presign: async (input) => {
376
+ calls++;
377
+ totalKeys += input.keys.length;
378
+ return {
379
+ results: input.keys.map((k) => ({
380
+ key: k.key,
381
+ op: k.op ?? input.op ?? "get",
382
+ url: `https://signed/${k.op ?? input.op}/${encodeURIComponent(k.key)}`,
383
+ ...(k.op === "put" || input.op === "put"
384
+ ? { headers: { "content-type": "text/plain" } }
385
+ : {}),
386
+ expiresIn: input.expiresIn ?? 900,
387
+ })),
388
+ expiresAt: "2099-01-01T00:00:00.000Z",
389
+ };
390
+ },
391
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
392
+ };
393
+ return {
394
+ vault,
395
+ calls: () => calls,
396
+ totalKeys: () => totalKeys,
397
+ };
398
+ }
399
+
400
+ describe("PresignObjectIO.prime — batch URL cache", () => {
401
+ let fetchMock: ReturnType<typeof vi.fn>;
402
+ beforeEach(() => {
403
+ fetchMock = vi.fn(async () => new Response(Buffer.from("x"), { status: 200 }));
404
+ vi.stubGlobal("fetch", fetchMock);
405
+ });
406
+ afterEach(() => vi.unstubAllGlobals());
407
+
408
+ it("chunks at 1000 keys per presign call (the server batch cap)", async () => {
409
+ const { vault, calls, totalKeys } = makeEchoVault();
410
+ const io = new PresignObjectIO(vault, COMPANY);
411
+ const keys = Array.from({ length: 2500 }, (_, i) => ({ key: `k${i}` }));
412
+ await io.prime("get", keys);
413
+ expect(calls()).toBe(3); // 1000 + 1000 + 500
414
+ expect(totalKeys()).toBe(2500);
415
+ });
416
+
417
+ it("primed getObject reuses the cache — zero extra presign calls", async () => {
418
+ const { vault, calls } = makeEchoVault();
419
+ const io = new PresignObjectIO(vault, COMPANY);
420
+ const keys = Array.from({ length: 5 }, (_, i) => ({ key: `f${i}` }));
421
+ await io.prime("get", keys);
422
+ const afterPrime = calls(); // 1 chunk
423
+ for (const k of keys) await io.getObject(k.key);
424
+ expect(calls()).toBe(afterPrime); // no new presign calls
425
+ expect(fetchMock).toHaveBeenCalledTimes(5); // but the bytes were fetched
426
+ // The fetched URL is the primed (cached) one.
427
+ expect(fetchMock.mock.calls[0][0]).toBe("https://signed/get/f0");
428
+ });
429
+
430
+ it("primed GET cache also serves headObject (HEAD reuses GET URLs)", async () => {
431
+ const { vault, calls } = makeEchoVault();
432
+ fetchMock.mockResolvedValue(
433
+ new Response(Buffer.from(""), {
434
+ status: 200,
435
+ headers: { etag: '"e"', "content-length": "0" },
436
+ }),
437
+ );
438
+ const io = new PresignObjectIO(vault, COMPANY);
439
+ await io.prime("get", [{ key: "doc.md" }]);
440
+ const afterPrime = calls();
441
+ const head = await io.headObject("doc.md");
442
+ expect(head).not.toBeNull();
443
+ expect(calls()).toBe(afterPrime); // head used the cached GET url, no presign
444
+ });
445
+
446
+ it("a key NOT primed falls back to a single presign", async () => {
447
+ const { vault, calls } = makeEchoVault();
448
+ const io = new PresignObjectIO(vault, COMPANY);
449
+ await io.prime("get", [{ key: "primed" }]);
450
+ const afterPrime = calls();
451
+ await io.getObject("not-primed");
452
+ expect(calls()).toBe(afterPrime + 1);
453
+ });
454
+
455
+ it("empty prime is a no-op (no presign call)", async () => {
456
+ const { vault, calls } = makeEchoVault();
457
+ const io = new PresignObjectIO(vault, COMPANY);
458
+ await io.prime("get", []);
459
+ expect(calls()).toBe(0);
460
+ });
461
+
462
+ it("a chunk that throws doesn't fail prime; those keys fall back per-file", async () => {
463
+ let calls = 0;
464
+ const vault: PresignTransportClient = {
465
+ presign: async (input) => {
466
+ calls++;
467
+ if (calls === 1) throw new Error("transient");
468
+ return {
469
+ results: input.keys.map((k) => ({
470
+ key: k.key,
471
+ op: "get" as const,
472
+ url: `https://signed/get/${k.key}`,
473
+ expiresIn: 900,
474
+ })),
475
+ expiresAt: "2099-01-01T00:00:00.000Z",
476
+ };
477
+ },
478
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
479
+ };
480
+ const io = new PresignObjectIO(vault, COMPANY);
481
+ // Single chunk that throws — prime resolves anyway, key stays uncached.
482
+ await expect(io.prime("get", [{ key: "k" }])).resolves.toBeUndefined();
483
+ // getObject then single-presigns (call #2 succeeds).
484
+ await io.getObject("k");
485
+ expect(calls).toBe(2);
486
+ });
487
+ });
488
+
489
+ describe("presignObjectIOFactory — memoization", () => {
490
+ afterEach(() => setObjectIOFactory(null));
491
+ it("returns the SAME instance per companyUid so prime + transfer share a cache", () => {
492
+ const { vault } = makeEchoVault();
493
+ const factory = presignObjectIOFactory(vault);
494
+ const a1 = factory(ctx());
495
+ const a2 = factory(ctx());
496
+ expect(a1).toBe(a2); // same company → same instance
497
+ const other = factory({ ...ctx(), uid: "cmp_other" });
498
+ expect(other).not.toBe(a1); // different company → different instance
499
+ });
500
+ });
501
+
502
+ describe("PresignObjectIO — transient-failure retry (SDK parity)", () => {
503
+ afterEach(() => {
504
+ vi.useRealTimers();
505
+ vi.unstubAllGlobals();
506
+ });
507
+
508
+ it("retries a transient 503 then succeeds", async () => {
509
+ vi.useFakeTimers();
510
+ const { vault, setPresign } = makeVault();
511
+ setPresign([{ key: "k", op: "get", url: "https://s3/get" }]);
512
+ const fetchMock = vi
513
+ .fn()
514
+ .mockResolvedValueOnce(new Response("SlowDown", { status: 503 }))
515
+ .mockResolvedValueOnce(new Response(Buffer.from("ok"), { status: 200 }));
516
+ vi.stubGlobal("fetch", fetchMock);
517
+
518
+ const io = new PresignObjectIO(vault, COMPANY);
519
+ const pr = io.getObject("k");
520
+ await vi.runAllTimersAsync();
521
+ const res = await pr;
522
+ expect(res.body.toString("utf-8")).toBe("ok");
523
+ expect(fetchMock).toHaveBeenCalledTimes(2);
524
+ });
525
+
526
+ it("retries a network error (fetch throws) then succeeds", async () => {
527
+ vi.useFakeTimers();
528
+ const { vault, setPresign } = makeVault();
529
+ setPresign([{ key: "k", op: "get", url: "https://s3/get" }]);
530
+ const fetchMock = vi
531
+ .fn()
532
+ .mockRejectedValueOnce(new Error("socket hang up"))
533
+ .mockResolvedValueOnce(new Response(Buffer.from("ok"), { status: 200 }));
534
+ vi.stubGlobal("fetch", fetchMock);
535
+
536
+ const io = new PresignObjectIO(vault, COMPANY);
537
+ const pr = io.getObject("k");
538
+ await vi.runAllTimersAsync();
539
+ const res = await pr;
540
+ expect(res.body.toString("utf-8")).toBe("ok");
541
+ expect(fetchMock).toHaveBeenCalledTimes(2);
542
+ });
543
+
544
+ it("throws after exhausting retries on persistent 503 (initial + 3 retries = 4)", async () => {
545
+ vi.useFakeTimers();
546
+ const { vault, setPresign } = makeVault();
547
+ setPresign([{ key: "k", op: "get", url: "https://s3/get" }]);
548
+ const fetchMock = vi.fn().mockResolvedValue(new Response("err", { status: 503 }));
549
+ vi.stubGlobal("fetch", fetchMock);
550
+
551
+ const io = new PresignObjectIO(vault, COMPANY);
552
+ const pr = io.getObject("k").catch((e) => e);
553
+ await vi.runAllTimersAsync();
554
+ const err = await pr;
555
+ expect(String(err)).toMatch(/503/);
556
+ expect(fetchMock).toHaveBeenCalledTimes(4);
557
+ });
558
+
559
+ it("does NOT retry a definitive 404", async () => {
560
+ const { vault, setPresign } = makeVault();
561
+ setPresign([{ key: "gone", op: "get", url: "https://s3/get" }]);
562
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404 }));
563
+ vi.stubGlobal("fetch", fetchMock);
564
+
565
+ const io = new PresignObjectIO(vault, COMPANY);
566
+ await expect(io.getObject("gone")).rejects.toMatchObject({ name: "NotFound" });
567
+ expect(fetchMock).toHaveBeenCalledTimes(1);
568
+ });
569
+ });
570
+
571
+ import { VaultClientError } from "./vault-client.js";
572
+
573
+ describe("PresignObjectIO — 429 circuit breaker", () => {
574
+ afterEach(() => vi.unstubAllGlobals());
575
+
576
+ it("trips on a 429: uncached keys fail fast (no further presign), primed keys still work", async () => {
577
+ let presignCalls = 0;
578
+ const vault: PresignTransportClient = {
579
+ presign: async (input) => {
580
+ presignCalls++;
581
+ if (input.keys.some((k) => k.key === "primed")) {
582
+ return {
583
+ results: input.keys.map((k) => ({
584
+ key: k.key,
585
+ op: k.op ?? "get",
586
+ url: `https://s/${k.key}`,
587
+ expiresIn: 900,
588
+ })),
589
+ expiresAt: "2099-01-01T00:00:00.000Z",
590
+ };
591
+ }
592
+ throw new VaultClientError("Rate limit exceeded: 100 requests per hour", 429);
593
+ },
594
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
595
+ };
596
+ vi.stubGlobal(
597
+ "fetch",
598
+ vi.fn(async () => new Response(Buffer.from("x"), { status: 200 })),
599
+ );
600
+ const io = new PresignObjectIO(vault, COMPANY);
601
+ await io.prime("get", [{ key: "primed" }]); // caches "primed"
602
+
603
+ await expect(io.getObject("uncached1")).rejects.toMatchObject({
604
+ name: "RateLimited",
605
+ });
606
+ const afterTrip = presignCalls;
607
+ // Second uncached key fails fast — NO new presign call.
608
+ await expect(io.getObject("uncached2")).rejects.toMatchObject({
609
+ name: "RateLimited",
610
+ });
611
+ expect(presignCalls).toBe(afterTrip);
612
+ // The primed key still resolves from cache (no presign needed).
613
+ const r = await io.getObject("primed");
614
+ expect(r.body.toString("utf-8")).toBe("x");
615
+ });
616
+
617
+ it("prime stops minting once a chunk 429s (breaker trips, remaining chunks skipped)", async () => {
618
+ let calls = 0;
619
+ const vault: PresignTransportClient = {
620
+ presign: async () => {
621
+ calls++;
622
+ throw new VaultClientError("rate", 429);
623
+ },
624
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
625
+ };
626
+ const io = new PresignObjectIO(vault, COMPANY);
627
+ // 10 chunks (10k keys @ 1000); concurrency is 4, so at most the first
628
+ // concurrent batch fires before the breaker trips — far fewer than 10.
629
+ await io.prime(
630
+ "get",
631
+ Array.from({ length: 10_000 }, (_, i) => ({ key: `k${i}` })),
632
+ );
633
+ expect(calls).toBeLessThanOrEqual(4);
634
+ expect(calls).toBeLessThan(10);
635
+ });
636
+ });
637
+
638
+ describe("PresignObjectIO.hasPrimedPut", () => {
639
+ it("reflects a put prime", async () => {
640
+ const { vault } = makeEchoVault();
641
+ const io = new PresignObjectIO(vault, COMPANY);
642
+ expect(io.hasPrimedPut("k")).toBe(false);
643
+ await io.prime("put", [
644
+ { key: "k", op: "put", contentType: "text/plain", metadata: { "hq-mode": "644" } },
645
+ ]);
646
+ expect(io.hasPrimedPut("k")).toBe(true);
647
+ // A different, unprimed key stays false.
648
+ expect(io.hasPrimedPut("other")).toBe(false);
649
+ });
650
+ });
651
+
652
+ describe("presignObjectIOFactory — personal vaults route to S3 SDK", () => {
653
+ it("routes a person entity (prs_*) to S3SdkObjectIO, a company (cmp_*) to presign", () => {
654
+ const { vault } = makeEchoVault();
655
+ const factory = presignObjectIOFactory(vault);
656
+ const company = factory({ ...ctx(), uid: "cmp_real" });
657
+ const personal = factory({ ...ctx(), uid: "prs_real" });
658
+ expect(company).toBeInstanceOf(PresignObjectIO);
659
+ // Personal vaults use the membership-less vend-self model; the
660
+ // membership-gated presign endpoints 403 for them, so they stay on STS.
661
+ expect(personal).toBeInstanceOf(S3SdkObjectIO);
662
+ });
663
+ });