@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,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
|
+
});
|
package/src/cognito-auth.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/daemon-worker.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
process.
|
|
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);
|