@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.
- 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/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +11 -0
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +5 -0
- package/dist/personal-vault-exclusions.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/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/personal-vault-exclusions.test.ts +5 -0
- package/src/personal-vault-exclusions.ts +11 -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
|
@@ -16,13 +16,15 @@ import {
|
|
|
16
16
|
runRunnerWithLoop,
|
|
17
17
|
resolveDeletePolicy,
|
|
18
18
|
resolveSkipPersonal,
|
|
19
|
-
|
|
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
|
-
//
|
|
3197
|
+
// selectObjectIOFactory — cmp_ ALWAYS presign (HQ-59), prs_ stays direct-S3
|
|
3196
3198
|
//
|
|
3197
|
-
//
|
|
3198
|
-
//
|
|
3199
|
-
//
|
|
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("
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
}
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
(
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
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("
|
|
3236
|
-
expect(
|
|
3237
|
-
expect(
|
|
3238
|
-
|
|
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
|
+
});
|
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: {
|
|
@@ -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
|
});
|