@indigoai-us/hq-cloud 5.1.0 → 5.1.9

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 +134 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +360 -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 +648 -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 +59 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +72 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +160 -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 +804 -0
  73. package/src/bin/sync-runner.ts +499 -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 +94 -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 +563 -0
  97. package/src/vault-client.ts +478 -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,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
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Integration test: share/sync lifecycle (VLT-5 US-003).
3
+ *
4
+ * Exercises the full share/sync cycle against a real dev-stage company entity
5
+ * using real STS vending (VLT-3). Requires:
6
+ * - VAULT_API_URL env var (vault-service API endpoint)
7
+ * - VAULT_AUTH_TOKEN env var (Cognito JWT for an active member)
8
+ * - VAULT_TEST_COMPANY env var (company slug with an active entity + bucket)
9
+ *
10
+ * Run: pnpm test:e2e
11
+ *
12
+ * This test:
13
+ * 1. M1 shares file A
14
+ * 2. M2 (same creds, different local dir) syncs → receives A
15
+ * 3. M2 shares file B
16
+ * 4. M1 syncs → receives B
17
+ * 5. M1 edits A locally + M2 pushes newer A → M1 syncs with --on-conflict=keep
18
+ *
19
+ * Cleanup: all shared files are deleted from S3 on teardown.
20
+ */
21
+
22
+ import { describe, it, expect, afterAll } from "vitest";
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import * as os from "os";
26
+ import {
27
+ resolveEntityContext,
28
+ clearContextCache,
29
+ deleteRemoteFile,
30
+ listRemoteFiles,
31
+ } from "../src/index.js";
32
+ import { share } from "../src/cli/share.js";
33
+ import { sync } from "../src/cli/sync.js";
34
+ import type { VaultServiceConfig, EntityContext } from "../src/types.js";
35
+
36
+ // Skip if env vars not set
37
+ const VAULT_API_URL = process.env.VAULT_API_URL;
38
+ const VAULT_AUTH_TOKEN = process.env.VAULT_AUTH_TOKEN;
39
+ const VAULT_TEST_COMPANY = process.env.VAULT_TEST_COMPANY;
40
+
41
+ const canRun = VAULT_API_URL && VAULT_AUTH_TOKEN && VAULT_TEST_COMPANY;
42
+
43
+ const TEST_PREFIX = `__integration-test-${Date.now()}`;
44
+
45
+ describe.skipIf(!canRun)("share-sync integration", () => {
46
+ let vaultConfig: VaultServiceConfig;
47
+ let m1Root: string;
48
+ let m2Root: string;
49
+ let ctx: EntityContext;
50
+
51
+ // Track files to clean up
52
+ const sharedKeys: string[] = [];
53
+
54
+ // Create two simulated machine roots
55
+ const setup = async () => {
56
+ vaultConfig = {
57
+ apiUrl: VAULT_API_URL!,
58
+ authToken: VAULT_AUTH_TOKEN!,
59
+ region: "us-east-1",
60
+ };
61
+
62
+ m1Root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-integ-m1-"));
63
+ m2Root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-integ-m2-"));
64
+
65
+ // Resolve entity context to verify connectivity
66
+ clearContextCache();
67
+ ctx = await resolveEntityContext(VAULT_TEST_COMPANY!, vaultConfig);
68
+
69
+ console.log(`Integration test: entity=${ctx.uid}, bucket=${ctx.bucketName}`);
70
+ console.log(`M1 root: ${m1Root}`);
71
+ console.log(`M2 root: ${m2Root}`);
72
+ };
73
+
74
+ afterAll(async () => {
75
+ // Cleanup: delete all test files from S3
76
+ try {
77
+ if (ctx) {
78
+ for (const key of sharedKeys) {
79
+ try {
80
+ await deleteRemoteFile(ctx, key);
81
+ } catch {
82
+ // Best-effort cleanup
83
+ }
84
+ }
85
+
86
+ // Also scan for any orphaned test files
87
+ const remoteFiles = await listRemoteFiles(ctx, TEST_PREFIX);
88
+ for (const file of remoteFiles) {
89
+ try {
90
+ await deleteRemoteFile(ctx, file.key);
91
+ } catch {
92
+ // Best-effort
93
+ }
94
+ }
95
+ }
96
+ } finally {
97
+ // Clean up temp directories
98
+ if (m1Root) fs.rmSync(m1Root, { recursive: true, force: true });
99
+ if (m2Root) fs.rmSync(m2Root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ it("completes the full share/sync lifecycle", async () => {
104
+ await setup();
105
+
106
+ // --- Step 1: M1 shares file A ---
107
+ const fileA = `${TEST_PREFIX}/docs/handoff.md`;
108
+ const fileALocal = path.join(m1Root, fileA);
109
+ fs.mkdirSync(path.dirname(fileALocal), { recursive: true });
110
+ fs.writeFileSync(fileALocal, "# Handoff from M1\n\nDiscovery notes here.");
111
+ sharedKeys.push(fileA);
112
+
113
+ const shareResult1 = await share({
114
+ paths: [fileALocal],
115
+ company: VAULT_TEST_COMPANY!,
116
+ message: "Initial handoff notes",
117
+ vaultConfig,
118
+ hqRoot: m1Root,
119
+ });
120
+
121
+ expect(shareResult1.filesUploaded).toBe(1);
122
+ expect(shareResult1.aborted).toBe(false);
123
+ console.log("Step 1 PASS: M1 shared file A");
124
+
125
+ // --- Step 2: M2 syncs → receives A ---
126
+ clearContextCache();
127
+
128
+ const syncResult1 = await sync({
129
+ company: VAULT_TEST_COMPANY!,
130
+ vaultConfig,
131
+ hqRoot: m2Root,
132
+ });
133
+
134
+ expect(syncResult1.filesDownloaded).toBeGreaterThanOrEqual(1);
135
+ expect(syncResult1.aborted).toBe(false);
136
+
137
+ const m2FileA = path.join(m2Root, fileA);
138
+ expect(fs.existsSync(m2FileA)).toBe(true);
139
+ expect(fs.readFileSync(m2FileA, "utf-8")).toContain("Handoff from M1");
140
+ console.log("Step 2 PASS: M2 synced and received file A");
141
+
142
+ // --- Step 3: M2 shares file B ---
143
+ const fileB = `${TEST_PREFIX}/knowledge/findings.md`;
144
+ const fileBLocal = path.join(m2Root, fileB);
145
+ fs.mkdirSync(path.dirname(fileBLocal), { recursive: true });
146
+ fs.writeFileSync(fileBLocal, "# Findings from M2\n\nNew discovery.");
147
+ sharedKeys.push(fileB);
148
+
149
+ const shareResult2 = await share({
150
+ paths: [fileBLocal],
151
+ company: VAULT_TEST_COMPANY!,
152
+ message: "Research findings",
153
+ vaultConfig,
154
+ hqRoot: m2Root,
155
+ });
156
+
157
+ expect(shareResult2.filesUploaded).toBe(1);
158
+ console.log("Step 3 PASS: M2 shared file B");
159
+
160
+ // --- Step 4: M1 syncs → receives B ---
161
+ clearContextCache();
162
+
163
+ const syncResult2 = await sync({
164
+ company: VAULT_TEST_COMPANY!,
165
+ vaultConfig,
166
+ hqRoot: m1Root,
167
+ });
168
+
169
+ expect(syncResult2.filesDownloaded).toBeGreaterThanOrEqual(1);
170
+
171
+ const m1FileB = path.join(m1Root, fileB);
172
+ expect(fs.existsSync(m1FileB)).toBe(true);
173
+ expect(fs.readFileSync(m1FileB, "utf-8")).toContain("Findings from M2");
174
+ console.log("Step 4 PASS: M1 synced and received file B");
175
+
176
+ // --- Step 5: Conflict — M1 edits A locally, M2 pushes newer A, M1 syncs with keep ---
177
+ // M1 edits locally
178
+ fs.writeFileSync(fileALocal, "# Handoff from M1\n\nEDITED LOCALLY by M1.");
179
+
180
+ // M2 pushes newer version
181
+ fs.writeFileSync(m2FileA, "# Handoff from M1\n\nUPDATED by M2.");
182
+ clearContextCache();
183
+
184
+ await share({
185
+ paths: [m2FileA],
186
+ company: VAULT_TEST_COMPANY!,
187
+ onConflict: "overwrite",
188
+ vaultConfig,
189
+ hqRoot: m2Root,
190
+ });
191
+
192
+ // M1 syncs with --on-conflict=keep
193
+ clearContextCache();
194
+
195
+ await sync({
196
+ company: VAULT_TEST_COMPANY!,
197
+ onConflict: "keep",
198
+ vaultConfig,
199
+ hqRoot: m1Root,
200
+ });
201
+
202
+ // M1's local version should be preserved
203
+ const m1Content = fs.readFileSync(fileALocal, "utf-8");
204
+ expect(m1Content).toContain("EDITED LOCALLY by M1");
205
+ expect(m1Content).not.toContain("UPDATED by M2");
206
+ console.log("Step 5 PASS: M1 kept local version on conflict");
207
+
208
+ console.log("\n=== All 5 lifecycle steps passed ===");
209
+ }, 60_000); // 60s timeout for network ops
210
+ });