@indigoai-us/hq-cloud 6.11.4 → 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.
Files changed (88) hide show
  1. package/dist/bin/sync-runner.d.ts +16 -16
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +50 -41
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +107 -33
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/context.d.ts +6 -0
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js +57 -17
  10. package/dist/context.js.map +1 -1
  11. package/dist/context.test.js +113 -1
  12. package/dist/context.test.js.map +1 -1
  13. package/dist/entity-resolver.test.js +3 -3
  14. package/dist/entity-resolver.test.js.map +1 -1
  15. package/dist/object-io.d.ts.map +1 -1
  16. package/dist/object-io.js +10 -0
  17. package/dist/object-io.js.map +1 -1
  18. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  19. package/dist/personal-vault-exclusions.js +11 -0
  20. package/dist/personal-vault-exclusions.js.map +1 -1
  21. package/dist/personal-vault-exclusions.test.js +5 -0
  22. package/dist/personal-vault-exclusions.test.js.map +1 -1
  23. package/dist/signals/get.d.ts.map +1 -1
  24. package/dist/signals/get.js +7 -11
  25. package/dist/signals/get.js.map +1 -1
  26. package/dist/signals/get.test.js +65 -1
  27. package/dist/signals/get.test.js.map +1 -1
  28. package/dist/signals/internals.d.ts +47 -3
  29. package/dist/signals/internals.d.ts.map +1 -1
  30. package/dist/signals/internals.js +110 -4
  31. package/dist/signals/internals.js.map +1 -1
  32. package/dist/signals/list.d.ts.map +1 -1
  33. package/dist/signals/list.js +16 -23
  34. package/dist/signals/list.js.map +1 -1
  35. package/dist/signals/list.test.js +84 -1
  36. package/dist/signals/list.test.js.map +1 -1
  37. package/dist/signals/types.d.ts +18 -1
  38. package/dist/signals/types.d.ts.map +1 -1
  39. package/dist/sources/get.d.ts.map +1 -1
  40. package/dist/sources/get.js +10 -22
  41. package/dist/sources/get.js.map +1 -1
  42. package/dist/sources/get.test.js +85 -1
  43. package/dist/sources/get.test.js.map +1 -1
  44. package/dist/sources/internals.d.ts +50 -3
  45. package/dist/sources/internals.d.ts.map +1 -1
  46. package/dist/sources/internals.js +113 -4
  47. package/dist/sources/internals.js.map +1 -1
  48. package/dist/sources/list.d.ts.map +1 -1
  49. package/dist/sources/list.js +16 -23
  50. package/dist/sources/list.js.map +1 -1
  51. package/dist/sources/list.test.js +101 -1
  52. package/dist/sources/list.test.js.map +1 -1
  53. package/dist/sources/types.d.ts +18 -1
  54. package/dist/sources/types.d.ts.map +1 -1
  55. package/dist/sync/event-sync.d.ts +6 -7
  56. package/dist/sync/event-sync.d.ts.map +1 -1
  57. package/dist/sync/event-sync.js +6 -7
  58. package/dist/sync/event-sync.js.map +1 -1
  59. package/dist/types.d.ts +33 -3
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/version.d.ts +14 -0
  62. package/dist/version.d.ts.map +1 -0
  63. package/dist/version.js +20 -0
  64. package/dist/version.js.map +1 -0
  65. package/package.json +1 -1
  66. package/src/bin/sync-runner.test.ts +130 -41
  67. package/src/bin/sync-runner.ts +55 -48
  68. package/src/context.test.ts +139 -1
  69. package/src/context.ts +59 -17
  70. package/src/entity-resolver.test.ts +3 -3
  71. package/src/object-io.ts +12 -0
  72. package/src/personal-vault-exclusions.test.ts +5 -0
  73. package/src/personal-vault-exclusions.ts +11 -0
  74. package/src/signals/get.test.ts +83 -1
  75. package/src/signals/get.ts +9 -13
  76. package/src/signals/internals.ts +153 -4
  77. package/src/signals/list.test.ts +114 -1
  78. package/src/signals/list.ts +16 -29
  79. package/src/signals/types.ts +18 -1
  80. package/src/sources/get.test.ts +104 -1
  81. package/src/sources/get.ts +12 -24
  82. package/src/sources/internals.ts +156 -4
  83. package/src/sources/list.test.ts +132 -1
  84. package/src/sources/list.ts +16 -29
  85. package/src/sources/types.ts +18 -1
  86. package/src/sync/event-sync.ts +6 -7
  87. package/src/types.ts +33 -3
  88. package/src/version.ts +24 -0
