@indigoai-us/hq-cloud 6.14.12 → 6.14.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/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +6 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +3 -1
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +7 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +3 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +10 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +83 -33
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +93 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +23 -60
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.d.ts +2 -2
- package/dist/cli/reindex.test.js +60 -109
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue.reindex.test.js +3 -2
- package/dist/cli/rescue.reindex.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +14 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +9 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +8 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +44 -12
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +440 -76
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +174 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/machine-auth.test.js +683 -5
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +2 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +45 -9
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +77 -7
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +2 -1
- package/src/bin/sync-runner-company.ts +5 -0
- package/src/bin/sync-runner-planning.ts +2 -1
- package/src/bin/sync-runner-watch-loop.ts +7 -1
- package/src/bin/sync-runner-watch-routes.ts +2 -0
- package/src/bin/sync-runner.test.ts +122 -4
- package/src/bin/sync-runner.ts +92 -32
- package/src/cli/reindex.test.ts +63 -120
- package/src/cli/reindex.ts +23 -62
- package/src/cli/rescue.reindex.test.ts +3 -2
- package/src/cli/share.test.ts +20 -0
- package/src/cli/share.ts +7 -1
- package/src/cli/sync.test.ts +14 -0
- package/src/cli/sync.ts +5 -1
- package/src/cognito-auth.test.ts +227 -0
- package/src/cognito-auth.ts +533 -71
- package/src/index.ts +5 -1
- package/src/machine-auth.test.ts +808 -5
- package/src/vault-client.test.ts +100 -11
- package/src/vault-client.ts +55 -10
- package/test/share-sync.integration.test.ts +12 -9
package/src/vault-client.test.ts
CHANGED
|
@@ -92,8 +92,8 @@ describe("auth header injection", () => {
|
|
|
92
92
|
|
|
93
93
|
describe("error mapping", () => {
|
|
94
94
|
it("maps 401 to VaultAuthError", async () => {
|
|
95
|
-
fetchSpy.
|
|
96
|
-
jsonResponse(401, { message: "Token expired" }),
|
|
95
|
+
fetchSpy.mockImplementation(() =>
|
|
96
|
+
Promise.resolve(jsonResponse(401, { message: "Token expired" })),
|
|
97
97
|
);
|
|
98
98
|
|
|
99
99
|
await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
@@ -197,13 +197,100 @@ describe("retry behavior", () => {
|
|
|
197
197
|
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
200
|
+
// Regression: the deploy access-gate 401 of 2026-07-19. A token that expired
|
|
201
|
+
// mid-request is recoverable — callers passing a getter can mint a fresh one —
|
|
202
|
+
// but the client threw instead of retrying, so a transient auth lapse surfaced
|
|
203
|
+
// as a hard failure and the caller degraded a members-only gate to a password.
|
|
204
|
+
it("retries a 401 once when the token getter yields a fresh token", async () => {
|
|
205
|
+
const tokens = ["stale-token", "fresh-token"];
|
|
206
|
+
const getToken = vi.fn(async () => tokens.shift() ?? "fresh-token");
|
|
207
|
+
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
202
208
|
|
|
203
|
-
|
|
209
|
+
fetchSpy
|
|
210
|
+
.mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
|
|
211
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
212
|
+
|
|
213
|
+
const result = await c.listMembersOfCompany("cmp_abc");
|
|
214
|
+
expect(result).toHaveLength(1);
|
|
215
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
216
|
+
|
|
217
|
+
const authHeader = (call: number) =>
|
|
218
|
+
(fetchSpy.mock.calls[call]![1] as RequestInit).headers as Record<string, string>;
|
|
219
|
+
expect(authHeader(0).Authorization).toBe("Bearer stale-token");
|
|
220
|
+
expect(authHeader(1).Authorization).toBe("Bearer fresh-token");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not retry a 401 when the getter returns the same token", async () => {
|
|
224
|
+
const getToken = vi.fn(async () => "unchanging-token");
|
|
225
|
+
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
226
|
+
|
|
227
|
+
fetchSpy.mockResolvedValue(jsonResponse(401, { message: "Nope" }));
|
|
228
|
+
|
|
229
|
+
await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
204
230
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
205
231
|
});
|
|
206
232
|
|
|
233
|
+
// Review finding: the auth retry used to share the transient budget, so a
|
|
234
|
+
// 401 on the LAST attempt set the flag, hit `continue`, and fell out of the
|
|
235
|
+
// loop having never actually retried — the recovery silently did nothing in
|
|
236
|
+
// exactly the case it was written for.
|
|
237
|
+
it("still retries when the 401 lands on the final attempt", async () => {
|
|
238
|
+
const tokens = ["stale", "stale", "stale", "stale", "fresh"];
|
|
239
|
+
const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
|
|
240
|
+
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
241
|
+
|
|
242
|
+
// Burn the whole transient budget first (attempts 0,1,2), so the 401 lands
|
|
243
|
+
// on attempt 3 — the last one the shared-budget loop would ever run.
|
|
244
|
+
fetchSpy
|
|
245
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
246
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
247
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
248
|
+
.mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
|
|
249
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
250
|
+
|
|
251
|
+
const result = await c.listMembersOfCompany("cmp_abc");
|
|
252
|
+
expect(result).toHaveLength(1);
|
|
253
|
+
// 5th call only happens if the auth retry got its own budget.
|
|
254
|
+
expect(fetchSpy).toHaveBeenCalledTimes(5);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Review finding: a 401 consumed a transient-retry slot, so a request that
|
|
258
|
+
// hit an auth lapse had fewer retries left for genuine 5xx afterwards.
|
|
259
|
+
it("does not spend transient-retry budget on the auth retry", async () => {
|
|
260
|
+
const tokens = ["stale", "fresh"];
|
|
261
|
+
const getToken = vi.fn(async () => tokens.shift() ?? "fresh");
|
|
262
|
+
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
263
|
+
|
|
264
|
+
// 401 (free retry) then three 500s — the full transient budget must still
|
|
265
|
+
// be available, so the 4th call succeeds.
|
|
266
|
+
fetchSpy
|
|
267
|
+
.mockResolvedValueOnce(jsonResponse(401, { message: "Token expired" }))
|
|
268
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
269
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
270
|
+
.mockResolvedValueOnce(jsonResponse(500, { message: "boom" }))
|
|
271
|
+
.mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
|
|
272
|
+
|
|
273
|
+
const result = await c.listMembersOfCompany("cmp_abc");
|
|
274
|
+
expect(result).toHaveLength(1);
|
|
275
|
+
expect(fetchSpy).toHaveBeenCalledTimes(5);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("retries a 401 at most once even if the token keeps changing", async () => {
|
|
279
|
+
let n = 0;
|
|
280
|
+
const getToken = vi.fn(async () => `token-${n++}`);
|
|
281
|
+
const c = new VaultClient({ ...TEST_CONFIG, authToken: getToken });
|
|
282
|
+
|
|
283
|
+
// mockImplementation (not mockResolvedValue): each attempt needs its own
|
|
284
|
+
// Response, since the body can only be read once.
|
|
285
|
+
fetchSpy.mockImplementation(() =>
|
|
286
|
+
Promise.resolve(jsonResponse(401, { message: "Still no" })),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
await expect(c.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
|
|
290
|
+
// 1 initial + exactly 1 auth retry — never a retry storm against the authorizer
|
|
291
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
292
|
+
});
|
|
293
|
+
|
|
207
294
|
it("does not retry on 403 (non-transient)", async () => {
|
|
208
295
|
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
|
|
209
296
|
|
|
@@ -908,8 +995,10 @@ describe("listMyExplicitGrants (US-004)", () => {
|
|
|
908
995
|
});
|
|
909
996
|
|
|
910
997
|
it("maps 401 to VaultAuthError", async () => {
|
|
911
|
-
fetchSpy.
|
|
912
|
-
|
|
998
|
+
fetchSpy.mockImplementation(() =>
|
|
999
|
+
Promise.resolve(
|
|
1000
|
+
jsonResponse(401, { error: "Missing or invalid authorization token" }),
|
|
1001
|
+
),
|
|
913
1002
|
);
|
|
914
1003
|
|
|
915
1004
|
await expect(client.listMyExplicitGrants("cmp_abc")).rejects.toThrow(
|
|
@@ -980,8 +1069,8 @@ describe("getMembershipSyncConfig (US-004)", () => {
|
|
|
980
1069
|
});
|
|
981
1070
|
|
|
982
1071
|
it("maps 401 to VaultAuthError", async () => {
|
|
983
|
-
fetchSpy.
|
|
984
|
-
jsonResponse(401, { error: "Token expired" }),
|
|
1072
|
+
fetchSpy.mockImplementation(() =>
|
|
1073
|
+
Promise.resolve(jsonResponse(401, { error: "Token expired" })),
|
|
985
1074
|
);
|
|
986
1075
|
|
|
987
1076
|
await expect(
|
|
@@ -1067,8 +1156,8 @@ describe("setMembershipSyncConfig (US-004)", () => {
|
|
|
1067
1156
|
});
|
|
1068
1157
|
|
|
1069
1158
|
it("maps 401 to VaultAuthError", async () => {
|
|
1070
|
-
fetchSpy.
|
|
1071
|
-
jsonResponse(401, { error: "Token expired" }),
|
|
1159
|
+
fetchSpy.mockImplementation(() =>
|
|
1160
|
+
Promise.resolve(jsonResponse(401, { error: "Token expired" })),
|
|
1072
1161
|
);
|
|
1073
1162
|
|
|
1074
1163
|
await expect(
|
package/src/vault-client.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { ClientInfo, VaultServiceConfig } from "./types.js";
|
|
|
10
10
|
import { buildClientHeaders } from "./client-info.js";
|
|
11
11
|
import { emitCloudTelemetry } from "./telemetry-events.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
+
import { accessTokenFingerprint } from "./cognito-auth.js";
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Error classes
|
|
@@ -27,7 +28,10 @@ export class VaultClientError extends Error {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export class VaultAuthError extends VaultClientError {
|
|
30
|
-
constructor(
|
|
31
|
+
constructor(
|
|
32
|
+
message = "Authentication failed — session expired or invalid",
|
|
33
|
+
public readonly tokenFingerprint?: string,
|
|
34
|
+
) {
|
|
31
35
|
super(message, 401);
|
|
32
36
|
this.name = "VaultAuthError";
|
|
33
37
|
}
|
|
@@ -1375,15 +1379,33 @@ export class VaultClient {
|
|
|
1375
1379
|
): Promise<T> {
|
|
1376
1380
|
let lastError: Error | undefined;
|
|
1377
1381
|
const maxRetries = options.maxRetries ?? MAX_RETRIES;
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1382
|
+
// A 401 is not in isTransient() because most 401s are genuinely terminal.
|
|
1383
|
+
// But one shape of 401 is fully recoverable: the access token expired
|
|
1384
|
+
// between the caller resolving it and this request landing. Callers that
|
|
1385
|
+
// pass a getter (getValidAccessToken) can mint a fresh token on demand, so
|
|
1386
|
+
// we re-resolve once and retry IF the token actually changed. Static-string
|
|
1387
|
+
// callers get the same value back and fall through to the throw, so this
|
|
1388
|
+
// costs them nothing. Bounded to one attempt: a genuinely bad credential
|
|
1389
|
+
// must not turn into a retry storm against the authorizer.
|
|
1390
|
+
let authRetried = false;
|
|
1391
|
+
// The auth retry gets its own budget rather than borrowing from the
|
|
1392
|
+
// transient one. Sharing had two defects: a 401 early on left fewer
|
|
1393
|
+
// retries for later 5xx, and a 401 on the FINAL attempt set the flag,
|
|
1394
|
+
// hit `continue`, and fell out of the loop having never actually retried.
|
|
1395
|
+
let maxAttempts = maxRetries;
|
|
1396
|
+
// Backoff exists to let a rate limit or a struggling server recover.
|
|
1397
|
+
// Neither applies to a token swap, so the auth retry goes out immediately.
|
|
1398
|
+
let noBackoffAt = -1;
|
|
1399
|
+
|
|
1400
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
|
1401
|
+
if (attempt > 0 && attempt !== noBackoffAt) {
|
|
1381
1402
|
const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
1382
1403
|
await sleep(delay);
|
|
1383
1404
|
}
|
|
1384
1405
|
|
|
1406
|
+
const authToken = await this.getAuthToken();
|
|
1385
1407
|
const headers: Record<string, string> = {
|
|
1386
|
-
Authorization: `Bearer ${
|
|
1408
|
+
Authorization: `Bearer ${authToken}`,
|
|
1387
1409
|
Accept: "application/json",
|
|
1388
1410
|
...buildClientHeaders(this.clientInfo),
|
|
1389
1411
|
};
|
|
@@ -1410,7 +1432,7 @@ export class VaultClient {
|
|
|
1410
1432
|
res = await fetch(`${this.apiUrl}${path}`, init);
|
|
1411
1433
|
} catch (err) {
|
|
1412
1434
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1413
|
-
if (attempt <
|
|
1435
|
+
if (attempt < maxAttempts) continue;
|
|
1414
1436
|
throw lastError;
|
|
1415
1437
|
} finally {
|
|
1416
1438
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
@@ -1444,24 +1466,47 @@ export class VaultClient {
|
|
|
1444
1466
|
|
|
1445
1467
|
const responseBody = await res.text();
|
|
1446
1468
|
|
|
1469
|
+
// Expired-token 401 → re-resolve once and retry with the fresh token.
|
|
1470
|
+
// Residual gap (deliberate): if the local cache still considers the token
|
|
1471
|
+
// valid — clock skew, or a server-side revocation the client can't see —
|
|
1472
|
+
// the getter returns the same string and we fall through to the throw.
|
|
1473
|
+
// Fixing that needs a force-refresh hook on the token getter contract.
|
|
1474
|
+
if (res.status === 401 && !authRetried) {
|
|
1475
|
+
authRetried = true;
|
|
1476
|
+
const refreshed = await this.getAuthToken().catch(() => undefined);
|
|
1477
|
+
if (refreshed && refreshed !== authToken) {
|
|
1478
|
+
lastError = this.mapError(res.status, responseBody, authToken);
|
|
1479
|
+
maxAttempts++; // free retry — must not eat the transient budget
|
|
1480
|
+
noBackoffAt = attempt + 1;
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1447
1485
|
// Non-retryable errors → throw immediately
|
|
1448
1486
|
if (!isTransient(res.status)) {
|
|
1449
|
-
throw this.mapError(res.status, responseBody);
|
|
1487
|
+
throw this.mapError(res.status, responseBody, authToken);
|
|
1450
1488
|
}
|
|
1451
1489
|
|
|
1452
1490
|
// Retryable — store and loop
|
|
1453
|
-
lastError = this.mapError(res.status, responseBody);
|
|
1491
|
+
lastError = this.mapError(res.status, responseBody, authToken);
|
|
1454
1492
|
}
|
|
1455
1493
|
|
|
1456
1494
|
throw lastError ?? new VaultClientError("Request failed after retries", 500);
|
|
1457
1495
|
}
|
|
1458
1496
|
|
|
1459
|
-
private mapError(
|
|
1497
|
+
private mapError(
|
|
1498
|
+
status: number,
|
|
1499
|
+
body: string,
|
|
1500
|
+
authToken?: string,
|
|
1501
|
+
): VaultClientError {
|
|
1460
1502
|
const message = this.extractMessage(body);
|
|
1461
1503
|
|
|
1462
1504
|
switch (status) {
|
|
1463
1505
|
case 401:
|
|
1464
|
-
return new VaultAuthError(
|
|
1506
|
+
return new VaultAuthError(
|
|
1507
|
+
message,
|
|
1508
|
+
authToken ? accessTokenFingerprint(authToken) : undefined,
|
|
1509
|
+
);
|
|
1465
1510
|
case 403:
|
|
1466
1511
|
return new VaultPermissionDeniedError(message);
|
|
1467
1512
|
case 404:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration test: share/sync lifecycle (VLT-5 US-003).
|
|
2
|
+
* Integration test: laptop → vault → mini share/sync lifecycle (VLT-5 US-003).
|
|
3
3
|
*
|
|
4
4
|
* Exercises the full share/sync cycle against a real dev-stage company entity
|
|
5
5
|
* using real STS vending (VLT-3). Requires:
|
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
* - VAULT_AUTH_TOKEN env var (Cognito JWT for an active member)
|
|
8
8
|
* - VAULT_TEST_COMPANY env var (company slug with an active entity + bucket)
|
|
9
9
|
*
|
|
10
|
-
* Run: pnpm test:e2e
|
|
10
|
+
* Run: pnpm test:e2e:laptop-mini
|
|
11
11
|
*
|
|
12
12
|
* This test:
|
|
13
|
-
* 1.
|
|
14
|
-
* 2.
|
|
15
|
-
* 3.
|
|
16
|
-
* 4.
|
|
17
|
-
* 5.
|
|
13
|
+
* 1. Laptop shares a handoff into the vault
|
|
14
|
+
* 2. Mini (same membership, separate local root) syncs and receives it
|
|
15
|
+
* 3. Mini shares a finding back through the vault
|
|
16
|
+
* 4. Laptop syncs and receives it
|
|
17
|
+
* 5. Laptop keeps a local edit when Mini pushes a divergent update
|
|
18
|
+
*
|
|
19
|
+
* This deliberately exercises the vault transport only. The optional git
|
|
20
|
+
* handoff mirror is a separate subsystem and is not required for this path.
|
|
18
21
|
*
|
|
19
22
|
* Cleanup: all shared files are deleted from S3 on teardown.
|
|
20
23
|
*/
|
|
@@ -42,7 +45,7 @@ const canRun = VAULT_API_URL && VAULT_AUTH_TOKEN && VAULT_TEST_COMPANY;
|
|
|
42
45
|
|
|
43
46
|
const TEST_PREFIX = `__integration-test-${Date.now()}`;
|
|
44
47
|
|
|
45
|
-
describe.skipIf(!canRun)("share-sync integration", () => {
|
|
48
|
+
describe.skipIf(!canRun)("laptop-vault-mini share-sync integration", () => {
|
|
46
49
|
let vaultConfig: VaultServiceConfig;
|
|
47
50
|
let m1Root: string;
|
|
48
51
|
let m2Root: string;
|
|
@@ -100,7 +103,7 @@ describe.skipIf(!canRun)("share-sync integration", () => {
|
|
|
100
103
|
}
|
|
101
104
|
});
|
|
102
105
|
|
|
103
|
-
it("
|
|
106
|
+
it("moves a handoff laptop → vault → mini and a finding back", async () => {
|
|
104
107
|
await setup();
|
|
105
108
|
|
|
106
109
|
// --- Step 1: M1 shares file A ---
|