@indigoai-us/hq-cloud 6.11.5 → 6.11.7
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 +16 -16
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +51 -41
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +108 -33
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +23 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +54 -0
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +142 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +16 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +1 -62
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/tombstones.d.ts +43 -0
- package/dist/cli/tombstones.d.ts.map +1 -0
- package/dist/cli/tombstones.js +78 -0
- package/dist/cli/tombstones.js.map +1 -0
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +57 -17
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +113 -1
- package/dist/context.test.js.map +1 -1
- package/dist/entity-resolver.test.js +3 -3
- package/dist/entity-resolver.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +10 -0
- package/dist/object-io.js.map +1 -1
- package/dist/personal-vault.d.ts +36 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +89 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +143 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/signals/get.d.ts.map +1 -1
- package/dist/signals/get.js +7 -11
- package/dist/signals/get.js.map +1 -1
- package/dist/signals/get.test.js +65 -1
- package/dist/signals/get.test.js.map +1 -1
- package/dist/signals/internals.d.ts +47 -3
- package/dist/signals/internals.d.ts.map +1 -1
- package/dist/signals/internals.js +110 -4
- package/dist/signals/internals.js.map +1 -1
- package/dist/signals/list.d.ts.map +1 -1
- package/dist/signals/list.js +16 -23
- package/dist/signals/list.js.map +1 -1
- package/dist/signals/list.test.js +84 -1
- package/dist/signals/list.test.js.map +1 -1
- package/dist/signals/types.d.ts +18 -1
- package/dist/signals/types.d.ts.map +1 -1
- package/dist/sources/get.d.ts.map +1 -1
- package/dist/sources/get.js +10 -22
- package/dist/sources/get.js.map +1 -1
- package/dist/sources/get.test.js +85 -1
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/internals.d.ts +50 -3
- package/dist/sources/internals.d.ts.map +1 -1
- package/dist/sources/internals.js +113 -4
- package/dist/sources/internals.js.map +1 -1
- package/dist/sources/list.d.ts.map +1 -1
- package/dist/sources/list.js +16 -23
- package/dist/sources/list.js.map +1 -1
- package/dist/sources/list.test.js +101 -1
- package/dist/sources/list.test.js.map +1 -1
- package/dist/sources/types.d.ts +18 -1
- package/dist/sources/types.d.ts.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -7
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +6 -7
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/types.d.ts +33 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +20 -0
- package/dist/version.js.map +1 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +22 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +29 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +131 -41
- package/src/bin/sync-runner.ts +56 -48
- package/src/cli/share.test.ts +169 -0
- package/src/cli/share.ts +81 -0
- package/src/cli/sync.ts +21 -88
- package/src/cli/tombstones.ts +106 -0
- package/src/context.test.ts +139 -1
- package/src/context.ts +59 -17
- package/src/entity-resolver.test.ts +3 -3
- package/src/index.ts +2 -0
- package/src/object-io.ts +12 -0
- package/src/personal-vault.test.ts +175 -0
- package/src/personal-vault.ts +86 -1
- package/src/signals/get.test.ts +83 -1
- package/src/signals/get.ts +9 -13
- package/src/signals/internals.ts +153 -4
- package/src/signals/list.test.ts +114 -1
- package/src/signals/list.ts +16 -29
- package/src/signals/types.ts +18 -1
- package/src/sources/get.test.ts +104 -1
- package/src/sources/get.ts +12 -24
- package/src/sources/internals.ts +156 -4
- package/src/sources/list.test.ts +132 -1
- package/src/sources/list.ts +16 -29
- package/src/sources/types.ts +18 -1
- package/src/sync/event-sync.ts +6 -7
- package/src/types.ts +33 -3
- package/src/version.ts +24 -0
- package/src/watcher.test.ts +41 -0
- package/src/watcher.ts +24 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared FILE_TOMBSTONE fetch used by BOTH the pull planner (`sync.ts`) and the
|
|
3
|
+
* push planner (`share.ts`).
|
|
4
|
+
*
|
|
5
|
+
* A FILE_TOMBSTONE is the authoritative record of an intentional, scoped delete
|
|
6
|
+
* (`hq files delete <prefix>` → `POST /v1/files/delete` → `recordDeletions`).
|
|
7
|
+
* Both sync legs consult it so a deleted key is neither re-downloaded (pull) nor
|
|
8
|
+
* re-uploaded (push) and therefore cannot be resurrected by a behind peer.
|
|
9
|
+
*
|
|
10
|
+
* Lives in its own module so `share.ts` can use the fetch without importing from
|
|
11
|
+
* `sync.ts` — `sync.ts` already imports values from `share.ts`
|
|
12
|
+
* (`isEphemeralPath`, `isMalformedVaultKey`), so the reverse value-import would
|
|
13
|
+
* create a runtime ESM cycle.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { VaultServiceConfig } from "../types.js";
|
|
17
|
+
import { toPosixKey } from "../s3.js";
|
|
18
|
+
|
|
19
|
+
/** Timeout for the best-effort FILE_TOMBSTONE fetch (GET /v1/files/tombstones). */
|
|
20
|
+
export const FETCH_TOMBSTONES_TIMEOUT_MS = 5000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A FILE_TOMBSTONE as the planners need it: the deleted key + when it was
|
|
24
|
+
* deleted. The `deletedAt` timestamp is the decisive precedence signal on the
|
|
25
|
+
* pull side — a remote object newer than it is a genuine re-create (sync it), an
|
|
26
|
+
* object at or older than it is a stale resurrection of a deleted key (suppress
|
|
27
|
+
* it).
|
|
28
|
+
*/
|
|
29
|
+
export interface CompanyTombstone {
|
|
30
|
+
deletedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fetch the company's FILE_TOMBSTONE rows from hq-pro (GET /v1/files/tombstones)
|
|
35
|
+
* and return them as a POSIX-keyed map the planners consult to avoid
|
|
36
|
+
* resurrecting an intentionally-deleted object (delete-resync). The endpoint is
|
|
37
|
+
* ACL-filtered server-side, so the map only ever contains keys this caller can
|
|
38
|
+
* read — exactly the keys that can appear in the (STS-scoped) remote LIST.
|
|
39
|
+
*
|
|
40
|
+
* Best-effort and bounded by a 5s timeout: a tombstone read that fails, times
|
|
41
|
+
* out, or returns non-2xx degrades to an EMPTY map — i.e. to the pre-fix
|
|
42
|
+
* behavior (no suppression). That is the safe failure direction: a missed
|
|
43
|
+
* tombstone re-pulls/re-pushes a deleted file (a known, recoverable annoyance),
|
|
44
|
+
* whereas a spurious tombstone would HIDE a file the user wants. The failure is
|
|
45
|
+
* logged (never silently swallowed) so a persistently-degraded read is visible.
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchCompanyTombstones(
|
|
48
|
+
vaultConfig: VaultServiceConfig,
|
|
49
|
+
companyUid: string,
|
|
50
|
+
): Promise<Map<string, CompanyTombstone>> {
|
|
51
|
+
const out = new Map<string, CompanyTombstone>();
|
|
52
|
+
try {
|
|
53
|
+
const token =
|
|
54
|
+
typeof vaultConfig.authToken === "function"
|
|
55
|
+
? await vaultConfig.authToken()
|
|
56
|
+
: vaultConfig.authToken;
|
|
57
|
+
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
58
|
+
const url = `${base}/v1/files/tombstones?company=${encodeURIComponent(
|
|
59
|
+
companyUid,
|
|
60
|
+
)}`;
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(
|
|
63
|
+
() => controller.abort(),
|
|
64
|
+
FETCH_TOMBSTONES_TIMEOUT_MS,
|
|
65
|
+
);
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(url, {
|
|
68
|
+
method: "GET",
|
|
69
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
// Non-2xx is non-fatal: log and degrade to no-suppression. A 404 means
|
|
74
|
+
// the endpoint is not deployed yet (hq-pro release lag) — the sync
|
|
75
|
+
// proceeds with the legacy behavior until it lands.
|
|
76
|
+
console.error(
|
|
77
|
+
`[hq-sync] tombstone fetch returned ${res.status} (degrading to no-suppression)`,
|
|
78
|
+
);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
const body = (await res.json()) as {
|
|
82
|
+
tombstones?: Array<{ key?: string; deletedAt?: string }>;
|
|
83
|
+
};
|
|
84
|
+
for (const t of body.tombstones ?? []) {
|
|
85
|
+
if (typeof t.key === "string" && typeof t.deletedAt === "string") {
|
|
86
|
+
out.set(toPosixKey(t.key), { deletedAt: t.deletedAt });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Best-effort: a failed tombstone read must never break the sync. Log
|
|
94
|
+
// (policy: never silently swallow) and degrade to no-suppression.
|
|
95
|
+
try {
|
|
96
|
+
console.error(
|
|
97
|
+
`[hq-sync] tombstone fetch failed (non-fatal, degrading to no-suppression): ${
|
|
98
|
+
err instanceof Error ? err.message : String(err)
|
|
99
|
+
}`,
|
|
100
|
+
);
|
|
101
|
+
} catch {
|
|
102
|
+
// swallow — logging must never break sync
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
package/src/context.test.ts
CHANGED
|
@@ -119,7 +119,7 @@ describe("resolveEntityContext", () => {
|
|
|
119
119
|
|
|
120
120
|
expect(ctx.uid).toBe("cmp_01ABCDEF");
|
|
121
121
|
expect(ctx.bucketName).toBe("hq-vault-acme-123");
|
|
122
|
-
expect(ctx.credentials
|
|
122
|
+
expect(ctx.credentials?.accessKeyId).toBe("ASIA_TEST_KEY");
|
|
123
123
|
expect(ctx.region).toBe("us-east-1");
|
|
124
124
|
|
|
125
125
|
// Verify entity lookup used the new per-user-namespace endpoint
|
|
@@ -327,6 +327,144 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
327
327
|
});
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// HQ-59 company vend-skip (presign transport)
|
|
332
|
+
//
|
|
333
|
+
// When the run uses the presigned-URL transport for company vaults
|
|
334
|
+
// (`companyVaultUsesPresign`), resolveEntityContext must NOT call the company
|
|
335
|
+
// `/sts/vend` route — the presign transport needs no STS creds, and the mere
|
|
336
|
+
// presence of a cmp_ vend call is the pre-6.11.6 signal the hq-pro min-version
|
|
337
|
+
// gate denies on. Personal vaults must still vend self.
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
describe("HQ-59 company vend-skip (companyVaultUsesPresign)", () => {
|
|
340
|
+
const presignConfig: VaultServiceConfig = {
|
|
341
|
+
...mockConfig,
|
|
342
|
+
companyVaultUsesPresign: true,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
beforeEach(() => {
|
|
346
|
+
clearContextCache();
|
|
347
|
+
vi.restoreAllMocks();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("cmp_* UID: SKIPS /sts/vend, returns no credentials + a far-future expiry", async () => {
|
|
351
|
+
const calls: string[] = [];
|
|
352
|
+
vi.stubGlobal(
|
|
353
|
+
"fetch",
|
|
354
|
+
vi.fn().mockImplementation(async (url: string) => {
|
|
355
|
+
const u = String(url);
|
|
356
|
+
calls.push(u);
|
|
357
|
+
if (u.includes("/entity/cmp_")) {
|
|
358
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
359
|
+
}
|
|
360
|
+
// If a vend ever fires, surface it as a hard failure so the test fails
|
|
361
|
+
// LOUDLY rather than silently allowing the route the gate watches.
|
|
362
|
+
if (u.includes("/sts/vend")) {
|
|
363
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
364
|
+
}
|
|
365
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const ctx = await resolveEntityContext("cmp_01ABCDEF", presignConfig);
|
|
370
|
+
|
|
371
|
+
// The load-bearing assertion: NO company vend route was hit.
|
|
372
|
+
expect(calls.some((u) => u.includes("/sts/vend"))).toBe(false);
|
|
373
|
+
expect(calls.some((u) => u.includes("/entity/cmp_01ABCDEF"))).toBe(true);
|
|
374
|
+
// Credential-less context with a sentinel far-future expiry → never re-vends.
|
|
375
|
+
expect(ctx.credentials).toBeUndefined();
|
|
376
|
+
expect(ctx.bucketName).toBe(mockEntity.bucketName);
|
|
377
|
+
expect(isExpiringSoon(ctx.expiresAt)).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("company slug: SKIPS /sts/vend after the namespace + entity lookup", async () => {
|
|
381
|
+
const calls: string[] = [];
|
|
382
|
+
vi.stubGlobal(
|
|
383
|
+
"fetch",
|
|
384
|
+
vi.fn().mockImplementation(async (url: string) => {
|
|
385
|
+
const u = String(url);
|
|
386
|
+
calls.push(u);
|
|
387
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
status: 200,
|
|
391
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
392
|
+
text: async () => "",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
396
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
397
|
+
}
|
|
398
|
+
if (u.includes("/sts/vend")) {
|
|
399
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
400
|
+
}
|
|
401
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const ctx = await resolveEntityContext("acme", presignConfig);
|
|
406
|
+
|
|
407
|
+
expect(calls.some((u) => u.includes("/sts/vend"))).toBe(false);
|
|
408
|
+
expect(ctx.credentials).toBeUndefined();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("prs_* UID: STILL vends self even with the company flag set", async () => {
|
|
412
|
+
const prsEntity = {
|
|
413
|
+
uid: "prs_01PERSON",
|
|
414
|
+
slug: "test-person",
|
|
415
|
+
bucketName: "hq-vault-prs-01person",
|
|
416
|
+
status: "active",
|
|
417
|
+
};
|
|
418
|
+
const calls: string[] = [];
|
|
419
|
+
vi.stubGlobal(
|
|
420
|
+
"fetch",
|
|
421
|
+
vi.fn().mockImplementation(async (url: string) => {
|
|
422
|
+
const u = String(url);
|
|
423
|
+
calls.push(u);
|
|
424
|
+
if (u.includes("/entity/prs_")) {
|
|
425
|
+
return { ok: true, status: 200, json: async () => ({ entity: prsEntity }), text: async () => "" };
|
|
426
|
+
}
|
|
427
|
+
if (u.includes("/sts/vend-self")) {
|
|
428
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
429
|
+
}
|
|
430
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
const ctx = await resolveEntityContext("prs_01PERSON", presignConfig);
|
|
435
|
+
|
|
436
|
+
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
437
|
+
expect(vendCalls).toHaveLength(1);
|
|
438
|
+
expect(vendCalls[0]).toContain("/sts/vend-self");
|
|
439
|
+
expect(ctx.credentials?.accessKeyId).toBe(mockVendResponse.credentials.accessKeyId);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("flag OFF (default): cmp_* still vends — no behavior change without the flag", async () => {
|
|
443
|
+
const calls: string[] = [];
|
|
444
|
+
vi.stubGlobal(
|
|
445
|
+
"fetch",
|
|
446
|
+
vi.fn().mockImplementation(async (url: string) => {
|
|
447
|
+
const u = String(url);
|
|
448
|
+
calls.push(u);
|
|
449
|
+
if (u.includes("/entity/cmp_")) {
|
|
450
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
451
|
+
}
|
|
452
|
+
if (u.includes("/sts/vend")) {
|
|
453
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
454
|
+
}
|
|
455
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const ctx = await resolveEntityContext("cmp_01ABCDEF", mockConfig);
|
|
460
|
+
|
|
461
|
+
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
462
|
+
expect(vendCalls).toHaveLength(1);
|
|
463
|
+
expect(vendCalls[0]).toContain("/sts/vend");
|
|
464
|
+
expect(ctx.credentials?.accessKeyId).toBe(mockVendResponse.credentials.accessKeyId);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
330
468
|
describe("refreshEntityContext", () => {
|
|
331
469
|
beforeEach(() => {
|
|
332
470
|
clearContextCache();
|
package/src/context.ts
CHANGED
|
@@ -15,6 +15,14 @@ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
|
|
|
15
15
|
/** STS session duration requested from vault-service (15 minutes). */
|
|
16
16
|
const DEFAULT_SESSION_DURATION_SECONDS = 900;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* `expiresAt` stamped on a presign-only company context (HQ-59). No STS vend
|
|
20
|
+
* happened, so there is nothing to expire — a far-future sentinel keeps
|
|
21
|
+
* `isExpiringSoon` false forever, so the auto-refresh path never re-vends (and
|
|
22
|
+
* thus never re-hits) the company `/sts/vend` route the gate keys on.
|
|
23
|
+
*/
|
|
24
|
+
const PRESIGN_NO_VEND_EXPIRES_AT = "9999-12-31T23:59:59.000Z";
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Cached contexts.
|
|
20
28
|
*
|
|
@@ -78,6 +86,12 @@ export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
|
|
|
78
86
|
*
|
|
79
87
|
* Caches the result and auto-refreshes when the credentials are within
|
|
80
88
|
* 2 minutes of expiry.
|
|
89
|
+
*
|
|
90
|
+
* HQ-59: when `config.companyVaultUsesPresign` is set, COMPANY (`cmp_*`)
|
|
91
|
+
* contexts skip the `POST /sts/vend` call and come back credential-less with a
|
|
92
|
+
* far-future expiry — the presign transport needs no STS creds, and skipping
|
|
93
|
+
* the vend keeps a compliant sync-runner off the company vend route. Personal
|
|
94
|
+
* (`prs_*`) contexts always vend self.
|
|
81
95
|
*/
|
|
82
96
|
export async function resolveEntityContext(
|
|
83
97
|
companyUidOrSlug: string,
|
|
@@ -122,26 +136,54 @@ export async function resolveEntityContext(
|
|
|
122
136
|
);
|
|
123
137
|
}
|
|
124
138
|
|
|
125
|
-
// Step 2: Dispatch credential vending by
|
|
139
|
+
// Step 2: Dispatch credential vending by RESOLVED-uid prefix.
|
|
126
140
|
// cmp_* → POST /sts/vend (company path; membership-gated)
|
|
127
141
|
// prs_* → POST /sts/vend-self (person path; self-ownership-gated)
|
|
128
|
-
// slug →
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
142
|
+
// slug → resolves to a cmp_* (the slug path is company-only)
|
|
143
|
+
//
|
|
144
|
+
// HQ-59 vend-skip: when the run uses the presigned-URL transport for company
|
|
145
|
+
// vaults, a `cmp_*` context needs no STS creds (the presign transport
|
|
146
|
+
// authorizes every object server-side) AND a compliant sync-runner must not
|
|
147
|
+
// call the company `/sts/vend` route at all — its mere presence is the
|
|
148
|
+
// pre-6.11.6 signal the hq-pro min-version gate denies on. So skip the vend
|
|
149
|
+
// entirely and return a credential-less context with a sentinel expiry. The
|
|
150
|
+
// discriminator is the RESOLVED entity uid (`cmp_`), the exact same prefix
|
|
151
|
+
// `presignObjectIOFactory` uses to route to `PresignObjectIO` — so the
|
|
152
|
+
// vend-skip and the transport choice can never disagree. Personal vaults
|
|
153
|
+
// (`prs_*`) always vend self.
|
|
154
|
+
const isCompanyEntity = entity.uid.startsWith("cmp_");
|
|
155
|
+
const skipCompanyVend =
|
|
156
|
+
config.companyVaultUsesPresign === true && isCompanyEntity;
|
|
132
157
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
158
|
+
let ctx: EntityContext;
|
|
159
|
+
if (skipCompanyVend) {
|
|
160
|
+
ctx = {
|
|
161
|
+
uid: entity.uid,
|
|
162
|
+
slug: entity.slug,
|
|
163
|
+
bucketName: entity.bucketName,
|
|
164
|
+
region: config.region ?? "us-east-1",
|
|
165
|
+
// No STS vend on the presign path — see PRESIGN_NO_VEND_EXPIRES_AT.
|
|
166
|
+
credentials: undefined,
|
|
167
|
+
expiresAt: PRESIGN_NO_VEND_EXPIRES_AT,
|
|
168
|
+
};
|
|
169
|
+
} else {
|
|
170
|
+
const vendResult = looksLikePerson
|
|
171
|
+
? await vendSelfCredentials(entity.uid, config)
|
|
172
|
+
: await vendCredentials(entity.uid, config);
|
|
173
|
+
|
|
174
|
+
ctx = {
|
|
175
|
+
uid: entity.uid,
|
|
176
|
+
slug: entity.slug,
|
|
177
|
+
bucketName: entity.bucketName,
|
|
178
|
+
region: config.region ?? "us-east-1",
|
|
179
|
+
credentials: {
|
|
180
|
+
accessKeyId: vendResult.credentials.accessKeyId,
|
|
181
|
+
secretAccessKey: vendResult.credentials.secretAccessKey,
|
|
182
|
+
sessionToken: vendResult.credentials.sessionToken,
|
|
183
|
+
},
|
|
184
|
+
expiresAt: vendResult.expiresAt,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
145
187
|
|
|
146
188
|
// Cache by UID (globally unique) and — if the caller asked by slug —
|
|
147
189
|
// by `(callerSub, slug)` so the same slug can resolve to different
|
|
@@ -171,9 +171,9 @@ describe("resolveEntity", () => {
|
|
|
171
171
|
expect(ctx.slug).toBe("indigo");
|
|
172
172
|
expect(ctx.bucketName).toBe("hq-vault-indigo");
|
|
173
173
|
expect(ctx.region).toBe("us-east-1");
|
|
174
|
-
expect(ctx.credentials
|
|
175
|
-
expect(ctx.credentials
|
|
176
|
-
expect(ctx.credentials
|
|
174
|
+
expect(ctx.credentials?.accessKeyId).toBe("ASIAMOCK000000000001");
|
|
175
|
+
expect(ctx.credentials?.secretAccessKey).toBe("mockSecret");
|
|
176
|
+
expect(ctx.credentials?.sessionToken).toBe("mockSession");
|
|
177
177
|
expect(ctx.expiresAt).toBeTruthy();
|
|
178
178
|
});
|
|
179
179
|
|
package/src/index.ts
CHANGED
|
@@ -142,8 +142,10 @@ export type { PullScope, PullScopeClient } from "./sync/pull-scope.js";
|
|
|
142
142
|
export {
|
|
143
143
|
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
144
144
|
PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
|
|
145
|
+
CONTINUITY_POINTER_REL,
|
|
145
146
|
computePersonalVaultPaths,
|
|
146
147
|
computePersonalCompanySubdirs,
|
|
148
|
+
computeContinuityPointerPaths,
|
|
147
149
|
} from "./personal-vault.js";
|
|
148
150
|
export type { PersonalVaultOptions } from "./personal-vault.js";
|
|
149
151
|
|
package/src/object-io.ts
CHANGED
|
@@ -199,6 +199,18 @@ export class S3SdkObjectIO implements ObjectIO {
|
|
|
199
199
|
|
|
200
200
|
constructor(ctx: EntityContext) {
|
|
201
201
|
this.bucket = ctx.bucketName;
|
|
202
|
+
if (!ctx.credentials) {
|
|
203
|
+
// A credential-less context only exists on the presign path (HQ-59
|
|
204
|
+
// company vaults skip the STS vend). Such a context must route through
|
|
205
|
+
// PresignObjectIO, never here — reaching the direct-S3 transport with no
|
|
206
|
+
// creds is a routing bug, so fail loudly instead of building a broken
|
|
207
|
+
// S3 client that would later 403 with an opaque AWS error.
|
|
208
|
+
throw new Error(
|
|
209
|
+
`S3SdkObjectIO requires STS credentials but got a presign-only ` +
|
|
210
|
+
`context for ${ctx.uid}; company presign contexts must use ` +
|
|
211
|
+
`PresignObjectIO. This is a transport-routing bug.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
202
214
|
this.client = new S3Client({
|
|
203
215
|
region: ctx.region,
|
|
204
216
|
credentials: {
|
|
@@ -16,8 +16,10 @@ import * as fs from "fs";
|
|
|
16
16
|
import * as os from "os";
|
|
17
17
|
import * as path from "path";
|
|
18
18
|
import {
|
|
19
|
+
computeContinuityPointerPaths,
|
|
19
20
|
computePersonalCompanySubdirs,
|
|
20
21
|
computePersonalVaultPaths,
|
|
22
|
+
CONTINUITY_POINTER_REL,
|
|
21
23
|
PERSONAL_VAULT_COMPANY_EXCLUDED_SLUGS,
|
|
22
24
|
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
23
25
|
} from "./personal-vault.js";
|
|
@@ -347,3 +349,176 @@ describe("personal-vault helpers", () => {
|
|
|
347
349
|
).toBe(false);
|
|
348
350
|
});
|
|
349
351
|
});
|
|
352
|
+
|
|
353
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
354
|
+
// DEV-1778 — session-continuity pointer carve-out.
|
|
355
|
+
//
|
|
356
|
+
// `workspace/` is in PERSONAL_VAULT_EXCLUDED_TOP_LEVEL (machine-local by
|
|
357
|
+
// design). The ONE exception is the session pointer
|
|
358
|
+
// `workspace/threads/handoff.json` + the single thread file it references via
|
|
359
|
+
// `thread_path`, so a `/handoff` on one machine reaches a second machine.
|
|
360
|
+
// Mirrors the companies/manifest.yaml special-case: a narrow file-level
|
|
361
|
+
// re-include that never broadens the rest of workspace/.
|
|
362
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
363
|
+
describe("personal-vault: continuity-pointer carve-out", () => {
|
|
364
|
+
let hqRoot: string;
|
|
365
|
+
|
|
366
|
+
beforeEach(() => {
|
|
367
|
+
hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cont-test-"));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
afterEach(() => {
|
|
371
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
/** Helper: relative-paths-sorted projection of an absolute-path list. */
|
|
375
|
+
function rel(abs: string[]): string[] {
|
|
376
|
+
return abs.map((p) => path.relative(hqRoot, p)).sort();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const THREADS = path.join("workspace", "threads");
|
|
380
|
+
|
|
381
|
+
/** Write workspace/threads/handoff.json with the given object. */
|
|
382
|
+
function writeHandoff(obj: unknown, raw?: string): void {
|
|
383
|
+
const dir = path.join(hqRoot, THREADS);
|
|
384
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
385
|
+
fs.writeFileSync(
|
|
386
|
+
path.join(dir, "handoff.json"),
|
|
387
|
+
raw !== undefined ? raw : JSON.stringify(obj),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Write a thread file under workspace/threads/ and return its rel path. */
|
|
392
|
+
function writeThread(name: string): string {
|
|
393
|
+
const dir = path.join(hqRoot, THREADS);
|
|
394
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
395
|
+
fs.writeFileSync(path.join(dir, name), "{}");
|
|
396
|
+
return path.join(THREADS, name).split(path.sep).join("/");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
it("constant: CONTINUITY_POINTER_REL is the canonical pointer path", () => {
|
|
400
|
+
expect(CONTINUITY_POINTER_REL).toBe("workspace/threads/handoff.json");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("includes handoff.json + the thread file it points to", () => {
|
|
404
|
+
const threadRel = writeThread("T-20260617-1200-demo.json");
|
|
405
|
+
writeHandoff({ thread_path: threadRel });
|
|
406
|
+
|
|
407
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual(
|
|
408
|
+
[
|
|
409
|
+
path.join(THREADS, "T-20260617-1200-demo.json"),
|
|
410
|
+
path.join(THREADS, "handoff.json"),
|
|
411
|
+
].sort(),
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("carve-out composes into computePersonalVaultPaths", () => {
|
|
416
|
+
const threadRel = writeThread("T-20260617-1200-demo.json");
|
|
417
|
+
writeHandoff({ thread_path: threadRel });
|
|
418
|
+
fs.mkdirSync(path.join(hqRoot, ".claude"));
|
|
419
|
+
|
|
420
|
+
const out = rel(computePersonalVaultPaths(hqRoot));
|
|
421
|
+
expect(out).toContain(path.join(THREADS, "handoff.json"));
|
|
422
|
+
expect(out).toContain(path.join(THREADS, "T-20260617-1200-demo.json"));
|
|
423
|
+
// Sibling top-level dirs still travel; the rest of workspace/ does not.
|
|
424
|
+
expect(out).toContain(".claude");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("does NOT broaden the rest of workspace/ (siblings/other threads stay local)", () => {
|
|
428
|
+
const threadRel = writeThread("T-active.json");
|
|
429
|
+
writeThread("T-old.json"); // an inactive thread — must NOT sync
|
|
430
|
+
writeHandoff({ thread_path: threadRel });
|
|
431
|
+
// Unrelated workspace litter that must stay machine-local.
|
|
432
|
+
fs.mkdirSync(path.join(hqRoot, "workspace", "locks"), { recursive: true });
|
|
433
|
+
fs.writeFileSync(path.join(hqRoot, "workspace", "locks", "x.lock"), "1");
|
|
434
|
+
fs.writeFileSync(path.join(hqRoot, "workspace", "threads", "INDEX.md"), "#");
|
|
435
|
+
|
|
436
|
+
const out = rel(computePersonalVaultPaths(hqRoot));
|
|
437
|
+
expect(out).toContain(path.join(THREADS, "handoff.json"));
|
|
438
|
+
expect(out).toContain(path.join(THREADS, "T-active.json"));
|
|
439
|
+
expect(out).not.toContain(path.join(THREADS, "T-old.json"));
|
|
440
|
+
expect(out).not.toContain(path.join(THREADS, "INDEX.md"));
|
|
441
|
+
expect(out.some((p) => p.startsWith(path.join("workspace", "locks")))).toBe(
|
|
442
|
+
false,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("handoff.json present but thread_path absent → only the pointer", () => {
|
|
447
|
+
writeHandoff({ message: "no pointer field" });
|
|
448
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
|
|
449
|
+
path.join(THREADS, "handoff.json"),
|
|
450
|
+
]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("handoff.json points to a non-existent thread file → only the pointer", () => {
|
|
454
|
+
writeHandoff({ thread_path: "workspace/threads/T-ghost.json" });
|
|
455
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
|
|
456
|
+
path.join(THREADS, "handoff.json"),
|
|
457
|
+
]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("no handoff.json at all → empty (nothing to carry)", () => {
|
|
461
|
+
fs.mkdirSync(path.join(hqRoot, THREADS), { recursive: true });
|
|
462
|
+
expect(computeContinuityPointerPaths(hqRoot)).toEqual([]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("malformed handoff.json → fail-soft to pointer only (no throw)", () => {
|
|
466
|
+
writeHandoff(null, "{ this is : not json ]");
|
|
467
|
+
expect(() => computeContinuityPointerPaths(hqRoot)).not.toThrow();
|
|
468
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
|
|
469
|
+
path.join(THREADS, "handoff.json"),
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ── Security: thread_path must never escape workspace/threads/ ──────────
|
|
474
|
+
it("rejects an absolute thread_path (only the pointer is included)", () => {
|
|
475
|
+
// Put a real file at the absolute target so the ONLY thing rejecting it
|
|
476
|
+
// is the containment guard, not a missing-file fallthrough.
|
|
477
|
+
const evil = path.join(hqRoot, "secret.txt");
|
|
478
|
+
fs.writeFileSync(evil, "top secret");
|
|
479
|
+
writeHandoff({ thread_path: evil });
|
|
480
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
|
|
481
|
+
path.join(THREADS, "handoff.json"),
|
|
482
|
+
]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("rejects a traversal thread_path (../../.env) — no smuggling out of threads/", () => {
|
|
486
|
+
fs.writeFileSync(path.join(hqRoot, ".env"), "SECRET=1");
|
|
487
|
+
writeHandoff({ thread_path: "workspace/threads/../../.env" });
|
|
488
|
+
const out = rel(computeContinuityPointerPaths(hqRoot));
|
|
489
|
+
expect(out).toEqual([path.join(THREADS, "handoff.json")]);
|
|
490
|
+
expect(out.some((p) => p.endsWith(".env"))).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("rejects a thread_path under workspace/ but outside threads/", () => {
|
|
494
|
+
fs.mkdirSync(path.join(hqRoot, "workspace", "reports"), {
|
|
495
|
+
recursive: true,
|
|
496
|
+
});
|
|
497
|
+
fs.writeFileSync(
|
|
498
|
+
path.join(hqRoot, "workspace", "reports", "secret.md"),
|
|
499
|
+
"x",
|
|
500
|
+
);
|
|
501
|
+
writeHandoff({ thread_path: "workspace/reports/secret.md" });
|
|
502
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
|
|
503
|
+
path.join(THREADS, "handoff.json"),
|
|
504
|
+
]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("rejects a symlink that escapes threads/ (realpath containment)", () => {
|
|
508
|
+
// A thread_path that names an in-threads symlink whose target is OUTSIDE
|
|
509
|
+
// threads/ must be rejected by the realpath re-check.
|
|
510
|
+
fs.writeFileSync(path.join(hqRoot, "outside.txt"), "secret");
|
|
511
|
+
fs.mkdirSync(path.join(hqRoot, THREADS), { recursive: true });
|
|
512
|
+
const link = path.join(hqRoot, THREADS, "escape.json");
|
|
513
|
+
try {
|
|
514
|
+
fs.symlinkSync(path.join(hqRoot, "outside.txt"), link);
|
|
515
|
+
} catch {
|
|
516
|
+
// Platform without symlink support — skip the assertion gracefully.
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
writeHandoff({ thread_path: "workspace/threads/escape.json" });
|
|
520
|
+
expect(rel(computeContinuityPointerPaths(hqRoot))).toEqual([
|
|
521
|
+
path.join(THREADS, "handoff.json"),
|
|
522
|
+
]);
|
|
523
|
+
});
|
|
524
|
+
});
|