@indigoai-us/hq-cloud 6.11.5 → 6.11.6
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 +50 -41
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +107 -33
- package/dist/bin/sync-runner.test.js.map +1 -1
- 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/object-io.d.ts.map +1 -1
- package/dist/object-io.js +10 -0
- package/dist/object-io.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/package.json +1 -1
- package/src/bin/sync-runner.test.ts +130 -41
- package/src/bin/sync-runner.ts +55 -48
- package/src/context.test.ts +139 -1
- package/src/context.ts +59 -17
- package/src/entity-resolver.test.ts +3 -3
- package/src/object-io.ts +12 -0
- 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/bin/sync-runner.ts
CHANGED
|
@@ -98,8 +98,10 @@ import type { UploadAuthor } from "../s3.js";
|
|
|
98
98
|
import {
|
|
99
99
|
setObjectIOFactory,
|
|
100
100
|
presignObjectIOFactory,
|
|
101
|
+
type ObjectIOFactory,
|
|
101
102
|
type PresignTransportClient,
|
|
102
103
|
} from "../object-io.js";
|
|
104
|
+
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
103
105
|
import { collectAndSendTelemetry } from "../telemetry.js";
|
|
104
106
|
import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
|
|
105
107
|
import { reindexAfterSync } from "../qmd-reindex.js";
|
|
@@ -227,34 +229,34 @@ export function resolveSkipPersonal(flag: boolean): boolean {
|
|
|
227
229
|
}
|
|
228
230
|
|
|
229
231
|
/**
|
|
230
|
-
*
|
|
232
|
+
* Resolve the object-transport factory for this sync session.
|
|
231
233
|
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
234
|
+
* Company vaults (`cmp_*`) ALWAYS use the presigned-URL transport: the client
|
|
235
|
+
* holds no raw AWS credentials (it fetches short-lived signed URLs) and every
|
|
236
|
+
* read/write is authorized server-side per-file, so it never hits the 2048-char
|
|
237
|
+
* STS session-policy ceiling that produced the HQ-59 lockout. The STS-direct-S3
|
|
238
|
+
* (`S3SdkObjectIO`) path for company vaults is RETIRED — there is no env
|
|
239
|
+
* override or rollback lever that can route a company vault back to direct S3.
|
|
240
|
+
* (The former `HQ_SYNC_PRESIGN_TRANSPORT` kill-switch is gone with this change.)
|
|
238
241
|
*
|
|
239
|
-
* `
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* falls through to the GA default (on).
|
|
242
|
+
* Personal vaults (`prs_*`) KEEP the direct-S3/STS path: the membership-gated
|
|
243
|
+
* `list`/`presign` endpoints 403 for the membership-less vend-self model, and a
|
|
244
|
+
* single-owner personal vault has no ACL-scale problem. That cmp_/prs_ split
|
|
245
|
+
* lives inside {@link presignObjectIOFactory}.
|
|
244
246
|
*
|
|
245
|
-
* `
|
|
246
|
-
*
|
|
247
|
-
* site; it is intentionally unused while the transport is GA.
|
|
247
|
+
* Returns `null` only when the client predates the presign methods (never in a
|
|
248
|
+
* shipped build); the caller then resets to the SDK default factory.
|
|
248
249
|
*/
|
|
249
|
-
export function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
250
|
+
export function selectObjectIOFactory(
|
|
251
|
+
client: Partial<PresignTransportClient>,
|
|
252
|
+
): ObjectIOFactory | null {
|
|
253
|
+
if (
|
|
254
|
+
typeof client.presign === "function" &&
|
|
255
|
+
typeof client.listFiles === "function"
|
|
256
|
+
) {
|
|
257
|
+
return presignObjectIOFactory(client as PresignTransportClient);
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
258
260
|
}
|
|
259
261
|
|
|
260
262
|
// Personal-vault scope (exclusion list + path computer) lives in
|
|
@@ -925,10 +927,18 @@ export async function runRunner(
|
|
|
925
927
|
}
|
|
926
928
|
|
|
927
929
|
// ---- vault client -----------------------------------------------------
|
|
930
|
+
// Stamp clientInfo so every vault request (incl. /sts/vend) carries
|
|
931
|
+
// x-hq-client-name=hq-sync + x-hq-client-version=<hq-cloud version>. The
|
|
932
|
+
// company-vend min-version gate (HQ-59, hq-pro) uses this to recognize a
|
|
933
|
+
// compliant (>= 6.11.6) sync-runner and NOT force-upgrade it — the
|
|
934
|
+
// belt-and-suspenders companion to skipping the cmp_ vend on the presign
|
|
935
|
+
// path. Version is the hq-cloud package version (what the gate compares to
|
|
936
|
+
// its floor), not the desktop app version.
|
|
928
937
|
const vaultConfig: VaultServiceConfig = {
|
|
929
938
|
apiUrl: DEFAULT_VAULT_API_URL,
|
|
930
939
|
authToken: getAccessToken,
|
|
931
940
|
region: DEFAULT_COGNITO.region,
|
|
941
|
+
clientInfo: { name: "hq-sync", version: HQ_CLOUD_VERSION },
|
|
932
942
|
};
|
|
933
943
|
const client =
|
|
934
944
|
deps.createVaultClient?.(vaultConfig) ?? new VaultClient(vaultConfig);
|
|
@@ -949,32 +959,29 @@ export async function runRunner(
|
|
|
949
959
|
? { userSub: claims.sub, email: claims.email }
|
|
950
960
|
: undefined;
|
|
951
961
|
|
|
952
|
-
// ---- transport selection (presigned-URL
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
// 0/false → force off) for testing or emergency rollback without a redeploy.
|
|
961
|
-
// Setting the factory unconditionally (even to the default) keeps the choice
|
|
962
|
+
// ---- transport selection (presigned-URL for cmp_, direct-S3/STS for prs_) -
|
|
963
|
+
// Company vaults ALWAYS use the presigned-URL transport now (HQ-59): the
|
|
964
|
+
// STS-direct-S3 path for cmp_ is retired — no env override routes a company
|
|
965
|
+
// vault back to direct S3. Personal vaults keep direct-S3 inside the factory
|
|
966
|
+
// (the cmp_/prs_ split lives in presignObjectIOFactory). selectObjectIOFactory
|
|
967
|
+
// runs ONCE here — every s3.ts call in this session's fanout resolves through
|
|
968
|
+
// the installed factory. Setting it unconditionally (even to null, which the
|
|
969
|
+
// default S3 SDK factory backs for a pre-presign client) keeps the choice
|
|
962
970
|
// deterministic if a prior run mutated module state.
|
|
963
971
|
const presignCapable = client as Partial<PresignTransportClient>;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
}
|
|
972
|
+
const objectIOFactory = selectObjectIOFactory(presignCapable);
|
|
973
|
+
setObjectIOFactory(objectIOFactory);
|
|
974
|
+
// HQ-59 vend-skip: when company vaults run over the presign transport (the
|
|
975
|
+
// shipped case — selectObjectIOFactory only returns null for a pre-presign
|
|
976
|
+
// client), tell resolveEntityContext to SKIP `POST /sts/vend` for cmp_
|
|
977
|
+
// contexts. Two reasons, both load-bearing: (1) the presign transport never
|
|
978
|
+
// reads STS creds, so the vend is dead weight; (2) a compliant sync-runner
|
|
979
|
+
// must not call the company vend route at all — its presence is the
|
|
980
|
+
// pre-6.11.6 signal the hq-pro min-version gate denies on. Gated on the SAME
|
|
981
|
+
// value that picks the transport, so the vend-skip and the transport can
|
|
982
|
+
// never diverge. If the factory is null (S3 SDK fallback), cmp_ keeps vending
|
|
983
|
+
// because S3SdkObjectIO needs the creds. Personal vaults always vend self.
|
|
984
|
+
vaultConfig.companyVaultUsesPresign = objectIOFactory !== null;
|
|
978
985
|
|
|
979
986
|
// ---- resolve targets --------------------------------------------------
|
|
980
987
|
let memberships: Pick<Membership, "companyUid">[];
|
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/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: {
|
package/src/signals/get.test.ts
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Unit tests for signals/get.ts.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
6
6
|
import { GetObjectCommand, type S3Client } from "@aws-sdk/client-s3";
|
|
7
7
|
import { getSignal, SignalNotFoundError } from "./get.js";
|
|
8
8
|
import {
|
|
9
9
|
_setSignalsS3Factory,
|
|
10
10
|
_resetSignalsS3Factory,
|
|
11
11
|
} from "./internals.js";
|
|
12
|
+
import type { PresignTransportClient } from "../object-io.js";
|
|
12
13
|
import type { EntityContext } from "../types.js";
|
|
13
14
|
import { InvalidSignalTypeError } from "../schemas/signal-types.js";
|
|
14
15
|
|
|
@@ -202,3 +203,84 @@ Body.
|
|
|
202
203
|
expect(doc.entityRefs).toEqual(["kept@example.com"]);
|
|
203
204
|
});
|
|
204
205
|
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Presigned transport (vault client + company vault) — HQ-59
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function makePresignVault(objects: Record<string, string>): PresignTransportClient {
|
|
212
|
+
vi.stubGlobal(
|
|
213
|
+
"fetch",
|
|
214
|
+
vi.fn(async (url: string) => {
|
|
215
|
+
const key = new URL(url).searchParams.get("key") ?? "";
|
|
216
|
+
const content = objects[key];
|
|
217
|
+
if (content === undefined) return new Response(null, { status: 404 });
|
|
218
|
+
return new Response(Buffer.from(content, "utf-8"), { status: 200 });
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
presign: async (input) => ({
|
|
223
|
+
results: input.keys.map((k) => ({
|
|
224
|
+
key: k.key,
|
|
225
|
+
op: k.op ?? input.op ?? "get",
|
|
226
|
+
url: `https://signed.example/?key=${encodeURIComponent(k.key)}`,
|
|
227
|
+
})),
|
|
228
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
229
|
+
}),
|
|
230
|
+
listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe("getSignal — presigned transport (vault + cmp_)", () => {
|
|
235
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
236
|
+
|
|
237
|
+
it("reads via presign (no S3) and surfaces typed cross-refs", async () => {
|
|
238
|
+
_setSignalsS3Factory(
|
|
239
|
+
() =>
|
|
240
|
+
({
|
|
241
|
+
send: async () => {
|
|
242
|
+
throw new Error("S3 must not be used on the presign path");
|
|
243
|
+
},
|
|
244
|
+
}) as unknown as S3Client,
|
|
245
|
+
);
|
|
246
|
+
const vault = makePresignVault({ "signals/action_item/foo.md": ACTION_ITEM_MD });
|
|
247
|
+
|
|
248
|
+
const doc = await getSignal({
|
|
249
|
+
entity: ENTITY,
|
|
250
|
+
signalType: "action_item",
|
|
251
|
+
signalId: "foo",
|
|
252
|
+
vault,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(doc.sourceRef).toBe("abc-meeting-001");
|
|
256
|
+
expect(doc.entityRefs).toEqual(["stefan@indigoai.us", "corey@indigoai.us"]);
|
|
257
|
+
expect(doc.body).toContain("Stefan will review");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("maps a presign 404 to SignalNotFoundError", async () => {
|
|
261
|
+
const vault = makePresignVault({});
|
|
262
|
+
await expect(
|
|
263
|
+
getSignal({
|
|
264
|
+
entity: ENTITY,
|
|
265
|
+
signalType: "action_item",
|
|
266
|
+
signalId: "missing",
|
|
267
|
+
vault,
|
|
268
|
+
}),
|
|
269
|
+
).rejects.toBeInstanceOf(SignalNotFoundError);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("personal vault (prs_) ignores the vault client and stays on direct S3", async () => {
|
|
273
|
+
installStub({ "signals/action_item/foo.md": ACTION_ITEM_MD });
|
|
274
|
+
const vault = makePresignVault({});
|
|
275
|
+
const personal: EntityContext = { ...ENTITY, uid: "prs_me" };
|
|
276
|
+
|
|
277
|
+
const doc = await getSignal({
|
|
278
|
+
entity: personal,
|
|
279
|
+
signalType: "action_item",
|
|
280
|
+
signalId: "foo",
|
|
281
|
+
vault,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(doc.body).toContain("Stefan will review");
|
|
285
|
+
});
|
|
286
|
+
});
|
package/src/signals/get.ts
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* `entityRefs` surfaced as typed fields.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
8
7
|
import { assertSignalType } from "../schemas/signal-types.js";
|
|
9
|
-
import {
|
|
8
|
+
import { readObjectText } from "./internals.js";
|
|
10
9
|
import { parseMarkdown } from "./parse.js";
|
|
11
10
|
import type { GetSignalOptions, SignalDocument } from "./types.js";
|
|
12
11
|
|
|
@@ -20,8 +19,12 @@ export class SignalNotFoundError extends Error {
|
|
|
20
19
|
function isNoSuchKey(err: unknown): boolean {
|
|
21
20
|
if (!err || typeof err !== "object") return false;
|
|
22
21
|
const name = (err as { name?: unknown }).name;
|
|
23
|
-
//
|
|
24
|
-
|
|
22
|
+
// The transport normalizes a missing object to a `NoSuchKey`-named error on
|
|
23
|
+
// both the S3 and presigned paths (a presign 404 → NoSuchKey). `NotFound` is
|
|
24
|
+
// kept for defense in depth (S3 HEAD-style errors).
|
|
25
|
+
return (
|
|
26
|
+
name === "NoSuchKey" || name === "NoSuchKeyException" || name === "NotFound"
|
|
27
|
+
);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
function asStringArray(value: unknown): string[] | undefined {
|
|
@@ -34,21 +37,14 @@ function asStringArray(value: unknown): string[] | undefined {
|
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export async function getSignal(opts: GetSignalOptions): Promise<SignalDocument> {
|
|
37
|
-
// Validate signalType BEFORE any
|
|
40
|
+
// Validate signalType BEFORE any read (acceptance criterion + agent-mitigation).
|
|
38
41
|
assertSignalType(opts.signalType);
|
|
39
42
|
|
|
40
|
-
const client = getS3Client(opts.entity);
|
|
41
43
|
const key = `signals/${opts.signalType}/${opts.signalId}.md`;
|
|
42
44
|
|
|
43
45
|
let body: string;
|
|
44
46
|
try {
|
|
45
|
-
|
|
46
|
-
new GetObjectCommand({
|
|
47
|
-
Bucket: opts.entity.bucketName,
|
|
48
|
-
Key: key,
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
51
|
-
body = await streamToString(response.Body);
|
|
47
|
+
body = await readObjectText(opts, key);
|
|
52
48
|
} catch (err) {
|
|
53
49
|
if (isNoSuchKey(err)) {
|
|
54
50
|
throw new SignalNotFoundError(key);
|