@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- 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
|
|
40
|
-
#
|
|
41
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
+
"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
|
-
"
|
|
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
|
+
});
|