@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.
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +104 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +190 -20
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +9 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/machine-auth.test.js +4 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +28 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +76 -5
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +93 -2
- package/dist/object-io.test.js.map +1 -1
- package/dist/s3.d.ts +3 -2
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +10 -5
- package/dist/s3.js.map +1 -1
- package/dist/vault-client.d.ts +9 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +245 -9
- package/src/cli/share.ts +116 -8
- package/src/cognito-auth.ts +9 -1
- package/src/machine-auth.test.ts +4 -2
- package/src/object-io.test.ts +105 -2
- package/src/object-io.ts +121 -8
- package/src/s3.ts +11 -4
- package/src/vault-client.ts +9 -0
package/src/object-io.test.ts
CHANGED
|
@@ -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("
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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?: {
|
|
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
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/vault-client.ts
CHANGED
|
@@ -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). */
|