@openparachute/hub 0.3.0-rc.1 → 0.5.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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
package/README.md CHANGED
@@ -36,12 +36,13 @@ parachute status
36
36
  # OpenCode, Cursor, Zed, Cline, your own agent) at:
37
37
  # http://127.0.0.1:1940/vault/default/mcp
38
38
 
39
- # 6. Expose beyond localhostTailscale Funnel or Cloudflare Tunnel.
40
- # Polishing for broad launch, but live today for early testers:
41
- parachute expose --help
39
+ # 6. Expose across your tailnet HTTPS, MagicDNS, only your devices.
40
+ # The supported exposure shape today; public-internet exposure is
41
+ # exploratory (see "Public exposure" below).
42
+ parachute expose tailnet
42
43
  ```
43
44
 
44
- Tear down with `parachute expose tailnet off` or `parachute expose public off`. Layers are independent — `off` only affects the layer you name.
45
+ Tear down with `parachute expose tailnet off`. The public layer (`expose public off`) tears down independently — `off` only affects the layer you name.
45
46
 
46
47
  ## Service lifecycle
47
48
 
@@ -87,13 +88,18 @@ parachute migrate --yes # unattended
87
88
 
88
89
  Anything swept goes to `~/.parachute/.archive-<YYYY-MM-DD>/` with its original name — nothing is deleted. Recognized entries (per-service dirs, `services.json`, `expose-state.json`, `well-known/`) are left in place, and so is anything starting with a dot (so `.env` and prior `.archive-*` dirs are safe).
89
90
 
90
- ## Three layers of addressability
91
+ ## Two supported layers (plus an exploratory third)
91
92
 
92
93
  Each additive; each can be turned off without affecting the layer below.
93
94
 
94
95
  - **Local** — services on loopback. Zero config. Browsers treat `localhost` as a secure context, so OAuth, PKCE, and Web Crypto all just work out of the box.
95
- - **Tailnet** — `parachute expose tailnet` wraps `tailscale serve` for every registered service. HTTPS via Tailscale's MagicDNS cert. Only machines on your tailnet can reach the URL.
96
- - **Public** — `parachute expose public` routes each handler through `tailscale funnel` so the same URLs become reachable from the public internet. At launch, Funnel is the only supported backend; Caddy + your-own-domain and cloudflared tunnels are planned post-launch.
96
+ - **Tailnet** — `parachute expose tailnet` wraps `tailscale serve` for every registered service. HTTPS via Tailscale's MagicDNS cert. Only machines on your tailnet can reach the URL. **This is the documented shape for the hub today.** Tailnet is already authenticated at the network layer, every user's tailnet is their own, and the OAuth + module access work happening in the hub is being designed against this shape first.
97
+
98
+ ### Public exposure (exploratory)
99
+
100
+ `parachute expose public` exists for early testers. It routes each handler through `tailscale funnel` (or, with `--cloudflare`, a named Cloudflare tunnel) so the same URLs become reachable from the public internet. The code path is live and the flag still works, but the public-internet posture (DNS, cross-internet OAuth, Funnel quirks) hasn't been hardened the way tailnet has — expect rough edges.
101
+
102
+ When the hub's OAuth issuer + per-module scope enforcement land, public will re-enter the documented narrative as "now safe." Until then, prefer tailnet.
97
103
 
98
104
  Under the hood, tailnet mode uses `tailscale serve` and public mode uses `tailscale funnel`; both write into the same node-level serve config. The CLI records which layer is live so that `expose <other-layer> off` is a no-op rather than a surprise teardown of the active layer.
99
105
 
@@ -142,11 +148,9 @@ The `/.well-known/parachute.json` document is an always-present descriptor — f
142
148
 
143
149
  Why path-routing and not subdomain-per-service? Two reasons:
144
150
 
145
- 1. **Tailscale Funnel HTTPS is capped at three ports per node** (443, 8443, 10000). Pinning every service to 443 behind a path means you can install any number of services without ever hitting that cap.
151
+ 1. **Tailscale Funnel HTTPS is capped at three ports per node** (443, 8443, 10000). Pinning every service to 443 behind a path means you can install any number of services without ever hitting that cap. (Funnel is the public-exposure backend; the cap shapes the tailnet-mode design too, since both modes share one serve config.)
146
152
  2. **Subdomain-per-service requires the Tailscale Services feature** (virtual-IP advertisement per service), which is more than a MagicDNS wildcard — it needs admin-side setup that's out of scope for a one-command install. When it's a launch-grade path, we'll add `parachute expose tailnet --mode subdomain`.
147
153
 
148
- Funnel has bandwidth quotas on Tailscale's free tier. See [tailscale.com/kb/1223/funnel](https://tailscale.com/kb/1223/funnel) for current limits; for heavy traffic, the post-launch Caddy / cloudflared modes will be the answer.
149
-
150
154
  ## Ports
151
155
 
152
156
  Parachute services reserve a block of loopback ports in the canonical range **1939–1949**. One range, one firewall rule, no surprises.
@@ -250,14 +254,12 @@ parachute expose tailnet
250
254
  # Also confirm the discovery document:
251
255
  curl -s https://parachute.<tailnet>.ts.net/.well-known/parachute.json | jq .
252
256
 
253
- # Flip to public (Funnel)
254
- parachute expose public
255
- # Open the same URL in a browser NOT on your tailnet — phone on cell, say.
256
-
257
257
  # Tear down
258
- parachute expose public off
258
+ parachute expose tailnet off
259
259
  ```