@@ -16,13 +16,15 @@ import {
16
16
  runRunnerWithLoop,
17
17
  resolveDeletePolicy,
18
18
  resolveSkipPersonal,
19
- resolvePresignTransport,
19
+ selectObjectIOFactory,
20
20
  routeChangeToTarget,
21
21
  buildTargetedPushArgv,
22
22
  resolvePullScope,
23
23
  readPinnedPrefixes,
24
24
  defaultCollectTelemetry,
25
25
  } from "./sync-runner.js";
26
+ import { PresignObjectIO, S3SdkObjectIO } from "../object-io.js";
27
+ import type { EntityContext, VaultServiceConfig } from "../types.js";
26
28
  import type {
27
29
  RunnerEvent,
28
30
  RunnerDeps,
@@ -3192,50 +3194,59 @@ describe("resolveSkipPersonal", () => {
3192
3194
  });
3193
3195
 
3194
3196
  // ---------------------------------------------------------------------------
3195
- // resolvePresignTransportGA default-on + env override
3197
+ // selectObjectIOFactorycmp_ ALWAYS presign (HQ-59), prs_ stays direct-S3
3196
3198
  //
3197
- // As of GA (2026-06-11) the presigned-URL transport is the default for every
3198
- // account; the former per-domain rollout gate is gone. The env override is now
3199
- // the only thing that changes the answer, and it is the emergency rollback
3200
- // lever, so it gets the bulk of the coverage below.
3199
+ // The former HQ_SYNC_PRESIGN_TRANSPORT escape hatch is retired: there is no
3200
+ // override that can route a company vault back to S3SdkObjectIO. These tests
3201
+ // pin that retirement and the cmp_/prs_ routing.
3201
3202
  // ---------------------------------------------------------------------------
3202
3203
 
3203
- describe("resolvePresignTransport", () => {
3204
- it("ON by default for ANY account, no override (rollout is GA)", () => {
3205
- // Formerly-enrolled domains stay on...
3206
- expect(resolvePresignTransport("me@getindigo.ai", undefined)).toBe(true);
3207
- expect(resolvePresignTransport("someone@gmail.com", undefined)).toBe(true);
3208
- // ...and so does every account that was previously OFF under the gate.
3209
- expect(resolvePresignTransport("me@example.com", undefined)).toBe(true);
3210
- expect(resolvePresignTransport("juan@ridge.com", undefined)).toBe(true);
3211
- // Email shape no longer matters — it is intentionally ignored at GA.
3212
- expect(resolvePresignTransport(undefined, undefined)).toBe(true);
3213
- expect(resolvePresignTransport("no-at-sign", undefined)).toBe(true);
3214
- expect(resolvePresignTransport("me@evil-gmail.com", undefined)).toBe(true);
3215
- });
3216
-
3217
- it.each(["1", "true", "yes", "on", "ON", " True "])(
3218
- "override '%s' forces ON",
3219
- (val) => {
3220
- expect(resolvePresignTransport("me@example.com", val)).toBe(true);
3221
- },
3222
- );
3223
-
3224
- it.each(["0", "false", "no", "off", "OFF", " Off "])(
3225
- "override '%s' forces OFF (the emergency rollback lever)",
3226
- (val) => {
3227
- // Rollback must win regardless of account — this is the only path back
3228
- // to the STS-direct transport without a redeploy.
3229
- expect(resolvePresignTransport("me@getindigo.ai", val)).toBe(false);
3230
- expect(resolvePresignTransport("me@example.com", val)).toBe(false);
3231
- expect(resolvePresignTransport(undefined, val)).toBe(false);
3232
- },
3233
- );
3204
+ describe("selectObjectIOFactory", () => {
3205
+ const presignCapable = {
3206
+ presign: () => Promise.resolve({ results: [], expiresAt: "" }),
3207
+ listFiles: () =>
3208
+ Promise.resolve({ objects: [], cursor: null, truncated: false }),
3209
+ };
3210
+ const ctx = (uid: string): EntityContext =>
3211
+ ({
3212
+ uid,
3213
+ slug: uid,
3214
+ bucketName: `bucket-${uid}`,
3215
+ region: "us-east-1",
3216
+ credentials: {
3217
+ accessKeyId: "AKIA",
3218
+ secretAccessKey: "secret",
3219
+ sessionToken: "token",
3220
+ },
3221
+ expiresAt: "2099-01-01T00:00:00Z",
3222
+ }) as unknown as EntityContext;
3223
+
3224
+ it("routes company vaults (cmp_) to presign and personal vaults (prs_) to direct-S3", () => {
3225
+ const factory = selectObjectIOFactory(presignCapable);
3226
+ expect(factory).not.toBeNull();
3227
+ expect(factory!(ctx("cmp_acme"))).toBeInstanceOf(PresignObjectIO);
3228
+ expect(factory!(ctx("prs_me"))).toBeInstanceOf(S3SdkObjectIO);
3229
+ });
3230
+
3231
+ it("RETIRES the escape hatch: HQ_SYNC_PRESIGN_TRANSPORT cannot send a cmp_ vault back to S3", () => {
3232
+ // The env var no longer participates — set it to every former 'off' value
3233
+ // and the company vault still resolves to presign.
3234
+ for (const val of ["0", "false", "no", "off", "OFF"]) {
3235
+ process.env.HQ_SYNC_PRESIGN_TRANSPORT = val;
3236
+ const factory = selectObjectIOFactory(presignCapable);
3237
+ expect(factory!(ctx("cmp_acme"))).toBeInstanceOf(PresignObjectIO);
3238
+ }
3239
+ delete process.env.HQ_SYNC_PRESIGN_TRANSPORT;
3240
+ });
3234
3241
 
3235
- it("blank/unrecognized override falls through to the GA default (on)", () => {
3236
- expect(resolvePresignTransport("me@getindigo.ai", "")).toBe(true);
3237
- expect(resolvePresignTransport("me@getindigo.ai", "maybe")).toBe(true);
3238
- expect(resolvePresignTransport("me@example.com", "maybe")).toBe(true);
3242
+ it("returns null when the client predates presign (caller falls back to the SDK default)", () => {
3243
+ expect(selectObjectIOFactory({})).toBeNull();
3244
+ expect(
3245
+ selectObjectIOFactory({ presign: presignCapable.presign }),
3246
+ ).toBeNull();
3247
+ expect(
3248
+ selectObjectIOFactory({ listFiles: presignCapable.listFiles }),
3249
+ ).toBeNull();
3239
3250
  });
3240
3251
  });
3241
3252
 
@@ -4124,3 +4135,81 @@ describe("defaultCollectTelemetry person-entity gate", () => {
4124
4135
  }
4125
4136
  });
4126
4137
  });
4138
+
4139
+ // ---------------------------------------------------------------------------
4140
+ // clientInfo stamping (HQ-59 company-vend min-version gate companion)
4141
+ // ---------------------------------------------------------------------------
4142
+ describe("runRunner — clientInfo stamping", () => {
4143
+ it("stamps clientInfo name=hq-sync + a non-empty version on the vault config", async () => {
4144
+ const deps = makeDeps();
4145
+ await runRunner(["--companies"], deps);
4146
+
4147
+ const createVaultClient = deps.createVaultClient as unknown as {
4148
+ mock: { calls: Array<[{ clientInfo?: { name?: string; version?: string } }]> };
4149
+ };
4150
+ expect(createVaultClient.mock.calls.length).toBeGreaterThan(0);
4151
+ const cfg = createVaultClient.mock.calls[0]![0];
4152
+ // The min-version gate (hq-pro) recognizes a compliant sync-runner by this.
4153
+ expect(cfg.clientInfo?.name).toBe("hq-sync");
4154
+ expect(typeof cfg.clientInfo?.version).toBe("string");
4155
+ expect((cfg.clientInfo?.version ?? "").length).toBeGreaterThan(0);
4156
+ });
4157
+ });
4158
+
4159
+ // ---------------------------------------------------------------------------
4160
+ // company vend-skip flag (HQ-59) — the runner tells resolveEntityContext to
4161
+ // skip cmp_ /sts/vend EXACTLY when it installs the presign transport, so the
4162
+ // vend-skip and the transport choice can never diverge.
4163
+ // ---------------------------------------------------------------------------
4164
+ describe("runRunner — companyVaultUsesPresign flag", () => {
4165
+ const presignMethods = {
4166
+ presign: () => Promise.resolve({ results: [], expiresAt: "" }),
4167
+ listFiles: () =>
4168
+ Promise.resolve({ objects: [], cursor: null, truncated: false }),
4169
+ };
4170
+
4171
+ it("sets companyVaultUsesPresign=true on the vaultConfig when the client is presign-capable", async () => {
4172
+ let capturedConfig: VaultServiceConfig | undefined;
4173
+ const deps = makeDeps({
4174
+ createVaultClient: () =>
4175
+ ({
4176
+ ...makeVaultStub({
4177
+ memberships: [{ companyUid: "cmp_a" }],
4178
+ entityGet: (uid: string) =>
4179
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
4180
+ }),
4181
+ ...presignMethods,
4182
+ }) as unknown as VaultClientSurface,
4183
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
4184
+ capturedConfig = opts.vaultConfig;
4185
+ return defaultSyncResult();
4186
+ }),
4187
+ });
4188
+
4189
+ const code = await runRunner(["--companies"], deps);
4190
+ expect(code).toBe(0);
4191
+ expect(capturedConfig?.companyVaultUsesPresign).toBe(true);
4192
+ });
4193
+
4194
+ it("leaves companyVaultUsesPresign false when the client predates presign (cmp_ keeps vending)", async () => {
4195
+ let capturedConfig: VaultServiceConfig | undefined;
4196
+ const deps = makeDeps({
4197
+ // A stub WITHOUT presign/listFiles → selectObjectIOFactory returns null →
4198
+ // cmp_ stays on the S3 SDK (STS) path → it must still vend.
4199
+ createVaultClient: () =>
4200
+ makeVaultStub({
4201
+ memberships: [{ companyUid: "cmp_a" }],
4202
+ entityGet: (uid: string) =>
4203
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
4204
+ }),
4205
+ sync: vi.fn().mockImplementation(async (opts: SyncOptions) => {
4206
+ capturedConfig = opts.vaultConfig;
4207
+ return defaultSyncResult();
4208
+ }),
4209
+ });
4210
+
4211
+ const code = await runRunner(["--companies"], deps);
4212
+ expect(code).toBe(0);
4213
+ expect(capturedConfig?.companyVaultUsesPresign).toBe(false);
4214
+ });
4215
+ });
@@ -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
- * Decide whether this session uses the presigned-URL transport.
232
+ * Resolve the object-transport factory for this sync session.
231
233
  *
