@indigoai-us/hq-cloud 5.1.8 → 5.1.10

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 (51) hide show
  1. package/dist/auth.js +2 -2
  2. package/dist/auth.js.map +1 -1
  3. package/dist/bin/sync-runner.d.ts +26 -3
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +77 -2
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +165 -9
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/accept.js +2 -2
  10. package/dist/cli/accept.js.map +1 -1
  11. package/dist/cli/share.d.ts.map +1 -1
  12. package/dist/cli/share.js +23 -7
  13. package/dist/cli/share.js.map +1 -1
  14. package/dist/cli/share.test.js +51 -13
  15. package/dist/cli/share.test.js.map +1 -1
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +6 -1
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +31 -12
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts +13 -2
  22. package/dist/cognito-auth.d.ts.map +1 -1
  23. package/dist/cognito-auth.js +18 -9
  24. package/dist/cognito-auth.js.map +1 -1
  25. package/dist/cognito-auth.test.d.ts +3 -3
  26. package/dist/cognito-auth.test.js +21 -10
  27. package/dist/cognito-auth.test.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/vault-client.d.ts +43 -0
  32. package/dist/vault-client.d.ts.map +1 -1
  33. package/dist/vault-client.js +53 -0
  34. package/dist/vault-client.js.map +1 -1
  35. package/dist/vault-client.test.js +135 -0
  36. package/dist/vault-client.test.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/auth.ts +2 -2
  39. package/src/bin/sync-runner.test.ts +200 -13
  40. package/src/bin/sync-runner.ts +114 -5
  41. package/src/cli/accept.ts +2 -2
  42. package/src/cli/share.test.ts +59 -13
  43. package/src/cli/share.ts +25 -6
  44. package/src/cli/sync.test.ts +33 -12
  45. package/src/cli/sync.ts +6 -1
  46. package/src/cognito-auth.test.ts +22 -14
  47. package/src/cognito-auth.ts +31 -11
  48. package/src/index.ts +1 -0
  49. package/src/vault-client.test.ts +173 -0
  50. package/src/vault-client.ts +78 -0
  51. package/test/invite-flow.integration.test.ts +1 -1
@@ -15,7 +15,11 @@ import type {
15
15
  VaultClientSurface,
16
16
  } from "./sync-runner.js";
17
17
  import type { SyncResult, SyncOptions } from "../cli/sync.js";
18
- import type { Membership, EntityInfo } from "../vault-client.js";
18
+ import type {
19
+ Membership,
20
+ EntityInfo,
21
+ PendingInviteByEmail,
22
+ } from "../vault-client.js";
19
23
  import { VaultAuthError } from "../vault-client.js";
20
24
 
21
25
  // ---------------------------------------------------------------------------
