@indigoai-us/hq-cloud 5.1.0 → 5.1.8

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 (100) hide show
  1. package/dist/bin/sync-runner.d.ts +111 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +285 -0
  4. package/dist/bin/sync-runner.js.map +1 -0
  5. package/dist/bin/sync-runner.test.d.ts +10 -0
  6. package/dist/bin/sync-runner.test.d.ts.map +1 -0
  7. package/dist/bin/sync-runner.test.js +492 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -0
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/share.js +2 -2
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +9 -1
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +28 -0
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +33 -10
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +15 -4
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts.map +1 -1
  22. package/dist/cognito-auth.js +19 -1
  23. package/dist/cognito-auth.js.map +1 -1
  24. package/dist/cognito-auth.test.d.ts +9 -0
  25. package/dist/cognito-auth.test.d.ts.map +1 -0
  26. package/dist/cognito-auth.test.js +113 -0
  27. package/dist/cognito-auth.test.js.map +1 -0
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +1 -0
  30. package/dist/context.js.map +1 -1
  31. package/dist/daemon-worker.d.ts +6 -1
  32. package/dist/daemon-worker.d.ts.map +1 -1
  33. package/dist/daemon-worker.js +12 -16
  34. package/dist/daemon-worker.js.map +1 -1
  35. package/dist/daemon.d.ts +2 -0
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +2 -0
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/ignore.d.ts +13 -2
  40. package/dist/ignore.d.ts.map +1 -1
  41. package/dist/ignore.js +69 -12
  42. package/dist/ignore.js.map +1 -1
  43. package/dist/index.d.ts +24 -28
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +19 -134
  46. package/dist/index.js.map +1 -1
  47. package/dist/journal.d.ts +20 -4
  48. package/dist/journal.d.ts.map +1 -1
  49. package/dist/journal.js +45 -8
  50. package/dist/journal.js.map +1 -1
  51. package/dist/journal.test.d.ts +9 -0
  52. package/dist/journal.test.d.ts.map +1 -0
  53. package/dist/journal.test.js +114 -0
  54. package/dist/journal.test.js.map +1 -0
  55. package/dist/s3.d.ts +18 -6
  56. package/dist/s3.d.ts.map +1 -1
  57. package/dist/s3.js +57 -56
  58. package/dist/s3.js.map +1 -1
  59. package/dist/types.d.ts +34 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/vault-client.d.ts +16 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +19 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +25 -0
  66. package/dist/vault-client.test.js.map +1 -1
  67. package/dist/watcher.d.ts +7 -1
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +11 -5
  70. package/dist/watcher.js.map +1 -1
  71. package/package.json +15 -3
  72. package/src/bin/sync-runner.test.ts +617 -0
  73. package/src/bin/sync-runner.ts +390 -0
  74. package/src/cli/accept.ts +97 -0
  75. package/src/cli/conflict.ts +119 -0
  76. package/src/cli/index.ts +25 -0
  77. package/src/cli/invite.test.ts +247 -0
  78. package/src/cli/invite.ts +180 -0
  79. package/src/cli/promote.ts +123 -0
  80. package/src/cli/share.test.ts +155 -0
  81. package/src/cli/share.ts +212 -0
  82. package/src/cli/sync.test.ts +225 -0
  83. package/src/cli/sync.ts +225 -0
  84. package/src/cognito-auth.test.ts +156 -0
  85. package/src/cognito-auth.ts +18 -1
  86. package/src/context.test.ts +202 -0
  87. package/src/context.ts +178 -0
  88. package/src/daemon-worker.ts +13 -19
  89. package/src/daemon.ts +2 -0
  90. package/src/ignore.ts +76 -12
  91. package/src/index.ts +93 -165
  92. package/src/journal.test.ts +146 -0
  93. package/src/journal.ts +53 -11
  94. package/src/s3.ts +76 -66
  95. package/src/types.ts +37 -0
  96. package/src/vault-client.test.ts +390 -0
  97. package/src/vault-client.ts +400 -0
  98. package/src/watcher.ts +12 -5
  99. package/test/invite-flow.integration.test.ts +244 -0
  100. package/test/share-sync.integration.test.ts +210 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * VaultClient — typed SDK for vault-service membership operations (VLT-7 US-001).
