@indigoai-us/hq-cloud 6.8.0 → 6.9.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.
@@ -109,6 +109,30 @@ describe("factory selection", () => {
109
109
  });
110
110
  });
111
111
 
112
+ describe("S3SdkObjectIO.putObject — conditional-write fence", () => {
113
+ it("maps ifMatch (re-quoted) and ifNoneMatch onto the PutObjectCommand", async () => {
114
+ const io = new S3SdkObjectIO(ctx());
115
+ const sent: Array<Record<string, unknown>> = [];
116
+ // Swap the private client for a recorder — the fence mapping is pure
117
+ // input-shaping, no wire needed.
118
+ (io as unknown as { client: { send: (c: { input: Record<string, unknown> }) => Promise<unknown> } }).client = {
119
+ send: async (cmd) => {
120
+ sent.push(cmd.input);
121
+ return { ETag: '"landed"' };
122
+ },
123
+ };
124
+ await io.putObject({ key: "a", body: Buffer.from("x"), contentType: "t", ifMatch: "abc" });
125
+ expect(sent[0].IfMatch).toBe('"abc"'); // stripped etags re-quoted for If-Match
126
+ await io.putObject({ key: "a2", body: Buffer.from("x"), contentType: "t", ifMatch: '"quoted"' });
127
+ expect(sent[1].IfMatch).toBe('"quoted"'); // already-quoted passes through
128
+ await io.putObject({ key: "b", body: Buffer.from("y"), contentType: "t", ifNoneMatch: "*" });
129
+ expect(sent[2].IfNoneMatch).toBe("*");
130
+ await io.putObject({ key: "c", body: Buffer.from("z"), contentType: "t" });
131
+ expect(sent[3].IfMatch).toBeUndefined(); // unfenced PUT stays unfenced
132
+ expect(sent[3].IfNoneMatch).toBeUndefined();
133
+ });
134
+ });
135
+
112
136
  describe("PresignObjectIO.putObject", () => {
113
137
  let fetchMock: ReturnType<typeof vi.fn>;
114
138
  beforeEach(() => {
@@ -190,6 +214,58 @@ describe("PresignObjectIO.putObject", () => {
190
214
  io.putObject({ key: "k", body: Buffer.from("z"), contentType: "x" }),
191
215
  ).rejects.toThrow(/presigned PUT failed for k: 403/);
192
216
  });
217
+
218
+ it("forwards the conditional-write fence on the presign request (ifMatch stripped, ifNoneMatch verbatim)", async () => {
219
+ // The server signs If-Match/If-None-Match into the URL (hq-pro follow-up)
220
+ // and echoes the headers for replay; the client's job is to ASK. Servers
221
+ // that predate the fields ignore them — never broken, just unconditional.
222
+ const { vault, presignCalls, setPresign } = makeVault();
223
+ setPresign([{ key: "fenced.md", op: "put", url: "https://s3/put-url" }]);
224
+ fetchMock.mockResolvedValue(
225
+ new Response(null, { status: 200, headers: { etag: '"v2"' } }),
226
+ );
227
+ const io = new PresignObjectIO(vault, COMPANY);
228
+ await io.putObject({
229
+ key: "fenced.md",
230
+ body: Buffer.from("x"),
231
+ contentType: "text/markdown",
232
+ ifMatch: '"v1"',
233
+ });
234
+ expect(presignCalls[0].keys[0]).toMatchObject({
235
+ key: "fenced.md",
236
+ op: "put",
237
+ ifMatch: "v1", // quotes stripped on the wire; server re-quotes when signing
238
+ });
239
+
240
+ await io.putObject({
241
+ key: "fresh.md",
242
+ body: Buffer.from("y"),
243
+ contentType: "text/markdown",
244
+ ifNoneMatch: "*",
245
+ });
246
+ expect(presignCalls[1].keys[0]).toMatchObject({
247
+ key: "fresh.md",
248
+ op: "put",
249
+ ifNoneMatch: "*",
250
+ });
251
+ });
252
+
253
+ it("maps a 412 PUT response to name:PreconditionFailed (the fence fired — conflict, not failure)", async () => {
254
+ const { vault, setPresign } = makeVault();
255
+ setPresign([{ key: "raced.md", op: "put", url: "https://s3/put-url" }]);
256
+ fetchMock.mockResolvedValue(
257
+ new Response("PreconditionFailed", { status: 412 }),
258
+ );
259
+ const io = new PresignObjectIO(vault, COMPANY);
260
+ await expect(
261
+ io.putObject({
262
+ key: "raced.md",
263
+ body: Buffer.from("z"),
264
+ contentType: "text/plain",
265
+ ifMatch: "old-etag",
266
+ }),
267
+ ).rejects.toMatchObject({ name: "PreconditionFailed" });
268
+ });
193
269
  });
194
270
 
195
271
  describe("PresignObjectIO.getObject", () => {
@@ -354,13 +430,40 @@ describe("PresignObjectIO.headObject", () => {
354
430
  expect(await io.headObject("gone")).toBeNull();
355
431
  });
356
432
 
357
- it("returns null when presign denies the key (no usable head)", async () => {
433
+ it("throws Forbidden when presign denies the key denial is NOT absence", async () => {
434
+ // Regression: pre-fix this returned null ("absent"), which made
435
+ // share.ts's push guard (`if (remoteMeta)`) skip every conflict check
436
+ // and issue an unconditional PUT — a transient denial mid-pass silently
437
+ // clobbered newer remote bytes (2026-06-10..12 vault regression storm).
358
438
  const { vault, setPresign } = makeVault();
359
439
  setPresign([{ key: "secret/x", op: "get", error: "forbidden" }]);
360
440
  const io = new PresignObjectIO(vault, COMPANY);
361
- expect(await io.headObject("secret/x")).toBeNull();
441
+ await expect(io.headObject("secret/x")).rejects.toMatchObject({
442
+ name: "Forbidden",
443
+ });
362
444
  expect(fetchMock).not.toHaveBeenCalled();
363
445
  });
446
+
447
+ it("throws Forbidden on a presigned-GET 403 — same SDK-parity contract", async () => {
448
+ // Expired presigned URL, expired signing creds, KMS or bucket-policy
449
+ // denial all surface as 403 on the signed GET. Unknown state — must
450
+ // never read as "object missing".
451
+ const { vault, setPresign } = makeVault();
452
+ setPresign([{ key: "shared/a.md", op: "get", url: "https://s3/get" }]);
453
+ fetchMock.mockResolvedValue(new Response("AccessDenied", { status: 403 }));
454
+ const io = new PresignObjectIO(vault, COMPANY);
455
+ await expect(io.headObject("shared/a.md")).rejects.toMatchObject({
456
+ name: "Forbidden",
457
+ });
458
+ });
459
+
460
+ it("still returns null on a definitive 404 (key truly absent)", async () => {
461
+ const { vault, setPresign } = makeVault();
462
+ setPresign([{ key: "gone2", op: "get", url: "https://s3/get" }]);
463
+ fetchMock.mockResolvedValue(new Response("", { status: 404 }));
464
+ const io = new PresignObjectIO(vault, COMPANY);
465
+ expect(await io.headObject("gone2")).toBeNull();
466
+ });
364
467
  });
365
468
 
366
469
  /**
package/src/object-io.ts CHANGED
@@ -68,7 +68,29 @@ export interface PresignTransportClient {
68
68
  // Wire-primitive shapes
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
- export interface PutObjectInput {
71
+ /**
72
+ * Conditional-write fence for a PUT (S3 conditional writes, GA 2024-11).
73
+ *
74
+ * `ifMatch` — only land the PUT if the remote object's ETag still equals
75
+ * this value (the journal baseline / last-observed HEAD). `ifNoneMatch: "*"`
76
+ * — only land the PUT if NO object exists at the key (creation fence).
77
+ * Either mismatch makes S3 reject with 412 PreconditionFailed, which the
78
+ * push path surfaces as a conflict instead of a silent overwrite.
79
+ *
80
+ * This is the storage-level backstop for the entire stale-clobber class:
81
+ * a HEAD-then-PUT race, a transport bug that misreads remote state, or an
82
+ * outdated client mid-pass can no longer regress a newer remote object —
83
+ * S3 itself refuses. (The 2026-06-10..12 vault regression storm was this
84
+ * class: stale machine copies blind-PUT over newer objects.)
85
+ */
86
+ export interface PutPrecondition {
87
+ /** Land only if the current remote ETag equals this (quotes optional). */
88
+ ifMatch?: string;
89
+ /** Land only if no object exists at the key. */
90
+ ifNoneMatch?: "*";
91
+ }
92
+
93
+ export interface PutObjectInput extends PutPrecondition {
72
94
  key: string;
73
95
  body: Buffer;
74
96
  contentType: string;
@@ -117,7 +139,12 @@ export interface ObjectIO {
117
139
  getObject(key: string): Promise<GetObjectResult>;
118
140
  listObjects(input: ListObjectsInput): Promise<ListObjectsResult>;
119
141
  deleteObject(key: string): Promise<void>;
120
- /** Null when the key does not exist (404 / 403-as-absent). */
142
+ /**
143
+ * Null ONLY when the key definitively does not exist (404). Access denial
144
+ * (403 / per-key presign denial) THROWS a `name: "Forbidden"` error — it is
145
+ * unknown state, never "absent". Conflating the two disables push-side
146
+ * conflict guards and clobbers newer remote objects.
147
+ */
121
148
  headObject(key: string): Promise<HeadObjectResult | null>;
122
149
  /**
123
150
  * Optional batch pre-mint. Warms an internal URL cache for `keys` under `op`
@@ -192,6 +219,13 @@ export class S3SdkObjectIO implements ObjectIO {
192
219
  ...(input.metadata && Object.keys(input.metadata).length > 0
193
220
  ? { Metadata: input.metadata }
194
221
  : {}),
222
+ // Conditional-write fence. If-Match wants the quoted entity-tag form;
223
+ // callers hand us journal/HEAD etags that may be stripped — re-quote
224
+ // so both shapes fence identically. A mismatch surfaces as the SDK's
225
+ // name:"PreconditionFailed" (HTTP 412), which the push path maps to
226
+ // its conflict flow.
227
+ ...(input.ifMatch ? { IfMatch: quoteEtag(input.ifMatch) } : {}),
228
+ ...(input.ifNoneMatch ? { IfNoneMatch: input.ifNoneMatch } : {}),
195
229
  }),
196
230
  );
197
231
  return { etag: res.ETag || "" };
@@ -311,6 +345,44 @@ function notFoundError(key: string): Error {
311
345
  return Object.assign(new Error(`Not found: ${key}`), { name: "NotFound" });
312
346
  }
313
347
 
348
+ /**
349
+ * An error shaped like the AWS SDK's HeadObject 403 (`name: "Forbidden"`) so
350
+ * presigned-transport denials route through the SAME catch sites as SDK ones
351
+ * (share.ts / sync.ts `isAccessDenied`: name === "AccessDenied" | "Forbidden").
352
+ * Critically this is NOT `null`: "can't read the key" must never be conflated
353
+ * with "the key does not exist" — that conflation let a transient 403 episode
354
+ * disable every push-side conflict guard and clobber newer remote objects.
355
+ */
356
+ function accessDeniedError(key: string, detail: string): Error {
357
+ return Object.assign(
358
+ new Error(`Access denied for ${key}: ${detail}`),
359
+ { name: "Forbidden" },
360
+ );
361
+ }
362
+
363
+ /**
364
+ * An error shaped like the AWS SDK's 412 (`name: "PreconditionFailed"`) so
365
+ * presigned-transport conditional-write rejections route through the same
366
+ * catch sites as SDK ones. A 412 means the fence WORKED: the remote moved
367
+ * past the caller's baseline (If-Match) or the key already exists
368
+ * (If-None-Match) — surface as a conflict, never overwrite.
369
+ */
370
+ function preconditionFailedError(key: string, detail: string): Error {
371
+ return Object.assign(
372
+ new Error(`Precondition failed for ${key}: ${detail}`),
373
+ { name: "PreconditionFailed" },
374
+ );
375
+ }
376
+
377
+ /**
378
+ * If-Match compares quoted entity-tags. Journal baselines store etags
379
+ * stripped (normalizeEtag) while SDK HEADs return them quoted — accept both
380
+ * and emit the canonical quoted form.
381
+ */
382
+ function quoteEtag(etag: string): string {
383
+ return etag.startsWith('"') ? etag : `"${etag}"`;
384
+ }
385
+
314
386
  /**
315
387
  * Max keys per presign request when priming — the server's hard batch cap
316
388
  * (hq-pro files-presign MAX_BATCH_KEYS = 1000). One presign call costs ONE
@@ -435,7 +507,13 @@ export class PresignObjectIO implements ObjectIO {
435
507
  private async resolveUrl(
436
508
  op: PresignOp,
437
509
  key: string,
438
- extra?: { contentType?: string; metadata?: Record<string, string> },
510
+ extra?: {
511
+ contentType?: string;
512
+ metadata?: Record<string, string>;
513
+ /** Conditional-write fence for PUT presigns — see PutPrecondition. */
514
+ ifMatch?: string;
515
+ ifNoneMatch?: "*";
516
+ },
439
517
  ): Promise<{ url: string; headers?: Record<string, string> }> {
440
518
  const hit = this.cached(op, key);
441
519
  if (hit) return { url: hit.url, headers: hit.headers };
@@ -509,11 +587,23 @@ export class PresignObjectIO implements ObjectIO {
509
587
  }
510
588
 
511
589
  async putObject(input: PutObjectInput): Promise<{ etag: string }> {
590
+ // Conditional-write fields (ifMatch/ifNoneMatch) are forwarded on the
591
+ // presign request so the server can sign If-Match/If-None-Match into the
592
+ // URL and echo them via `headers` for replay. Until hq-pro's
593
+ // files-presign signs them (follow-up to this PR), the server ignores
594
+ // the fields and the returned header set carries no condition — the PUT
595
+ // stays unconditional on this transport, exactly today's behavior. We
596
+ // deliberately do NOT inject the header client-side: an unsigned
597
+ // conditional header breaks the SigV4 signature. Enforcement on this
598
+ // transport activates the moment the server starts signing; the SDK
599
+ // transport enforces immediately.
512
600
  const row = await this.resolveUrl("put", input.key, {
513
601
  contentType: input.contentType,
514
602
  ...(input.metadata && Object.keys(input.metadata).length > 0
515
603
  ? { metadata: input.metadata }
516
604
  : {}),
605
+ ...(input.ifMatch ? { ifMatch: stripQuotes(input.ifMatch) } : {}),
606
+ ...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}),
517
607
  });
518
608
  // The server signs Content-Type, SSE-KMS, and every x-amz-meta-* into the
519
609
  // signature and returns them in `headers`; they MUST be replayed verbatim
@@ -523,6 +613,14 @@ export class PresignObjectIO implements ObjectIO {
523
613
  { method: "PUT", body: input.body, headers: row.headers ?? {} },
524
614
  `presigned PUT ${input.key}`,
525
615
  );
616
+ if (res.status === 412) {
617
+ // The signed conditional header fenced this write off: the remote
618
+ // moved past our baseline (If-Match) or the key already exists
619
+ // (If-None-Match). Same shape as the SDK's PreconditionFailed so the
620
+ // push path routes both transports through one conflict handler.
621
+ const detail = await safeText(res);
622
+ throw preconditionFailedError(input.key, detail);
623
+ }
526
624
  if (!res.ok) {
527
625
  const detail = await safeText(res);
528
626
  throw new Error(
@@ -607,18 +705,33 @@ export class PresignObjectIO implements ObjectIO {
607
705
  }
608
706
  const row = results[0];
609
707
  if (!row || row.error || !row.url) {
610
- // A per-key denial here means the caller can't read the key — treat as
611
- // absent for HEAD semantics (the SDK path would 403, which callers map
612
- // to "no usable head"); they all tolerate null.
613
- return null;
708
+ // A per-key denial means the caller can't READ the key — it says
709
+ // nothing about whether the object EXISTS. Pre-fix this returned
710
+ // null ("absent"), which made push call sites skip every conflict
711
+ // guard (`if (remoteMeta)`) and issue an UNCONDITIONAL PUT — a
712
+ // transient denial episode mid-pass silently clobbered newer remote
713
+ // bytes with this machine's stale copy (the 2026-06-10..12 vault
714
+ // regression storm). Throw the same access-denied shape the SDK
715
+ // transport raises so callers route through their existing
716
+ // isAccessDenied skip/defer paths instead of "object missing".
717
+ throw accessDeniedError(key, row?.error ?? "presign denied");
614
718
  }
615
719
  url = row.url;
616
720
  }
617
721
  const res = await fetchWithRetry(url, { method: "GET" }, `presigned HEAD ${key}`);
618
- if (res.status === 404 || res.status === 403) {
722
+ if (res.status === 404) {
619
723
  await cancelBody(res);
620
724
  return null;
621
725
  }
726
+ if (res.status === 403) {
727
+ // 403 on the signed GET (expired URL, expired signing creds, KMS or
728
+ // bucket-policy denial) is UNKNOWN state, not absence — see the presign
729
+ // denial branch above. The SDK transport throws name:"Forbidden" here;
730
+ // mirror it so both transports agree and no caller mistakes a denial
731
+ // for a missing object.
732
+ await cancelBody(res);
733
+ throw accessDeniedError(key, "presigned HEAD returned 403");
734
+ }
622
735
  if (!res.ok) {
623
736
  await cancelBody(res);
624
737
  const detail = await safeText(res);
package/src/s3.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import type { EntityContext } from "./types.js";
12
- import { resolveObjectIO, type ObjectIO } from "./object-io.js";
12
+ import { resolveObjectIO, type ObjectIO, type PutPrecondition } from "./object-io.js";
13
13
 
14
14
  // Byte/metadata transport is resolved per-call via resolveObjectIO(ctx) — the
15
15
  // default is the AWS S3 SDK over STS-vended credentials (S3SdkObjectIO), but a
@@ -427,6 +427,7 @@ export async function uploadFile(
427
427
  localPath: string,
428
428
  key: string,
429
429
  author?: UploadAuthor,
430
+ precondition?: PutPrecondition,
430
431
  ): Promise<{ etag: string }> {
431
432
  // Boundary guardrail: never store a non-POSIX key (see toPosixKey).
432
433
  key = toPosixKey(key);
@@ -438,7 +439,9 @@ export async function uploadFile(
438
439
  // send the body — putObject replays the cached headers (computed by the SAME
439
440
  // builders below, so identical). hasPrimedPut only reports true with >60s of
440
441
  // URL lifetime left, so the cache can't expire before the putObject below.
441
- if (io.hasPrimedPut?.(key)) {
442
+ // Fenced PUTs bypass the fast path: a primed URL was signed WITHOUT the
443
+ // conditional header, so replaying it would silently drop the fence.
444
+ if (!precondition && io.hasPrimedPut?.(key)) {
442
445
  const primed = await io.putObject({
443
446
  key,
444
447
  body,
@@ -469,6 +472,7 @@ export async function uploadFile(
469
472
  body,
470
473
  contentType: getMimeType(key),
471
474
  metadata: Metadata,
475
+ ...(precondition ?? {}),
472
476
  });
473
477
 
474
478
  return { etag: response.etag };
@@ -491,6 +495,7 @@ export async function uploadSymlink(
491
495
  target: string,
492
496
  key: string,
493
497
  author?: UploadAuthor,
498
+ precondition?: PutPrecondition,
494
499
  ): Promise<{ etag: string }> {
495
500
  // Boundary guardrail: never store a non-POSIX key (see toPosixKey).
496
501
  key = toPosixKey(key);
@@ -498,8 +503,9 @@ export async function uploadSymlink(
498
503
  const symlinkBody = encodeSymlinkBody(target);
499
504
 
500
505
  // Fast path: primeUploads() already signed this symlink's metadata into a
501
- // cached PUT URL — send the body, replay the cached headers.
502
- if (io.hasPrimedPut?.(key)) {
506
+ // cached PUT URL — send the body, replay the cached headers. Fenced PUTs
507
+ // bypass it — the primed URL carries no conditional header (see uploadFile).
508
+ if (!precondition && io.hasPrimedPut?.(key)) {
503
509
  const primed = await io.putObject({
504
510
  key,
505
511
  body: symlinkBody,
@@ -533,6 +539,7 @@ export async function uploadSymlink(
533
539
  body: symlinkBody,
534
540
  contentType: "application/octet-stream",
535
541
  metadata: Metadata,
542
+ ...(precondition ?? {}),
536
543
  });
537
544
 
538
545
  return { etag: response.etag };
@@ -206,6 +206,15 @@ export interface PresignKeyInput {
206
206
  contentType?: string;
207
207
  /** Custom object metadata to sign into a PUT (x-amz-meta-*). */
208
208
  metadata?: Record<string, string>;
209
+ /**
210
+ * Conditional-write fence for a PUT presign (S3 conditional writes). When
211
+ * the server supports it (hq-pro files-presign follow-up), it signs
212
+ * `If-Match: "<etag>"` / `If-None-Match: *` into the URL and echoes the
213
+ * header in the result row's `headers` for verbatim replay. Servers that
214
+ * predate the field ignore it — the PUT stays unconditional, never broken.
215
+ */
216
+ ifMatch?: string;
217
+ ifNoneMatch?: "*";
209
218
  }
210
219
 
211
220
  /** One result row from POST /v1/files/presign (per key, request order). */