@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.
- package/dist/bin/sync-runner.d.ts +111 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +285 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +492 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +16 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +19 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +25 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +617 -0
- package/src/bin/sync-runner.ts +390 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +93 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +390 -0
- package/src/vault-client.ts +400 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- 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.
|
|
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.
|
|
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
|
+
});
|