@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
package/src/s3.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  /**
2
- * S3 operations — upload, download, list, delete
2
+ * S3 operations — upload, download, list, delete.
3
+ *
4
+ * VLT-5: All operations now accept an EntityContext (entity-aware bucket +
5
+ * STS-scoped credentials) instead of reading static env config. The caller
6
+ * is responsible for resolving the context via resolveEntityContext().
3
7
  */
4
8
 
5
9
  import * as fs from "fs";
@@ -10,79 +14,56 @@ import {
10
14
  GetObjectCommand,
11
15
  ListObjectsV2Command,
12
16
  DeleteObjectCommand,
17
+ HeadObjectCommand,
13
18
  } from "@aws-sdk/client-s3";
14
- import type { Credentials, SyncConfig } from "./types.js";
15
- import { readCredentials, refreshAwsCredentials } from "./auth.js";
16
-
17
- let s3Client: S3Client | null = null;
18
-
19
- function getConfig(creds: Credentials): SyncConfig {
20
- const prefix = creds.teamId
21
- ? `teams/${creds.teamId}/users/${creds.userId}/hq/`
22
- : `users/${creds.userId}/hq/`;
23
- return {
24
- bucket: creds.bucket,
25
- region: creds.region,
26
- userId: creds.userId,
27
- prefix,
28
- };
29
- }
30
-
31
- async function getClient(): Promise<{ client: S3Client; config: SyncConfig }> {
32
- let creds = readCredentials();
33
- if (!creds) {
34
- throw new Error("Not authenticated. Run 'hq sync init' first.");
35
- }
36
-
37
- // Refresh if expired or missing access key
38
- if (!creds.accessKeyId || (creds.expiration && new Date(creds.expiration) < new Date())) {
39
- creds = await refreshAwsCredentials(creds);
40
- }
41
-
42
- if (!s3Client) {
43
- s3Client = new S3Client({
44
- region: creds.region,
45
- credentials: {
46
- accessKeyId: creds.accessKeyId,
47
- secretAccessKey: creds.secretAccessKey,
48
- sessionToken: creds.sessionToken,
49
- },
50
- });
51
- }
19
+ import type { EntityContext } from "./types.js";
52
20
 
53
- return { client: s3Client, config: getConfig(creds) };
21
+ /**
22
+ * Build an S3Client from an EntityContext's STS-scoped credentials.
23
+ * A new client is created each time to ensure fresh credentials are used
24
+ * (the caller handles caching/refresh at the EntityContext level).
25
+ */
26
+ function buildClient(ctx: EntityContext): S3Client {
27
+ return new S3Client({
28
+ region: ctx.region,
29
+ credentials: {
30
+ accessKeyId: ctx.credentials.accessKeyId,
31
+ secretAccessKey: ctx.credentials.secretAccessKey,
32
+ sessionToken: ctx.credentials.sessionToken,
33
+ },
34
+ });
54
35
  }
55
36
 
56
37
  export async function uploadFile(
38
+ ctx: EntityContext,
57
39
  localPath: string,
58
- relativePath: string
40
+ key: string,
59
41
  ): Promise<void> {
60
- const { client, config } = await getClient();
61
- const key = `${config.prefix}${relativePath}`;
42
+ const client = buildClient(ctx);
62
43
  const body = fs.readFileSync(localPath);
63
44
 
64
45
  await client.send(
65
46
  new PutObjectCommand({
66
- Bucket: config.bucket,
47
+ Bucket: ctx.bucketName,
67
48
  Key: key,
68
49
  Body: body,
69
- ContentType: getMimeType(relativePath),
70
- })
50
+ ContentType: getMimeType(key),
51
+ }),
71
52
  );
72
53
  }
73
54
 