232
- * GA (2026-06-11): the presigned-URL transport is now the DEFAULT for every
233
- * account the staged per-domain rollout (getindigo.ai pilot gmail.com /
234
- * vyg.ai / amass.com batch 2) is complete. The transport only NARROWS client
235
- * privilege: the client holds no raw AWS credentials (it just fetches signed
236
- * URLs) and every upload is validated server-side via the vault API, so it is
237
- * safe as the universal default.
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
- * `HQ_SYNC_PRESIGN_TRANSPORT` still overrides in both directions
240
- * (`1`/`true`/`yes`/`on` force on, `0`/`false`/`no`/`off` force off), so
241
- * an individual account can be rolled back to the STS-direct path WITHOUT a
242
- * redeploy if a regression surfaces. An unset/blank or unrecognized override
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
- * `email` is retained in the signature for the override contract and so the
246
- * gate can be re-narrowed to a domain set in future without touching the call
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 resolvePresignTransport(
250
- email: string | undefined,
251
- override: string | undefined,
252
- ): boolean {
253
- void email;
254
- const o = (override ?? "").trim().toLowerCase();
255
- if (o === "1" || o === "true" || o === "yes" || o === "on") return true;
256
- if (o === "0" || o === "false" || o === "no" || o === "off") return false;
257
- return true;
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 vs STS-direct-S3) -------------
953
- // The presigned-URL transport (vault `list` + `presign` endpoints) is now
954
- // the GA default for every account. The STS-direct-S3 path (STS-vended
955
- // credentials + direct S3 SDK) remains the fallback only when an account is
956
- // force-disabled via HQ_SYNC_PRESIGN_TRANSPORT or the client build predates
957
- // the presign methods. The gate runs ONCE here — every s3.ts call in this
958
- // session's fanout resolves through the installed factory.
959
- // HQ_SYNC_PRESIGN_TRANSPORT is an explicit override (1/true force on,
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
- if (
965
- resolvePresignTransport(
966
- claims?.email,
967
- process.env.HQ_SYNC_PRESIGN_TRANSPORT,
968
- ) &&
969
- typeof presignCapable.presign === "function" &&
970
- typeof presignCapable.listFiles === "function"
971
- ) {
972
- setObjectIOFactory(
973
- presignObjectIOFactory(presignCapable as PresignTransportClient),
974
- );
975
- } else {
976
- setObjectIOFactory(null);
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">[];
@@ -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.accessKeyId).toBe("ASIA_TEST_KEY");
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 UID prefix.
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 → POST /sts/vend (legacy slug path is company-only)
129
- const vendResult = looksLikePerson
130
- ? await vendSelfCredentials(entity.uid, config)
131
- : await vendCredentials(entity.uid, config);
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
- const ctx: EntityContext = {
134
- uid: entity.uid,
135
- slug: entity.slug,
136
- bucketName: entity.bucketName,
137
- region: config.region ?? "us-east-1",
138
- credentials: {
139
- accessKeyId: vendResult.credentials.accessKeyId,
140
- secretAccessKey: vendResult.credentials.secretAccessKey,
141
- sessionToken: vendResult.credentials.sessionToken,
142
- },
143
- expiresAt: vendResult.expiresAt,
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.accessKeyId).toBe("ASIAMOCK000000000001");
175
- expect(ctx.credentials.secretAccessKey).toBe("mockSecret");
176
- expect(ctx.credentials.sessionToken).toBe("mockSession");
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: {
@@ -173,6 +173,11 @@ describe("OS / build cruft exclusions", () => {
173
173
  ["dist/bundle.js", "dist-dir"],
174
174
  [".next/build/file.js", "next-dir"],
175
175
  ["build/output.js", "build-dir"],
176
+ // Regression: the personal vault was re-uploading the pnpm cache because
177
+ // its exclusion list (separate from the company DEFAULT_IGNORES) missed
178
+ // .pnpm-store — 9.5k+ junk files dominated the personal sync count.
179
+ [".pnpm-store/v10/files/00/abc", "pnpm-store"],
180
+ ["personal/x/.pnpm-store/v10/y", "pnpm-store"],
176
181
  ])("matches %s as %s", (p, expectedId) => {
177
182
  expect(matchPersonalVaultExclusion(p)?.id).toBe(expectedId);
178
183
  });