@indigoai-us/hq-cloud 6.11.10 → 6.11.12

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 (173) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +330 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/reindex.d.ts.map +1 -1
  8. package/dist/cli/reindex.js +16 -1
  9. package/dist/cli/reindex.js.map +1 -1
  10. package/dist/cli/reindex.test.js +39 -1
  11. package/dist/cli/reindex.test.js.map +1 -1
  12. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  13. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  14. package/dist/cli/rescue-core.js +229 -15
  15. package/dist/cli/rescue-core.js.map +1 -1
  16. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
  17. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
  18. package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
  19. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
  20. package/dist/cli/share.d.ts +2 -1
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +100 -32
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +30 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts +28 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +188 -59
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/cli/sync.test.js +487 -1
  31. package/dist/cli/sync.test.js.map +1 -1
  32. package/dist/cognito-auth.d.ts.map +1 -1
  33. package/dist/cognito-auth.js +55 -10
  34. package/dist/cognito-auth.js.map +1 -1
  35. package/dist/cognito-auth.test.js +61 -0
  36. package/dist/cognito-auth.test.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +93 -6
  43. package/dist/journal.js.map +1 -1
  44. package/dist/journal.test.js +59 -0
  45. package/dist/journal.test.js.map +1 -1
  46. package/dist/machine-auth.test.js +60 -2
  47. package/dist/machine-auth.test.js.map +1 -1
  48. package/dist/object-io.d.ts +37 -1
  49. package/dist/object-io.d.ts.map +1 -1
  50. package/dist/object-io.js +148 -29
  51. package/dist/object-io.js.map +1 -1
  52. package/dist/object-io.test.js +121 -0
  53. package/dist/object-io.test.js.map +1 -1
  54. package/dist/operation-lock.d.ts +8 -8
  55. package/dist/operation-lock.d.ts.map +1 -1
  56. package/dist/operation-lock.js +99 -32
  57. package/dist/operation-lock.js.map +1 -1
  58. package/dist/operation-lock.test.js +51 -4
  59. package/dist/operation-lock.test.js.map +1 -1
  60. package/dist/personal-vault.d.ts +8 -0
  61. package/dist/personal-vault.d.ts.map +1 -1
  62. package/dist/personal-vault.js +17 -3
  63. package/dist/personal-vault.js.map +1 -1
  64. package/dist/personal-vault.test.js +34 -0
  65. package/dist/personal-vault.test.js.map +1 -1
  66. package/dist/prefix-coalesce.d.ts +20 -9
  67. package/dist/prefix-coalesce.d.ts.map +1 -1
  68. package/dist/prefix-coalesce.js +124 -28
  69. package/dist/prefix-coalesce.js.map +1 -1
  70. package/dist/prefix-coalesce.test.js +57 -2
  71. package/dist/prefix-coalesce.test.js.map +1 -1
  72. package/dist/remote-pull.d.ts +6 -1
  73. package/dist/remote-pull.d.ts.map +1 -1
  74. package/dist/remote-pull.js +62 -13
  75. package/dist/remote-pull.js.map +1 -1
  76. package/dist/remote-pull.test.js +189 -0
  77. package/dist/remote-pull.test.js.map +1 -1
  78. package/dist/s3.d.ts +2 -0
  79. package/dist/s3.d.ts.map +1 -1
  80. package/dist/s3.js +197 -116
  81. package/dist/s3.js.map +1 -1
  82. package/dist/s3.test.js +109 -0
  83. package/dist/s3.test.js.map +1 -1
  84. package/dist/scope-shrink.d.ts +3 -2
  85. package/dist/scope-shrink.d.ts.map +1 -1
  86. package/dist/scope-shrink.js +1 -1
  87. package/dist/scope-shrink.js.map +1 -1
  88. package/dist/skill-telemetry.d.ts +1 -1
  89. package/dist/skill-telemetry.d.ts.map +1 -1
  90. package/dist/skill-telemetry.js +69 -9
  91. package/dist/skill-telemetry.js.map +1 -1
  92. package/dist/skill-telemetry.test.js +86 -0
  93. package/dist/skill-telemetry.test.js.map +1 -1
  94. package/dist/sync/event-sync.d.ts +6 -0
  95. package/dist/sync/event-sync.d.ts.map +1 -1
  96. package/dist/sync/event-sync.js +34 -1
  97. package/dist/sync/event-sync.js.map +1 -1
  98. package/dist/sync/event-sync.test.js +73 -0
  99. package/dist/sync/event-sync.test.js.map +1 -1
  100. package/dist/sync/metrics.d.ts +17 -1
  101. package/dist/sync/metrics.d.ts.map +1 -1
  102. package/dist/sync/metrics.js +32 -1
  103. package/dist/sync/metrics.js.map +1 -1
  104. package/dist/sync/metrics.test.js +74 -1
  105. package/dist/sync/metrics.test.js.map +1 -1
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +15 -7
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/sync/push-receiver.d.ts +6 -5
  110. package/dist/sync/push-receiver.d.ts.map +1 -1
  111. package/dist/sync/push-receiver.js +13 -15
  112. package/dist/sync/push-receiver.js.map +1 -1
  113. package/dist/sync/push-receiver.test.js +36 -1
  114. package/dist/sync/push-receiver.test.js.map +1 -1
  115. package/dist/telemetry.d.ts +1 -1
  116. package/dist/telemetry.d.ts.map +1 -1
  117. package/dist/telemetry.js +59 -6
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/telemetry.test.js +74 -0
  120. package/dist/telemetry.test.js.map +1 -1
  121. package/dist/types.d.ts +8 -0
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/watcher.d.ts +36 -0
  124. package/dist/watcher.d.ts.map +1 -1
  125. package/dist/watcher.js +152 -30
  126. package/dist/watcher.js.map +1 -1
  127. package/dist/watcher.test.js +103 -0
  128. package/dist/watcher.test.js.map +1 -1
  129. package/package.json +1 -1
  130. package/src/bin/sync-runner.test.ts +396 -11
  131. package/src/bin/sync-runner.ts +254 -52
  132. package/src/cli/reindex.test.ts +47 -1
  133. package/src/cli/reindex.ts +17 -1
  134. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  135. package/src/cli/rescue-core.ts +261 -15
  136. package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
  137. package/src/cli/share.test.ts +38 -0
  138. package/src/cli/share.ts +103 -34
  139. package/src/cli/sync.test.ts +594 -1
  140. package/src/cli/sync.ts +229 -65
  141. package/src/cognito-auth.test.ts +77 -0
  142. package/src/cognito-auth.ts +73 -11
  143. package/src/index.ts +8 -0
  144. package/src/journal.test.ts +72 -0
  145. package/src/journal.ts +95 -8
  146. package/src/machine-auth.test.ts +64 -2
  147. package/src/object-io.test.ts +142 -0
  148. package/src/object-io.ts +182 -30
  149. package/src/operation-lock.test.ts +63 -4
  150. package/src/operation-lock.ts +99 -31
  151. package/src/personal-vault.test.ts +42 -0
  152. package/src/personal-vault.ts +18 -3
  153. package/src/prefix-coalesce.test.ts +71 -1
  154. package/src/prefix-coalesce.ts +155 -30
  155. package/src/remote-pull.test.ts +205 -0
  156. package/src/remote-pull.ts +77 -14
  157. package/src/s3.test.ts +126 -0
  158. package/src/s3.ts +237 -122
  159. package/src/scope-shrink.ts +6 -3
  160. package/src/skill-telemetry.test.ts +109 -0
  161. package/src/skill-telemetry.ts +82 -14
  162. package/src/sync/event-sync.test.ts +75 -0
  163. package/src/sync/event-sync.ts +54 -1
  164. package/src/sync/metrics.test.ts +81 -0
  165. package/src/sync/metrics.ts +59 -4
  166. package/src/sync/pull-scope.ts +23 -7
  167. package/src/sync/push-receiver.test.ts +38 -1
  168. package/src/sync/push-receiver.ts +15 -18
  169. package/src/telemetry.test.ts +85 -0
  170. package/src/telemetry.ts +69 -6
  171. package/src/types.ts +8 -0
  172. package/src/watcher.test.ts +117 -0
  173. package/src/watcher.ts +209 -33
