@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,156 @@
1
+ /**
2
+ * Unit tests for cognito-auth.ts — focus on the `expiresAt` shape contract.
3
+ *
4
+ * Canonical on-disk shape is ISO 8601 (what both writers emit). The reader
5
+ * also tolerates a raw number (ms since epoch) for forward/backward compat
6
+ * during rollouts, and fails safe on anything unparseable.
7
+ */
8
+
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ // Sandbox HOME *before* importing the module — it reads os.homedir() at load
15
+ // time to compute the cache file path.
16
+ let originalHome: string | undefined;
17
+ let tmpHome: string;
18
+
19
+ beforeEach(() => {
20
+ originalHome = process.env.HOME;
21
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cognito-auth-test-"));
22
+ process.env.HOME = tmpHome;
23
+ vi.resetModules();
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (originalHome === undefined) delete process.env.HOME;
28
+ else process.env.HOME = originalHome;
29
+ fs.rmSync(tmpHome, { recursive: true, force: true });
30
+ vi.unstubAllGlobals();
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ async function importModule() {
35
+ return await import("./cognito-auth.js");
36
+ }
37
+
38
+ const baseTokens = {
39
+ accessToken: "access",
40
+ idToken: "id",
41
+ refreshToken: "refresh",
42
+ tokenType: "Bearer" as const,
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Reader: isExpiring accepts both shapes and fails safe
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("isExpiring — expiresAt shape tolerance", () => {
50
+ it("returns false for ISO string far in the future", async () => {
51
+ const { isExpiring } = await importModule();
52
+ const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
53
+ expect(isExpiring({ ...baseTokens, expiresAt: future })).toBe(false);
54
+ });
55
+
56
+ it("returns true for ISO string within the buffer window", async () => {
57
+ const { isExpiring } = await importModule();
58
+ const soon = new Date(Date.now() + 10 * 1000).toISOString();
59
+ expect(isExpiring({ ...baseTokens, expiresAt: soon }, 60)).toBe(true);
60
+ });
61
+
62
+ it("returns false for raw number (ms) far in the future", async () => {
63
+ const { isExpiring } = await importModule();
64
+ const future = Date.now() + 60 * 60 * 1000;
65
+ // Cast because the type says string; the point is runtime tolerance.
66
+ expect(
67
+ isExpiring({ ...baseTokens, expiresAt: future as unknown as string }),
68
+ ).toBe(false);
69
+ });
70
+
71
+ it("returns true for raw number (ms) within the buffer window", async () => {
72
+ const { isExpiring } = await importModule();
73
+ const soon = Date.now() + 10 * 1000;
74
+ expect(
75
+ isExpiring(
76
+ { ...baseTokens, expiresAt: soon as unknown as string },
77
+ 60,
78
+ ),
79
+ ).toBe(true);
80
+ });
81
+
82
+ it("fails safe (returns true) for malformed expiresAt", async () => {
83
+ const { isExpiring } = await importModule();
84
+ expect(
85
+ isExpiring({ ...baseTokens, expiresAt: "not a date" }),
86
+ ).toBe(true);
87
+ expect(
88
+ isExpiring({
89
+ ...baseTokens,
90
+ expiresAt: undefined as unknown as string,
91
+ }),
92
+ ).toBe(true);
93
+ expect(
94
+ isExpiring({
95
+ ...baseTokens,
96
+ expiresAt: Number.NaN as unknown as string,
97
+ }),
98
+ ).toBe(true);
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Round-trip: writers emit ISO, readers read ISO
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("expiresAt shape round-trip", () => {
107
+ it("saveCachedTokens + loadCachedTokens preserves ISO string shape", async () => {
108
+ const { saveCachedTokens, loadCachedTokens } = await importModule();
109
+ const iso = new Date(Date.now() + 3600 * 1000).toISOString();
110
+ saveCachedTokens({ ...baseTokens, expiresAt: iso });
111
+ const loaded = loadCachedTokens();
112
+ expect(loaded).not.toBeNull();
113
+ expect(typeof loaded?.expiresAt).toBe("string");
114
+ expect(loaded?.expiresAt).toBe(iso);
115
+ expect(loaded?.expiresAt).toMatch(
116
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
117
+ );
118
+ });
119
+
120
+ it("refreshTokens writes an ISO string to cache", async () => {
121
+ vi.stubGlobal(
122
+ "fetch",
123
+ vi.fn(async () =>
124
+ new Response(
125
+ JSON.stringify({
126
+ access_token: "new-access",
127
+ id_token: "new-id",
128
+ refresh_token: "new-refresh",
129
+ expires_in: 3600,
130
+ token_type: "Bearer",
131
+ }),
132
+ { status: 200, headers: { "Content-Type": "application/json" } },
133
+ ),
134
+ ),
135
+ );
136
+
137
+ const { refreshTokens, loadCachedTokens } = await importModule();
138
+ const result = await refreshTokens(
139
+ {
140
+ region: "us-east-1",
141
+ userPoolDomain: "hq-vault-dev",
142
+ clientId: "test-client",
143
+ },
144
+ "prior-refresh-token",
145
+ );
146
+
147
+ expect(typeof result.expiresAt).toBe("string");
148
+ expect(result.expiresAt).toMatch(
149
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/,
150
+ );
151
+
152
+ const onDisk = loadCachedTokens();
153
+ expect(onDisk?.expiresAt).toBe(result.expiresAt);
154
+ expect(typeof onDisk?.expiresAt).toBe("string");
155
+ });
156
+ });
@@ -85,9 +85,26 @@ export function clearCachedTokens(): void {
85
85
  if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
86
86
  }
87
87
 
88
+ /**
89
+ * Parse `expiresAt` to epoch-ms. Canonical on-disk shape is ISO 8601 (what
90
+ * both writers in this file emit), but older/external writers may have left a
91
+ * raw number. Accept both so a shape mismatch during rollout doesn't wedge
92
+ * sign-in. Returns null for anything unparseable — callers should treat that
93
+ * as "expired" and force a refresh.
94
+ */
95
+ function parseExpiresAtMs(raw: unknown): number | null {
96
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
97
+ if (typeof raw === "string") {
98
+ const ms = new Date(raw).getTime();
99
+ return Number.isFinite(ms) ? ms : null;
100
+ }
101
+ return null;
102
+ }
103
+
88
104
  /** True when the token expires within the given buffer (default 60s). */
89
105
  export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
90
- const expiresAt = new Date(tokens.expiresAt).getTime();
106
+ const expiresAt = parseExpiresAtMs(tokens.expiresAt);
107
+ if (expiresAt === null) return true;
91
108
  return expiresAt - Date.now() < bufferSeconds * 1000;
92
109
  }
93
110
 
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Unit tests for context.ts — entity context resolution (VLT-5 US-001).
3
+ *
4
+ * Uses a mock fetch to simulate vault-service API responses.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from "vitest";
8
+ import {
9
+ resolveEntityContext,
10
+ refreshEntityContext,
11
+ clearContextCache,
12
+ isExpiringSoon,
13
+ } from "./context.js";
14
+ import type { VaultServiceConfig } from "./types.js";
15
+
16
+ const mockConfig: VaultServiceConfig = {
17
+ apiUrl: "https://vault-api.test",
18
+ authToken: "test-jwt-token",
19
+ region: "us-east-1",
20
+ };
21
+
22
+ const mockEntity = {
23
+ uid: "cmp_01ABCDEF",
24
+ slug: "acme",
25
+ bucketName: "hq-vault-acme-123",
26
+ status: "active",
27
+ };
28
+
29
+ const mockVendResponse = {
30
+ credentials: {
31
+ accessKeyId: "ASIA_TEST_KEY",
32
+ secretAccessKey: "test-secret",
33
+ sessionToken: "test-session-token",
34
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
35
+ },
36
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
37
+ };
38
+
39
+ function setupFetchMock(overrides?: {
40
+ entityStatus?: number;
41
+ entityBody?: unknown;
42
+ vendStatus?: number;
43
+ vendBody?: unknown;
44
+ }) {
45
+ const fetchMock = vi.fn();
46
+
47
+ fetchMock.mockImplementation(async (url: string) => {
48
+ const urlStr = String(url);
49
+
50
+ if (urlStr.includes("/entity/by-slug/")) {
51
+ return {
52
+ ok: (overrides?.entityStatus ?? 200) < 400,
53
+ status: overrides?.entityStatus ?? 200,
54
+ json: async () => overrides?.entityBody ?? { entity: mockEntity },
55
+ text: async () => JSON.stringify(overrides?.entityBody ?? { entity: mockEntity }),
56
+ };
57
+ }
58
+
59
+ if (urlStr.includes("/entity/cmp_")) {
60
+ return {
61
+ ok: (overrides?.entityStatus ?? 200) < 400,
62
+ status: overrides?.entityStatus ?? 200,
63
+ json: async () => overrides?.entityBody ?? { entity: mockEntity },
64
+ text: async () => JSON.stringify(overrides?.entityBody ?? { entity: mockEntity }),
65
+ };
66
+ }
67
+
68
+ if (urlStr.includes("/sts/vend")) {
69
+ return {
70
+ ok: (overrides?.vendStatus ?? 200) < 400,
71
+ status: overrides?.vendStatus ?? 200,
72
+ json: async () => overrides?.vendBody ?? mockVendResponse,
73
+ text: async () => JSON.stringify(overrides?.vendBody ?? mockVendResponse),
74
+ };
75
+ }
76
+
77
+ return { ok: false, status: 404, text: async () => "Not found" };
78
+ });
79
+
80
+ vi.stubGlobal("fetch", fetchMock);
81
+ return fetchMock;
82
+ }
83
+
84
+ describe("resolveEntityContext", () => {
85
+ beforeEach(() => {
86
+ clearContextCache();
87
+ vi.restoreAllMocks();
88
+ });
89
+
90
+ it("resolves context by slug", async () => {
91
+ const fetchMock = setupFetchMock();
92
+
93
+ const ctx = await resolveEntityContext("acme", mockConfig);
94
+
95
+ expect(ctx.uid).toBe("cmp_01ABCDEF");
96
+ expect(ctx.bucketName).toBe("hq-vault-acme-123");
97
+ expect(ctx.credentials.accessKeyId).toBe("ASIA_TEST_KEY");
98
+ expect(ctx.region).toBe("us-east-1");
99
+
100
+ // Verify entity lookup used by-slug endpoint
101
+ expect(fetchMock).toHaveBeenCalledTimes(2);
102
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/entity/by-slug/company/acme");
103
+ expect(String(fetchMock.mock.calls[1][0])).toContain("/sts/vend");
104
+ });
105
+
106
+ it("resolves context by UID directly", async () => {
107
+ const fetchMock = setupFetchMock();
108
+
109
+ const ctx = await resolveEntityContext("cmp_01ABCDEF", mockConfig);
110
+
111
+ expect(ctx.uid).toBe("cmp_01ABCDEF");
112
+ // Verify entity lookup used direct UID endpoint
113
+ expect(String(fetchMock.mock.calls[0][0])).toContain("/entity/cmp_01ABCDEF");
114
+ });
115
+
116
+ it("returns cached context when credentials are fresh", async () => {
117
+ const fetchMock = setupFetchMock();
118
+
119
+ const ctx1 = await resolveEntityContext("acme", mockConfig);
120
+ const ctx2 = await resolveEntityContext("acme", mockConfig);
121
+
122
+ expect(ctx1).toBe(ctx2); // Same reference
123
+ expect(fetchMock).toHaveBeenCalledTimes(2); // Only 1 entity + 1 vend call
124
+ });
125
+
126
+ it("auto-refreshes when credentials expire soon", async () => {
127
+ const almostExpired = new Date(Date.now() + 60 * 1000).toISOString(); // 1 min left
128
+ const fetchMock = setupFetchMock({
129
+ vendBody: {
130
+ credentials: mockVendResponse.credentials,
131
+ expiresAt: almostExpired,
132
+ },
133
+ });
134
+
135
+ const ctx1 = await resolveEntityContext("acme", mockConfig);
136
+
137
+ // Second call should refresh because <2 min remaining
138
+ const ctx2 = await resolveEntityContext("acme", mockConfig);
139
+ expect(ctx2).not.toBe(ctx1);
140
+ expect(fetchMock).toHaveBeenCalledTimes(4); // 2 entity + 2 vend calls
141
+ });
142
+
143
+ it("throws when entity has no bucket", async () => {
144
+ setupFetchMock({
145
+ entityBody: { entity: { ...mockEntity, bucketName: undefined } },
146
+ });
147
+
148
+ await expect(resolveEntityContext("acme", mockConfig)).rejects.toThrow(
149
+ /no bucket provisioned/,
150
+ );
151
+ });
152
+
153
+ it("throws on entity lookup failure", async () => {
154
+ setupFetchMock({ entityStatus: 404 });
155
+
156
+ await expect(resolveEntityContext("nonexistent", mockConfig)).rejects.toThrow(
157
+ /Failed to find entity/,
158
+ );
159
+ });
160
+
161
+ it("throws on STS vend failure", async () => {
162
+ setupFetchMock({ vendStatus: 403 });
163
+
164
+ await expect(resolveEntityContext("acme", mockConfig)).rejects.toThrow(
165
+ /STS vend failed/,
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("refreshEntityContext", () => {
171
+ beforeEach(() => {
172
+ clearContextCache();
173
+ vi.restoreAllMocks();
174
+ });
175
+
176
+ it("evicts cache and fetches fresh credentials", async () => {
177
+ const fetchMock = setupFetchMock();
178
+
179
+ const ctx1 = await resolveEntityContext("acme", mockConfig);
180
+ const ctx2 = await refreshEntityContext("acme", mockConfig);
181
+
182
+ expect(ctx2).not.toBe(ctx1);
183
+ expect(fetchMock).toHaveBeenCalledTimes(4); // 2 initial + 2 refresh
184
+ });
185
+ });
186
+
187
+ describe("isExpiringSoon", () => {
188
+ it("returns false when well within TTL", () => {
189
+ const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
190
+ expect(isExpiringSoon(future)).toBe(false);
191
+ });
192
+
193
+ it("returns true when within 2 minutes", () => {
194
+ const soon = new Date(Date.now() + 90 * 1000).toISOString();
195
+ expect(isExpiringSoon(soon)).toBe(true);
196
+ });
197
+
198
+ it("returns true when already expired", () => {
199
+ const past = new Date(Date.now() - 1000).toISOString();
200
+ expect(isExpiringSoon(past)).toBe(true);
201
+ });
202
+ });
package/src/context.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Entity context resolution (VLT-5 US-001).
3
+ *
4
+ * Resolves an entity (company) via vault-service, vends STS-scoped credentials,
5
+ * and returns an EntityContext for S3 operations. Handles auto-refresh when
6
+ * credentials are within 2 minutes of expiry.
7
+ */
8
+
9
+ import type { EntityContext, VaultServiceConfig } from "./types.js";
10
+
11
+ /** Minimum remaining TTL before auto-refresh triggers (2 minutes). */
12
+ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
13
+
14
+ /** STS session duration requested from vault-service (15 minutes). */
15
+ const DEFAULT_SESSION_DURATION_SECONDS = 900;
16
+
17
+ /** Cached contexts keyed by entity UID. */
18
+ const contextCache = new Map<string, EntityContext>();
19
+
20
+ /**
21
+ * Look up an entity by slug or UID via vault-service, then vend STS-scoped
22
+ * credentials for that entity. Returns an EntityContext ready for S3 ops.
23
+ *
24
+ * Caches the result and auto-refreshes when the credentials are within
25
+ * 2 minutes of expiry.
26
+ */
27
+ export async function resolveEntityContext(
28
+ companyUidOrSlug: string,
29
+ config: VaultServiceConfig,
30
+ ): Promise<EntityContext> {
31
+ // Check cache — return if credentials still fresh
32
+ const cached = contextCache.get(companyUidOrSlug);
33
+ if (cached && !isExpiringSoon(cached.expiresAt)) {
34
+ return cached;
35
+ }
36
+
37
+ // Step 1: Resolve entity — if it looks like a UID (cmp_*), fetch directly;
38
+ // otherwise look up by slug
39
+ const entity = companyUidOrSlug.startsWith("cmp_")
40
+ ? await fetchEntity(companyUidOrSlug, config)
41
+ : await fetchEntityBySlug("company", companyUidOrSlug, config);
42
+
43
+ if (!entity.bucketName) {
44
+ throw new Error(
45
+ `Entity ${entity.uid} (${entity.slug}) has no bucket provisioned. ` +
46
+ `Run VLT-2 bucket provisioning first.`,
47
+ );
48
+ }
49
+
50
+ // Step 2: Vend STS-scoped credentials
51
+ const vendResult = await vendCredentials(entity.uid, config);
52
+
53
+ const ctx: EntityContext = {
54
+ uid: entity.uid,
55
+ slug: entity.slug,
56
+ bucketName: entity.bucketName,
57
+ region: config.region ?? "us-east-1",
58
+ credentials: {
59
+ accessKeyId: vendResult.credentials.accessKeyId,
60
+ secretAccessKey: vendResult.credentials.secretAccessKey,
61
+ sessionToken: vendResult.credentials.sessionToken,
62
+ },
63
+ expiresAt: vendResult.expiresAt,
64
+ };
65
+
66
+ // Cache by both UID and slug for fast lookups
67
+ contextCache.set(entity.uid, ctx);
68
+ contextCache.set(entity.slug, ctx);
69
+
70
+ return ctx;
71
+ }
72
+
73
+ /**
74
+ * Check if credentials are expiring within the refresh threshold.
75
+ */
76
+ export function isExpiringSoon(expiresAt: string): boolean {
77
+ const expiryMs = new Date(expiresAt).getTime();
78
+ const nowMs = Date.now();
79
+ return expiryMs - nowMs < REFRESH_THRESHOLD_MS;
80
+ }
81
+
82
+ /**
83
+ * Force-refresh a cached context. Useful when an S3 operation fails with
84
+ * an expired credentials error.
85
+ */
86
+ export async function refreshEntityContext(
87
+ companyUidOrSlug: string,
88
+ config: VaultServiceConfig,
89
+ ): Promise<EntityContext> {
90
+ // Evict cache entry to force fresh resolution
91
+ contextCache.delete(companyUidOrSlug);
92
+ return resolveEntityContext(companyUidOrSlug, config);
93
+ }
94
+
95
+ /**
96
+ * Clear the entire context cache. Useful for tests.
97
+ */
98
+ export function clearContextCache(): void {
99
+ contextCache.clear();
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Vault-service API calls
104
+ // ---------------------------------------------------------------------------
105
+
106
+ interface EntityResponse {
107
+ uid: string;
108
+ slug: string;
109
+ bucketName?: string;
110
+ status: string;
111
+ }
112
+
113
+ interface VendResponse {
114
+ credentials: {
115
+ accessKeyId: string;
116
+ secretAccessKey: string;
117
+ sessionToken: string;
118
+ expiration: string;
119
+ };
120
+ expiresAt: string;
121
+ }
122
+
123
+ async function fetchEntity(
124
+ uid: string,
125
+ config: VaultServiceConfig,
126
+ ): Promise<EntityResponse> {
127
+ const res = await fetch(`${config.apiUrl}/entity/${uid}`, {
128
+ headers: { Authorization: `Bearer ${config.authToken}` },
129
+ });
130
+ if (!res.ok) {
131
+ const body = await res.text();
132
+ throw new Error(`Failed to fetch entity ${uid}: ${res.status} ${body}`);
133
+ }
134
+ const data = (await res.json()) as { entity: EntityResponse };
135
+ return data.entity;
136
+ }
137
+
138
+ async function fetchEntityBySlug(
139
+ type: string,
140
+ slug: string,
141
+ config: VaultServiceConfig,
142
+ ): Promise<EntityResponse> {
143
+ const res = await fetch(`${config.apiUrl}/entity/by-slug/${type}/${slug}`, {
144
+ headers: { Authorization: `Bearer ${config.authToken}` },
145
+ });
146
+ if (!res.ok) {
147
+ const body = await res.text();
148
+ throw new Error(
149
+ `Failed to find entity by slug ${type}/${slug}: ${res.status} ${body}`,
150
+ );
151
+ }
152
+ const data = (await res.json()) as { entity: EntityResponse };
153
+ return data.entity;
154
+ }
155
+
156
+ async function vendCredentials(
157
+ companyUid: string,
158
+ config: VaultServiceConfig,
159
+ ): Promise<VendResponse> {
160
+ const res = await fetch(`${config.apiUrl}/sts/vend`, {
161
+ method: "POST",
162
+ headers: {
163
+ "Content-Type": "application/json",
164
+ Authorization: `Bearer ${config.authToken}`,
165
+ },
166
+ body: JSON.stringify({
167
+ companyUid,
168
+ durationSeconds: DEFAULT_SESSION_DURATION_SECONDS,
169
+ }),
170
+ });
171
+ if (!res.ok) {
172
+ const body = await res.text();
173
+ throw new Error(
174
+ `STS vend failed for ${companyUid}: ${res.status} ${body}`,
175
+ );
176
+ }
177
+ return (await res.json()) as VendResponse;
178
+ }
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Daemon worker — runs as a detached child process
3
3
  * Watches HQ directory and syncs changes to S3
4
+ *
5
+ * Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
6
+ * When re-enabled, this worker will need to resolve an EntityContext before
7
+ * constructing the SyncWatcher. The process argv will need to include company
8
+ * context (slug or UID) and vault-service config.
4
9
  */
5
10
 
6
- import { SyncWatcher } from "./watcher.js";
11
+ // Day 1: SyncWatcher now requires an EntityContext.
12
+ // This file is retained for the automatic-sync milestone but is not functional
13
+ // until the daemon startup path is updated to resolve entity context.
7
14
 
8
15
  const hqRoot = process.argv[2];
9
16
 
@@ -12,21 +19,8 @@ if (!hqRoot) {
12
19
  process.exit(1);
13
20
  }
14
21
 
15
- const watcher = new SyncWatcher(hqRoot);
16
- watcher.start();
17
-
18
- // Handle graceful shutdown
19
- process.on("SIGTERM", () => {
20
- watcher.stop();
21
- process.exit(0);
22
- });
23
-
24
- process.on("SIGINT", () => {
25
- watcher.stop();
26
- process.exit(0);
27
- });
28
-
29
- // Keep process alive
30
- setInterval(() => {
31
- // Heartbeat — could add remote change polling here
32
- }, 30_000);
22
+ console.error(
23
+ "Day 1: daemon-worker is not yet wired to entity context resolution. " +
24
+ "Use 'hq share' and 'hq sync' for manual sync.",
25
+ );
26
+ process.exit(1);
package/src/daemon.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Background sync daemon management
3
3
  * Manages a child process that runs the file watcher
4
+ *
5
+ * Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
4
6
  */
5
7
 
6
8
  import * as fs from "fs";