260
260
 
261
+ Public-internet exposure (`parachute expose public`) is exploratory — see "Public exposure" above. The flag still works for early testers; the supported smoke is tailnet.
262
+
261
263
  ## Subcommand reference
262
264
 
263
265
  Run `parachute --help` for the top-level list, and `parachute <subcommand> --help` for details on any individual command.
@@ -269,8 +271,8 @@ parachute start [service] start services in the background
269
271
  parachute stop [service] stop services (SIGTERM → 10s → SIGKILL)
270
272
  parachute restart [service] stop + start
271
273
  parachute logs <service> [-f] print/tail service logs
272
- parachute expose tailnet [off] HTTPS across your tailnet
273
- parachute expose public [off] HTTPS on the public internet (Funnel)
274
+ parachute expose tailnet [off] HTTPS across your tailnet (supported)
275
+ parachute expose public [off] HTTPS on the public internet (exploratory)
274
276
  parachute migrate [--dry-run] archive legacy files at ecosystem root
275
277
  parachute vault <args...> dispatch to parachute-vault
276
278
  ```
package/package.json CHANGED
@@ -1,25 +1,32 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.3.0-rc.1",
3
+ "version": "0.5.1",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
6
9
  "type": "module",
7
10
  "module": "src/cli.ts",
8
11
  "bin": {
9
12
  "parachute": "src/cli.ts"
10
13
  },
11
- "files": ["src", "README.md", "LICENSE"],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
12
16
  "repository": {
13
17
  "type": "git",
14
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
15
19
  },
16
20
  "scripts": {
17
21
  "start": "bun src/cli.ts",
18
- "test": "bun test",
22
+ "test": "bun test ./src",
19
23
  "lint": "biome check .",
20
24
  "lint:fix": "biome check --write .",
21
25
  "format": "biome format --write .",
22
- "typecheck": "tsc --noEmit"
26
+ "typecheck": "tsc --noEmit",
27
+ "build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
28
+ "postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
29
+ "prepack": "bun run build:spa"
23
30
  },
24
31
  "devDependencies": {
25
32
  "@biomejs/biome": "^1.9.4",
@@ -27,5 +34,9 @@
27
34
  },
28
35
  "peerDependencies": {
29
36
  "typescript": "^5"
37
+ },
38
+ "dependencies": {
39
+ "@node-rs/argon2": "^2.0.2",
40
+ "jose": "^6.2.2"
30
41
  }
31
42
  }
@@ -0,0 +1,197 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ AdminAuthError,
7
+ adminAuthErrorResponse,
8
+ extractBearerToken,
9
+ requireScope,
10
+ } from "../admin-auth.ts";
11
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
12
+ import { signAccessToken } from "../jwt-sign.ts";
13
+ import { rotateSigningKey } from "../signing-keys.ts";
14
+
15
+ const ISSUER = "http://127.0.0.1:1939";
16
+
17
+ interface Harness {
18
+ dir: string;
19
+ cleanup: () => void;
20
+ }
21
+
22
+ function makeHarness(): Harness {
23
+ const dir = mkdtempSync(join(tmpdir(), "phub-admin-auth-"));
24
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
25
+ }
26
+
27
+ async function mintToken(
28
+ db: ReturnType<typeof openHubDb>,
29
+ scopes: string[],
30
+ opts: { audience?: string; issuer?: string } = {},
31
+ ): Promise<string> {
32
+ const { token } = await signAccessToken(db, {
33
+ sub: "user-test",
34
+ scopes,
35
+ audience: opts.audience ?? "operator",
36
+ clientId: "test-client",
37
+ issuer: opts.issuer ?? ISSUER,
38
+ });
39
+ return token;
40
+ }
41
+
42
+ function reqWithAuth(authHeader: string | null): Request {
43
+ const headers = new Headers();
44
+ if (authHeader !== null) headers.set("authorization", authHeader);
45
+ return new Request("http://127.0.0.1:1939/test", { method: "POST", headers });
46
+ }
47
+
48
+ describe("extractBearerToken", () => {
49
+ test("returns the token from a well-formed header", () => {
50
+ const r = reqWithAuth("Bearer abc.def.ghi");
51
+ expect(extractBearerToken(r)).toBe("abc.def.ghi");
52
+ });
53
+
54
+ test("accepts lowercase scheme", () => {
55
+ const r = reqWithAuth("bearer abc.def.ghi");
56
+ expect(extractBearerToken(r)).toBe("abc.def.ghi");
57
+ });
58
+
59
+ test("throws 401 when header missing", () => {
60
+ const r = reqWithAuth(null);
61
+ expect(() => extractBearerToken(r)).toThrow(AdminAuthError);
62
+ try {
63
+ extractBearerToken(r);
64
+ } catch (err) {
65
+ expect((err as AdminAuthError).status).toBe(401);
66
+ }
67
+ });
68
+
69
+ test("throws 401 when scheme is not Bearer", () => {
70
+ const r = reqWithAuth("Basic dXNlcjpwYXNz");
71
+ expect(() => extractBearerToken(r)).toThrow(AdminAuthError);
72
+ });
73
+ });
74
+
75
+ describe("requireScope", () => {
76
+ test("returns context for a token with the required scope", async () => {
77
+ const h = makeHarness();
78
+ try {
79
+ const db = openHubDb(hubDbPath(h.dir));
80
+ try {
81
+ rotateSigningKey(db);
82
+ const token = await mintToken(db, ["parachute:host:admin", "vault:admin"]);
83
+ const ctx = await requireScope(
84
+ db,
85
+ reqWithAuth(`Bearer ${token}`),
86
+ "parachute:host:admin",
87
+ ISSUER,
88
+ );
89
+ expect(ctx.sub).toBe("user-test");
90
+ expect(ctx.scopes).toContain("parachute:host:admin");
91
+ expect(ctx.clientId).toBe("test-client");
92
+ expect(ctx.audience).toBe("operator");
93
+ } finally {
94
+ db.close();
95
+ }
96
+ } finally {
97
+ h.cleanup();
98
+ }
99
+ });
100
+
101
+ test("rejects 403 when token lacks the required scope", async () => {
102
+ const h = makeHarness();
103
+ try {
104
+ const db = openHubDb(hubDbPath(h.dir));
105
+ try {
106
+ rotateSigningKey(db);
107
+ const token = await mintToken(db, ["vault:read"]);
108
+ let caught: AdminAuthError | null = null;
109
+ try {
110
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", ISSUER);
111
+ } catch (err) {
112
+ caught = err as AdminAuthError;
113
+ }
114
+ expect(caught).not.toBeNull();
115
+ expect(caught?.status).toBe(403);
116
+ } finally {
117
+ db.close();
118
+ }
119
+ } finally {
120
+ h.cleanup();
121
+ }
122
+ });
123
+
124
+ test("rejects 401 when issuer mismatches", async () => {
125
+ const h = makeHarness();
126
+ try {
127
+ const db = openHubDb(hubDbPath(h.dir));
128
+ try {
129
+ rotateSigningKey(db);
130
+ const token = await mintToken(db, ["parachute:host:admin"], {
131
+ issuer: "http://127.0.0.1:9999",
132
+ });
133
+ let caught: AdminAuthError | null = null;
134
+ try {
135
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", ISSUER);
136
+ } catch (err) {
137
+ caught = err as AdminAuthError;
138
+ }
139
+ expect(caught?.status).toBe(401);
140
+ } finally {
141
+ db.close();
142
+ }
143
+ } finally {
144
+ h.cleanup();
145
+ }
146
+ });
147
+
148
+ test("rejects 401 when token is unverifiable garbage", async () => {
149
+ const h = makeHarness();
150
+ try {
151
+ const db = openHubDb(hubDbPath(h.dir));
152
+ try {
153
+ rotateSigningKey(db);
154
+ let caught: AdminAuthError | null = null;
155
+ try {
156
+ await requireScope(
157
+ db,
158
+ reqWithAuth("Bearer not-a-real-jwt"),
159
+ "parachute:host:admin",
160
+ ISSUER,
161
+ );
162
+ } catch (err) {
163
+ caught = err as AdminAuthError;
164
+ }
165
+ expect(caught?.status).toBe(401);
166
+ } finally {
167
+ db.close();
168
+ }
169
+ } finally {
170
+ h.cleanup();
171
+ }
172
+ });
173
+ });
174
+
175
+ describe("adminAuthErrorResponse", () => {
176
+ test("403 → insufficient_scope with WWW-Authenticate", async () => {
177
+ const res = adminAuthErrorResponse(new AdminAuthError(403, "needs admin"));
178
+ expect(res.status).toBe(403);
179
+ const body = (await res.json()) as { error: string };
180
+ expect(body.error).toBe("insufficient_scope");
181
+ expect(res.headers.get("www-authenticate") ?? "").toContain("insufficient_scope");
182
+ });
183
+
184
+ test("401 → invalid_token", async () => {
185
+ const res = adminAuthErrorResponse(new AdminAuthError(401, "bad sig"));
186
+ expect(res.status).toBe(401);
187
+ const body = (await res.json()) as { error: string };
188
+ expect(body.error).toBe("invalid_token");
189
+ });
190
+
191
+ test("non-AdminAuthError → 500 server_error", async () => {
192
+ const res = adminAuthErrorResponse(new Error("boom"));
193
+ expect(res.status).toBe(500);
194
+ const body = (await res.json()) as { error: string };
195
+ expect(body.error).toBe("server_error");
196
+ });
197
+ });
@@ -0,0 +1,281 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ configPathFor,
7
+ discoverConfigurableModules,
8
+ readModuleConfig,
9
+ validateAndCoerce,
10
+ writeModuleConfig,
11
+ } from "../admin-config.ts";
12
+ import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
13
+ import type { ServicesManifest } from "../services-manifest.ts";
14
+
15
+ function tmp(): string {
16
+ return mkdtempSync(join(tmpdir(), "admin-config-"));
17
+ }
18
+
19
+ const VAULT_SCHEMA: ConfigSchema = {
20
+ type: "object",
21
+ required: ["transcribe_provider"],
22
+ properties: {
23
+ transcribe_provider: {
24
+ type: "string",
25
+ description: "Speech-to-text backend.",
26
+ enum: ["openai", "deepgram", "groq"],
27
+ default: "openai",
28
+ },
29
+ max_tags_per_note: { type: "integer", default: 10 },
30
+ public: { type: "boolean", default: false },
31
+ },
32
+ };
33
+
34
+ const VAULT_MANIFEST: ModuleManifest = {
35
+ name: "vault",
36
+ manifestName: "parachute-vault",
37
+ displayName: "Vault",
38
+ kind: "api",
39
+ port: 1940,
40
+ paths: ["/vault"],
41
+ health: "/health",
42
+ configSchema: VAULT_SCHEMA,
43
+ };
44
+
45
+ const NOTES_MANIFEST: ModuleManifest = {
46
+ name: "notes",
47
+ manifestName: "parachute-notes",
48
+ displayName: "Notes",
49
+ kind: "frontend",
50
+ port: 1941,
51
+ paths: ["/"],
52
+ health: "/health",
53
+ // No configSchema — should be skipped.
54
+ };
55
+
56
+ function services(...entries: { name: string; installDir?: string }[]): ServicesManifest {
57
+ return {
58
+ services: entries.map((e) => ({
59
+ name: e.name,
60
+ port: 1940,
61
+ paths: ["/"],
62
+ health: "/health",
63
+ version: "0.0.0",
64
+ ...(e.installDir ? { installDir: e.installDir } : {}),
65
+ })),
66
+ };
67
+ }
68
+
69
+ describe("discoverConfigurableModules", () => {
70
+ test("includes only modules with a configSchema", async () => {
71
+ const dir = tmp();
72
+ try {
73
+ const result = await discoverConfigurableModules({
74
+ loadServicesManifest: () =>
75
+ services(
76
+ { name: "vault", installDir: "/fake/vault" },
77
+ { name: "notes", installDir: "/fake/notes" },
78
+ ),
79
+ configDir: dir,
80
+ readManifest: async (installDir) => {
81
+ if (installDir === "/fake/vault") return VAULT_MANIFEST;
82
+ if (installDir === "/fake/notes") return NOTES_MANIFEST;
83
+ return null;
84
+ },
85
+ });
86
+ expect(result.map((m) => m.name)).toEqual(["vault"]);
87
+ const first = result[0]!;
88
+ expect(first.schema).toBe(VAULT_SCHEMA);
89
+ expect(first.configPath).toBe(configPathFor(dir, "vault"));
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("skips entries without an installDir", async () => {
96
+ const result = await discoverConfigurableModules({
97
+ loadServicesManifest: () => services({ name: "vault" }),
98
+ configDir: tmp(),
99
+ readManifest: async () => VAULT_MANIFEST,
100
+ });
101
+ expect(result).toEqual([]);
102
+ });
103
+
104
+ test("skips entries whose manifest fails to read (returns null)", async () => {
105
+ const result = await discoverConfigurableModules({
106
+ loadServicesManifest: () => services({ name: "vault", installDir: "/missing" }),
107
+ configDir: tmp(),
108
+ readManifest: async () => null,
109
+ });
110
+ expect(result).toEqual([]);
111
+ });
112
+
113
+ test("doesn't take down the portal when one manifest throws", async () => {
114
+ const result = await discoverConfigurableModules({
115
+ loadServicesManifest: () =>
116
+ services(
117
+ { name: "vault", installDir: "/fake/vault" },
118
+ { name: "rogue", installDir: "/fake/rogue" },
119
+ ),
120
+ configDir: tmp(),
121
+ readManifest: async (installDir) => {
122
+ if (installDir === "/fake/vault") return VAULT_MANIFEST;
123
+ throw new Error("malformed module.json");
124
+ },
125
+ });
126
+ expect(result.map((m) => m.name)).toEqual(["vault"]);
127
+ });
128
+
129
+ test("sorts results by displayName", async () => {
130
+ const aManifest: ModuleManifest = { ...VAULT_MANIFEST, name: "alpha", displayName: "Alpha" };
131
+ const zManifest: ModuleManifest = { ...VAULT_MANIFEST, name: "omega", displayName: "Omega" };
132
+ const result = await discoverConfigurableModules({
133
+ loadServicesManifest: () =>
134
+ services({ name: "omega", installDir: "/o" }, { name: "alpha", installDir: "/a" }),
135
+ configDir: tmp(),
136
+ readManifest: async (d) => (d === "/o" ? zManifest : aManifest),
137
+ });
138
+ expect(result.map((m) => m.name)).toEqual(["alpha", "omega"]);
139
+ });
140
+ });
141
+
142
+ describe("validateAndCoerce", () => {
143
+ test("coerces strings, integers, numbers, booleans", () => {
144
+ const r = validateAndCoerce(
145
+ {
146
+ transcribe_provider: "deepgram",
147
+ max_tags_per_note: "42",
148
+ public: true,
149
+ },
150
+ VAULT_SCHEMA,
151
+ );
152
+ expect(r.ok).toBe(true);
153
+ expect(r.data).toEqual({
154
+ transcribe_provider: "deepgram",
155
+ max_tags_per_note: 42,
156
+ public: true,
157
+ });
158
+ });
159
+
160
+ test("rejects an integer that is not an integer", () => {
161
+ const r = validateAndCoerce(
162
+ { transcribe_provider: "openai", max_tags_per_note: "3.14", public: false },
163
+ VAULT_SCHEMA,
164
+ );
165
+ expect(r.ok).toBe(false);
166
+ expect(r.errors.max_tags_per_note).toBe("must be an integer");
167
+ });
168
+
169
+ test("rejects values outside the enum", () => {
170
+ const r = validateAndCoerce(
171
+ { transcribe_provider: "whisper", max_tags_per_note: "10", public: false },
172
+ VAULT_SCHEMA,
173
+ );
174
+ expect(r.ok).toBe(false);
175
+ expect(r.errors.transcribe_provider).toContain("must be one of");
176
+ });
177
+
178
+ test("rejects required fields when missing", () => {
179
+ const r = validateAndCoerce({ public: false }, VAULT_SCHEMA);
180
+ expect(r.ok).toBe(false);
181
+ expect(r.errors.transcribe_provider).toBe("required");
182
+ });
183
+
184
+ test("missing optional non-boolean fields are omitted from output", () => {
185
+ const r = validateAndCoerce({ transcribe_provider: "openai", public: false }, VAULT_SCHEMA);
186
+ expect(r.ok).toBe(true);
187
+ expect(r.data).toEqual({ transcribe_provider: "openai", public: false });
188
+ expect("max_tags_per_note" in (r.data ?? {})).toBe(false);
189
+ });
190
+
191
+ test("missing booleans default to false rather than failing required", () => {
192
+ const required: ConfigSchema = {
193
+ type: "object",
194
+ required: ["public"],
195
+ properties: { public: { type: "boolean" } },
196
+ };
197
+ const r = validateAndCoerce({}, required);
198
+ expect(r.ok).toBe(true);
199
+ expect(r.data).toEqual({ public: false });
200
+ });
201
+
202
+ test("number coercion accepts decimals", () => {
203
+ const schema: ConfigSchema = {
204
+ type: "object",
205
+ properties: { ratio: { type: "number" } },
206
+ };
207
+ expect(validateAndCoerce({ ratio: "0.25" }, schema).data).toEqual({ ratio: 0.25 });
208
+ expect(validateAndCoerce({ ratio: "garbage" }, schema).errors.ratio).toBe("must be a number");
209
+ });
210
+
211
+ test("string values pass through verbatim", () => {
212
+ const schema: ConfigSchema = {
213
+ type: "object",
214
+ properties: { motto: { type: "string" } },
215
+ };
216
+ const r = validateAndCoerce({ motto: " whitespace preserved " }, schema);
217
+ expect(r.data?.motto).toBe(" whitespace preserved ");
218
+ });
219
+ });
220
+
221
+ describe("readModuleConfig + writeModuleConfig", () => {
222
+ test("returns {} when the file does not exist", () => {
223
+ const dir = tmp();
224
+ try {
225
+ expect(readModuleConfig(join(dir, "missing.json"))).toEqual({ data: {} });
226
+ } finally {
227
+ rmSync(dir, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test("round-trips a config object atomically", () => {
232
+ const dir = tmp();
233
+ try {
234
+ const path = join(dir, "vault", "config.json");
235
+ writeModuleConfig(path, { transcribe_provider: "openai", max_tags_per_note: 10 });
236
+ expect(existsSync(path)).toBe(true);
237
+ const { data, parseError } = readModuleConfig(path);
238
+ expect(parseError).toBeUndefined();
239
+ expect(data).toEqual({ transcribe_provider: "openai", max_tags_per_note: 10 });
240
+ } finally {
241
+ rmSync(dir, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ test("surfaces a parse error without erroring", () => {
246
+ const dir = tmp();
247
+ try {
248
+ const path = join(dir, "config.json");
249
+ writeFileSync(path, "{not valid json");
250
+ const r = readModuleConfig(path);
251
+ expect(r.data).toEqual({});
252
+ expect(r.parseError).toBeDefined();
253
+ } finally {
254
+ rmSync(dir, { recursive: true, force: true });
255
+ }
256
+ });
257
+
258
+ test("surfaces a parse error when the file is a JSON array, not object", () => {
259
+ const dir = tmp();
260
+ try {
261
+ const path = join(dir, "config.json");
262
+ writeFileSync(path, "[]");
263
+ const r = readModuleConfig(path);
264
+ expect(r.data).toEqual({});
265
+ expect(r.parseError).toContain("must contain a JSON object");
266
+ } finally {
267
+ rmSync(dir, { recursive: true, force: true });
268
+ }
269
+ });
270
+
271
+ test("trailing newline preserved on write", () => {
272
+ const dir = tmp();
273
+ try {
274
+ const path = join(dir, "config.json");
275
+ writeModuleConfig(path, { x: 1 });
276
+ expect(readFileSync(path, "utf8").endsWith("\n")).toBe(true);
277
+ } finally {
278
+ rmSync(dir, { recursive: true, force: true });
279
+ }
280
+ });
281
+ });