@@ -115,6 +115,58 @@ describe("journal", () => {
115
115
  expect(roundTripped.files).toEqual(original.files);
116
116
  expect(roundTripped.pulls).toEqual([]);
117
117
  });
118
+
119
+ it("F08: recovers the last good journal when the primary JSON is torn", () => {
120
+ const lastGood: SyncJournal = {
121
+ version: "2",
122
+ lastSync: "2026-06-18T00:00:00.000Z",
123
+ files: {
124
+ "docs/stable.md": {
125
+ hash: "last-good-hash",
126
+ size: 128,
127
+ syncedAt: "2026-06-18T00:00:00.000Z",
128
+ direction: "down",
129
+ },
130
+ },
131
+ pulls: [],
132
+ };
133
+ writeJournal("indigo", lastGood);
134
+
135
+ // Simulate a torn direct write of the primary journal file. The next
136
+ // read must use the durable last-good copy rather than aborting sync.
137
+ fs.writeFileSync(getJournalPath("indigo"), '{"version":"2","files":');
138
+
139
+ const recovered = readJournal("indigo");
140
+ expect(recovered.version).toBe("2");
141
+ expect(recovered.lastSync).toBe(lastGood.lastSync);
142
+ expect(recovered.files["docs/stable.md"]).toEqual(
143
+ lastGood.files["docs/stable.md"],
144
+ );
145
+ expect(recovered.pulls).toEqual([]);
146
+ });
147
+
148
+ it("R-F08: surfaces primary IO errors instead of masking them with last-good", () => {
149
+ const lastGood: SyncJournal = {
150
+ version: "2",
151
+ lastSync: "2026-06-18T00:00:00.000Z",
152
+ files: {
153
+ "docs/stale.md": {
154
+ hash: "stale-hash",
155
+ size: 128,
156
+ syncedAt: "2026-06-18T00:00:00.000Z",
157
+ direction: "down",
158
+ },
159
+ },
160
+ pulls: [],
161
+ };
162
+ writeJournal("indigo", lastGood);
163
+
164
+ const journalPath = getJournalPath("indigo");
165
+ fs.unlinkSync(journalPath);
166
+ fs.mkdirSync(journalPath);
167
+
168
+ expect(() => readJournal("indigo")).toThrow(/EISDIR|directory/i);
169
+ });
118
170
  });
