@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.
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +330 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +16 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +229 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
- package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
- package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +188 -59
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +487 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts +8 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +17 -3
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +396 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/reindex.test.ts +47 -1
- package/src/cli/reindex.ts +17 -1
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +261 -15
- package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +594 -1
- package/src/cli/sync.ts +229 -65
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +18 -3
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
package/src/journal.test.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/machine-auth.test.ts
CHANGED
|
@@ -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({
|
|
251
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/object-io.test.ts
CHANGED
|
@@ -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(
|