74
55
  export async function downloadFile(
75
- relativePath: string,
76
- localPath: string
56
+ ctx: EntityContext,
57
+ key: string,
58
+ localPath: string,
77
59
  ): Promise<void> {
78
- const { client, config } = await getClient();
79
- const key = `${config.prefix}${relativePath}`;
60
+ const client = buildClient(ctx);
80
61
 
81
62
  const response = await client.send(
82
63
  new GetObjectCommand({
83
- Bucket: config.bucket,
64
+ Bucket: ctx.bucketName,
84
65
  Key: key,
85
- })
66
+ }),
86
67
  );
87
68
 
88
69
  if (!response.Body) {
@@ -104,34 +85,33 @@ export async function downloadFile(
104
85
 
105
86
  export interface RemoteFile {
106
87
  key: string;
107
- relativePath: string;
108
88
  size: number;
109
89
  lastModified: Date;
110
90
  etag: string;
111
91
  }
112
92
 
113
- export async function listRemoteFiles(): Promise<RemoteFile[]> {
114
- const { client, config } = await getClient();
93
+ export async function listRemoteFiles(
94
+ ctx: EntityContext,
95
+ prefix?: string,
96
+ ): Promise<RemoteFile[]> {
97
+ const client = buildClient(ctx);
115
98
  const files: RemoteFile[] = [];
116
99
  let continuationToken: string | undefined;
117
100
 
118
101
  do {
119
102
  const response = await client.send(
120
103
  new ListObjectsV2Command({
121
- Bucket: config.bucket,
122
- Prefix: config.prefix,
104
+ Bucket: ctx.bucketName,
105
+ Prefix: prefix,
123
106
  ContinuationToken: continuationToken,
124
- })
107
+ }),
125
108
  );
126
109
 
127
110
  for (const obj of response.Contents || []) {
128
111
  if (!obj.Key || !obj.Size) continue;
129
- const relativePath = obj.Key.replace(config.prefix, "");
130
- if (!relativePath) continue;
131
112
 
132
113
  files.push({
133
114
  key: obj.Key,
134
- relativePath,
135
115
  size: obj.Size,
136
116
  lastModified: obj.LastModified || new Date(),
137
117
  etag: obj.ETag || "",
@@ -144,18 +124,48 @@ export async function listRemoteFiles(): Promise<RemoteFile[]> {
144
124
  return files;
145
125
  }
146
126
 
147
- export async function deleteRemoteFile(relativePath: string): Promise<void> {
148
- const { client, config } = await getClient();
149
- const key = `${config.prefix}${relativePath}`;
127
+ export async function deleteRemoteFile(
128
+ ctx: EntityContext,
129
+ key: string,
130
+ ): Promise<void> {
131
+ const client = buildClient(ctx);
150
132
 
151
133
  await client.send(
152
134
  new DeleteObjectCommand({
153
- Bucket: config.bucket,
135
+ Bucket: ctx.bucketName,
154
136
  Key: key,
155
- })
137
+ }),
156
138
  );
157
139
  }
158
140
 
141
+ /**
142
+ * Check if a remote key exists and return its metadata.
143
+ */
144
+ export async function headRemoteFile(
145
+ ctx: EntityContext,
146
+ key: string,
147
+ ): Promise<{ lastModified: Date; etag: string; size: number } | null> {
148
+ const client = buildClient(ctx);
149
+ try {
150
+ const response = await client.send(
151
+ new HeadObjectCommand({
152
+ Bucket: ctx.bucketName,
153
+ Key: key,
154
+ }),
155
+ );
156
+ return {
157
+ lastModified: response.LastModified || new Date(),
158
+ etag: response.ETag || "",
159
+ size: response.ContentLength || 0,
160
+ };
161
+ } catch (err: unknown) {
162
+ if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {
163
+ return null;
164
+ }
165
+ throw err;
166
+ }
167
+ }
168
+
159
169
  function getMimeType(filePath: string): string {
160
170
  const ext = path.extname(filePath).toLowerCase();
161
171
  const mimeTypes: Record<string, string> = {
package/src/types.ts CHANGED
@@ -57,3 +57,40 @@ export interface DaemonState {
57
57
  startedAt: string;
58
58
  hqRoot: string;
59
59
  }
60
+
61
+ /**
62
+ * Entity-aware context for vault-backed S3 operations (VLT-5).
63
+ * Resolved from vault-service entity registry + STS vending.
64
+ */
65
+ export interface EntityContext {
66
+ /** Entity UID (cmp_*) */
67
+ uid: string;
68
+ /** Entity slug (human-readable, stable key for per-company local state). */
69
+ slug: string;
70
+ /** S3 bucket name for this entity */
71
+ bucketName: string;
72
+ /** AWS region */
73
+ region: string;
74
+ /** STS-scoped credentials */
75
+ credentials: VaultCredentials;
76
+ /** When the credentials expire (ISO 8601) */
77
+ expiresAt: string;
78
+ }
79
+
80
+ export interface VaultCredentials {
81
+ accessKeyId: string;
82
+ secretAccessKey: string;
83
+ sessionToken: string;
84
+ }
85
+
86
+ /**
87
+ * Configuration for connecting to the vault-service API.
88
+ */
89
+ export interface VaultServiceConfig {
90
+ /** Vault API base URL (e.g. https://vault-api.example.com) */
91
+ apiUrl: string;
92
+ /** Cognito JWT token for authentication */
93
+ authToken: string;
94
+ /** AWS region for S3 client (defaults to entity region or us-east-1) */
95
+ region?: string;
96
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * VaultClient unit tests (VLT-7 US-001).
3
+ *
4
+ * Uses mocked fetch to assert retry behavior, error mapping, and auth header injection.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
8
+ import {
9
+ VaultClient,
10
+ VaultAuthError,
11
+ VaultPermissionDeniedError,
12
+ VaultNotFoundError,
13
+ VaultConflictError,
14
+ VaultClientError,
15
+ } from "./vault-client.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function jsonResponse(status: number, body: unknown): Response {
22
+ return new Response(JSON.stringify(body), {
23
+ status,
24
+ headers: { "Content-Type": "application/json" },
25
+ });
26
+ }
27
+
28
+ function textResponse(status: number, body: string): Response {
29
+ return new Response(body, { status });
30
+ }
31
+
32
+ const TEST_CONFIG = {
33
+ apiUrl: "https://vault.test.example.com",
34
+ authToken: "test-jwt-token-123",
35
+ };
36
+
37
+ let client: VaultClient;
38
+ let fetchSpy: MockInstance<typeof fetch>;
39
+
40
+ beforeEach(() => {
41
+ client = new VaultClient(TEST_CONFIG);
42
+ fetchSpy = vi.spyOn(globalThis, "fetch");
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Auth header injection
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe("auth header injection", () => {
54
+ it("sends Bearer token on every request", async () => {
55
+ fetchSpy.mockResolvedValueOnce(
56
+ jsonResponse(200, { members: [] }),
57
+ );
58
+
59
+ await client.listMembersOfCompany("cmp_abc");
60
+
61
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
62
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
63
+ const headers = init.headers as Record<string, string>;
64
+ expect(headers.Authorization).toBe("Bearer test-jwt-token-123");
65
+ });
66
+
67
+ it("sets Content-Type on POST requests", async () => {
68
+ fetchSpy.mockResolvedValueOnce(
69
+ jsonResponse(200, { membership: {}, inviteToken: "tok" }),
70
+ );
71
+
72
+ await client.createInvite({
73
+ companyUid: "cmp_abc",
74
+ role: "member",
75
+ invitedBy: "psn_xyz",
76
+ });
77
+
78
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
79
+ const headers = init.headers as Record<string, string>;
80
+ expect(headers["Content-Type"]).toBe("application/json");
81
+ });
82
+ });
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Error mapping
86
+ // ---------------------------------------------------------------------------
87
+
88
+ describe("error mapping", () => {
89
+ it("maps 401 to VaultAuthError", async () => {
90
+ fetchSpy.mockResolvedValueOnce(
91
+ jsonResponse(401, { message: "Token expired" }),
92
+ );
93
+
94
+ await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
95
+ });
96
+
97
+ it("maps 403 to VaultPermissionDeniedError", async () => {
98
+ fetchSpy.mockResolvedValueOnce(
99
+ jsonResponse(403, { message: "Admin required" }),
100
+ );
101
+
102
+ await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultPermissionDeniedError);
103
+ });
104
+
105
+ it("maps 404 to VaultNotFoundError", async () => {
106
+ fetchSpy.mockResolvedValueOnce(
107
+ jsonResponse(404, { message: "Not found" }),
108
+ );
109
+
110
+ await expect(client.entity.get("cmp_missing")).rejects.toThrow(VaultNotFoundError);
111
+ });
112
+
113
+ it("maps 409 to VaultConflictError", async () => {
114
+ fetchSpy.mockResolvedValueOnce(
115
+ jsonResponse(409, { message: "Already accepted" }),
116
+ );
117
+
118
+ await expect(client.acceptInvite("tok", "psn_abc")).rejects.toThrow(VaultConflictError);
119
+ });
120
+
121
+ it("preserves error message from response body", async () => {
122
+ fetchSpy.mockResolvedValueOnce(
123
+ jsonResponse(403, { message: "Only admins can invite" }),
124
+ );
125
+
126
+ try {
127
+ await client.createInvite({
128
+ companyUid: "cmp_abc",
129
+ role: "member",
130
+ invitedBy: "psn_xyz",
131
+ });
132
+ expect.fail("Should have thrown");
133
+ } catch (err) {
134
+ expect(err).toBeInstanceOf(VaultPermissionDeniedError);
135
+ expect((err as VaultPermissionDeniedError).message).toBe("Only admins can invite");
136
+ }
137
+ });
138
+
139
+ it("handles non-JSON error bodies gracefully", async () => {
140
+ fetchSpy.mockResolvedValueOnce(
141
+ textResponse(404, "Not Found"),
142
+ );
143
+
144
+ try {
145
+ await client.entity.findBySlug("company", "test");
146
+ expect.fail("Should have thrown");
147
+ } catch (err) {
148
+ expect(err).toBeInstanceOf(VaultNotFoundError);
149
+ expect((err as VaultNotFoundError).message).toBe("Not Found");
150
+ }
151
+ });
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Retry behavior
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("retry behavior", () => {
159
+ it("retries on 429 and succeeds on second attempt", async () => {
160
+ fetchSpy
161
+ .mockResolvedValueOnce(jsonResponse(429, { message: "Rate limited" }))
162
+ .mockResolvedValueOnce(jsonResponse(200, { members: [{ personUid: "psn_1" }] }));
163
+
164
+ const result = await client.listMembersOfCompany("cmp_abc");
165
+ expect(result).toHaveLength(1);
166
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
167
+ });
168
+
169
+ it("retries on 500 and succeeds on third attempt", async () => {
170
+ fetchSpy
171
+ .mockResolvedValueOnce(jsonResponse(500, { message: "Internal error" }))
172
+ .mockResolvedValueOnce(jsonResponse(502, { message: "Bad gateway" }))
173
+ .mockResolvedValueOnce(jsonResponse(200, { membership: { role: "admin" } }));
174
+
175
+ const result = await client.updateRole({
176
+ membershipKey: "psn_1#cmp_abc",
177
+ newRole: "admin",
178
+ updaterUid: "psn_owner",
179
+ companyUid: "cmp_abc",
180
+ });
181
+ expect(result.role).toBe("admin");
182
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
183
+ });
184
+
185
+ it("throws after exhausting all retries on persistent 500", async () => {
186
+ fetchSpy.mockImplementation(() =>
187
+ Promise.resolve(jsonResponse(500, { message: "Down" })),
188
+ );
189
+
190
+ await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultClientError);
191
+ // 1 initial + 3 retries = 4
192
+ expect(fetchSpy).toHaveBeenCalledTimes(4);
193
+ });
194
+
195
+ it("does not retry on 401 (non-transient)", async () => {
196
+ fetchSpy.mockResolvedValueOnce(jsonResponse(401, { message: "Expired" }));
197
+
198
+ await expect(client.listMembersOfCompany("cmp_abc")).rejects.toThrow(VaultAuthError);
199
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
200
+ });
201
+
202
+ it("does not retry on 403 (non-transient)", async () => {
203
+ fetchSpy.mockResolvedValueOnce(jsonResponse(403, { message: "Forbidden" }));
204
+
205
+ await expect(client.createInvite({
206
+ companyUid: "cmp_abc",
207
+ role: "member",
208
+ invitedBy: "psn_xyz",
209
+ })).rejects.toThrow(VaultPermissionDeniedError);
210
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
211
+ });
212
+
213
+ it("retries on network errors (fetch throws)", async () => {
214
+ fetchSpy
215
+ .mockRejectedValueOnce(new Error("ECONNRESET"))
216
+ .mockResolvedValueOnce(jsonResponse(200, { members: [] }));
217
+
218
+ const result = await client.listMembersOfCompany("cmp_abc");
219
+ expect(result).toEqual([]);
220
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // API surface
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe("API surface", () => {
229
+ it("createInvite sends correct body and URL", async () => {
230
+ fetchSpy.mockResolvedValueOnce(
231
+ jsonResponse(200, {
232
+ membership: { membershipKey: "psn_1#cmp_abc", role: "member", status: "pending" },
233
+ inviteToken: "tok_secure_random",
234
+ }),
235
+ );
236
+
237
+ const result = await client.createInvite({
238
+ companyUid: "cmp_abc",
239
+ role: "member",
240
+ invitedBy: "psn_owner",
241
+ inviteeEmail: "alice@example.com",
242
+ });
243
+
244
+ expect(result.inviteToken).toBe("tok_secure_random");
245
+
246
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
247
+ expect(url).toBe("https://vault.test.example.com/membership/invite");
248
+ expect(JSON.parse(init.body as string)).toEqual({
249
+ companyUid: "cmp_abc",
250
+ role: "member",
251
+ invitedBy: "psn_owner",
252
+ inviteeEmail: "alice@example.com",
253
+ });
254
+ });
255
+
256
+ it("acceptInvite sends token and personUid", async () => {
257
+ fetchSpy.mockResolvedValueOnce(
258
+ jsonResponse(200, {
259
+ membership: { status: "active", role: "member" },
260
+ }),
261
+ );
262
+
263
+ const result = await client.acceptInvite("tok_abc", "psn_invitee");
264
+ expect(result.membership.status).toBe("active");
265
+
266
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
267
+ expect(url).toBe("https://vault.test.example.com/membership/accept");
268
+ expect(JSON.parse(init.body as string)).toEqual({
269
+ token: "tok_abc",
270
+ personUid: "psn_invitee",
271
+ });
272
+ });
273
+
274
+ it("updateRole sends correct payload", async () => {
275
+ fetchSpy.mockResolvedValueOnce(
276
+ jsonResponse(200, {
277
+ membership: { role: "guest", allowedPrefixes: ["docs/"] },
278
+ }),
279
+ );
280
+
281
+ const result = await client.updateRole({
282
+ membershipKey: "psn_1#cmp_abc",
283
+ newRole: "guest",
284
+ allowedPrefixes: ["docs/"],
285
+ updaterUid: "psn_admin",
286
+ companyUid: "cmp_abc",
287
+ });
288
+
289
+ expect(result.role).toBe("guest");
290
+ expect(result.allowedPrefixes).toEqual(["docs/"]);
291
+
292
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
293
+ expect(url).toBe("https://vault.test.example.com/membership/role");
294
+ expect(JSON.parse(init.body as string)).toEqual({
295
+ membershipKey: "psn_1#cmp_abc",
296
+ newRole: "guest",
297
+ allowedPrefixes: ["docs/"],
298
+ updaterUid: "psn_admin",
299
+ companyUid: "cmp_abc",
300
+ });
301
+ });
302
+
303
+ it("entity.get calls correct URL", async () => {
304
+ fetchSpy.mockResolvedValueOnce(
305
+ jsonResponse(200, {
306
+ entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
307
+ }),
308
+ );
309
+
310
+ const entity = await client.entity.get("cmp_abc");
311
+ expect(entity.slug).toBe("acme");
312
+
313
+ const [url] = fetchSpy.mock.calls[0] as [string];
314
+ expect(url).toBe("https://vault.test.example.com/entity/cmp_abc");
315
+ });
316
+
317
+ it("entity.findBySlug calls correct URL", async () => {
318
+ fetchSpy.mockResolvedValueOnce(
319
+ jsonResponse(200, {
320
+ entity: { uid: "cmp_abc", slug: "acme", type: "company", status: "active" },
321
+ }),
322
+ );
323
+
324
+ const entity = await client.entity.findBySlug("company", "acme");
325
+ expect(entity.uid).toBe("cmp_abc");
326
+
327
+ const [url] = fetchSpy.mock.calls[0] as [string];
328
+ expect(url).toBe("https://vault.test.example.com/entity/by-slug/company/acme");
329
+ });
330
+
331
+ it("revokeMembership calls POST /membership/revoke with companyUid", async () => {
332
+ fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
333
+
334
+ await client.revokeMembership("psn_1#cmp_abc", "cmp_abc");
335
+
336
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
337
+ expect(url).toBe("https://vault.test.example.com/membership/revoke");
338
+ expect(init.method).toBe("POST");
339
+ expect(JSON.parse(init.body as string)).toEqual({
340
+ membershipKey: "psn_1#cmp_abc",
341
+ companyUid: "cmp_abc",
342
+ });
343
+ });
344
+
345
+ it("listPendingInvites calls correct URL", async () => {
346
+ fetchSpy.mockResolvedValueOnce(
347
+ jsonResponse(200, { invites: [{ status: "pending" }] }),
348
+ );
349
+
350
+ const invites = await client.listPendingInvites("cmp_abc");
351
+ expect(invites).toHaveLength(1);
352
+
353
+ const [url] = fetchSpy.mock.calls[0] as [string];
354
+ expect(url).toBe("https://vault.test.example.com/membership/company/cmp_abc/pending");
355
+ });
356
+
357
+ it("listMyMemberships hits GET /membership/me and unwraps memberships[]", async () => {
358
+ fetchSpy.mockResolvedValueOnce(
359
+ jsonResponse(200, {
360
+ memberships: [
361
+ { membershipKey: "psn_1#cmp_a", role: "owner", status: "active" },
362
+ { membershipKey: "psn_1#cmp_b", role: "member", status: "active" },
363
+ ],
364
+ }),
365
+ );
366
+
367
+ const memberships = await client.listMyMemberships();
368
+ expect(memberships).toHaveLength(2);
369
+ expect(memberships[0].membershipKey).toBe("psn_1#cmp_a");
370
+
371
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
372
+ expect(url).toBe("https://vault.test.example.com/membership/me");
373
+ expect(init.method).toBe("GET");
374
+ // No body on GET.
375
+ expect(init.body).toBeUndefined();
376
+ });
377
+
378
+ it("listMyMemberships returns [] for callers with no person entity (bootstrap case)", async () => {
379
+ // Server returns 200 + { memberships: [] } rather than 404 when the
380
+ // caller is signed in but hasn't been provisioned yet. The SDK must
381
+ // surface an empty array, NOT throw — hq-sync-runner relies on this
382
+ // to emit `setup-needed` without catching HTTP errors.
383
+ fetchSpy.mockResolvedValueOnce(
384
+ jsonResponse(200, { memberships: [] }),
385
+ );
386
+
387
+ const memberships = await client.listMyMemberships();
388
+ expect(memberships).toEqual([]);
389
+ });
390
+ });