119
171
 
120
172
  describe("writeJournal", () => {
@@ -754,6 +806,26 @@ describe("journal", () => {
754
806
  expect(all.map((j) => j.slug)).toEqual(["marshalops"]);
755
807
  });
756
808
 
809
+ it("R-F08: recovers a torn shard primary from .last-good in listJournals", () => {
810
+ seed("marshalops", {
811
+ "k/recovered.md": {
812
+ hash: "last-good-hash",
813
+ size: 2,
814
+ syncedAt: "2026-06-19T11:00:00.000Z",
815
+ direction: "down",
816
+ },
817
+ });
818
+ fs.writeFileSync(getJournalPath("marshalops"), "");
819
+
820
+ const all = listJournals();
821
+
822
+ expect(all).toHaveLength(1);
823
+ expect(all[0]!.slug).toBe("marshalops");
824
+ expect(all[0]!.journal.files["k/recovered.md"]?.hash).toBe(
825
+ "last-good-hash",
826
+ );
827
+ });
828
+
757
829
  it("ignores unrelated files in the state dir", () => {
758
830
  seed("marshalops", {});
759
831
  fs.writeFileSync(path.join(stateDir, "config.json"), "{}");
package/src/journal.ts CHANGED
@@ -28,6 +28,7 @@ export const JOURNAL_VERSION_CURRENT = "2" as const;
28
28
 
29
29
  const JOURNAL_FILE_PREFIX = "sync-journal.";
30
30
  const JOURNAL_FILE_SUFFIX = ".json";
31
+ const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
31
32
 
32
33
  /**
33
34
  * Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
@@ -129,12 +130,40 @@ export function migratePersonalVaultJournal(): void {
129
130
  export function readJournal(slug: string): SyncJournal {
130
131
  const journalPath = getJournalPath(slug);
131
132
  if (fs.existsSync(journalPath)) {
132
- const content = fs.readFileSync(journalPath, "utf-8");
133
- return JSON.parse(content) as SyncJournal;
133
+ return readJournalFileWithLastGood(journalPath);
134
134
  }
135
135
  return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
136
136
  }
137
137
 
138
+ function parseJournalContent(content: string): SyncJournal {
139
+ if (content.length === 0) {
140
+ throw new SyntaxError("journal JSON is empty");
141
+ }
142
+ return JSON.parse(content) as SyncJournal;
143
+ }
144
+
145
+ function isCorruptJournalJson(err: unknown): boolean {
146
+ return err instanceof SyntaxError;
147
+ }
148
+
149
+ function readJournalFileWithLastGood(journalPath: string): SyncJournal {
150
+ let primaryErr: unknown;
151
+ try {
152
+ return parseJournalContent(fs.readFileSync(journalPath, "utf-8"));
153
+ } catch (err) {
154
+ if (!isCorruptJournalJson(err)) throw err;
155
+ primaryErr = err;
156
+ }
157
+
158
+ try {
159
+ return parseJournalContent(
160
+ fs.readFileSync(lastGoodJournalPath(journalPath), "utf-8"),
161
+ );
162
+ } catch {
163
+ throw primaryErr;
164
+ }
165
+ }
166
+
138
167
  /** One enumerated journal shard: its recovered slug, on-disk path, contents. */
139
168
  export interface JournalSummary {
140
169
  /**
@@ -189,12 +218,12 @@ export function listJournals(): JournalSummary[] {
189
218
  if (!slug) continue; // guard against a stray "sync-journal..json"
190
219
  const filePath = path.join(dir, name);
191
220
  try {
192
- const journal = JSON.parse(
193
- fs.readFileSync(filePath, "utf-8"),
194
- ) as SyncJournal;
221
+ const journal = readJournalFileWithLastGood(filePath);
195
222
  out.push({ slug, path: filePath, journal });
196
- } catch {
197
- // Corrupt/unreadable shard — skip; don't blind the caller to the rest.
223
+ } catch (err) {
224
+ if (!isCorruptJournalJson(err)) throw err;
225
+ // Corrupt shard with no usable last-good — skip; don't blind the caller
226
+ // to the rest. Genuine IO/permission failures surface to the caller.
198
227
  }
199
228
  }
200
229
  out.sort((a, b) => a.slug.localeCompare(b.slug));
@@ -269,7 +298,65 @@ export function writeJournal(slug: string, journal: SyncJournal): void {
269
298
  migrateToV2(journal);
270
299
  const journalPath = getJournalPath(slug);
271
300
  fs.mkdirSync(path.dirname(journalPath), { recursive: true });
272
- fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
301
+ const content = JSON.stringify(journal, null, 2);
302
+ atomicWriteFileSync(journalPath, content);
303
+ atomicWriteFileSync(lastGoodJournalPath(journalPath), content);
304
+ }
305
+
306
+ function lastGoodJournalPath(journalPath: string): string {
307
+ return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
308
+ }
309
+
310
+ function atomicWriteFileSync(filePath: string, content: string): void {
311
+ const dir = path.dirname(filePath);
312
+ const tmpPath = path.join(
313
+ dir,
314
+ `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${crypto
315
+ .randomBytes(6)
316
+ .toString("hex")}.tmp`,
317
+ );
318
+ let fd: number | undefined;
319
+ try {
320
+ fd = fs.openSync(tmpPath, "wx");
321
+ fs.writeFileSync(fd, content);
322
+ fs.fsyncSync(fd);
323
+ fs.closeSync(fd);
324
+ fd = undefined;
325
+ fs.renameSync(tmpPath, filePath);
326
+ fsyncDirSync(dir);
327
+ } catch (err) {
328
+ if (fd !== undefined) {
329
+ try {
330
+ fs.closeSync(fd);
331
+ } catch {
332
+ /* ignore close failure while surfacing the original write error */
333
+ }
334
+ }
335
+ try {
336
+ fs.rmSync(tmpPath, { force: true });
337
+ } catch {
338
+ /* best-effort cleanup */
339
+ }
340
+ throw err;
341
+ }
342
+ }
343
+
344
+ function fsyncDirSync(dir: string): void {
345
+ let fd: number | undefined;
346
+ try {
347
+ fd = fs.openSync(dir, "r");
348
+ fs.fsyncSync(fd);
349
+ } catch {
350
+ // Directory fsync is unsupported on some platforms/filesystems.
351
+ } finally {
352
+ if (fd !== undefined) {
353
+ try {
354
+ fs.closeSync(fd);
355
+ } catch {
356
+ /* ignore */
357
+ }
358
+ }
359
+ }
273
360
  }
274
361
 
275
362
  export function hashFile(filePath: string): string {
@@ -246,9 +246,22 @@ describe("getValidMachineTokens", () => {
246
246
  writeCreds();
247
247
  const { fetchMock } = stubMintFetch();
248
248
  const { saveCachedTokens, getValidMachineTokens } = await importModule();
249
+ // A valid machine cache must prove this exact agent identity (F06): the
250
+ // cached tokens carry the agent claims that distinguish a machine token
251
+ // from a same-client human token, so cache reuse is safe and skips the wire.
249
252
  const cached = {
250
- accessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
251
- idToken: fakeJwt({ token_use: "id" }),
253
+ accessToken: fakeJwt({
254
+ token_use: "access",
255
+ client_id: CONFIG.clientId,
256
+ username: "machine-agt_01TEST",
257
+ }),
258
+ idToken: fakeJwt({
259
+ token_use: "id",
260
+ aud: CONFIG.clientId,
261
+ "custom:entityType": "agent",
262
+ "custom:entityUid": "agt_01TEST",
263
+ "cognito:username": "machine-agt_01TEST",
264
+ }),
252
265
  refreshToken: "",
253
266
  expiresAt: Date.now() + 30 * 60 * 1000,
254
267
  tokenType: "Bearer" as const,
@@ -291,6 +304,55 @@ describe("getValidMachineTokens", () => {
291
304
  await getValidMachineTokens(CONFIG);
292
305
  expect(fetchMock).toHaveBeenCalledTimes(1);
293
306
  });
307
+
308
+ it("R-F06: re-mints when cached access and ID tokens belong to different agents", async () => {
309
+ writeCreds();
310
+ const freshAccess = fakeJwt({
311
+ token_use: "access",
312
+ client_id: CONFIG.clientId,
313
+ username: "machine-agt_01TEST",
314
+ sub: "sub-agent-1",
315
+ });
316
+ const freshId = fakeJwt({
317
+ token_use: "id",
318
+ aud: CONFIG.clientId,
319
+ "custom:entityType": "agent",
320
+ "custom:entityUid": "agt_01TEST",
321
+ "cognito:username": "machine-agt_01TEST",
322
+ sub: "sub-agent-1",
323
+ });
324
+ const { fetchMock } = stubMintFetch({
325
+ AccessToken: freshAccess,
326
+ IdToken: freshId,
327
+ });
328
+ const { saveCachedTokens, getValidAccessToken, loadCachedTokens } =
329
+ await importModule();
330
+ saveCachedTokens({
331
+ accessToken: fakeJwt({
332
+ token_use: "access",
333
+ client_id: CONFIG.clientId,
334
+ username: "machine-agt_01TEST",
335
+ sub: "sub-agent-1",
336
+ }),
337
+ idToken: fakeJwt({
338
+ token_use: "id",
339
+ aud: CONFIG.clientId,
340
+ "custom:entityType": "agent",
341
+ "custom:entityUid": "agt_OTHER",
342
+ "cognito:username": "machine-agt_OTHER",
343
+ sub: "sub-agent-2",
344
+ }),
345
+ refreshToken: "",
346
+ expiresAt: Date.now() + 30 * 60 * 1000,
347
+ tokenType: "Bearer",
348
+ });
349
+
350
+ const token = await getValidAccessToken(CONFIG, { interactive: false });
351
+
352
+ expect(fetchMock).toHaveBeenCalledTimes(1);
353
+ expect(token).toBe(freshId);
354
+ expect(loadCachedTokens()?.idToken).toBe(freshId);
355
+ });
294
356
  });
295
357
 
296
358
  // ---------------------------------------------------------------------------
@@ -215,6 +215,92 @@ describe("PresignObjectIO.putObject", () => {
215
215
  ).rejects.toThrow(/presigned PUT failed for k: 403/);
216
216
  });
217
217
 
218
+ it("F01: fails closed when a fenced PUT presign omits If-Match", async () => {
219
+ const previous = process.env.HQ_PRESIGN_FENCE_STRICT;
220
+ process.env.HQ_PRESIGN_FENCE_STRICT = "true";
221
+ try {
222
+ const { vault, setPresign } = makeVault();
223
+ setPresign([
224
+ {
225
+ key: "stale.md",
226
+ op: "put",
227
+ url: "https://s3/unfenced-put",
228
+ headers: { "content-type": "text/markdown" },
229
+ },
230
+ ]);
231
+ fetchMock.mockResolvedValue(
232
+ new Response(null, { status: 200, headers: { etag: '"v2"' } }),
233
+ );
234
+
235
+ const io = new PresignObjectIO(vault, COMPANY);
236
+ await expect(
237
+ io.putObject({
238
+ key: "stale.md",
239
+ body: Buffer.from("old bytes"),
240
+ contentType: "text/markdown",
241
+ ifMatch: '"v1"',
242
+ }),
243
+ ).rejects.toThrow(/if-match|condition|precondition/i);
244
+ expect(fetchMock).not.toHaveBeenCalled();
245
+ } finally {
246
+ if (previous === undefined) {
247
+ delete process.env.HQ_PRESIGN_FENCE_STRICT;
248
+ } else {
249
+ process.env.HQ_PRESIGN_FENCE_STRICT = previous;
250
+ }
251
+ }
252
+ });
253
+
254
+ it("F01: does not satisfy a fenced PUT with an unfenced primed URL", async () => {
255
+ const presignCalls: Array<Parameters<PresignTransportClient["presign"]>[0]> = [];
256
+ const vault: PresignTransportClient = {
257
+ presign: async (input) => {
258
+ presignCalls.push(input);
259
+ const isFenced = input.keys.some((k) => k.ifNoneMatch === "*");
260
+ return {
261
+ results: input.keys.map((k): PresignResultRow => ({
262
+ key: k.key,
263
+ op: k.op ?? input.op ?? "put",
264
+ url: isFenced ? "https://s3/fenced-put" : "https://s3/unfenced-put",
265
+ headers: isFenced
266
+ ? { "content-type": "text/plain", "if-none-match": "*" }
267
+ : { "content-type": "text/plain" },
268
+ expiresIn: 900,
269
+ })),
270
+ expiresAt: "2099-01-01T00:00:00.000Z",
271
+ };
272
+ },
273
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
274
+ };
275
+ fetchMock.mockResolvedValue(
276
+ new Response(null, { status: 200, headers: { etag: '"created"' } }),
277
+ );
278
+
279
+ const io = new PresignObjectIO(vault, COMPANY);
280
+ await io.prime("put", [{ key: "new.md", op: "put", contentType: "text/plain" }]);
281
+ const afterPrime = presignCalls.length;
282
+
283
+ await io.putObject({
284
+ key: "new.md",
285
+ body: Buffer.from("new bytes"),
286
+ contentType: "text/plain",
287
+ ifNoneMatch: "*",
288
+ });
289
+
290
+ expect(presignCalls).toHaveLength(afterPrime + 1);
291
+ expect(presignCalls[afterPrime].keys[0]).toMatchObject({
292
+ key: "new.md",
293
+ op: "put",
294
+ ifNoneMatch: "*",
295
+ });
296
+ const [url, init] = fetchMock.mock.calls[0];
297
+ expect(url).toBe("https://s3/fenced-put");
298
+ expect(init.headers).toEqual({
299
+ "content-type": "text/plain",
300
+ "if-none-match": "*",
301
+ });
302
+ });
303
+
218
304
  it("forwards the conditional-write fence on the presign request (ifMatch stripped, ifNoneMatch verbatim)", async () => {
219
305
  // The server signs If-Match/If-None-Match into the URL (hq-pro follow-up)
220
306
  // and echoes the headers for replay; the client's job is to ASK. Servers
@@ -530,6 +616,62 @@ describe("PresignObjectIO.prime — batch URL cache", () => {
530
616
  expect(fetchMock.mock.calls[0][0]).toBe("https://signed/get/f0");
531
617
  });
532
618
 
619
+ it("F21: consumes primed GET URLs instead of retaining them", async () => {
620
+ const { vault, calls } = makeEchoVault();
621
+ const io = new PresignObjectIO(vault, COMPANY);
622
+ await io.prime("get", [{ key: "single" }]);
623
+ const afterPrime = calls();
624
+
625
+ await io.getObject("single");
626
+ expect(calls()).toBe(afterPrime);
627
+
628
+ await io.getObject("single");
629
+ expect(calls()).toBe(afterPrime + 1);
630
+ expect(fetchMock).toHaveBeenCalledTimes(2);
631
+ });
632
+
633
+ it("R-F21: reuses a primed GET for later HEAD and clears stale primed PUT on fenced PUT", async () => {
634
+ fetchMock.mockImplementation(async () => (
635
+ new Response(Buffer.from("body"), {
636
+ status: 200,
637
+ headers: {
638
+ etag: '"etag-1"',
639
+ "content-length": "4",
640
+ "last-modified": "Wed, 01 Jan 2026 00:00:00 GMT",
641
+ },
642
+ })
643
+ ));
644
+ const { vault, calls } = makeEchoVault();
645
+ const io = new PresignObjectIO(vault, COMPANY);
646
+
647
+ await io.prime("get", [{ key: "doc.md" }]);
648
+ const afterGetPrime = calls();
649
+ const got = await io.getObject("doc.md");
650
+ expect(got.body.toString("utf-8")).toBe("body");
651
+
652
+ const head = await io.headObject("doc.md");
653
+ expect(head?.etag).toBe("etag-1");
654
+ expect(calls()).toBe(afterGetPrime);
655
+ expect(fetchMock.mock.calls[0][0]).toBe("https://signed/get/doc.md");
656
+ expect(fetchMock.mock.calls[1][0]).toBe("https://signed/get/doc.md");
657
+
658
+ await io.prime("put", [
659
+ { key: "stale.md", op: "put", contentType: "text/plain" },
660
+ ]);
661
+ expect(io.hasPrimedPut("stale.md")).toBe(true);
662
+ const afterPutPrime = calls();
663
+
664
+ await io.putObject({
665
+ key: "stale.md",
666
+ body: Buffer.from("new body"),
667
+ contentType: "text/plain",
668
+ ifMatch: "old-etag",
669
+ });
670
+
671
+ expect(calls()).toBe(afterPutPrime + 1);
672
+ expect(io.hasPrimedPut("stale.md")).toBe(false);
673
+ });
674
+
533
675
  it("primed GET cache also serves headObject (HEAD reuses GET URLs)", async () => {
534
676
  const { vault, calls } = makeEchoVault();
535
677
  fetchMock.mockResolvedValue(