3
+ *
4
+ * Wraps vault-service HTTP API with shared auth, retry, and typed errors.
5
+ * Colocated with hq-cloud so /invite, /promote, /accept and future commands
6
+ * share one client instead of each rolling its own HTTP layer.
7
+ */
8
+
9
+ import type { VaultServiceConfig } from "./types.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Error classes
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export class VaultClientError extends Error {
16
+ constructor(
17
+ message: string,
18
+ public readonly statusCode: number,
19
+ public readonly body?: string,
20
+ ) {
21
+ super(message);
22
+ this.name = "VaultClientError";
23
+ }
24
+ }
25
+
26
+ export class VaultAuthError extends VaultClientError {
27
+ constructor(message = "Authentication failed — session expired or invalid") {
28
+ super(message, 401);
29
+ this.name = "VaultAuthError";
30
+ }
31
+ }
32
+
33
+ export class VaultPermissionDeniedError extends VaultClientError {
34
+ constructor(message = "Permission denied — admin role required") {
35
+ super(message, 403);
36
+ this.name = "VaultPermissionDeniedError";
37
+ }
38
+ }
39
+
40
+ export class VaultNotFoundError extends VaultClientError {
41
+ constructor(message = "Resource not found") {
42
+ super(message, 404);
43
+ this.name = "VaultNotFoundError";
44
+ }
45
+ }
46
+
47
+ export class VaultConflictError extends VaultClientError {
48
+ constructor(message = "Conflict — resource already exists or was already accepted") {
49
+ super(message, 409);
50
+ this.name = "VaultConflictError";
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Types
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export type MembershipRole = "owner" | "admin" | "member" | "guest";
59
+ export type MembershipStatus = "pending" | "active" | "revoked";
60
+
61
+ export interface Membership {
62
+ membershipKey: string;
63
+ personUid: string;
64
+ companyUid: string;
65
+ role: MembershipRole;
66
+ status: MembershipStatus;
67
+ allowedPrefixes?: string[];
68
+ inviteToken?: string;
69
+ invitedBy: string;
70
+ invitedAt: string;
71
+ acceptedAt?: string;
72
+ revokedAt?: string;
73
+ createdAt: string;
74
+ updatedAt: string;
75
+ }
76
+
77
+ export interface CreateInviteInput {
78
+ personUid?: string;
79
+ inviteeEmail?: string;
80
+ companyUid: string;
81
+ role: MembershipRole;
82
+ allowedPrefixes?: string[];
83
+ invitedBy: string;
84
+ }
85
+
86
+ export interface CreateInviteResult {
87
+ membership: Membership;
88
+ inviteToken: string;
89
+ }
90
+
91
+ export interface AcceptInviteResult {
92
+ membership: Membership;
93
+ }
94
+
95
+ export interface UpdateRoleInput {
96
+ membershipKey: string;
97
+ newRole: MembershipRole;
98
+ allowedPrefixes?: string[];
99
+ updaterUid: string;
100
+ /** Required so the server can authorize the caller as admin/owner of the company. */
101
+ companyUid: string;
102
+ }
103
+
104
+ export interface EntityInfo {
105
+ uid: string;
106
+ slug: string;
107
+ type: string;
108
+ bucketName?: string;
109
+ status: string;
110
+ }
111
+
112
+ export interface CreateEntityInput {
113
+ type: "person" | "company";
114
+ slug: string;
115
+ name: string;
116
+ email?: string;
117
+ ownerUid?: string;
118
+ }
119
+
120
+ export interface CreateEntityResult {
121
+ entity: EntityInfo;
122
+ }
123
+
124
+ // -- STS child vending (VLT-8) --------------------------------------------
125
+
126
+ export type TaskAction = "read" | "write";
127
+
128
+ export interface TaskScope {
129
+ /** S3 key prefixes the child may access (e.g. ["drafts/"]). */
130
+ allowedPrefixes: string[];
131
+ /** Defaults to ["read", "write"]. Use ["read"] for read-only children. */
132
+ allowedActions?: TaskAction[];
133
+ }
134
+
135
+ export interface VendChildInput {
136
+ companyUid: string;
137
+ /** ULID generated by the parent task. Flows into STS session name for audit. */
138
+ taskId: string;
139
+ /** Short human-readable description (<256 chars). Logged alongside the session. */
140
+ taskDescription: string;
141
+ taskScope: TaskScope;
142
+ /**
143
+ * Child session duration in seconds. Defaults to 900 on the server — AWS STS
144
+ * AssumeRole enforces a 900s floor. The task-scoped policy is the security
145
+ * boundary, not the duration.
146
+ */
147
+ durationSeconds?: number;
148
+ }
149
+
150
+ export interface StsChildCredentials {
151
+ accessKeyId: string;
152
+ secretAccessKey: string;
153
+ sessionToken: string;
154
+ }
155
+
156
+ export interface VendChildResult {
157
+ credentials: StsChildCredentials;
158
+ /** STS session name: `${parentPersonUid}--task--${taskId}` — used in CloudTrail.
159
+ * (Dash-separated because AWS STS `roleSessionName` disallows colons.) */
160
+ sessionName: string;
161
+ /** ISO-8601 session expiration. */
162
+ expiresAt: string;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Retry config
167
+ // ---------------------------------------------------------------------------
168
+
169
+ const MAX_RETRIES = 3;
170
+ const BASE_DELAY_MS = 500;
171
+
172
+ function isTransient(status: number): boolean {
173
+ return status === 429 || status >= 500;
174
+ }
175
+
176
+ async function sleep(ms: number): Promise<void> {
177
+ return new Promise((resolve) => setTimeout(resolve, ms));
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // VaultClient
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export class VaultClient {
185
+ private readonly apiUrl: string;
186
+ private readonly authToken: string;
187
+
188
+ constructor(config: VaultServiceConfig) {
189
+ this.apiUrl = config.apiUrl.replace(/\/+$/, "");
190
+ this.authToken = config.authToken;
191
+ }
192
+
193
+ // -- Membership operations ------------------------------------------------
194
+
195
+ async createInvite(input: CreateInviteInput): Promise<CreateInviteResult> {
196
+ const data = await this.post<{ membership: Membership; inviteToken: string }>(
197
+ "/membership/invite",
198
+ input,
199
+ );
200
+ return data;
201
+ }
202
+
203
+ async acceptInvite(token: string, personUid: string): Promise<AcceptInviteResult> {
204
+ const data = await this.post<{ membership: Membership }>(
205
+ "/membership/accept",
206
+ { token, personUid },
207
+ );
208
+ return data;
209
+ }
210
+
211
+ /**
212
+ * Revoke a membership. The handler needs both the membershipKey AND the
213
+ * companyUid so it can authorize the caller as admin/owner of the company
214
+ * before performing the revoke. (We can't infer companyUid from the key
215
+ * alone without an extra DDB read, and the caller already knows it.)
216
+ */
217
+ async revokeMembership(membershipKey: string, companyUid: string): Promise<void> {
218
+ await this.post("/membership/revoke", { membershipKey, companyUid });
219
+ }
220
+
221
+ /**
222
+ * List the caller's own active memberships.
223
+ *
224
+ * Server infers the caller's identity from the Cognito JWT `sub` claim and
225
+ * returns the union of active memberships across every person entity owned
226
+ * by that sub (orphan-tolerant — prior failed provisioning runs can leave
227
+ * multiple `prs_*` rows for the same Cognito identity).
228
+ *
229
+ * Returns `[]` — NOT a 404 — when the caller has no person entity yet.
230
+ * This lets `hq-sync-runner` distinguish "signed in but not bootstrapped"
231
+ * (empty array → emit `setup-needed`) from "auth broken" (throws
232
+ * VaultAuthError) without catching HTTP errors for flow control.
233
+ *
234
+ * Backed by `GET /membership/me` (see hq-pro ADR-0002).
235
+ */
236
+ async listMyMemberships(): Promise<Membership[]> {
237
+ const data = await this.get<{ memberships: Membership[] }>("/membership/me");
238
+ return data.memberships;
239
+ }
240
+
241
+ async listMembersOfCompany(companyUid: string): Promise<Membership[]> {
242
+ const data = await this.get<{ members: Membership[] }>(
243
+ `/membership/company/${encodeURIComponent(companyUid)}`,
244
+ );
245
+ return data.members;
246
+ }
247
+
248
+ async updateRole(input: UpdateRoleInput): Promise<Membership> {
249
+ const data = await this.post<{ membership: Membership }>(
250
+ "/membership/role",
251
+ input,
252
+ );
253
+ return data.membership;
254
+ }
255
+
256
+ async listPendingInvites(companyUid: string): Promise<Membership[]> {
257
+ const data = await this.get<{ invites: Membership[] }>(
258
+ `/membership/company/${encodeURIComponent(companyUid)}/pending`,
259
+ );
260
+ return data.invites;
261
+ }
262
+
263
+ // -- Entity operations ----------------------------------------------------
264
+
265
+ readonly entity = {
266
+ get: async (uid: string): Promise<EntityInfo> => {
267
+ const data = await this.get<{ entity: EntityInfo }>(`/entity/${encodeURIComponent(uid)}`);
268
+ return data.entity;
269
+ },
270
+
271
+ findBySlug: async (type: string, slug: string): Promise<EntityInfo> => {
272
+ const data = await this.get<{ entity: EntityInfo }>(
273
+ `/entity/by-slug/${encodeURIComponent(type)}/${encodeURIComponent(slug)}`,
274
+ );
275
+ return data.entity;
276
+ },
277
+
278
+ create: async (input: CreateEntityInput): Promise<EntityInfo> => {
279
+ const data = await this.post<CreateEntityResult>("/entity", input);
280
+ return data.entity;
281
+ },
282
+ };
283
+
284
+ // -- Provisioning operations (VLT-2) -----------------------------------------
285
+
286
+ async provisionBucket(companyUid: string): Promise<{ bucketName: string; kmsKeyId: string }> {
287
+ const data = await this.post<{ bucketName: string; kmsKeyId: string }>(
288
+ "/provision/bucket",
289
+ { companyUid },
290
+ );
291
+ return data;
292
+ }
293
+
294
+ // -- STS operations (VLT-8) -----------------------------------------------
295
+
296
+ readonly sts = {
297
+ /**
298
+ * Vend task-scoped child credentials strictly narrower than the caller's
299
+ * own membership. Backed by the vault-service `POST /sts/vend-child`
300
+ * route (kebab-case to match the rest of the vault-service API).
301
+ *
302
+ * The child policy is intersected with the caller's membership on the
303
+ * server — if `taskScope.allowedPrefixes` requests anything the parent
304
+ * can't see, the server throws ScopeExceedsParentError before calling STS.
305
+ *
306
+ * Session name format: `${parentPersonUid}--task--${taskId}` — this lands
307
+ * in CloudTrail verbatim, so every child S3 action can be traced back to
308
+ * the parent task for incident response.
309
+ */
310
+ vendChild: async (input: VendChildInput): Promise<VendChildResult> => {
311
+ const data = await this.post<VendChildResult>("/sts/vend-child", input);
312
+ return data;
313
+ },
314
+ };
315
+
316
+ // -- HTTP primitives with retry -------------------------------------------
317
+
318
+ private async get<T>(path: string): Promise<T> {
319
+ return this.request<T>("GET", path);
320
+ }
321
+
322
+ private async post<T>(path: string, body?: unknown): Promise<T> {
323
+ return this.request<T>("POST", path, body);
324
+ }
325
+
326
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
327
+ let lastError: Error | undefined;
328
+
329
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
330
+ if (attempt > 0) {
331
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
332
+ await sleep(delay);
333
+ }
334
+
335
+ const headers: Record<string, string> = {
336
+ Authorization: `Bearer ${this.authToken}`,
337
+ Accept: "application/json",
338
+ };
339
+
340
+ const init: RequestInit = { method, headers };
341
+
342
+ if (body !== undefined) {
343
+ headers["Content-Type"] = "application/json";
344
+ init.body = JSON.stringify(body);
345
+ }
346
+
347
+ let res: Response;
348
+ try {
349
+ res = await fetch(`${this.apiUrl}${path}`, init);
350
+ } catch (err) {
351
+ lastError = err instanceof Error ? err : new Error(String(err));
352
+ if (attempt < MAX_RETRIES) continue;
353
+ throw lastError;
354
+ }
355
+
356
+ if (res.ok) {
357
+ if (res.status === 204) return undefined as T;
358
+ return (await res.json()) as T;
359
+ }
360
+
361
+ const responseBody = await res.text();
362
+
363
+ // Non-retryable errors → throw immediately
364
+ if (!isTransient(res.status)) {
365
+ throw this.mapError(res.status, responseBody);
366
+ }
367
+
368
+ // Retryable — store and loop
369
+ lastError = this.mapError(res.status, responseBody);
370
+ }
371
+
372
+ throw lastError ?? new VaultClientError("Request failed after retries", 500);
373
+ }
374
+
375
+ private mapError(status: number, body: string): VaultClientError {
376
+ const message = this.extractMessage(body);
377
+
378
+ switch (status) {
379
+ case 401:
380
+ return new VaultAuthError(message);
381
+ case 403:
382
+ return new VaultPermissionDeniedError(message);
383
+ case 404:
384
+ return new VaultNotFoundError(message);
385
+ case 409:
386
+ return new VaultConflictError(message);
387
+ default:
388
+ return new VaultClientError(message || `Request failed with status ${status}`, status, body);
389
+ }
390
+ }
391
+
392
+ private extractMessage(body: string): string {
393
+ try {
394
+ const parsed = JSON.parse(body) as { message?: string; error?: string };
395
+ return parsed.message ?? parsed.error ?? body;
396
+ } catch {
397
+ return body;
398
+ }
399
+ }
400
+ }
package/src/watcher.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * File watcher — monitors HQ directory for changes
3
3
  * Uses chokidar with debounced batching
4
+ *
5
+ * Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
6
+ * When re-enabled, the constructor will need an EntityContext (or a context resolver)
7
+ * to be passed in for entity-aware S3 operations.
4
8
  */
5
9
 
6
10
  import * as fs from "fs";
7
11
  import * as path from "path";
8
12
  import { watch } from "chokidar";
9
13
  import type { FSWatcher } from "chokidar";
14
+ import type { EntityContext } from "./types.js";
10
15
  import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
11
16
  import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
12
17
  import { uploadFile, deleteRemoteFile } from "./s3.js";
@@ -22,13 +27,15 @@ interface PendingChange {
22
27
  export class SyncWatcher {
23
28
  private watcher: FSWatcher | null = null;
24
29
  private hqRoot: string;
30
+ private ctx: EntityContext;
25
31
  private shouldSync: (filePath: string) => boolean;
26
32
  private pendingChanges = new Map<string, PendingChange>();
27
33
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
28
34
  private processing = false;
29
35
 
30
- constructor(hqRoot: string) {
36
+ constructor(hqRoot: string, ctx: EntityContext) {
31
37
  this.hqRoot = hqRoot;
38
+ this.ctx = ctx;
32
39
  this.shouldSync = createIgnoreFilter(hqRoot);
33
40
  }
34
41
 
@@ -91,12 +98,12 @@ export class SyncWatcher {
91
98
  const batch = new Map(this.pendingChanges);
92
99
  this.pendingChanges.clear();
93
100
 
94
- const journal = readJournal(this.hqRoot);
101
+ const journal = readJournal(this.ctx.slug);
95
102
 
96
103
  for (const [relativePath, change] of batch) {
97
104
  try {
98
105
  if (change.type === "unlink") {
99
- await deleteRemoteFile(relativePath);
106
+ await deleteRemoteFile(this.ctx, relativePath);
100
107
  delete journal.files[relativePath];
101
108
  } else {
102
109
  const hash = hashFile(change.absolutePath);
@@ -106,7 +113,7 @@ export class SyncWatcher {
106
113
  const existing = journal.files[relativePath];
107
114
  if (existing && existing.hash === hash) continue;
108
115
 
109
- await uploadFile(change.absolutePath, relativePath);
116
+ await uploadFile(this.ctx, change.absolutePath, relativePath);
110
117
  updateEntry(journal, relativePath, hash, stat.size, "up");
111
118
  }
112
119
  } catch (err) {
@@ -119,7 +126,7 @@ export class SyncWatcher {
119
126
  }
120
127
  }
121
128
 
122
- writeJournal(this.hqRoot, journal);
129
+ writeJournal(this.ctx.slug, journal);
123
130
  this.processing = false;
124
131
 
125
132
  // Process any changes that came in while we were flushing
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Invite → Accept → Promote integration test (VLT-7 US-003).
3
+ *
4
+ * Mocks the vault-service HTTP API to test the full lifecycle round-trip:
5
+ * admin creates invite → invitee accepts → admin promotes to guest with paths.
6
+ *
7
+ * Uses mocked fetch (not a real vault-service) to keep the test self-contained
8
+ * and runnable offline. Real E2E tests against dev stage are in the e2eTests
9
+ * section of the PRD.
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
13
+ import { invite } from "../src/cli/invite.js";
14
+ import { accept, parseToken } from "../src/cli/accept.js";
15
+ import { promote } from "../src/cli/promote.js";
16
+ import type { VaultServiceConfig } from "../src/types.js";
17
+ import type { Membership } from "../src/vault-client.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function jsonResponse(status: number, body: unknown): Response {
24
+ return new Response(JSON.stringify(body), {
25
+ status,
26
+ headers: { "Content-Type": "application/json" },
27
+ });
28
+ }
29
+
30
+ const VAULT_CONFIG: VaultServiceConfig = {
31
+ apiUrl: "https://vault.test.example.com",
32
+ authToken: "admin-jwt",
33
+ };
34
+
35
+ const INVITEE_CONFIG: VaultServiceConfig = {
36
+ apiUrl: "https://vault.test.example.com",
37
+ authToken: "invitee-jwt",
38
+ };
39
+
40
+ let fetchSpy: ReturnType<typeof vi.spyOn>;
41
+
42
+ beforeEach(() => {
43
+ fetchSpy = vi.spyOn(globalThis, "fetch");
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Token parsing
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("parseToken", () => {
55
+ it("extracts token from hq:// magic link", () => {
56
+ expect(parseToken("hq://accept/tok_abc123")).toBe("tok_abc123");
57
+ });
58
+
59
+ it("extracts token from https:// URL", () => {
60
+ expect(parseToken("https://hq.indigoai.com/accept/tok_xyz")).toBe("tok_xyz");
61
+ });
62
+
63
+ it("returns raw token unchanged", () => {
64
+ expect(parseToken("tok_raw_token")).toBe("tok_raw_token");
65
+ });
66
+
67
+ it("trims whitespace", () => {
68
+ expect(parseToken(" hq://accept/tok_abc ")).toBe("tok_abc");
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Full round-trip
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("invite → accept → promote lifecycle", () => {
77
+ const pendingMembership: Membership = {
78
+ membershipKey: "psn_invitee#cmp_acme",
79
+ personUid: "psn_invitee",
80
+ companyUid: "cmp_acme",
81
+ role: "member",
82
+ status: "pending",
83
+ inviteToken: "tok_secure_random_32bytes",
84
+ invitedBy: "psn_admin",
85
+ invitedAt: "2026-04-15T00:00:00Z",
86
+ createdAt: "2026-04-15T00:00:00Z",
87
+ updatedAt: "2026-04-15T00:00:00Z",
88
+ };
89
+
90
+ const activeMembership: Membership = {
91
+ ...pendingMembership,
92
+ status: "active",
93
+ inviteToken: undefined,
94
+ acceptedAt: "2026-04-15T00:01:00Z",
95
+ updatedAt: "2026-04-15T00:01:00Z",
96
+ };
97
+
98
+ it("admin invites → invitee accepts → admin promotes to guest with paths", async () => {
99
+ // --- Step 1: Admin creates invite ---
100
+ fetchSpy
101
+ // entity.findBySlug("company", "acme")
102
+ .mockResolvedValueOnce(
103
+ jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
104
+ )
105
+ // createInvite
106
+ .mockResolvedValueOnce(
107
+ jsonResponse(200, {
108
+ membership: pendingMembership,
109
+ inviteToken: "tok_secure_random_32bytes",
110
+ }),
111
+ );
112
+
113
+ const inviteResult = await invite({
114
+ target: "alice@example.com",
115
+ role: "member",
116
+ company: "acme",
117
+ vaultConfig: VAULT_CONFIG,
118
+ callerUid: "psn_admin",
119
+ });
120
+
121
+ expect(inviteResult.magicLink).toBe("hq://accept/tok_secure_random_32bytes");
122
+ expect(inviteResult.membership.status).toBe("pending");
123
+
124
+ // --- Step 2: Invitee accepts ---
125
+ fetchSpy
126
+ // acceptInvite
127
+ .mockResolvedValueOnce(
128
+ jsonResponse(200, { membership: activeMembership }),
129
+ )
130
+ // entity.get for company slug resolution
131
+ .mockResolvedValueOnce(
132
+ jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
133
+ );
134
+
135
+ const acceptResult = await accept({
136
+ tokenOrLink: inviteResult.magicLink,
137
+ callerUid: "psn_invitee",
138
+ vaultConfig: INVITEE_CONFIG,
139
+ });
140
+
141
+ expect(acceptResult.membership.status).toBe("active");
142
+ expect(acceptResult.membership.role).toBe("member");
143
+ expect(acceptResult.companySlug).toBe("acme");
144
+
145
+ // --- Step 3: Admin promotes member → guest with paths ---
146
+ fetchSpy
147
+ // entity.findBySlug for company resolution
148
+ .mockResolvedValueOnce(
149
+ jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
150
+ )
151
+ // updateRole
152
+ .mockResolvedValueOnce(
153
+ jsonResponse(200, {
154
+ membership: {
155
+ ...activeMembership,
156
+ role: "guest",
157
+ allowedPrefixes: ["docs/"],
158
+ updatedAt: "2026-04-15T00:02:00Z",
159
+ },
160
+ }),
161
+ );
162
+
163
+ const promoteResult = await promote({
164
+ target: "psn_invitee",
165
+ newRole: "guest",
166
+ paths: "docs/",
167
+ company: "acme",
168
+ callerUid: "psn_admin",
169
+ vaultConfig: VAULT_CONFIG,
170
+ });
171
+
172
+ expect(promoteResult.membership.role).toBe("guest");
173
+ expect(promoteResult.membership.allowedPrefixes).toEqual(["docs/"]);
174
+ });
175
+
176
+ it("double-accept returns conflict error", async () => {
177
+ fetchSpy.mockResolvedValueOnce(
178
+ jsonResponse(409, { message: "Already accepted" }),
179
+ );
180
+
181
+ await expect(
182
+ accept({
183
+ tokenOrLink: "tok_already_accepted",
184
+ callerUid: "psn_invitee",
185
+ vaultConfig: INVITEE_CONFIG,
186
+ }),
187
+ ).rejects.toThrow("This invite was already accepted");
188
+ });
189
+
190
+ it("demoting last owner returns conflict error", async () => {
191
+ fetchSpy
192
+ // entity.findBySlug
193
+ .mockResolvedValueOnce(
194
+ jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
195
+ )
196
+ // updateRole — 409 because last owner
197
+ .mockResolvedValueOnce(
198
+ jsonResponse(409, { message: "Cannot remove last owner" }),
199
+ );
200
+
201
+ await expect(
202
+ promote({
203
+ target: "psn_owner",
204
+ newRole: "member",
205
+ company: "acme",
206
+ callerUid: "psn_owner",
207
+ vaultConfig: VAULT_CONFIG,
208
+ }),
209
+ ).rejects.toThrow("Cannot leave company without an owner");
210
+ });
211
+
212
+ it("non-admin invite returns permission error", async () => {
213
+ fetchSpy
214
+ .mockResolvedValueOnce(
215
+ jsonResponse(200, { entity: { uid: "cmp_acme", slug: "acme", type: "company", status: "active" } }),
216
+ )
217
+ .mockResolvedValueOnce(
218
+ jsonResponse(403, { message: "Forbidden" }),
219
+ );
220
+
221
+ await expect(
222
+ invite({
223
+ target: "bob@example.com",
224
+ company: "acme",
225
+ vaultConfig: VAULT_CONFIG,
226
+ callerUid: "psn_member",
227
+ }),
228
+ ).rejects.toThrow("Permission denied — only admins and owners can invite members");
229
+ });
230
+
231
+ it("accept with wrong person returns permission error", async () => {
232
+ fetchSpy.mockResolvedValueOnce(
233
+ jsonResponse(403, { message: "Identity mismatch" }),
234
+ );
235
+
236
+ await expect(
237
+ accept({
238
+ tokenOrLink: "tok_for_someone_else",
239
+ callerUid: "psn_wrong",
240
+ vaultConfig: INVITEE_CONFIG,
241
+ }),
242
+ ).rejects.toThrow("This invite was for a different person");
243
+ });
244
+ });