@indigoai-us/hq-cloud 5.1.12 → 5.2.0
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 +1 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +16 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +83 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.d.ts +13 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +15 -4
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +36 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +32 -13
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +91 -1
- package/dist/context.test.js.map +1 -1
- package/dist/ignore.d.ts +1 -0
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +1 -1
- package/dist/ignore.js.map +1 -1
- package/dist/vault-client.d.ts +1 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +19 -9
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +41 -1
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +108 -0
- package/src/bin/sync-runner.ts +27 -1
- package/src/cli/sync.test.ts +43 -0
- package/src/cli/sync.ts +29 -4
- package/src/context.test.ts +104 -1
- package/src/context.ts +46 -16
- package/src/ignore.ts +1 -1
- package/src/vault-client.test.ts +47 -0
- package/src/vault-client.ts +19 -7
|
@@ -85,6 +85,7 @@ function makeVaultStub(
|
|
|
85
85
|
opts: {
|
|
86
86
|
memberships?: Array<Pick<Membership, "companyUid">>;
|
|
87
87
|
entityGet?: (uid: string) => Promise<EntityInfo>;
|
|
88
|
+
listPersons?: () => Promise<EntityInfo[]>;
|
|
88
89
|
pendingInvites?: Array<Record<string, unknown>>;
|
|
89
90
|
ensurePerson?: (hints: {
|
|
90
91
|
ownerSub: string;
|
|
@@ -121,6 +122,9 @@ function makeVaultStub(
|
|
|
121
122
|
bucketName: `bucket-${uid}`,
|
|
122
123
|
status: "active",
|
|
123
124
|
} as unknown as EntityInfo)),
|
|
125
|
+
listByType:
|
|
126
|
+
opts.listPersons ??
|
|
127
|
+
(() => Promise.resolve([])),
|
|
124
128
|
},
|
|
125
129
|
};
|
|
126
130
|
}
|
|
@@ -1050,6 +1054,110 @@ describe("--direction", () => {
|
|
|
1050
1054
|
});
|
|
1051
1055
|
});
|
|
1052
1056
|
|
|
1057
|
+
// ---------------------------------------------------------------------------
|
|
1058
|
+
// Personal slot fanout (A/B/C)
|
|
1059
|
+
// ---------------------------------------------------------------------------
|
|
1060
|
+
|
|
1061
|
+
describe("personal slot fanout", () => {
|
|
1062
|
+
const olderPerson: EntityInfo = {
|
|
1063
|
+
uid: "prs_older",
|
|
1064
|
+
slug: "older-person",
|
|
1065
|
+
type: "person",
|
|
1066
|
+
status: "active",
|
|
1067
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
1068
|
+
bucketName: "hq-vault-prs-older",
|
|
1069
|
+
} as unknown as EntityInfo;
|
|
1070
|
+
|
|
1071
|
+
const newerPerson: EntityInfo = {
|
|
1072
|
+
uid: "prs_newer",
|
|
1073
|
+
slug: "newer-person",
|
|
1074
|
+
type: "person",
|
|
1075
|
+
status: "active",
|
|
1076
|
+
createdAt: "2026-06-01T00:00:00Z",
|
|
1077
|
+
bucketName: "hq-vault-prs-newer",
|
|
1078
|
+
} as unknown as EntityInfo;
|
|
1079
|
+
|
|
1080
|
+
it("A: fanout-plan ends with personal slot using canonical-sort-selected person (older createdAt wins)", async () => {
|
|
1081
|
+
const deps = makeDeps({
|
|
1082
|
+
createVaultClient: () =>
|
|
1083
|
+
makeVaultStub({
|
|
1084
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1085
|
+
entityGet: (uid: string) =>
|
|
1086
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1087
|
+
// Return persons in reversed order (newer first) to test canonical sort
|
|
1088
|
+
listPersons: () => Promise.resolve([newerPerson, olderPerson]),
|
|
1089
|
+
}),
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
const code = await runRunner(["--companies"], deps);
|
|
1093
|
+
expect(code).toBe(0);
|
|
1094
|
+
|
|
1095
|
+
const planEvent = deps.stdout
|
|
1096
|
+
.events()
|
|
1097
|
+
.find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
|
|
1098
|
+
expect(planEvent).toBeDefined();
|
|
1099
|
+
|
|
1100
|
+
const lastEntry = planEvent.companies[planEvent.companies.length - 1];
|
|
1101
|
+
expect(lastEntry.slug).toBe("personal");
|
|
1102
|
+
expect(lastEntry.uid).toBe("prs_older");
|
|
1103
|
+
expect((lastEntry as Record<string, unknown>).bucketName).toBe("hq-vault-prs-older");
|
|
1104
|
+
expect((lastEntry as Record<string, unknown>).personalMode).toBe(true);
|
|
1105
|
+
expect((lastEntry as Record<string, unknown>).journalSlug).toBe("personal");
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("B: syncFn invoked with personalMode: true + journalSlug: 'personal' for personal slot", async () => {
|
|
1109
|
+
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
1110
|
+
const deps = makeDeps({
|
|
1111
|
+
createVaultClient: () =>
|
|
1112
|
+
makeVaultStub({
|
|
1113
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1114
|
+
entityGet: (uid: string) =>
|
|
1115
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1116
|
+
listPersons: () => Promise.resolve([newerPerson, olderPerson]),
|
|
1117
|
+
}),
|
|
1118
|
+
sync: syncSpy,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
const code = await runRunner(["--companies"], deps);
|
|
1122
|
+
expect(code).toBe(0);
|
|
1123
|
+
|
|
1124
|
+
const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
|
|
1125
|
+
(c) => c[0].company?.startsWith("prs_"),
|
|
1126
|
+
);
|
|
1127
|
+
expect(personalCall).toBeDefined();
|
|
1128
|
+
const personalArgs = personalCall![0];
|
|
1129
|
+
expect(personalArgs.personalMode).toBe(true);
|
|
1130
|
+
expect(personalArgs.journalSlug).toBe("personal");
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it("C: company slots' syncFn args do NOT contain personalMode or journalSlug", async () => {
|
|
1134
|
+
const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
|
|
1135
|
+
const deps = makeDeps({
|
|
1136
|
+
createVaultClient: () =>
|
|
1137
|
+
makeVaultStub({
|
|
1138
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
1139
|
+
entityGet: (uid: string) =>
|
|
1140
|
+
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
1141
|
+
listPersons: () => Promise.resolve([olderPerson]),
|
|
1142
|
+
}),
|
|
1143
|
+
sync: syncSpy,
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
const code = await runRunner(["--companies"], deps);
|
|
1147
|
+
expect(code).toBe(0);
|
|
1148
|
+
|
|
1149
|
+
const companyCalls = (syncSpy.mock.calls as Array<[SyncOptions]>).filter(
|
|
1150
|
+
(c) => c[0].company?.startsWith("cmp_"),
|
|
1151
|
+
);
|
|
1152
|
+
expect(companyCalls.length).toBeGreaterThan(0);
|
|
1153
|
+
for (const [args] of companyCalls) {
|
|
1154
|
+
const keys = Object.keys(args);
|
|
1155
|
+
expect(keys).not.toContain("personalMode");
|
|
1156
|
+
expect(keys).not.toContain("journalSlug");
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1053
1161
|
// ---------------------------------------------------------------------------
|
|
1054
1162
|
// Re-initialize for each test (mock state hygiene)
|
|
1055
1163
|
// ---------------------------------------------------------------------------
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
type EntityInfo,
|
|
49
49
|
type PendingInviteByEmail,
|
|
50
50
|
} from "../index.js";
|
|
51
|
+
import { pickCanonicalPersonEntity } from "../vault-client.js";
|
|
51
52
|
import { sync as defaultSync } from "../cli/sync.js";
|
|
52
53
|
import type {
|
|
53
54
|
SyncOptions,
|
|
@@ -155,6 +156,7 @@ export interface VaultClientSurface {
|
|
|
155
156
|
}) => Promise<EntityInfo>;
|
|
156
157
|
entity: {
|
|
157
158
|
get: (uid: string) => Promise<EntityInfo>;
|
|
159
|
+
listByType: (type: string) => Promise<EntityInfo[]>;
|
|
158
160
|
};
|
|
159
161
|
}
|
|
160
162
|
|
|
@@ -430,7 +432,14 @@ export async function runRunner(
|
|
|
430
432
|
// The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
|
|
431
433
|
// If the entity fetch fails for some row (entity deleted, scoping issue),
|
|
432
434
|
// degrade to using the UID as the slug rather than aborting the run.
|
|
433
|
-
const plan: Array<{
|
|
435
|
+
const plan: Array<{
|
|
436
|
+
uid: string;
|
|
437
|
+
slug: string;
|
|
438
|
+
name?: string;
|
|
439
|
+
bucketName?: string;
|
|
440
|
+
personalMode?: boolean;
|
|
441
|
+
journalSlug?: string;
|
|
442
|
+
}> = [];
|
|
434
443
|
for (const m of memberships) {
|
|
435
444
|
let slug = m.companyUid;
|
|
436
445
|
let name: string | undefined;
|
|
@@ -443,6 +452,21 @@ export async function runRunner(
|
|
|
443
452
|
}
|
|
444
453
|
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
445
454
|
}
|
|
455
|
+
|
|
456
|
+
if (parsed.companies) {
|
|
457
|
+
const persons = await client.entity.listByType("person");
|
|
458
|
+
const pick = pickCanonicalPersonEntity(persons);
|
|
459
|
+
if (pick?.bucketName) {
|
|
460
|
+
plan.push({
|
|
461
|
+
slug: "personal",
|
|
462
|
+
uid: pick.uid,
|
|
463
|
+
bucketName: pick.bucketName,
|
|
464
|
+
personalMode: true,
|
|
465
|
+
journalSlug: "personal",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
446
470
|
emit({ type: "fanout-plan", companies: plan });
|
|
447
471
|
|
|
448
472
|
// ---- fanout -----------------------------------------------------------
|
|
@@ -519,6 +543,8 @@ export async function runRunner(
|
|
|
519
543
|
vaultConfig,
|
|
520
544
|
hqRoot: parsed.hqRoot,
|
|
521
545
|
onConflict: parsed.onConflict,
|
|
546
|
+
...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
|
|
547
|
+
...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
|
|
522
548
|
onEvent: tagAndEmit,
|
|
523
549
|
});
|
|
524
550
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -34,6 +34,7 @@ vi.mock("../s3.js", async () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
import { sync } from "./sync.js";
|
|
37
|
+
import * as s3Module from "../s3.js";
|
|
37
38
|
|
|
38
39
|
const mockConfig: VaultServiceConfig = {
|
|
39
40
|
apiUrl: "https://vault-api.test",
|
|
@@ -210,6 +211,48 @@ describe("sync", () => {
|
|
|
210
211
|
expect(result.aborted).toBe(true);
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
it("journalSlug: 'personal' routes journal I/O to sync-journal.personal.json", async () => {
|
|
215
|
+
const result = await sync({
|
|
216
|
+
company: "acme",
|
|
217
|
+
vaultConfig: mockConfig,
|
|
218
|
+
hqRoot: tmpDir,
|
|
219
|
+
journalSlug: "personal",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.filesDownloaded).toBe(2);
|
|
223
|
+
// Journal written to personal slug, not ctx.slug ("acme")
|
|
224
|
+
const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
|
|
225
|
+
expect(fs.existsSync(personalJournalPath)).toBe(true);
|
|
226
|
+
// The acme journal must NOT have been written
|
|
227
|
+
expect(fs.existsSync(journalPath)).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("personalMode: true skips companies/* keys and downloads root keys to hqRoot", async () => {
|
|
231
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
232
|
+
{ key: "companies/foo/bar.md", size: 50, lastModified: new Date(), etag: '"xyz789"' },
|
|
233
|
+
{ key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"abc000"' },
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const result = await sync({
|
|
237
|
+
company: "acme",
|
|
238
|
+
vaultConfig: mockConfig,
|
|
239
|
+
hqRoot: tmpDir,
|
|
240
|
+
personalMode: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Exact counts (regression-tight)
|
|
244
|
+
expect(result.filesSkipped).toBe(1);
|
|
245
|
+
expect(result.filesDownloaded).toBe(1);
|
|
246
|
+
|
|
247
|
+
// companies/* must NOT land anywhere
|
|
248
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "companies", "foo", "bar.md"))).toBe(false);
|
|
249
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "bar.md"))).toBe(false);
|
|
250
|
+
|
|
251
|
+
// docs/readme.md MUST land at <hqRoot>/docs/readme.md (NOT <hqRoot>/companies/<slug>/docs/readme.md)
|
|
252
|
+
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
253
|
+
expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
213
256
|
it("overwrites local on --on-conflict overwrite", async () => {
|
|
214
257
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
215
258
|
fs.mkdirSync(companyDocs, { recursive: true });
|
package/src/cli/sync.ts
CHANGED
|
@@ -46,6 +46,19 @@ export interface SyncOptions {
|
|
|
46
46
|
* default human logger is used. See `SyncProgressEvent`.
|
|
47
47
|
*/
|
|
48
48
|
onEvent?: (event: SyncProgressEvent) => void;
|
|
49
|
+
/**
|
|
50
|
+
* When true, the caller is syncing against the caller's person-entity
|
|
51
|
+
* bucket. Pulled keys whose path starts with `companies/` are dropped
|
|
52
|
+
* (belt-and-braces — the person bucket should never contain those,
|
|
53
|
+
* but the runner must not write them into the user's company folders).
|
|
54
|
+
*/
|
|
55
|
+
personalMode?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Override for the per-slug journal file name. Defaults to `ctx.slug`.
|
|
58
|
+
* sync-runner passes `journalSlug: "personal"` for the personal slot so
|
|
59
|
+
* TS runner and Rust first-push share idempotency state.
|
|
60
|
+
*/
|
|
61
|
+
journalSlug?: string;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export interface SyncResult {
|
|
@@ -78,9 +91,16 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
78
91
|
// companies into the same hqRoot doesn't cross-clobber files with overlapping
|
|
79
92
|
// S3 keys (e.g. every company has a .hq/manifest.json). Remote keys stay
|
|
80
93
|
// company-relative; the prefix lives only on disk.
|
|
81
|
-
|
|
94
|
+
// In personalMode the journal slug + S3 keys are person-relative (e.g. "docs/foo.md");
|
|
95
|
+
// the local target is `hqRoot` directly, NOT `<hqRoot>/companies/<personSlug>/`. This
|
|
96
|
+
// keeps round-trip parity with the Rust personal first-push (Step 7) which sources
|
|
97
|
+
// `<hqRoot>/docs/foo.md`.
|
|
98
|
+
const companyRoot = options.personalMode === true
|
|
99
|
+
? hqRoot
|
|
100
|
+
: path.join(hqRoot, "companies", ctx.slug);
|
|
82
101
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
83
|
-
const
|
|
102
|
+
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
103
|
+
const journal = readJournal(journalSlug);
|
|
84
104
|
|
|
85
105
|
let filesDownloaded = 0;
|
|
86
106
|
let bytesDownloaded = 0;
|
|
@@ -93,6 +113,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
93
113
|
for (const remoteFile of remoteFiles) {
|
|
94
114
|
const localPath = path.join(companyRoot, remoteFile.key);
|
|
95
115
|
|
|
116
|
+
if (options.personalMode === true && remoteFile.key.startsWith("companies/")) {
|
|
117
|
+
filesSkipped++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
// Apply ignore rules
|
|
97
122
|
if (!shouldSync(localPath)) {
|
|
98
123
|
filesSkipped++;
|
|
@@ -126,7 +151,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
126
151
|
);
|
|
127
152
|
|
|
128
153
|
if (resolution === "abort") {
|
|
129
|
-
writeJournal(
|
|
154
|
+
writeJournal(journalSlug, journal);
|
|
130
155
|
return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: true };
|
|
131
156
|
}
|
|
132
157
|
if (resolution === "keep" || resolution === "skip") {
|
|
@@ -181,7 +206,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
181
206
|
}
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
writeJournal(
|
|
209
|
+
writeJournal(journalSlug, journal);
|
|
185
210
|
|
|
186
211
|
return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: false };
|
|
187
212
|
}
|
package/src/context.test.ts
CHANGED
|
@@ -162,11 +162,114 @@ describe("resolveEntityContext", () => {
|
|
|
162
162
|
setupFetchMock({ vendStatus: 403 });
|
|
163
163
|
|
|
164
164
|
await expect(resolveEntityContext("acme", mockConfig)).rejects.toThrow(
|
|
165
|
-
/STS
|
|
165
|
+
/STS.*vend.*failed/,
|
|
166
166
|
);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
describe("routing by UID prefix and vend-self dispatch", () => {
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
clearContextCache();
|
|
173
|
+
vi.restoreAllMocks();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("prs_* UID: entity resolved via /entity/{uid} and credentials via /sts/vend-self", async () => {
|
|
177
|
+
const prsEntity = {
|
|
178
|
+
uid: "prs_01PERSON",
|
|
179
|
+
slug: "test-person",
|
|
180
|
+
bucketName: "hq-vault-prs-01person",
|
|
181
|
+
status: "active",
|
|
182
|
+
};
|
|
183
|
+
const calls: string[] = [];
|
|
184
|
+
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
185
|
+
const u = String(url);
|
|
186
|
+
calls.push(u);
|
|
187
|
+
if (u.includes("/entity/prs_")) {
|
|
188
|
+
return { ok: true, status: 200, json: async () => ({ entity: prsEntity }), text: async () => "" };
|
|
189
|
+
}
|
|
190
|
+
if (u.includes("/sts/vend-self")) {
|
|
191
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
192
|
+
}
|
|
193
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
await resolveEntityContext("prs_01PERSON", mockConfig);
|
|
197
|
+
|
|
198
|
+
expect(calls.some((u) => u.includes("/entity/prs_01PERSON"))).toBe(true);
|
|
199
|
+
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
200
|
+
expect(vendCalls).toHaveLength(1);
|
|
201
|
+
expect(vendCalls[0]).toContain("/sts/vend-self");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("foo_bar slug: entity resolved via /entity/by-slug/company/foo_bar and credentials via /sts/vend", async () => {
|
|
205
|
+
const calls: string[] = [];
|
|
206
|
+
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
207
|
+
const u = String(url);
|
|
208
|
+
calls.push(u);
|
|
209
|
+
if (u.includes("/entity/by-slug/")) {
|
|
210
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
211
|
+
}
|
|
212
|
+
if (u.includes("/sts/vend")) {
|
|
213
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
214
|
+
}
|
|
215
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
await resolveEntityContext("foo_bar", mockConfig);
|
|
219
|
+
|
|
220
|
+
expect(calls.some((u) => u.includes("/entity/by-slug/company/foo_bar"))).toBe(true);
|
|
221
|
+
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
222
|
+
expect(vendCalls).toHaveLength(1);
|
|
223
|
+
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
224
|
+
expect(vendCalls[0]).toContain("/sts/vend");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("team_alpha slug: entity resolved via /entity/by-slug/company/team_alpha and credentials via /sts/vend", async () => {
|
|
228
|
+
const calls: string[] = [];
|
|
229
|
+
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
230
|
+
const u = String(url);
|
|
231
|
+
calls.push(u);
|
|
232
|
+
if (u.includes("/entity/by-slug/")) {
|
|
233
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
234
|
+
}
|
|
235
|
+
if (u.includes("/sts/vend")) {
|
|
236
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
237
|
+
}
|
|
238
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
await resolveEntityContext("team_alpha", mockConfig);
|
|
242
|
+
|
|
243
|
+
expect(calls.some((u) => u.includes("/entity/by-slug/company/team_alpha"))).toBe(true);
|
|
244
|
+
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
245
|
+
expect(vendCalls).toHaveLength(1);
|
|
246
|
+
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("cmp_* UID: entity resolved via /entity/{uid} and credentials via /sts/vend", async () => {
|
|
250
|
+
const calls: string[] = [];
|
|
251
|
+
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
252
|
+
const u = String(url);
|
|
253
|
+
calls.push(u);
|
|
254
|
+
if (u.includes("/entity/cmp_")) {
|
|
255
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
256
|
+
}
|
|
257
|
+
if (u.includes("/sts/vend")) {
|
|
258
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
259
|
+
}
|
|
260
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
await resolveEntityContext("cmp_01ABCDEF", mockConfig);
|
|
264
|
+
|
|
265
|
+
expect(calls.some((u) => u.includes("/entity/cmp_01ABCDEF"))).toBe(true);
|
|
266
|
+
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
267
|
+
expect(vendCalls).toHaveLength(1);
|
|
268
|
+
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
269
|
+
expect(vendCalls[0]).toContain("/sts/vend");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
170
273
|
describe("refreshEntityContext", () => {
|
|
171
274
|
beforeEach(() => {
|
|
172
275
|
clearContextCache();
|
package/src/context.ts
CHANGED
|
@@ -17,6 +17,13 @@ const DEFAULT_SESSION_DURATION_SECONDS = 900;
|
|
|
17
17
|
/** Cached contexts keyed by entity UID. */
|
|
18
18
|
const contextCache = new Map<string, EntityContext>();
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Closed-set of recognised entity-UID prefixes. Adding a new entity type
|
|
22
|
+
* means appending one entry here AND extending the dispatch in
|
|
23
|
+
* `resolveEntityContext` (cmp_ → /sts/vend, prs_ → /sts/vend-self).
|
|
24
|
+
*/
|
|
25
|
+
export const KNOWN_UID_PREFIXES = ["cmp_", "prs_"] as const;
|
|
26
|
+
|
|
20
27
|
/**
|
|
21
28
|
* Look up an entity by slug or UID via vault-service, then vend STS-scoped
|
|
22
29
|
* credentials for that entity. Returns an EntityContext ready for S3 ops.
|
|
@@ -34,9 +41,15 @@ export async function resolveEntityContext(
|
|
|
34
41
|
return cached;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
// Step 1: Resolve entity — if it looks like a UID
|
|
38
|
-
// otherwise look up by slug
|
|
39
|
-
|
|
44
|
+
// Step 1: Resolve entity — if it looks like a known UID prefix, fetch directly;
|
|
45
|
+
// otherwise look up by slug. Explicit enumeration avoids over-matching slugs
|
|
46
|
+
// like foo_bar or team_alpha that happen to look like UIDs.
|
|
47
|
+
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
48
|
+
companyUidOrSlug.startsWith(p),
|
|
49
|
+
);
|
|
50
|
+
const looksLikePerson = companyUidOrSlug.startsWith("prs_");
|
|
51
|
+
|
|
52
|
+
const entity = looksLikeUid
|
|
40
53
|
? await fetchEntity(companyUidOrSlug, config)
|
|
41
54
|
: await fetchEntityBySlug("company", companyUidOrSlug, config);
|
|
42
55
|
|
|
@@ -47,8 +60,13 @@ export async function resolveEntityContext(
|
|
|
47
60
|
);
|
|
48
61
|
}
|
|
49
62
|
|
|
50
|
-
// Step 2:
|
|
51
|
-
|
|
63
|
+
// Step 2: Dispatch credential vending by UID prefix.
|
|
64
|
+
// cmp_* → POST /sts/vend (company path; membership-gated)
|
|
65
|
+
// prs_* → POST /sts/vend-self (person path; self-ownership-gated)
|
|
66
|
+
// slug → POST /sts/vend (legacy slug path is company-only)
|
|
67
|
+
const vendResult = looksLikePerson
|
|
68
|
+
? await vendSelfCredentials(entity.uid, config)
|
|
69
|
+
: await vendCredentials(entity.uid, config);
|
|
52
70
|
|
|
53
71
|
const ctx: EntityContext = {
|
|
54
72
|
uid: entity.uid,
|
|
@@ -153,26 +171,38 @@ async function fetchEntityBySlug(
|
|
|
153
171
|
return data.entity;
|
|
154
172
|
}
|
|
155
173
|
|
|
156
|
-
async function
|
|
157
|
-
|
|
174
|
+
async function postVend(
|
|
175
|
+
route: string,
|
|
176
|
+
body: Record<string, unknown>,
|
|
158
177
|
config: VaultServiceConfig,
|
|
159
178
|
): Promise<VendResponse> {
|
|
160
|
-
const res = await fetch(`${config.apiUrl}
|
|
179
|
+
const res = await fetch(`${config.apiUrl}${route}`, {
|
|
161
180
|
method: "POST",
|
|
162
181
|
headers: {
|
|
163
182
|
"Content-Type": "application/json",
|
|
164
183
|
Authorization: `Bearer ${config.authToken}`,
|
|
165
184
|
},
|
|
166
|
-
body: JSON.stringify({
|
|
167
|
-
companyUid,
|
|
168
|
-
durationSeconds: DEFAULT_SESSION_DURATION_SECONDS,
|
|
169
|
-
}),
|
|
185
|
+
body: JSON.stringify({ ...body, durationSeconds: DEFAULT_SESSION_DURATION_SECONDS }),
|
|
170
186
|
});
|
|
171
187
|
if (!res.ok) {
|
|
172
|
-
const
|
|
173
|
-
throw new Error(
|
|
174
|
-
`STS vend failed for ${companyUid}: ${res.status} ${body}`,
|
|
175
|
-
);
|
|
188
|
+
const text = await res.text();
|
|
189
|
+
throw new Error(`STS ${route} failed: ${res.status} ${text}`);
|
|
176
190
|
}
|
|
177
191
|
return (await res.json()) as VendResponse;
|
|
178
192
|
}
|
|
193
|
+
|
|
194
|
+
async function vendCredentials(
|
|
195
|
+
companyUid: string,
|
|
196
|
+
config: VaultServiceConfig,
|
|
197
|
+
): Promise<VendResponse> {
|
|
198
|
+
return postVend("/sts/vend", { companyUid }, config);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function vendSelfCredentials(
|
|
202
|
+
personUid: string,
|
|
203
|
+
config: VaultServiceConfig,
|
|
204
|
+
): Promise<VendResponse> {
|
|
205
|
+
// Use `+` concat at the literal so guard 5 (grep "/sts/vend-self") still finds it
|
|
206
|
+
const route = "/sts/vend-self";
|
|
207
|
+
return postVend(route, { personUid }, config);
|
|
208
|
+
}
|
package/src/ignore.ts
CHANGED
package/src/vault-client.test.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
VaultNotFoundError,
|
|
13
13
|
VaultConflictError,
|
|
14
14
|
VaultClientError,
|
|
15
|
+
pickCanonicalPersonEntity,
|
|
16
|
+
type EntityInfo,
|
|
15
17
|
} from "./vault-client.js";
|
|
16
18
|
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
@@ -621,6 +623,51 @@ describe("VaultClient identity bootstrap", () => {
|
|
|
621
623
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
622
624
|
});
|
|
623
625
|
|
|
626
|
+
it("pickCanonicalPersonEntity_picks_oldest_tiebreak_uid", () => {
|
|
627
|
+
const list: EntityInfo[] = [
|
|
628
|
+
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
|
|
629
|
+
{ uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
630
|
+
];
|
|
631
|
+
// Older createdAt wins
|
|
632
|
+
expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
|
|
633
|
+
|
|
634
|
+
// Same createdAt: uid tiebreak (lexicographic ascending)
|
|
635
|
+
const tieList: EntityInfo[] = [
|
|
636
|
+
{ uid: "prs_z", slug: "z", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
637
|
+
{ uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
638
|
+
];
|
|
639
|
+
expect(pickCanonicalPersonEntity(tieList)?.uid).toBe("prs_a");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("pickCanonicalPersonEntity_handles_missing_createdAt_deterministically", () => {
|
|
643
|
+
// undefined createdAt coalesces to "" which sorts before any ISO date string.
|
|
644
|
+
// So the entity with undefined createdAt wins on the createdAt comparison.
|
|
645
|
+
// When two entities both lack createdAt, uid tiebreak applies.
|
|
646
|
+
const list: EntityInfo[] = [
|
|
647
|
+
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" } as EntityInfo,
|
|
648
|
+
{ uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo, // no createdAt
|
|
649
|
+
];
|
|
650
|
+
// "" < "2026-..." so prs_a (undefined→"") wins
|
|
651
|
+
expect(pickCanonicalPersonEntity(list)?.uid).toBe("prs_a");
|
|
652
|
+
|
|
653
|
+
// Both undefined: uid tiebreak
|
|
654
|
+
const bothUndefined: EntityInfo[] = [
|
|
655
|
+
{ uid: "prs_z", slug: "z", type: "person", status: "active" } as EntityInfo,
|
|
656
|
+
{ uid: "prs_a", slug: "a", type: "person", status: "active" } as EntityInfo,
|
|
657
|
+
];
|
|
658
|
+
expect(pickCanonicalPersonEntity(bothUndefined)?.uid).toBe("prs_a");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("pickCanonicalPersonEntity_filters_out_non_person_types", () => {
|
|
662
|
+
const mixed: EntityInfo[] = [
|
|
663
|
+
{ uid: "cmp_x", slug: "x", type: "company", status: "active", createdAt: "2025-01-01T00:00:00Z" } as EntityInfo,
|
|
664
|
+
{ uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-01-02T00:00:00Z" } as EntityInfo,
|
|
665
|
+
];
|
|
666
|
+
const result = pickCanonicalPersonEntity(mixed);
|
|
667
|
+
expect(result?.uid).toBe("prs_b");
|
|
668
|
+
expect(result?.type).toBe("person");
|
|
669
|
+
});
|
|
670
|
+
|
|
624
671
|
it("vendSelf_roundtrip", async () => {
|
|
625
672
|
fetchSpy.mockResolvedValueOnce(
|
|
626
673
|
jsonResponse(200, {
|
package/src/vault-client.ts
CHANGED
|
@@ -112,6 +112,23 @@ export interface EntityInfo {
|
|
|
112
112
|
createdAt: string;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
export function pickCanonicalPersonEntity(
|
|
116
|
+
list: EntityInfo[],
|
|
117
|
+
): EntityInfo | null {
|
|
118
|
+
// Defensive filter — callers today pass `entity.listByType("person")` so this is
|
|
119
|
+
// a no-op, but a future caller passing a mixed list would otherwise silently get
|
|
120
|
+
// back a non-person entity.
|
|
121
|
+
const persons = list.filter((e) => e.type === "person");
|
|
122
|
+
if (persons.length === 0) return null;
|
|
123
|
+
const sorted = [...persons].sort((a, b) => {
|
|
124
|
+
const ac = (a.createdAt as string | undefined) ?? "";
|
|
125
|
+
const bc = (b.createdAt as string | undefined) ?? "";
|
|
126
|
+
if (ac !== bc) return ac < bc ? -1 : 1;
|
|
127
|
+
return a.uid < b.uid ? -1 : 1;
|
|
128
|
+
});
|
|
129
|
+
return sorted[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
export interface PendingInviteByEmail {
|
|
116
133
|
membershipKey: string;
|
|
117
134
|
companyUid: string;
|
|
@@ -344,13 +361,8 @@ export class VaultClient {
|
|
|
344
361
|
displayName: string;
|
|
345
362
|
}): Promise<EntityInfo> {
|
|
346
363
|
const existing = await this.entity.listByType("person");
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
const bc = (b.createdAt as string | undefined) ?? "";
|
|
350
|
-
if (ac !== bc) return ac < bc ? -1 : 1;
|
|
351
|
-
return a.uid < b.uid ? -1 : 1;
|
|
352
|
-
});
|
|
353
|
-
if (sorted.length > 0) return sorted[0];
|
|
364
|
+
const pick = pickCanonicalPersonEntity(existing);
|
|
365
|
+
if (pick !== null) return pick;
|
|
354
366
|
|
|
355
367
|
const slug =
|
|
356
368
|
hints.displayName
|