@indigoai-us/hq-cloud 6.11.11 → 6.11.13
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-company.d.ts +35 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.js +290 -0
- package/dist/bin/sync-runner-company.js.map +1 -0
- package/dist/bin/sync-runner-events.d.ts +12 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.js +12 -0
- package/dist/bin/sync-runner-events.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +53 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.js +59 -0
- package/dist/bin/sync-runner-planning.js.map +1 -0
- package/dist/bin/sync-runner-rollup.d.ts +24 -0
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
- package/dist/bin/sync-runner-rollup.js +46 -0
- package/dist/bin/sync-runner-rollup.js.map +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts +5 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
- package/dist/bin/sync-runner-telemetry.js +5 -0
- package/dist/bin/sync-runner-telemetry.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-loop.js +372 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.js +74 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +5 -54
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +76 -978
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -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 +34 -17
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -5
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +75 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +45 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +320 -170
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +276 -660
- 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 +541 -748
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +382 -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/daemon-worker.d.ts +2 -2
- package/dist/daemon-worker.js +3 -3
- package/dist/daemon-worker.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 +149 -30
- 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.map +1 -1
- package/dist/personal-vault.js +8 -2
- 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 +8 -3
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +85 -16
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +213 -2
- 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 +12 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +45 -17
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +67 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync-core.d.ts +27 -0
- package/dist/sync-core.d.ts.map +1 -0
- package/dist/sync-core.js +54 -0
- package/dist/sync-core.js.map +1 -0
- 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/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +284 -36
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +59 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +38 -20
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +155 -143
- 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-company.ts +350 -0
- package/src/bin/sync-runner-events.ts +25 -0
- package/src/bin/sync-runner-planning.ts +121 -0
- package/src/bin/sync-runner-rollup.ts +72 -0
- package/src/bin/sync-runner-telemetry.ts +8 -0
- package/src/bin/sync-runner-watch-loop.ts +443 -0
- package/src/bin/sync-runner-watch-routes.ts +86 -0
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +99 -1054
- package/src/cli/reindex.test.ts +41 -3
- package/src/cli/reindex.ts +35 -19
- package/src/cli/rescue-classify-ordering.test.ts +81 -0
- package/src/cli/rescue-core.ts +400 -165
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +420 -693
- package/src/cli/sync.test.ts +460 -1
- package/src/cli/sync.ts +788 -825
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/daemon-worker.ts +3 -3
- 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 +183 -31
- 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 +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +235 -1
- package/src/remote-pull.ts +106 -18
- 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 +73 -1
- package/src/sync/push-receiver.ts +56 -20
- package/src/sync-core.ts +58 -0
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/vault-client.test.ts +74 -0
- package/src/vault-client.ts +395 -43
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +215 -174
package/src/cognito-auth.test.ts
CHANGED
|
@@ -198,6 +198,83 @@ describe("getValidAccessToken stale-pool detection", () => {
|
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Machine identity cache isolation — machine mode must not reuse human tokens
|
|
203
|
+
// from the shared Cognito cache even when the app client matches.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("machine identity cache isolation", () => {
|
|
207
|
+
it("F06: machine mode re-mints instead of reusing a cached human token", async () => {
|
|
208
|
+
const { saveCachedTokens, getValidAccessToken } = await importModule();
|
|
209
|
+
|
|
210
|
+
const cachedHumanAccessToken = makeAccessToken({
|
|
211
|
+
token_use: "access",
|
|
212
|
+
client_id: PROD_CLIENT,
|
|
213
|
+
username: "human@example.com",
|
|
214
|
+
sub: "human-sub",
|
|
215
|
+
});
|
|
216
|
+
const cachedHumanIdToken = makeAccessToken({
|
|
217
|
+
token_use: "id",
|
|
218
|
+
aud: PROD_CLIENT,
|
|
219
|
+
email: "human@example.com",
|
|
220
|
+
"cognito:username": "human@example.com",
|
|
221
|
+
sub: "human-sub",
|
|
222
|
+
});
|
|
223
|
+
saveCachedTokens({
|
|
224
|
+
...baseTokens,
|
|
225
|
+
accessToken: cachedHumanAccessToken,
|
|
226
|
+
idToken: cachedHumanIdToken,
|
|
227
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const machineUid = "agt_01JZ0000000000000000000000";
|
|
231
|
+
const machineCredsDir = path.join(tmpHome, ".hq-agent");
|
|
232
|
+
fs.mkdirSync(machineCredsDir, { recursive: true });
|
|
233
|
+
fs.writeFileSync(
|
|
234
|
+
path.join(machineCredsDir, "machine-creds.json"),
|
|
235
|
+
JSON.stringify({
|
|
236
|
+
username: "agt-01jz0000000000000000000000@agents.getindigo.ai",
|
|
237
|
+
secret: "machine-secret",
|
|
238
|
+
clientId: PROD_CLIENT,
|
|
239
|
+
region: "us-east-1",
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const mintedMachineAccessToken = makeAccessToken({
|
|
244
|
+
token_use: "access",
|
|
245
|
+
client_id: PROD_CLIENT,
|
|
246
|
+
username: "agt-01jz0000000000000000000000@agents.getindigo.ai",
|
|
247
|
+
sub: machineUid,
|
|
248
|
+
});
|
|
249
|
+
const mintedMachineIdToken = makeAccessToken({
|
|
250
|
+
token_use: "id",
|
|
251
|
+
aud: PROD_CLIENT,
|
|
252
|
+
"custom:entityType": "agent",
|
|
253
|
+
"custom:entityUid": machineUid,
|
|
254
|
+
"cognito:username": "agt-01jz0000000000000000000000@agents.getindigo.ai",
|
|
255
|
+
sub: machineUid,
|
|
256
|
+
});
|
|
257
|
+
const fetchMock = vi.fn(async () =>
|
|
258
|
+
new Response(
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
AuthenticationResult: {
|
|
261
|
+
AccessToken: mintedMachineAccessToken,
|
|
262
|
+
IdToken: mintedMachineIdToken,
|
|
263
|
+
ExpiresIn: 3600,
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
270
|
+
|
|
271
|
+
await expect(
|
|
272
|
+
getValidAccessToken(baseConfig, { interactive: false }),
|
|
273
|
+
).resolves.toBe(mintedMachineIdToken);
|
|
274
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
201
278
|
// ---------------------------------------------------------------------------
|
|
202
279
|
// Round-trip: writers emit epoch-ms, readers read epoch-ms
|
|
203
280
|
// ---------------------------------------------------------------------------
|
package/src/cognito-auth.ts
CHANGED
|
@@ -134,19 +134,80 @@ export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
|
|
|
134
134
|
* forcing a re-login is the only safe self-heal.
|
|
135
135
|
*/
|
|
136
136
|
export function decodeAccessTokenClientId(accessToken: string): string | null {
|
|
137
|
+
const claims = decodeJwtClaims(accessToken);
|
|
138
|
+
return typeof claims?.client_id === "string" ? claims.client_id : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function decodeJwtClaims(token: string): Record<string, unknown> | null {
|
|
137
142
|
try {
|
|
138
|
-
const parts =
|
|
143
|
+
const parts = token.split(".");
|
|
139
144
|
if (parts.length < 2) return null;
|
|
140
145
|
const payloadB64 = parts[1];
|
|
141
|
-
const
|
|
146
|
+
const normalized = payloadB64.replace(/-/g, "+").replace(/_/g, "/");
|
|
147
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
142
148
|
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
143
|
-
const claims = JSON.parse(json)
|
|
144
|
-
return typeof claims
|
|
149
|
+
const claims = JSON.parse(json);
|
|
150
|
+
return claims && typeof claims === "object" && !Array.isArray(claims)
|
|
151
|
+
? (claims as Record<string, unknown>)
|
|
152
|
+
: null;
|
|
145
153
|
} catch {
|
|
146
154
|
return null;
|
|
147
155
|
}
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
function cachedTokensMatchMachineIdentity(
|
|
159
|
+
tokens: CognitoTokens,
|
|
160
|
+
creds: MachineCreds,
|
|
161
|
+
expectedClientId: string,
|
|
162
|
+
): boolean {
|
|
163
|
+
const accessClaims = decodeJwtClaims(tokens.accessToken);
|
|
164
|
+
const idClaims = decodeJwtClaims(tokens.idToken);
|
|
165
|
+
if (!accessClaims || !idClaims) return false;
|
|
166
|
+
|
|
167
|
+
const stringClaim = (
|
|
168
|
+
claims: Record<string, unknown>,
|
|
169
|
+
key: string,
|
|
170
|
+
): string | null => {
|
|
171
|
+
const value = claims[key];
|
|
172
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const accessClientId = accessClaims.client_id;
|
|
176
|
+
const idAudience = idClaims.aud;
|
|
177
|
+
const accessUsername =
|
|
178
|
+
stringClaim(accessClaims, "username") ??
|
|
179
|
+
stringClaim(accessClaims, "cognito:username");
|
|
180
|
+
const accessSub = stringClaim(accessClaims, "sub");
|
|
181
|
+
const idUsername =
|
|
182
|
+
stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
|
|
183
|
+
const idSub = stringClaim(idClaims, "sub");
|
|
184
|
+
const entityType = idClaims["custom:entityType"];
|
|
185
|
+
const entityUid = idClaims["custom:entityUid"];
|
|
186
|
+
const idTokenMatchesCreds =
|
|
187
|
+
idUsername === creds.username || idSub === creds.username;
|
|
188
|
+
const subjectBindings: boolean[] = [];
|
|
189
|
+
if (accessUsername !== null && idUsername !== null) {
|
|
190
|
+
subjectBindings.push(accessUsername === idUsername);
|
|
191
|
+
}
|
|
192
|
+
if (accessSub !== null && idSub !== null) {
|
|
193
|
+
subjectBindings.push(accessSub === idSub);
|
|
194
|
+
}
|
|
195
|
+
const tokensShareSubject =
|
|
196
|
+
subjectBindings.length > 0 && subjectBindings.every(Boolean);
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
accessClaims.token_use === "access" &&
|
|
200
|
+
accessClientId === expectedClientId &&
|
|
201
|
+
idClaims.token_use === "id" &&
|
|
202
|
+
idAudience === expectedClientId &&
|
|
203
|
+
entityType === "agent" &&
|
|
204
|
+
typeof entityUid === "string" &&
|
|
205
|
+
entityUid.startsWith("agt_") &&
|
|
206
|
+
idTokenMatchesCreds &&
|
|
207
|
+
tokensShareSubject
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
150
211
|
// ---------------------------------------------------------------------------
|
|
151
212
|
// Machine identity (company agents)
|
|
152
213
|
// ---------------------------------------------------------------------------
|
|
@@ -309,17 +370,18 @@ export async function mintMachineTokens(
|
|
|
309
370
|
export async function getValidMachineTokens(
|
|
310
371
|
config: CognitoAuthConfig,
|
|
311
372
|
): Promise<CognitoTokens> {
|
|
373
|
+
const machineCreds = loadMachineCreds();
|
|
312
374
|
const cached = loadCachedTokens();
|
|
313
|
-
if (cached && !isExpiring(cached, 120)) {
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
const expectedClientId =
|
|
318
|
-
if (
|
|
375
|
+
if (machineCreds && cached && !isExpiring(cached, 120)) {
|
|
376
|
+
// Compare against the client we'd actually mint with (creds-file clientId
|
|
377
|
+
// wins over config), and require the cached ID token to prove this exact
|
|
378
|
+
// agent identity. Opaque/missing/human-shaped claims are treated as stale.
|
|
379
|
+
const expectedClientId = machineCreds.clientId ?? config.clientId;
|
|
380
|
+
if (cachedTokensMatchMachineIdentity(cached, machineCreds, expectedClientId)) {
|
|
319
381
|
return cached;
|
|
320
382
|
}
|
|
321
383
|
}
|
|
322
|
-
return mintMachineTokens(config);
|
|
384
|
+
return mintMachineTokens(config, machineCreds ?? undefined);
|
|
323
385
|
}
|
|
324
386
|
|
|
325
387
|
// ---------------------------------------------------------------------------
|
package/src/daemon-worker.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
|
|
6
6
|
* When re-enabled, this worker will need to resolve an EntityContext before
|
|
7
|
-
* constructing the
|
|
8
|
-
* context (slug or UID) and vault-service config.
|
|
7
|
+
* constructing the active watcher/runner path. The process argv will need to
|
|
8
|
+
* include company context (slug or UID) and vault-service config.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
// Day 1:
|
|
11
|
+
// Day 1: this worker still requires EntityContext-aware startup wiring.
|
|
12
12
|
// This file is retained for the automatic-sync milestone but is not functional
|
|
13
13
|
// until the daemon startup path is updated to resolve entity context.
|
|
14
14
|
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,14 @@ export {
|
|
|
62
62
|
coalescePrefixes,
|
|
63
63
|
isCoveredByAny,
|
|
64
64
|
grantPathToPrefix,
|
|
65
|
+
grantPathToScopePrefix,
|
|
66
|
+
pathToScopePrefix,
|
|
67
|
+
toScopePrefixEntries,
|
|
68
|
+
} from "./prefix-coalesce.js";
|
|
69
|
+
export type {
|
|
70
|
+
ScopePrefixEntry,
|
|
71
|
+
ScopePrefixInput,
|
|
72
|
+
ScopePrefixMatch,
|
|
65
73
|
} from "./prefix-coalesce.js";
|
|
66
74
|
|
|
67
75
|
// Scope-shrink detection + application (US-005)
|
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(
|