@hearth-auth/sdk 0.0.1

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 (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +680 -0
  3. package/package.json +44 -0
  4. package/src/admin.ts +157 -0
  5. package/src/browser-auth.ts +130 -0
  6. package/src/claims.ts +180 -0
  7. package/src/client.ts +251 -0
  8. package/src/errors.ts +173 -0
  9. package/src/generated/google/api/annotations_pb.ts +44 -0
  10. package/src/generated/google/api/http_pb.ts +467 -0
  11. package/src/generated/hearth/authz/v1/authz_pb.ts +593 -0
  12. package/src/generated/hearth/cluster/v1/raft_pb.ts +183 -0
  13. package/src/generated/hearth/events/v1/audit_pb.ts +886 -0
  14. package/src/generated/hearth/identity/v1/identity_pb.ts +1673 -0
  15. package/src/generated/hearth/identity/v1/oauth_pb.ts +1138 -0
  16. package/src/generated/hearth/rbac/v1/rbac_pb.ts +2000 -0
  17. package/src/hearth-client.ts +288 -0
  18. package/src/hearth.ts +224 -0
  19. package/src/index.ts +106 -0
  20. package/src/introspection-client.ts +83 -0
  21. package/src/jwks-client.ts +45 -0
  22. package/src/middleware.ts +82 -0
  23. package/src/pkce.ts +129 -0
  24. package/src/react.tsx +57 -0
  25. package/src/session-version-cache.ts +167 -0
  26. package/src/types.ts +188 -0
  27. package/tests/admin-crud.test.ts +97 -0
  28. package/tests/auth-flow.test.ts +75 -0
  29. package/tests/authorize.test.ts +386 -0
  30. package/tests/claims.test.ts +159 -0
  31. package/tests/hasPermission.test.ts +152 -0
  32. package/tests/hearth-client.test.ts +243 -0
  33. package/tests/helpers.ts +90 -0
  34. package/tests/jwks.test.ts +62 -0
  35. package/tests/pkce.test.ts +210 -0
  36. package/tests/react-useHasPermission.test.tsx +92 -0
  37. package/tests/required-action.test.ts +276 -0
  38. package/tests/session-version.test.ts +391 -0
  39. package/tsconfig.json +16 -0
  40. package/vitest.config.ts +8 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Unit tests for Claims class — spec §4.
3
+ * Tests are written before implementation (TDD).
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import { Claims } from "../src/claims.js";
8
+
9
+ /** Build a fake JWT string with the given payload (no signature verification). */
10
+ function forgeJwt(payload: Record<string, unknown>): string {
11
+ const header = Buffer.from(
12
+ JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
13
+ "utf8",
14
+ ).toString("base64url");
15
+ const body = Buffer.from(JSON.stringify(payload), "utf8").toString(
16
+ "base64url",
17
+ );
18
+ const sig = Buffer.from("fake-sig").toString("base64url");
19
+ return `${header}.${body}.${sig}`;
20
+ }
21
+
22
+ // ── scope() ────────────────────────────────────────────────────────────────
23
+
24
+ describe("Claims.scope()", () => {
25
+ it("returns the raw space-delimited scope string", () => {
26
+ const c = new Claims({ scope: "openid profile email" });
27
+ expect(c.scope()).toBe("openid profile email");
28
+ });
29
+
30
+ it("returns empty string when scope is absent", () => {
31
+ const c = new Claims({});
32
+ expect(c.scope()).toBe("");
33
+ });
34
+ });
35
+
36
+ // ── inGroup() ──────────────────────────────────────────────────────────────
37
+
38
+ describe("Claims.inGroup()", () => {
39
+ it("returns true when groups claim contains the group", () => {
40
+ const c = new Claims({ groups: ["engineering", "security"] });
41
+ expect(c.inGroup("engineering")).toBe(true);
42
+ expect(c.inGroup("security")).toBe(true);
43
+ });
44
+
45
+ it("returns false when group is not in the list", () => {
46
+ const c = new Claims({ groups: ["engineering"] });
47
+ expect(c.inGroup("security")).toBe(false);
48
+ });
49
+
50
+ it("returns false when groups claim is absent", () => {
51
+ const c = new Claims({});
52
+ expect(c.inGroup("engineering")).toBe(false);
53
+ });
54
+
55
+ it("returns false when groups claim is not an array", () => {
56
+ const c = new Claims({ groups: "engineering" as unknown });
57
+ expect(c.inGroup("engineering")).toBe(false);
58
+ });
59
+ });
60
+
61
+ // ── inOrg() ────────────────────────────────────────────────────────────────
62
+
63
+ describe("Claims.inOrg()", () => {
64
+ it("returns true when oid claim exactly matches", () => {
65
+ const c = new Claims({ oid: "org_abc123" });
66
+ expect(c.inOrg("org_abc123")).toBe(true);
67
+ });
68
+
69
+ it("returns false when oid claim does not match", () => {
70
+ const c = new Claims({ oid: "org_abc123" });
71
+ expect(c.inOrg("org_xyz")).toBe(false);
72
+ });
73
+
74
+ it("returns false when oid claim is absent", () => {
75
+ const c = new Claims({});
76
+ expect(c.inOrg("org_abc123")).toBe(false);
77
+ });
78
+ });
79
+
80
+ // ── tokenType() ────────────────────────────────────────────────────────────
81
+
82
+ describe("Claims.tokenType()", () => {
83
+ it("returns the token_type claim value", () => {
84
+ const c = new Claims({ token_type: "access" });
85
+ expect(c.tokenType()).toBe("access");
86
+ });
87
+
88
+ it("returns 'required_action' for required-action tokens", () => {
89
+ const c = new Claims({ token_type: "required_action" });
90
+ expect(c.tokenType()).toBe("required_action");
91
+ });
92
+
93
+ it("returns 'refresh' for refresh tokens", () => {
94
+ const c = new Claims({ token_type: "refresh" });
95
+ expect(c.tokenType()).toBe("refresh");
96
+ });
97
+
98
+ it("returns empty string when token_type is absent", () => {
99
+ const c = new Claims({});
100
+ expect(c.tokenType()).toBe("");
101
+ });
102
+ });
103
+
104
+ // ── organizationId() ──────────────────────────────────────────────────────
105
+
106
+ describe("Claims.organizationId()", () => {
107
+ it("returns the oid claim value", () => {
108
+ const c = new Claims({ oid: "org_abc123" });
109
+ expect(c.organizationId()).toBe("org_abc123");
110
+ });
111
+
112
+ it("returns undefined when oid is absent", () => {
113
+ const c = new Claims({});
114
+ expect(c.organizationId()).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ // ── orgGroups() ────────────────────────────────────────────────────────────
119
+
120
+ describe("Claims.orgGroups()", () => {
121
+ it("returns the org_groups claim array", () => {
122
+ const c = new Claims({
123
+ org_groups: ["/acme-corp/admins", "/acme-corp/engineering"],
124
+ });
125
+ expect(c.orgGroups()).toEqual(["/acme-corp/admins", "/acme-corp/engineering"]);
126
+ });
127
+
128
+ it("returns empty array when org_groups is absent", () => {
129
+ const c = new Claims({});
130
+ expect(c.orgGroups()).toEqual([]);
131
+ });
132
+
133
+ it("returns empty array when org_groups is not an array", () => {
134
+ const c = new Claims({ org_groups: "invalid" as unknown });
135
+ expect(c.orgGroups()).toEqual([]);
136
+ });
137
+ });
138
+
139
+ // ── decode() integration — all new claims survive round-trip ──────────────
140
+
141
+ describe("Claims.decode() — new claims present in JWT", () => {
142
+ it("parses all new claims from a forged JWT", () => {
143
+ const jwt = forgeJwt({
144
+ sub: "user_1",
145
+ scope: "openid profile",
146
+ groups: ["eng"],
147
+ oid: "org_42",
148
+ token_type: "access",
149
+ org_groups: ["/org/eng"],
150
+ });
151
+ const c = Claims.decode(jwt);
152
+ expect(c.scope()).toBe("openid profile");
153
+ expect(c.inGroup("eng")).toBe(true);
154
+ expect(c.inOrg("org_42")).toBe(true);
155
+ expect(c.tokenType()).toBe("access");
156
+ expect(c.organizationId()).toBe("org_42");
157
+ expect(c.orgGroups()).toEqual(["/org/eng"]);
158
+ });
159
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createHearth } from "../src/hearth.js";
3
+
4
+ /**
5
+ * Build a syntactically valid three-segment JWT around the given claims.
6
+ * The signature segment is arbitrary — `jose.decodeJwt` does not verify
7
+ * it, which matches what `createHearth` does on every check.
8
+ */
9
+ function forgeJwt(claims: Record<string, unknown>): string {
10
+ const header = Buffer.from(
11
+ JSON.stringify({ alg: "EdDSA", typ: "JWT" }),
12
+ "utf8",
13
+ ).toString("base64url");
14
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
15
+ const sig = Buffer.from("not-a-real-signature").toString("base64url");
16
+ return `${header}.${body}.${sig}`;
17
+ }
18
+
19
+ describe("createHearth — hasPermission", () => {
20
+ it("returns true when the JWT permissions claim contains the permission", () => {
21
+ const token = forgeJwt({
22
+ sub: "user_1",
23
+ permissions: ["docs.edit", "docs.view"],
24
+ });
25
+ const hearth = createHearth({
26
+ baseUrl: "http://localhost",
27
+ realmId: "r1",
28
+ getToken: () => token,
29
+ });
30
+ expect(hearth.hasPermission("docs.edit")).toBe(true);
31
+ expect(hearth.hasPermission("docs.view")).toBe(true);
32
+ });
33
+
34
+ it("returns false when the permission is absent", () => {
35
+ const token = forgeJwt({ permissions: ["docs.view"] });
36
+ const hearth = createHearth({
37
+ baseUrl: "http://localhost",
38
+ realmId: "r1",
39
+ getToken: () => token,
40
+ });
41
+ expect(hearth.hasPermission("docs.edit")).toBe(false);
42
+ });
43
+
44
+ it("returns false when the token is absent", () => {
45
+ const hearth = createHearth({
46
+ baseUrl: "http://localhost",
47
+ realmId: "r1",
48
+ getToken: () => null,
49
+ });
50
+ expect(hearth.hasPermission("docs.edit")).toBe(false);
51
+ });
52
+
53
+ it("returns false when the token is malformed", () => {
54
+ const hearth = createHearth({
55
+ baseUrl: "http://localhost",
56
+ realmId: "r1",
57
+ getToken: () => "not.a.jwt",
58
+ });
59
+ expect(hearth.hasPermission("docs.edit")).toBe(false);
60
+ });
61
+
62
+ it("returns false when the permissions claim is missing", () => {
63
+ const token = forgeJwt({ sub: "user_1" });
64
+ const hearth = createHearth({
65
+ baseUrl: "http://localhost",
66
+ realmId: "r1",
67
+ getToken: () => token,
68
+ });
69
+ expect(hearth.hasPermission("docs.edit")).toBe(false);
70
+ });
71
+
72
+ it("calls getToken on every invocation (no caching)", () => {
73
+ let current: string | null = null;
74
+ const hearth = createHearth({
75
+ baseUrl: "http://localhost",
76
+ realmId: "r1",
77
+ getToken: () => current,
78
+ });
79
+ expect(hearth.hasPermission("docs.edit")).toBe(false);
80
+ current = forgeJwt({ permissions: ["docs.edit"] });
81
+ expect(hearth.hasPermission("docs.edit")).toBe(true);
82
+ current = null;
83
+ expect(hearth.hasPermission("docs.edit")).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("createHearth — hasRole", () => {
88
+ it("returns true when the JWT roles claim contains the role", () => {
89
+ const token = forgeJwt({ roles: ["admin", "editor"] });
90
+ const hearth = createHearth({
91
+ baseUrl: "http://localhost",
92
+ realmId: "r1",
93
+ getToken: () => token,
94
+ });
95
+ expect(hearth.hasRole("admin")).toBe(true);
96
+ expect(hearth.hasRole("viewer")).toBe(false);
97
+ });
98
+
99
+ it("returns false when the roles claim is missing or malformed", () => {
100
+ const hearth = createHearth({
101
+ baseUrl: "http://localhost",
102
+ realmId: "r1",
103
+ getToken: () => forgeJwt({ roles: "admin" as unknown }),
104
+ });
105
+ expect(hearth.hasRole("admin")).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe("createHearth — inGroup", () => {
110
+ it("returns true when the JWT groups claim contains the group", () => {
111
+ const token = forgeJwt({ groups: ["engineering", "security"] });
112
+ const hearth = createHearth({
113
+ baseUrl: "http://localhost",
114
+ realmId: "r1",
115
+ getToken: () => token,
116
+ });
117
+ expect(hearth.inGroup("engineering")).toBe(true);
118
+ expect(hearth.inGroup("marketing")).toBe(false);
119
+ });
120
+
121
+ it("returns false when no token", () => {
122
+ const hearth = createHearth({
123
+ baseUrl: "http://localhost",
124
+ realmId: "r1",
125
+ getToken: () => undefined,
126
+ });
127
+ expect(hearth.inGroup("engineering")).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("createHearth — inOrg", () => {
132
+ it("returns true when the JWT oid claim equals the org", () => {
133
+ const token = forgeJwt({ oid: "org_42" });
134
+ const hearth = createHearth({
135
+ baseUrl: "http://localhost",
136
+ realmId: "r1",
137
+ getToken: () => token,
138
+ });
139
+ expect(hearth.inOrg("org_42")).toBe(true);
140
+ expect(hearth.inOrg("org_7")).toBe(false);
141
+ });
142
+
143
+ it("returns false when oid is missing", () => {
144
+ const token = forgeJwt({ sub: "user_1" });
145
+ const hearth = createHearth({
146
+ baseUrl: "http://localhost",
147
+ realmId: "r1",
148
+ getToken: () => token,
149
+ });
150
+ expect(hearth.inOrg("org_42")).toBe(false);
151
+ });
152
+ });
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { HearthClient } from "../src/hearth-client.js";
3
+ import { ConfigurationError, DiscoveryError } from "../src/errors.js";
4
+
5
+ const DISCOVERY_DOC = {
6
+ issuer: "https://auth.example.com",
7
+ jwks_uri: "https://auth.example.com/jwks",
8
+ introspection_endpoint: "https://auth.example.com/introspect",
9
+ };
10
+
11
+ describe("HearthClient — construction", () => {
12
+ it("throws ConfigurationError when issuerUrl is absent", () => {
13
+ expect(
14
+ () => new HearthClient({ issuerUrl: "" }),
15
+ ).toThrow(ConfigurationError);
16
+ });
17
+
18
+ it("throws ConfigurationError when issuerUrl is not a valid URL", () => {
19
+ expect(
20
+ () => new HearthClient({ issuerUrl: "not-a-url" }),
21
+ ).toThrow(ConfigurationError);
22
+ });
23
+
24
+ it("normalises a trailing slash on issuerUrl", () => {
25
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com/" });
26
+ expect(client.issuerUrl).toBe("https://auth.example.com");
27
+ });
28
+
29
+ it("defaults httpTimeout to 10 000 ms", () => {
30
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
31
+ expect(client.httpTimeout).toBe(10_000);
32
+ });
33
+
34
+ it("accepts a custom httpTimeout", () => {
35
+ const client = new HearthClient({
36
+ issuerUrl: "https://auth.example.com",
37
+ httpTimeout: 5_000,
38
+ });
39
+ expect(client.httpTimeout).toBe(5_000);
40
+ });
41
+ });
42
+
43
+ describe("HearthClient — OIDC discovery", () => {
44
+ beforeEach(() => {
45
+ vi.stubGlobal("fetch", vi.fn());
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.unstubAllGlobals();
50
+ });
51
+
52
+ function mockFetch(body: unknown, status = 200): void {
53
+ const mockedFetch = vi.mocked(fetch);
54
+ mockedFetch.mockResolvedValueOnce(
55
+ new Response(JSON.stringify(body), {
56
+ status,
57
+ headers: { "Content-Type": "application/json" },
58
+ }),
59
+ );
60
+ }
61
+
62
+ it("fetches discovery from {issuerUrl}/.well-known/openid-configuration", async () => {
63
+ mockFetch(DISCOVERY_DOC);
64
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
65
+ await client.discover();
66
+ expect(vi.mocked(fetch)).toHaveBeenCalledWith(
67
+ "https://auth.example.com/.well-known/openid-configuration",
68
+ expect.objectContaining({ signal: expect.anything() }),
69
+ );
70
+ });
71
+
72
+ it("caches the discovery document on repeated calls", async () => {
73
+ mockFetch(DISCOVERY_DOC);
74
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
75
+ await client.discover();
76
+ await client.discover();
77
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("throws DiscoveryError when fetch rejects (network error)", async () => {
81
+ vi.mocked(fetch).mockRejectedValueOnce(new Error("ECONNREFUSED"));
82
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
83
+ await expect(client.discover()).rejects.toThrow(DiscoveryError);
84
+ });
85
+
86
+ it("throws DiscoveryError on non-2xx HTTP response", async () => {
87
+ mockFetch({ error: "not found" }, 404);
88
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
89
+ await expect(client.discover()).rejects.toThrow(DiscoveryError);
90
+ });
91
+
92
+ it("throws DiscoveryError when discovery document is missing jwks_uri", async () => {
93
+ mockFetch({ issuer: "https://auth.example.com" });
94
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
95
+ await expect(client.discover()).rejects.toThrow(DiscoveryError);
96
+ });
97
+ });
98
+
99
+ describe("HearthClient — jwksClient()", () => {
100
+ beforeEach(() => {
101
+ vi.stubGlobal("fetch", vi.fn());
102
+ });
103
+
104
+ afterEach(() => {
105
+ vi.unstubAllGlobals();
106
+ });
107
+
108
+ function mockDiscovery(): void {
109
+ vi.mocked(fetch).mockResolvedValueOnce(
110
+ new Response(JSON.stringify(DISCOVERY_DOC), {
111
+ status: 200,
112
+ headers: { "Content-Type": "application/json" },
113
+ }),
114
+ );
115
+ }
116
+
117
+ it("returns a JwksClient bound to the discovered jwks_uri", async () => {
118
+ mockDiscovery();
119
+ const client = new HearthClient({
120
+ issuerUrl: "https://auth.example.com",
121
+ jwksTtl: 60_000,
122
+ });
123
+ const jwks = await client.jwksClient();
124
+ expect(jwks.ttl).toBe(60_000);
125
+ });
126
+
127
+ it("reuses the same JwksClient instance across calls", async () => {
128
+ mockDiscovery();
129
+ const client = new HearthClient({ issuerUrl: "https://auth.example.com" });
130
+ const a = await client.jwksClient();
131
+ const b = await client.jwksClient();
132
+ expect(a).toBe(b);
133
+ // Discovery fetched only once
134
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it("passes httpTimeout to the JwksClient", async () => {
138
+ mockDiscovery();
139
+ const client = new HearthClient({
140
+ issuerUrl: "https://auth.example.com",
141
+ httpTimeout: 3_000,
142
+ });
143
+ const jwks = await client.jwksClient();
144
+ expect(jwks.httpTimeout).toBe(3_000);
145
+ });
146
+ });
147
+
148
+ describe("HearthClient — introspectionClient()", () => {
149
+ beforeEach(() => {
150
+ vi.stubGlobal("fetch", vi.fn());
151
+ });
152
+
153
+ afterEach(() => {
154
+ vi.unstubAllGlobals();
155
+ });
156
+
157
+ function mockDiscovery(): void {
158
+ vi.mocked(fetch).mockResolvedValueOnce(
159
+ new Response(JSON.stringify(DISCOVERY_DOC), {
160
+ status: 200,
161
+ headers: { "Content-Type": "application/json" },
162
+ }),
163
+ );
164
+ }
165
+
166
+ it("throws ConfigurationError when clientId is absent", async () => {
167
+ const client = new HearthClient({
168
+ issuerUrl: "https://auth.example.com",
169
+ clientSecret: "secret",
170
+ });
171
+ await expect(client.introspectionClient()).rejects.toThrow(
172
+ ConfigurationError,
173
+ );
174
+ });
175
+
176
+ it("throws ConfigurationError when clientSecret is absent", async () => {
177
+ const client = new HearthClient({
178
+ issuerUrl: "https://auth.example.com",
179
+ clientId: "my-client",
180
+ });
181
+ await expect(client.introspectionClient()).rejects.toThrow(
182
+ ConfigurationError,
183
+ );
184
+ });
185
+
186
+ it("uses the introspection_endpoint from the discovery document", async () => {
187
+ mockDiscovery();
188
+ const client = new HearthClient({
189
+ issuerUrl: "https://auth.example.com",
190
+ clientId: "my-client",
191
+ clientSecret: "secret",
192
+ });
193
+ const ic = await client.introspectionClient();
194
+ expect(ic.httpTimeout).toBe(10_000);
195
+ });
196
+
197
+ it("prefers introspectionEndpoint override over discovered value", async () => {
198
+ const client = new HearthClient({
199
+ issuerUrl: "https://auth.example.com",
200
+ clientId: "my-client",
201
+ clientSecret: "secret",
202
+ introspectionEndpoint: "https://custom.example.com/introspect",
203
+ });
204
+ // No fetch mock needed — override bypasses discovery
205
+ const ic = await client.introspectionClient();
206
+ expect(ic).toBeDefined();
207
+ expect(vi.mocked(fetch)).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("reuses the same IntrospectionClient instance", async () => {
211
+ mockDiscovery();
212
+ const client = new HearthClient({
213
+ issuerUrl: "https://auth.example.com",
214
+ clientId: "my-client",
215
+ clientSecret: "secret",
216
+ });
217
+ const a = await client.introspectionClient();
218
+ const b = await client.introspectionClient();
219
+ expect(a).toBe(b);
220
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ it("throws ConfigurationError when discovery has no introspection_endpoint and no override", async () => {
224
+ const docWithoutIntrospect = {
225
+ issuer: "https://auth.example.com",
226
+ jwks_uri: "https://auth.example.com/jwks",
227
+ };
228
+ vi.mocked(fetch).mockResolvedValueOnce(
229
+ new Response(JSON.stringify(docWithoutIntrospect), {
230
+ status: 200,
231
+ headers: { "Content-Type": "application/json" },
232
+ }),
233
+ );
234
+ const client = new HearthClient({
235
+ issuerUrl: "https://auth.example.com",
236
+ clientId: "my-client",
237
+ clientSecret: "secret",
238
+ });
239
+ await expect(client.introspectionClient()).rejects.toThrow(
240
+ ConfigurationError,
241
+ );
242
+ });
243
+ });
@@ -0,0 +1,90 @@
1
+ import { execSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { HearthApiClient } from "../src/client.js";
3
+ import type { BootstrapResponse } from "../src/types.js";
4
+
5
+ const PROJECT_ROOT = new URL("../../..", import.meta.url).pathname.replace(
6
+ /\/$/,
7
+ "",
8
+ );
9
+
10
+ /** Resolve the hearth binary path, respecting CARGO_TARGET_DIR. */
11
+ function hearthBinPath(): string {
12
+ const targetDir = process.env.CARGO_TARGET_DIR ?? `${PROJECT_ROOT}/target`;
13
+ return `${targetDir}/debug/hearth`;
14
+ }
15
+
16
+ /** Build the hearth binary if not already built. */
17
+ export function ensureBinary(): void {
18
+ execSync("cargo build", { cwd: PROJECT_ROOT, stdio: "pipe" });
19
+ }
20
+
21
+ /** Find a free port by briefly binding to port 0. */
22
+ async function findFreePort(): Promise<number> {
23
+ const net = await import("node:net");
24
+ return new Promise((resolve, reject) => {
25
+ const server = net.createServer();
26
+ server.listen(0, "127.0.0.1", () => {
27
+ const addr = server.address();
28
+ if (!addr || typeof addr === "string") {
29
+ server.close();
30
+ reject(new Error("failed to get port"));
31
+ return;
32
+ }
33
+ const port = addr.port;
34
+ server.close(() => resolve(port));
35
+ });
36
+ });
37
+ }
38
+
39
+ export interface TestServer {
40
+ port: number;
41
+ baseUrl: string;
42
+ process: ChildProcess;
43
+ bootstrap: BootstrapResponse;
44
+ client: HearthApiClient;
45
+ }
46
+
47
+ /** Start a Hearth dev server and bootstrap admin credentials. */
48
+ export async function startServer(): Promise<TestServer> {
49
+ const port = await findFreePort();
50
+ const baseUrl = `http://127.0.0.1:${port}`;
51
+
52
+ const proc = spawn(hearthBinPath(), ["serve", "--dev", "--port", String(port)], {
53
+ stdio: "pipe",
54
+ env: { ...process.env, RUST_LOG: "warn" },
55
+ });
56
+
57
+ // Wait for health endpoint
58
+ const maxWait = 15_000;
59
+ const start = Date.now();
60
+ while (Date.now() - start < maxWait) {
61
+ try {
62
+ const resp = await fetch(`${baseUrl}/health`);
63
+ if (resp.ok) break;
64
+ } catch {
65
+ // Not ready yet
66
+ }
67
+ await new Promise((r) => setTimeout(r, 100));
68
+ }
69
+
70
+ // Verify server is actually up
71
+ const healthResp = await fetch(`${baseUrl}/health`);
72
+ if (!healthResp.ok) {
73
+ proc.kill();
74
+ throw new Error("Hearth server failed to start");
75
+ }
76
+
77
+ // Bootstrap admin credentials
78
+ const bootstrap = await HearthApiClient.bootstrap(baseUrl);
79
+ const client = new HearthApiClient({
80
+ baseUrl,
81
+ realmId: bootstrap.realm_id,
82
+ });
83
+
84
+ return { port, baseUrl, process: proc, bootstrap, client };
85
+ }
86
+
87
+ /** Stop a running test server. */
88
+ export function stopServer(server: TestServer): void {
89
+ server.process.kill("SIGTERM");
90
+ }