@@ -70,11 +74,31 @@ function makeVaultStub(
70
74
  opts: {
71
75
  memberships?: Array<Pick<Membership, "companyUid">>;
72
76
  entityGet?: (uid: string) => Promise<EntityInfo>;
77
+ pendingInvites?: Array<Record<string, unknown>>;
78
+ ensurePerson?: (hints: {
79
+ ownerSub: string;
80
+ displayName: string;
81
+ }) => Promise<EntityInfo>;
82
+ claim?: (personUid: string) => Promise<void>;
73
83
  } = {},
74
84
  ): VaultClientSurface {
75
85
  const memberships = opts.memberships ?? [];
86
+ const pending = opts.pendingInvites ?? [];
76
87
  return {
77
88
  listMyMemberships: () => Promise.resolve(memberships as Membership[]),
89
+ listMyPendingInvitesByEmail: () =>
90
+ Promise.resolve(pending as unknown as PendingInviteByEmail[]),
91
+ claimPendingInvitesByEmail:
92
+ opts.claim ?? (() => Promise.resolve(undefined)),
93
+ ensureMyPersonEntity:
94
+ opts.ensurePerson ??
95
+ (() =>
96
+ Promise.resolve({
97
+ uid: "ent_person_default",
98
+ type: "person",
99
+ slug: "default-person",
100
+ status: "active",
101
+ } as unknown as EntityInfo)),
78
102
  entity: {
79
103
  get:
80
104
  opts.entityGet ??
@@ -181,12 +205,9 @@ describe("auth", () => {
181
205
  it("emits auth-error when VaultAuthError thrown during discovery", async () => {
182
206
  const deps = makeDeps({
183
207
  createVaultClient: () => ({
208
+ ...makeVaultStub(),
184
209
  listMyMemberships: () =>
185
210
  Promise.reject(new VaultAuthError("token expired")),
186
- entity: {
187
- get: (uid: string) =>
188
- Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
189
- },
190
211
  }),
191
212
  });
192
213
  const code = await runRunner(["--companies"], deps);
@@ -199,11 +220,8 @@ describe("auth", () => {
199
220
  it("emits error event and returns 1 on non-auth discovery failure", async () => {
200
221
  const deps = makeDeps({
201
222
  createVaultClient: () => ({
223
+ ...makeVaultStub(),
202
224
  listMyMemberships: () => Promise.reject(new Error("network down")),
203
- entity: {
204
- get: (uid: string) =>
205
- Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
206
- },
207
225
  }),
208
226
  });
209
227
  const code = await runRunner(["--companies"], deps);
@@ -218,6 +236,150 @@ describe("auth", () => {
218
236
  });
219
237
  });
220
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // claim-dance (first sign-in)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe("claim-dance", () => {
244
+ const claims = {
245
+ sub: "sub-abc",
246
+ email: "stefan@getindigo.ai",
247
+ name: "Stefan Johnson",
248
+ };
249
+
250
+ it("claims pending invites + ensures person before listing memberships", async () => {
251
+ const ensureSpy = vi.fn().mockResolvedValue({
252
+ uid: "ent_person_stefan",
253
+ type: "person",
254
+ slug: "stefan-johnson",
255
+ status: "active",
256
+ });
257
+ const claimSpy = vi.fn().mockResolvedValue(undefined);
258
+ // First listMyMemberships returns the just-claimed row.
259
+ let listCalls = 0;
260
+ const stub = makeVaultStub({
261
+ pendingInvites: [
262
+ {
263
+ membershipKey: "inv_1",
264
+ companyUid: "cmp_indigo",
265
+ role: "owner",
266
+ invitedBy: "sub-admin",
267
+ invitedAt: "2026-04-20T00:00:00Z",
268
+ },
269
+ ],
270
+ ensurePerson: ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
271
+ claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
272
+ });
273
+ stub.listMyMemberships = () => {
274
+ listCalls++;
275
+ return Promise.resolve([{ companyUid: "cmp_indigo" }] as Membership[]);
276
+ };
277
+
278
+ const deps = makeDeps({
279
+ createVaultClient: () => stub,
280
+ getIdTokenClaims: () => claims,
281
+ });
282
+ const code = await runRunner(["--companies"], deps);
283
+ expect(code).toBe(0);
284
+ expect(ensureSpy).toHaveBeenCalledWith({
285
+ ownerSub: "sub-abc",
286
+ displayName: "Stefan Johnson",
287
+ });
288
+ expect(claimSpy).toHaveBeenCalledWith("ent_person_stefan");
289
+ expect(listCalls).toBe(1);
290
+ // setup-needed must NOT fire — the user has memberships after the claim.
291
+ expect(deps.stdout.events().some((e) => e.type === "setup-needed")).toBe(
292
+ false,
293
+ );
294
+ });
295
+
296
+ it("skips ensurePerson + claim when no pending invites exist", async () => {
297
+ const ensureSpy = vi.fn();
298
+ const claimSpy = vi.fn();
299
+ const deps = makeDeps({
300
+ createVaultClient: () =>
301
+ makeVaultStub({
302
+ pendingInvites: [],
303
+ ensurePerson:
304
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
305
+ claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
306
+ }),
307
+ getIdTokenClaims: () => claims,
308
+ });
309
+ const code = await runRunner(["--companies"], deps);
310
+ expect(code).toBe(0);
311
+ expect(ensureSpy).not.toHaveBeenCalled();
312
+ expect(claimSpy).not.toHaveBeenCalled();
313
+ // No memberships, no invites — truly empty → setup-needed is correct here.
314
+ expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
315
+ });
316
+
317
+ it("skips claim-dance entirely when no idToken claims are available", async () => {
318
+ const pendingSpy = vi.fn().mockResolvedValue([]);
319
+ const stub = makeVaultStub();
320
+ stub.listMyPendingInvitesByEmail =
321
+ pendingSpy as unknown as VaultClientSurface["listMyPendingInvitesByEmail"];
322
+ const deps = makeDeps({
323
+ createVaultClient: () => stub,
324
+ getIdTokenClaims: () => null,
325
+ });
326
+ await runRunner(["--companies"], deps);
327
+ expect(pendingSpy).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it("does not fail the run when claim-dance throws (best-effort)", async () => {
331
+ const stub = makeVaultStub({
332
+ memberships: [{ companyUid: "cmp_a" }],
333
+ });
334
+ stub.listMyPendingInvitesByEmail = () =>
335
+ Promise.reject(new Error("vault 500"));
336
+ const deps = makeDeps({
337
+ createVaultClient: () => stub,
338
+ getIdTokenClaims: () => claims,
339
+ });
340
+ const code = await runRunner(["--companies"], deps);
341
+ expect(code).toBe(0);
342
+ // Sync proceeds as usual for the existing membership.
343
+ expect(deps.sync).toHaveBeenCalledTimes(1);
344
+ expect(deps.stderr.raw()).toContain("claim-dance skipped");
345
+ });
346
+
347
+ it("falls back to given_name + family_name when name claim is absent", async () => {
348
+ const ensureSpy = vi.fn().mockResolvedValue({
349
+ uid: "ent_person_x",
350
+ type: "person",
351
+ slug: "x",
352
+ status: "active",
353
+ });
354
+ const deps = makeDeps({
355
+ createVaultClient: () =>
356
+ makeVaultStub({
357
+ pendingInvites: [
358
+ {
359
+ membershipKey: "inv_1",
360
+ companyUid: "cmp_x",
361
+ role: "owner",
362
+ invitedBy: "sub-admin",
363
+ invitedAt: "2026-04-20T00:00:00Z",
364
+ },
365
+ ],
366
+ ensurePerson:
367
+ ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
368
+ }),
369
+ getIdTokenClaims: () => ({
370
+ sub: "sub-xyz",
371
+ given_name: "Ada",
372
+ family_name: "Lovelace",
373
+ }),
374
+ });
375
+ await runRunner(["--companies"], deps);
376
+ expect(ensureSpy).toHaveBeenCalledWith({
377
+ ownerSub: "sub-xyz",
378
+ displayName: "Ada Lovelace",
379
+ });
380
+ });
381
+ });
382
+
221
383
  // ---------------------------------------------------------------------------
222
384
  // target resolution
223
385
  // ---------------------------------------------------------------------------
@@ -236,11 +398,11 @@ describe("target resolution", () => {
236
398
  const listSpy = vi.fn();
237
399
  const deps = makeDeps({
238
400
  createVaultClient: () => ({
239
- listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
240
- entity: {
241
- get: (uid: string) =>
401
+ ...makeVaultStub({
402
+ entityGet: (uid: string) =>
242
403
  Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
243
- },
404
+ }),
405
+ listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
244
406
  }),
245
407
  });
246
408
  const code = await runRunner(["--company", "cmp_abc"], deps);
@@ -303,6 +465,31 @@ describe("fanout-plan", () => {
303
465
  expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
304
466
  });
305
467
 
468
+ it("includes entity.name on plan entries when available", async () => {
469
+ const deps = makeDeps({
470
+ createVaultClient: () =>
471
+ makeVaultStub({
472
+ memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
473
+ entityGet: (uid: string) =>
474
+ Promise.resolve({
475
+ uid,
476
+ slug: uid === "cmp_a" ? "acme" : "beta",
477
+ name: uid === "cmp_a" ? "Acme Corp" : undefined,
478
+ } as unknown as EntityInfo),
479
+ }),
480
+ });
481
+
482
+ const code = await runRunner(["--companies"], deps);
483
+ expect(code).toBe(0);
484
+ const plan = deps.stdout
485
+ .events()
486
+ .find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
487
+ expect(plan.companies).toEqual([
488
+ { uid: "cmp_a", slug: "acme", name: "Acme Corp" },
489
+ { uid: "cmp_b", slug: "beta" },
490
+ ]);
491
+ });
492
+
306
493
  it("degrades to UID when entity.get returns falsy slug", async () => {
307
494
  const deps = makeDeps({
308
495
  createVaultClient: () =>
@@ -38,12 +38,15 @@ import * as fs from "fs";
38
38
  import { fileURLToPath } from "url";
39
39
  import {
40
40
  getValidAccessToken,
41
+ loadCachedTokens,
41
42
  VaultClient,
42
43
  VaultAuthError,
43
44
  type CognitoAuthConfig,
45
+ type CognitoTokens,
44
46
  type VaultServiceConfig,
45
47
  type Membership,
46
48
  type EntityInfo,
49
+ type PendingInviteByEmail,
47
50
  } from "../index.js";
48
51
  import { sync as defaultSync } from "../cli/sync.js";
49
52
  import type {
@@ -90,7 +93,7 @@ export type RunnerEvent =
90
93
  | { type: "auth-error"; message: string }
91
94
  | {
92
95
  type: "fanout-plan";
93
- companies: Array<{ uid: string; slug: string }>;
96
+ companies: Array<{ uid: string; slug: string; name?: string }>;
94
97
  }
95
98
  | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
96
99
  | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
@@ -113,11 +116,26 @@ export type RunnerEvent =
113
116
  */
114
117
  export interface VaultClientSurface {
115
118
  listMyMemberships: () => Promise<Membership[]>;
119
+ listMyPendingInvitesByEmail: () => Promise<PendingInviteByEmail[]>;
120
+ claimPendingInvitesByEmail: (personUid: string) => Promise<void>;
121
+ ensureMyPersonEntity: (hints: {
122
+ ownerSub: string;
123
+ displayName: string;
124
+ }) => Promise<EntityInfo>;
116
125
  entity: {
117
126
  get: (uid: string) => Promise<EntityInfo>;
118
127
  };
119
128
  }
120
129
 
130
+ /** Minimal shape of the claims we read off the Cognito idToken. */
131
+ interface IdTokenClaims {
132
+ sub?: string;
133
+ email?: string;
134
+ name?: string;
135
+ given_name?: string;
136
+ family_name?: string;
137
+ }
138
+
121
139
  export interface RunnerDeps {
122
140
  /** Where to write ndjson events. Defaults to `process.stdout`. */
123
141
  stdout?: { write: (chunk: string) => boolean | void };
@@ -125,16 +143,93 @@ export interface RunnerDeps {
125
143
  stderr?: { write: (chunk: string) => boolean | void };
126
144
  /** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
127
145
  getAccessToken?: () => Promise<string>;
146
+ /**
147
+ * Read the caller's identity claims (sub/email/name) off the cached Cognito
148
+ * idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
149
+ * when no cached tokens exist — the runner will then skip the claim-dance
150
+ * and fall through to the usual listMyMemberships path.
151
+ */
152
+ getIdTokenClaims?: () => IdTokenClaims | null;
128
153
  /**
129
154
  * Produce a VaultClient-like object. Defaults to `new VaultClient(config)`.
130
- * Tests inject a stub here — only `listMyMemberships` and `entity.get` are
131
- * called by the runner, so stubs only need to implement those.
155
+ * Tests inject a stub here — the runner only calls the methods listed in
156
+ * `VaultClientSurface`.
132
157
  */
133
158
  createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
134
159
  /** Sync function. Defaults to `cli/sync.sync`. */
135
160
  sync?: (options: SyncOptions) => Promise<SyncResult>;
136
161
  }
137
162
 
163
+ // ---------------------------------------------------------------------------
164
+ // JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
165
+ // We do NOT verify the signature here — Cognito already did that when it
166
+ // issued the token, and we only read the public claims (sub/email/name) to
167
+ // drive the claim-dance + create the person entity. If the token is tampered
168
+ // with, the downstream vault-service call will reject it (signature-verified
169
+ // there) long before any claimed value causes harm.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function decodeJwtClaims(jwt: string): IdTokenClaims | null {
173
+ const parts = jwt.split(".");
174
+ if (parts.length !== 3) return null;
175
+ try {
176
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
177
+ const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
178
+ const json = Buffer.from(padded, "base64").toString("utf-8");
179
+ return JSON.parse(json) as IdTokenClaims;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function defaultGetIdTokenClaims(): IdTokenClaims | null {
186
+ const tokens: CognitoTokens | null = loadCachedTokens();
187
+ if (!tokens?.idToken) return null;
188
+ return decodeJwtClaims(tokens.idToken);
189
+ }
190
+
191
+ /**
192
+ * Best-effort: claim any email-keyed pending invites that were sent before
193
+ * this user had a person entity. Mirrors the installer's vault-handoff flow.
194
+ *
195
+ * Silent on the happy path — only logs to stderr on soft failures (so a
196
+ * transient network blip doesn't block the sync). Never throws: a caller who
197
+ * can't list memberships despite an unclaimed invite is no worse off than the
198
+ * pre-claim-dance behavior (which was to emit setup-needed).
199
+ */
200
+ async function runClaimDance(
201
+ client: VaultClientSurface,
202
+ claims: IdTokenClaims,
203
+ stderr: { write: (chunk: string) => boolean | void },
204
+ ): Promise<void> {
205
+ try {
206
+ const pending = await client.listMyPendingInvitesByEmail();
207
+ if (pending.length === 0) return;
208
+
209
+ const displayName =
210
+ claims.name ??
211
+ [claims.given_name, claims.family_name].filter(Boolean).join(" ") ??
212
+ claims.email ??
213
+ "";
214
+ const ownerSub = claims.sub ?? "";
215
+ if (!ownerSub || !displayName) {
216
+ stderr.write(
217
+ "hq-sync-runner: skipping claim-dance — idToken missing sub/name\n",
218
+ );
219
+ return;
220
+ }
221
+
222
+ const person = await client.ensureMyPersonEntity({
223
+ ownerSub,
224
+ displayName,
225
+ });
226
+ await client.claimPendingInvitesByEmail(person.uid);
227
+ } catch (err) {
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ stderr.write(`hq-sync-runner: claim-dance skipped — ${msg}\n`);
230
+ }
231
+ }
232
+
138
233
  // ---------------------------------------------------------------------------
139
234
  // argv parser — intentionally minimal (no commander/yargs dep)
140
235
  // ---------------------------------------------------------------------------
@@ -244,8 +339,20 @@ export async function runRunner(
244
339
  let memberships: Pick<Membership, "companyUid">[];
245
340
  try {
246
341
  if (parsed.companies) {
342
+ // Before giving up on memberships, run the claim-dance: new users signed
343
+ // in via the tray may have email-keyed invites waiting for them. Without
344
+ // this, an invited user would see "setup-needed" on every tray click.
345
+ const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
346
+ const claims = getClaims();
347
+ if (claims) {
348
+ await runClaimDance(client, claims, stderr);
349
+ }
350
+
247
351
  memberships = await client.listMyMemberships();
248
352
  if (memberships.length === 0) {
353
+ // Truly empty — still a valid state (no memberships = nothing to
354
+ // sync). The tray will show a friendly "create your first company"
355
+ // CTA rather than an alarm banner.
249
356
  emit({ type: "setup-needed" });
250
357
  return 0;
251
358
  }
@@ -278,16 +385,18 @@ export async function runRunner(
278
385
  // The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
279
386
  // If the entity fetch fails for some row (entity deleted, scoping issue),
280
387
  // degrade to using the UID as the slug rather than aborting the run.
281
- const plan: Array<{ uid: string; slug: string }> = [];
388
+ const plan: Array<{ uid: string; slug: string; name?: string }> = [];
282
389
  for (const m of memberships) {
283
390
  let slug = m.companyUid;
391
+ let name: string | undefined;
284
392
  try {
285
393
  const info = await client.entity.get(m.companyUid);
286
394
  slug = info.slug || m.companyUid;
395
+ name = info.name;
287
396
  } catch {
288
397
  // Best-effort — keep UID as the display identifier.
289
398
  }
290
- plan.push({ uid: m.companyUid, slug });
399
+ plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
291
400
  }
292
401
  emit({ type: "fanout-plan", companies: plan });
293
402
 
package/src/cli/accept.ts CHANGED
@@ -40,8 +40,8 @@ export function parseToken(tokenOrLink: string): string {
40
40
  return trimmed.slice("hq://accept/".length);
41
41
  }
42
42
 
43
- // https://hq.indigoai.com/accept/<token> (future web route)
44
- const httpsPrefix = "https://hq.indigoai.com/accept/";
43
+ // https://example.com/accept/<token> (future web route)
44
+ const httpsPrefix = "https://example.com/accept/";
45
45
  if (trimmed.startsWith(httpsPrefix)) {
46
46
  return trimmed.slice(httpsPrefix.length);
47
47
  }
@@ -19,7 +19,7 @@ vi.mock("../s3.js", () => ({
19
19
  }));
20
20
 
21
21
  import { share } from "./share.js";
22
- import { headRemoteFile } from "../s3.js";
22
+ import { headRemoteFile, uploadFile } from "../s3.js";
23
23
 
24
24
  const mockConfig: VaultServiceConfig = {
25
25
  apiUrl: "https://vault-api.test",
@@ -82,8 +82,10 @@ describe("share", () => {
82
82
  delete process.env.HQ_STATE_DIR;
83
83
  });
84
84
 
85
- it("shares a single file", async () => {
86
- const testFile = path.join(tmpDir, "test.md");
85
+ it("shares a single file keyed relative to the company root", async () => {
86
+ const companyRoot = path.join(tmpDir, "companies", "acme");
87
+ fs.mkdirSync(companyRoot, { recursive: true });
88
+ const testFile = path.join(companyRoot, "test.md");
87
89
  fs.writeFileSync(testFile, "# Hello World");
88
90
 
89
91
  const result = await share({
@@ -95,15 +97,18 @@ describe("share", () => {
95
97
 
96
98
  expect(result.filesUploaded).toBe(1);
97
99
  expect(result.aborted).toBe(false);
100
+ // Remote key must be company-relative, not hqRoot-relative
101
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md");
98
102
  });
99
103
 
100
104
  it("respects ignore rules", async () => {
101
- fs.mkdirSync(path.join(tmpDir, ".git"));
102
- fs.writeFileSync(path.join(tmpDir, ".git", "config"), "git config");
103
- fs.writeFileSync(path.join(tmpDir, "readme.md"), "readme");
105
+ const companyRoot = path.join(tmpDir, "companies", "acme");
106
+ fs.mkdirSync(path.join(companyRoot, ".git"), { recursive: true });
107
+ fs.writeFileSync(path.join(companyRoot, ".git", "config"), "git config");
108
+ fs.writeFileSync(path.join(companyRoot, "readme.md"), "readme");
104
109
 
105
110
  const result = await share({
106
- paths: [tmpDir],
111
+ paths: [companyRoot],
107
112
  company: "acme",
108
113
  vaultConfig: mockConfig,
109
114
  hqRoot: tmpDir,
@@ -113,12 +118,13 @@ describe("share", () => {
113
118
  });
114
119
 
115
120
  it("shares a directory of files", async () => {
116
- fs.mkdirSync(path.join(tmpDir, "docs"));
117
- fs.writeFileSync(path.join(tmpDir, "docs", "a.md"), "doc a");
118
- fs.writeFileSync(path.join(tmpDir, "docs", "b.md"), "doc b");
121
+ const companyRoot = path.join(tmpDir, "companies", "acme");
122
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
123
+ fs.writeFileSync(path.join(companyRoot, "docs", "a.md"), "doc a");
124
+ fs.writeFileSync(path.join(companyRoot, "docs", "b.md"), "doc b");
119
125
 
120
126
  const result = await share({
121
- paths: [path.join(tmpDir, "docs")],
127
+ paths: [path.join(companyRoot, "docs")],
122
128
  company: "acme",
123
129
  vaultConfig: mockConfig,
124
130
  hqRoot: tmpDir,
@@ -127,6 +133,44 @@ describe("share", () => {
127
133
  expect(result.filesUploaded).toBe(2);
128
134
  });
129
135
 
136
+ it("keys nested paths relative to the company root, not hqRoot", async () => {
137
+ const companyRoot = path.join(tmpDir, "companies", "acme");
138
+ fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
139
+ const nested = path.join(companyRoot, "knowledge", "crawl.json");
140
+ fs.writeFileSync(nested, "{}");
141
+
142
+ await share({
143
+ paths: [nested],
144
+ company: "acme",
145
+ vaultConfig: mockConfig,
146
+ hqRoot: tmpDir,
147
+ });
148
+
149
+ // Key is "knowledge/crawl.json", not "companies/acme/knowledge/crawl.json"
150
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json");
151
+ });
152
+
153
+ it("skips files outside the company folder with a warning", async () => {
154
+ const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
155
+ // File at hqRoot, outside companies/acme/
156
+ const outsideFile = path.join(tmpDir, "stray.md");
157
+ fs.writeFileSync(outsideFile, "stray");
158
+
159
+ const result = await share({
160
+ paths: [outsideFile],
161
+ company: "acme",
162
+ vaultConfig: mockConfig,
163
+ hqRoot: tmpDir,
164
+ });
165
+
166
+ expect(result.filesUploaded).toBe(0);
167
+ expect(uploadFile).not.toHaveBeenCalled();
168
+ expect(warnSpy).toHaveBeenCalledWith(
169
+ expect.stringMatching(/outside company folder/i),
170
+ );
171
+ warnSpy.mockRestore();
172
+ });
173
+
130
174
  it("throws when no company specified and no active company", async () => {
131
175
  fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
132
176
 
@@ -140,12 +184,14 @@ describe("share", () => {
140
184
  });
141
185
 
142
186
  it("resolves active company from .hq/config.json", async () => {
187
+ const companyRoot = path.join(tmpDir, "companies", "acme");
188
+ fs.mkdirSync(companyRoot, { recursive: true });
143
189
  fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
144
190
  fs.writeFileSync(path.join(tmpDir, ".hq", "config.json"), JSON.stringify({ activeCompany: "acme" }));
145
- fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
191
+ fs.writeFileSync(path.join(companyRoot, "test.md"), "test");
146
192
 
147
193
  const result = await share({
148
- paths: [path.join(tmpDir, "test.md")],
194
+ paths: [path.join(companyRoot, "test.md")],
149
195
  vaultConfig: mockConfig,
150
196
  hqRoot: tmpDir,
151
197
  });
package/src/cli/share.ts CHANGED
@@ -54,6 +54,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
54
54
 
55
55
  // Resolve entity context (handles STS vending + caching)
56
56
  let ctx = await resolveEntityContext(companyRef, vaultConfig);
57
+ // Remote keys are company-relative; the on-disk scoping prefix is
58
+ // companies/{slug}/. Anything outside this folder gets skipped to avoid
59
+ // leaking cross-company state into the vault.
60
+ const syncRoot = path.join(hqRoot, "companies", ctx.slug);
57
61
  const shouldSync = createIgnoreFilter(hqRoot);
58
62
  const journal = readJournal(ctx.slug);
59
63
 
@@ -62,7 +66,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
62
66
  let filesSkipped = 0;
63
67
 
64
68
  // Collect all files to share
65
- const filesToShare = collectFiles(paths, hqRoot, shouldSync);
69
+ const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
66
70
 
67
71
  for (const { absolutePath, relativePath } of filesToShare) {
68
72
  if (!isWithinSizeLimit(absolutePath)) {
@@ -155,10 +159,15 @@ function resolveActiveCompany(hqRoot: string): string | undefined {
155
159
 
156
160
  /**
157
161
  * Collect files from paths (expanding directories recursively).
162
+ *
163
+ * Remote S3 keys are computed relative to `syncRoot` (companies/{slug}/), not
164
+ * `hqRoot`. Files outside `syncRoot` are skipped with a warning — sharing
165
+ * anything outside a company's folder would leak state into the wrong vault.
158
166
  */
159
167
  function collectFiles(
160
168
  paths: string[],
161
169
  hqRoot: string,
170
+ syncRoot: string,
162
171
  filter: (p: string) => boolean,
163
172
  ): { absolutePath: string; relativePath: string }[] {
164
173
  const results: { absolutePath: string; relativePath: string }[] = [];
@@ -171,11 +180,16 @@ function collectFiles(
171
180
  continue;
172
181
  }
173
182
 
183
+ if (!isWithin(syncRoot, absolutePath)) {
184
+ console.error(` Warning: ${p} is outside company folder, skipping.`);
185
+ continue;
186
+ }
187
+
174
188
  const stat = fs.statSync(absolutePath);
175
189
  if (stat.isDirectory()) {
176
- results.push(...walkDir(absolutePath, hqRoot, filter));
190
+ results.push(...walkDir(absolutePath, syncRoot, filter));
177
191
  } else if (stat.isFile()) {
178
- const relativePath = path.relative(hqRoot, absolutePath);
192
+ const relativePath = path.relative(syncRoot, absolutePath);
179
193
  if (filter(absolutePath)) {
180
194
  results.push({ absolutePath, relativePath });
181
195
  }
@@ -187,7 +201,7 @@ function collectFiles(
187
201
 
188
202
  function walkDir(
189
203
  dir: string,
190
- root: string,
204
+ syncRoot: string,
191
205
  filter: (p: string) => boolean,
192
206
  ): { absolutePath: string; relativePath: string }[] {
193
207
  const results: { absolutePath: string; relativePath: string }[] = [];
@@ -199,14 +213,19 @@ function walkDir(
199
213
  if (!filter(absolutePath)) continue;
200
214
 
201
215
  if (entry.isDirectory()) {
202
- results.push(...walkDir(absolutePath, root, filter));
216
+ results.push(...walkDir(absolutePath, syncRoot, filter));
203
217
  } else if (entry.isFile()) {
204
218
  results.push({
205
219
  absolutePath,
206
- relativePath: path.relative(root, absolutePath),
220
+ relativePath: path.relative(syncRoot, absolutePath),
207
221
  });
208
222
  }
209
223
  }
210
224
 
211
225
  return results;
212
226
  }
227
+
228
+ function isWithin(parent: string, child: string): boolean {
229
+ const rel = path.relative(parent, child);
230
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
231
+ }