@openparachute/hub 0.3.0-rc.1 → 0.5.0

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 (90) 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 +712 -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 +519 -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 +652 -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 +242 -37
  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-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -92,7 +92,7 @@ describe("vaultInstanceName", () => {
92
92
  });
93
93
 
94
94
  describe("buildWellKnown", () => {
95
- test("vaults is always an array, other services are flat entries, services[] includes all", () => {
95
+ test("every kind is a plural array; services[] includes all (#92)", () => {
96
96
  const doc = buildWellKnown({
97
97
  services: [vault, notes, scribe],
98
98
  canonicalOrigin: "https://parachute.taildf9ce2.ts.net",
@@ -104,14 +104,18 @@ describe("buildWellKnown", () => {
104
104
  version: "0.2.4",
105
105
  },
106
106
  ]);
107
- expect(doc.notes).toEqual({
108
- url: "https://parachute.taildf9ce2.ts.net/notes",
109
- version: "0.0.1",
110
- });
111
- expect(doc.scribe).toEqual({
112
- url: "https://parachute.taildf9ce2.ts.net/scribe",
113
- version: "0.1.0",
114
- });
107
+ expect(doc.notes).toEqual([
108
+ {
109
+ url: "https://parachute.taildf9ce2.ts.net/notes",
110
+ version: "0.0.1",
111
+ },
112
+ ]);
113
+ expect(doc.scribe).toEqual([
114
+ {
115
+ url: "https://parachute.taildf9ce2.ts.net/scribe",
116
+ version: "0.1.0",
117
+ },
118
+ ]);
115
119
  expect(doc.services.map((s) => s.name)).toEqual([
116
120
  "parachute-vault",
117
121
  "parachute-notes",
@@ -119,6 +123,18 @@ describe("buildWellKnown", () => {
119
123
  ]);
120
124
  });
121
125
 
126
+ test("multiple installs of the same kind both land in the array (#92)", () => {
127
+ const work: ServiceEntry = { ...notes, paths: ["/notes-work"], port: 5174 };
128
+ const doc = buildWellKnown({
129
+ services: [notes, work],
130
+ canonicalOrigin: "https://x.example",
131
+ });
132
+ expect(doc.notes).toEqual([
133
+ { url: "https://x.example/notes", version: "0.0.1" },
134
+ { url: "https://x.example/notes-work", version: "0.0.1" },
135
+ ]);
136
+ });
137
+
122
138
  test("services[] entries include infoUrl pointing at /.parachute/info", () => {
123
139
  const doc = buildWellKnown({
124
140
  services: [vault, notes],
@@ -158,7 +174,7 @@ describe("buildWellKnown", () => {
158
174
  });
159
175
  expect(doc.vaults).toEqual([]);
160
176
  expect(doc.services).toHaveLength(1);
161
- expect(doc.notes).toEqual({ url: "https://x.example/notes", version: "0.0.1" });
177
+ expect(doc.notes).toEqual([{ url: "https://x.example/notes", version: "0.0.1" }]);
162
178
  });
163
179
 
164
180
  test("multiple vault instances all land in the vaults array", () => {
@@ -177,6 +193,63 @@ describe("buildWellKnown", () => {
177
193
  expect(doc.vaults.map((v) => v.name).sort()).toEqual(["default", "work"]);
178
194
  });
179
195
 
196
+ test("single vault ServiceEntry with multiple paths emits one entry per path (closes #141)", () => {
197
+ // Reflects the post-#179/vault#208 manifest shape: one parachute-vault
198
+ // backend hosts every vault instance, expressed as one ServiceEntry with
199
+ // multiple paths.
200
+ const multi: ServiceEntry = {
201
+ ...vault,
202
+ paths: ["/vault/default", "/vault/techne"],
203
+ };
204
+ const doc = buildWellKnown({
205
+ services: [multi],
206
+ canonicalOrigin: "https://x.example",
207
+ });
208
+ expect(doc.vaults).toEqual([
209
+ { name: "default", url: "https://x.example/vault/default", version: "0.2.4" },
210
+ { name: "techne", url: "https://x.example/vault/techne", version: "0.2.4" },
211
+ ]);
212
+ // services[] mirrors the per-path expansion so the hub page and any
213
+ // generic consumer iterate every instance.
214
+ expect(doc.services).toEqual([
215
+ {
216
+ name: "parachute-vault",
217
+ url: "https://x.example/vault/default",
218
+ path: "/vault/default",
219
+ version: "0.2.4",
220
+ infoUrl: "https://x.example/vault/default/.parachute/info",
221
+ },
222
+ {
223
+ name: "parachute-vault",
224
+ url: "https://x.example/vault/techne",
225
+ path: "/vault/techne",
226
+ version: "0.2.4",
227
+ infoUrl: "https://x.example/vault/techne/.parachute/info",
228
+ },
229
+ ]);
230
+ });
231
+
232
+ test("multi-path vault entry is independent of multi-ServiceEntry shape (#141)", () => {
233
+ // A user could plausibly mix shapes: one multi-path bare `parachute-vault`
234
+ // plus a separately-installed `parachute-vault-archive`. All instances
235
+ // should surface.
236
+ const multi: ServiceEntry = {
237
+ ...vault,
238
+ paths: ["/vault/default", "/vault/techne"],
239
+ };
240
+ const archive: ServiceEntry = {
241
+ ...vault,
242
+ name: "parachute-vault-archive",
243
+ paths: ["/vault/archive"],
244
+ port: 1942,
245
+ };
246
+ const doc = buildWellKnown({
247
+ services: [multi, archive],
248
+ canonicalOrigin: "https://x.example",
249
+ });
250
+ expect(doc.vaults.map((v) => v.name).sort()).toEqual(["archive", "default", "techne"]);
251
+ });
252
+
180
253
  test("handles canonicalOrigin with trailing slash", () => {
181
254
  const doc = buildWellKnown({
182
255
  services: [vault],
@@ -185,6 +258,50 @@ describe("buildWellKnown", () => {
185
258
  expect(doc.vaults[0]?.url).toBe("https://parachute.taildf9ce2.ts.net/vault/default");
186
259
  });
187
260
 
261
+ test("managementUrl rides through when the resolver returns one", () => {
262
+ const doc = buildWellKnown({
263
+ services: [vault],
264
+ canonicalOrigin: "https://x.example",
265
+ managementUrlFor: () => "/admin",
266
+ });
267
+ expect(doc.vaults[0]?.managementUrl).toBe("/admin");
268
+ });
269
+
270
+ test("managementUrl absent when the resolver returns undefined", () => {
271
+ const doc = buildWellKnown({
272
+ services: [vault],
273
+ canonicalOrigin: "https://x.example",
274
+ managementUrlFor: () => undefined,
275
+ });
276
+ expect(doc.vaults[0]).not.toHaveProperty("managementUrl");
277
+ });
278
+
279
+ test("managementUrl is per-entry — multi-instance vaults can differ", () => {
280
+ const work: ServiceEntry = {
281
+ ...vault,
282
+ name: "parachute-vault-work",
283
+ paths: ["/vault/work"],
284
+ port: 1941,
285
+ };
286
+ const doc = buildWellKnown({
287
+ services: [vault, work],
288
+ canonicalOrigin: "https://x.example",
289
+ managementUrlFor: (e) => (e.name === "parachute-vault-work" ? "/admin" : undefined),
290
+ });
291
+ const byName = new Map(doc.vaults.map((v) => [v.name, v.managementUrl]));
292
+ expect(byName.get("default")).toBeUndefined();
293
+ expect(byName.get("work")).toBe("/admin");
294
+ });
295
+
296
+ test("managementUrl is not emitted on non-vault services", () => {
297
+ const doc = buildWellKnown({
298
+ services: [notes],
299
+ canonicalOrigin: "https://x.example",
300
+ managementUrlFor: () => "/admin",
301
+ });
302
+ expect(doc.notes).toEqual([{ url: "https://x.example/notes", version: "0.0.1" }]);
303
+ });
304
+
188
305
  test("falls back to / for empty paths", () => {
189
306
  const entry: ServiceEntry = { ...vault, paths: [] };
190
307
  const doc = buildWellKnown({
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Bearer-token auth for hub-native admin endpoints (POST /vaults, future
3
+ * `/admin/*` routes). The hub validates its own JWTs against local signing
4
+ * keys — no JWKS round-trip — and asserts the presented token carries the
5
+ * required scope.
6
+ *
7
+ * Why this exists: until now the hub has been a *pure issuer* with no
8
+ * authenticated endpoints of its own. Phase 1 of the vault-config-and-scopes
9
+ * design adds POST /vaults, which mints+config-writes through privileged
10
+ * code paths. That call needs an admin scope, and its first reader is
11
+ * `parachute:host:admin` (the cross-vault provisioning capability).
12
+ *
13
+ * Errors are HTTP-shaped: `AdminAuthError(status, message)` so the route
14
+ * handler can `throw` and the boundary translates straight to a Response.
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import { validateAccessToken } from "./jwt-sign.ts";
18
+
19
+ export interface AdminAuthContext {
20
+ /** JWT `sub` — the hub user id. */
21
+ sub: string;
22
+ /** Parsed `scope` claim. */
23
+ scopes: string[];
24
+ /** `client_id` claim, if present (operator token vs OAuth client). */
25
+ clientId: string | undefined;
26
+ /** `aud` claim, if present. Surfaced for logs / future cross-aud rules. */
27
+ audience: string | undefined;
28
+ }
29
+
30
+ export class AdminAuthError extends Error {
31
+ override name = "AdminAuthError";
32
+ status: number;
33
+ constructor(status: number, message: string) {
34
+ super(message);
35
+ this.status = status;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Pull a Bearer token from `Authorization: Bearer <token>`. Throws
41
+ * `AdminAuthError(401)` when missing / malformed. Match is case-insensitive
42
+ * on the scheme (some clients send "bearer" lowercase) but the token itself
43
+ * is the raw JWT string.
44
+ */
45
+ export function extractBearerToken(req: Request): string {
46
+ const header = req.headers.get("authorization");
47
+ if (!header) {
48
+ throw new AdminAuthError(401, "missing Authorization header");
49
+ }
50
+ const match = header.match(/^Bearer\s+(.+)$/i);
51
+ if (!match || !match[1]) {
52
+ throw new AdminAuthError(401, "Authorization header must be 'Bearer <token>'");
53
+ }
54
+ return match[1].trim();
55
+ }
56
+
57
+ /**
58
+ * Validate a presented bearer token against the hub's local signing keys
59
+ * and check it carries `requiredScope`. Returns surfaced claims on success;
60
+ * throws `AdminAuthError` (401 or 403) otherwise.
61
+ *
62
+ * `expectedIssuer` MUST be the hub's own origin — the same value baked into
63
+ * tokens we sign. Defense in depth: even though we can only verify our own
64
+ * keys, the `iss` mismatch reject keeps cross-issuer confusion impossible.
65
+ */
66
+ export async function requireScope(
67
+ db: Database,
68
+ req: Request,
69
+ requiredScope: string,
70
+ expectedIssuer: string,
71
+ ): Promise<AdminAuthContext> {
72
+ const token = extractBearerToken(req);
73
+
74
+ let validated: Awaited<ReturnType<typeof validateAccessToken>>;
75
+ try {
76
+ validated = await validateAccessToken(db, token, expectedIssuer);
77
+ } catch (err) {
78
+ const msg = err instanceof Error ? err.message : String(err);
79
+ throw new AdminAuthError(401, `invalid token: ${msg}`);
80
+ }
81
+
82
+ const sub = typeof validated.payload.sub === "string" ? validated.payload.sub : null;
83
+ if (!sub) throw new AdminAuthError(401, "token missing required `sub` claim");
84
+
85
+ const scopeClaim = (validated.payload as { scope?: unknown }).scope;
86
+ const scopes =
87
+ typeof scopeClaim === "string" ? scopeClaim.split(/\s+/).filter((s) => s.length > 0) : [];
88
+
89
+ if (!scopes.includes(requiredScope)) {
90
+ throw new AdminAuthError(403, `token missing required scope: ${requiredScope}`);
91
+ }
92
+
93
+ const clientIdRaw = (validated.payload as { client_id?: unknown }).client_id;
94
+ const clientId = typeof clientIdRaw === "string" ? clientIdRaw : undefined;
95
+ const aud = typeof validated.payload.aud === "string" ? validated.payload.aud : undefined;
96
+
97
+ return { sub, scopes, clientId, audience: aud };
98
+ }
99
+
100
+ /**
101
+ * Translate an AdminAuthError to an RFC-6750-style JSON Response.
102
+ * Convenience for route handlers that want to do
103
+ * `try { ctx = await requireScope(...) } catch (err) { return adminAuthErrorResponse(err); }`.
104
+ */
105
+ export function adminAuthErrorResponse(err: unknown): Response {
106
+ if (err instanceof AdminAuthError) {
107
+ return new Response(
108
+ JSON.stringify({
109
+ error: err.status === 403 ? "insufficient_scope" : "invalid_token",
110
+ error_description: err.message,
111
+ }),
112
+ {
113
+ status: err.status,
114
+ headers: {
115
+ "content-type": "application/json",
116
+ "www-authenticate": `Bearer error="${err.status === 403 ? "insufficient_scope" : "invalid_token"}", error_description="${err.message.replace(/"/g, "'")}"`,
117
+ },
118
+ },
119
+ );
120
+ }
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ return new Response(JSON.stringify({ error: "server_error", error_description: msg }), {
123
+ status: 500,
124
+ headers: { "content-type": "application/json" },
125
+ });
126
+ }