@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
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Hub config portal logic (#46) — pure functions over the module manifest +
3
+ * filesystem. Render-side and HTTP-side helpers live in `admin-config-ui.ts`
4
+ * and `admin-handlers.ts`; this file is io + validation.
5
+ *
6
+ * Design choices:
7
+ *
8
+ * - **JSON-only at v1.** The team-lead's brief covers .env / YAML / TOML
9
+ * follow-ups; this commit ships the JSON path so the surface is real
10
+ * for at least one module. Each configurable module's values land at
11
+ * `<configDir>/<name>/config.json` — the same per-module config dir
12
+ * auto-wire and lifecycle already use.
13
+ *
14
+ * - **Skip modules without a `configSchema`.** A module that hasn't
15
+ * declared its operator-editable keys gets no card on the portal, no
16
+ * empty form. This is the explicit edge case from the brief.
17
+ *
18
+ * - **Atomic writes.** Same `tmp + rename` shape as
19
+ * `services-manifest.ts` and `auto-wire.ts` — a crash mid-write must
20
+ * not leave a half-truncated config.json next to a running module.
21
+ *
22
+ * - **Validation = coercion + check.** HTML form values arrive as
23
+ * strings; `validateAndCoerce` coerces each per its declared type
24
+ * (and the `enum` allow-list, when present), reports the first error
25
+ * per field, and returns the typed object on success. Booleans
26
+ * follow the standard form convention: present (any non-empty value)
27
+ * = true, absent = false. Required booleans are accepted as either
28
+ * state — required means "the key is in the schema", not "must be
29
+ * truthy".
30
+ */
31
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
32
+ import { dirname, join } from "node:path";
33
+ import {
34
+ type ConfigSchema,
35
+ type ConfigSchemaProperty,
36
+ type ModuleManifest,
37
+ readModuleManifest,
38
+ } from "./module-manifest.ts";
39
+ import type { ServiceEntry, ServicesManifest } from "./services-manifest.ts";
40
+
41
+ export interface ConfigurableModule {
42
+ /** Stable ecosystem name (services.json key, route segment). */
43
+ name: string;
44
+ /** Operator-facing label rendered in the portal. */
45
+ displayName: string;
46
+ /** One-line subtitle if the manifest provides one. */
47
+ tagline?: string;
48
+ /** The validated schema from `.parachute/module.json`. */
49
+ schema: ConfigSchema;
50
+ /** Absolute path to `<configDir>/<name>/config.json`. */
51
+ configPath: string;
52
+ }
53
+
54
+ export interface DiscoverDeps {
55
+ /** Resolves installed services (production: `services-manifest.readManifest`). */
56
+ loadServicesManifest: () => ServicesManifest;
57
+ /** `~/.parachute` (or `$PARACHUTE_HOME`) — per-module config lives at `<configDir>/<name>/config.json`. */
58
+ configDir: string;
59
+ /** Test seam — defaults to `module-manifest.readModuleManifest`. */
60
+ readManifest?: (installDir: string) => Promise<ModuleManifest | null>;
61
+ }
62
+
63
+ export function configPathFor(configDir: string, moduleName: string): string {
64
+ return join(configDir, moduleName, "config.json");
65
+ }
66
+
67
+ /**
68
+ * Walk services.json, read each module's `.parachute/module.json` (when its
69
+ * row carries an `installDir`), keep only those with a `configSchema`. The
70
+ * result is sorted by `displayName` so the portal renders deterministically.
71
+ */
72
+ export async function discoverConfigurableModules(
73
+ deps: DiscoverDeps,
74
+ ): Promise<ConfigurableModule[]> {
75
+ const reader = deps.readManifest ?? readModuleManifest;
76
+ const manifest = deps.loadServicesManifest();
77
+ const out: ConfigurableModule[] = [];
78
+ for (const svc of manifest.services) {
79
+ const mod = await readModuleFor(svc, reader);
80
+ if (!mod || !mod.configSchema) continue;
81
+ const entry: ConfigurableModule = {
82
+ name: svc.name,
83
+ displayName: svc.displayName ?? mod.displayName ?? mod.manifestName ?? svc.name,
84
+ schema: mod.configSchema,
85
+ configPath: configPathFor(deps.configDir, svc.name),
86
+ };
87
+ if (svc.tagline ?? mod.tagline) entry.tagline = svc.tagline ?? mod.tagline;
88
+ out.push(entry);
89
+ }
90
+ out.sort((a, b) => a.displayName.localeCompare(b.displayName));
91
+ return out;
92
+ }
93
+
94
+ async function readModuleFor(
95
+ svc: ServiceEntry,
96
+ reader: (installDir: string) => Promise<ModuleManifest | null>,
97
+ ): Promise<ModuleManifest | null> {
98
+ if (!svc.installDir) return null;
99
+ try {
100
+ return await reader(svc.installDir);
101
+ } catch {
102
+ // A malformed third-party manifest shouldn't take down the whole portal —
103
+ // skip the module and let the operator see the others. Logging the
104
+ // skip is the lifecycle layer's job; this stays pure.
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Read `<configDir>/<name>/config.json`. Returns `{}` for a missing file,
111
+ * `{}` for a malformed file (with a `parseError` flag) — the portal renders
112
+ * defaults for missing keys either way; surfacing parse failures in the UI
113
+ * without erroring the page lets the operator overwrite a corrupted file.
114
+ */
115
+ export interface ReadConfigResult {
116
+ data: Record<string, unknown>;
117
+ parseError?: string;
118
+ }
119
+
120
+ export function readModuleConfig(configPath: string): ReadConfigResult {
121
+ if (!existsSync(configPath)) return { data: {} };
122
+ let raw: string;
123
+ try {
124
+ raw = readFileSync(configPath, "utf8");
125
+ } catch (err) {
126
+ return { data: {}, parseError: err instanceof Error ? err.message : String(err) };
127
+ }
128
+ try {
129
+ const parsed = JSON.parse(raw);
130
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
131
+ return { data: {}, parseError: "config.json must contain a JSON object" };
132
+ }
133
+ return { data: parsed as Record<string, unknown> };
134
+ } catch (err) {
135
+ return { data: {}, parseError: err instanceof Error ? err.message : String(err) };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Atomic write: tmp file + rename, mkdir -p first. Same pattern as
141
+ * services-manifest. Trailing newline so the file plays nice with
142
+ * line-oriented tooling (`wc -l`, diffs).
143
+ */
144
+ export function writeModuleConfig(configPath: string, data: Record<string, unknown>): void {
145
+ mkdirSync(dirname(configPath), { recursive: true });
146
+ const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
147
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
148
+ renameSync(tmp, configPath);
149
+ }
150
+
151
+ export interface ValidateConfigResult {
152
+ ok: boolean;
153
+ /** Field-level errors; empty when ok === true. */
154
+ errors: Record<string, string>;
155
+ /** Coerced typed values; populated only when ok === true. */
156
+ data?: Record<string, unknown>;
157
+ }
158
+
159
+ /**
160
+ * Coerce + validate a form-data submission against `schema`. `formValues`
161
+ * is keyed by property name; checkbox semantics are encoded in the caller
162
+ * (booleans missing → false). The first failure per field is reported;
163
+ * once any field fails, `data` is omitted.
164
+ */
165
+ export function validateAndCoerce(
166
+ formValues: Record<string, string | boolean | undefined>,
167
+ schema: ConfigSchema,
168
+ ): ValidateConfigResult {
169
+ const errors: Record<string, string> = {};
170
+ const data: Record<string, unknown> = {};
171
+ const required = new Set(schema.required ?? []);
172
+ for (const [key, prop] of Object.entries(schema.properties)) {
173
+ const raw = formValues[key];
174
+ const present = raw !== undefined && raw !== "";
175
+ if (!present) {
176
+ if (prop.type === "boolean") {
177
+ // Booleans are always "present" — checkbox absence = false.
178
+ data[key] = false;
179
+ continue;
180
+ }
181
+ if (required.has(key)) {
182
+ errors[key] = "required";
183
+ continue;
184
+ }
185
+ // Missing optional field → omit from output rather than write a null.
186
+ continue;
187
+ }
188
+ const coerced = coerceValue(raw, prop);
189
+ if ("error" in coerced) {
190
+ errors[key] = coerced.error;
191
+ continue;
192
+ }
193
+ if (prop.enum && !prop.enum.includes(coerced.value as string | number)) {
194
+ errors[key] = `must be one of: ${prop.enum.join(", ")}`;
195
+ continue;
196
+ }
197
+ data[key] = coerced.value;
198
+ }
199
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
200
+ return { ok: true, errors, data };
201
+ }
202
+
203
+ function coerceValue(
204
+ raw: string | boolean,
205
+ prop: ConfigSchemaProperty,
206
+ ): { value: string | number | boolean } | { error: string } {
207
+ switch (prop.type) {
208
+ case "string":
209
+ return { value: typeof raw === "string" ? raw : String(raw) };
210
+ case "boolean":
211
+ if (typeof raw === "boolean") return { value: raw };
212
+ return { value: raw.length > 0 && raw !== "false" && raw !== "0" };
213
+ case "number": {
214
+ const s = typeof raw === "string" ? raw : String(raw);
215
+ const n = Number(s);
216
+ if (!Number.isFinite(n)) return { error: "must be a number" };
217
+ return { value: n };
218
+ }
219
+ case "integer": {
220
+ const s = typeof raw === "string" ? raw : String(raw);
221
+ const n = Number(s);
222
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return { error: "must be an integer" };
223
+ return { value: n };
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Admin endpoints for the operator's OAuth-grant skip-list.
3
+ *
4
+ * GET /api/grants[?vault=<name>] list operator's grants
5
+ * DELETE /api/grants/<client_id> revoke one grant
6
+ *
7
+ * Both gated by `parachute:host:admin` Bearer (the SPA mints one via the
8
+ * session cookie at `/admin/host-admin-token`). The "operator" is the JWT
9
+ * `sub` — list and revoke are both scoped to that user, so a host-admin
10
+ * token can't enumerate or delete another user's grants. (The hub is
11
+ * single-operator today; this is forward-looking.)
12
+ *
13
+ * The grants table's primary key is composite `(user_id, client_id)`. Since
14
+ * `user_id` is fixed by the bearer, `client_id` alone is sufficient as the
15
+ * URL-segment "id" — and matches the operator's mental model: "revoke this
16
+ * app's access".
17
+ *
18
+ * Optional `?vault=<name>` filter narrows the list to grants whose scope
19
+ * set touches `vault:<name>:*`. The match is per-grant (any matching scope
20
+ * keeps the row); the row's full scope set is still returned, not a slice.
21
+ *
22
+ * Audit: revocation emits a `console.log("grant revoked: ...")` line in the
23
+ * same `key=value` shape as the existing `consent skipped:` line at
24
+ * `oauth-handlers.ts`. No structured-logging infra exists in the hub yet;
25
+ * matching the prevailing format keeps log-grep ergonomics consistent.
26
+ */
27
+ import type { Database } from "bun:sqlite";
28
+ import {
29
+ type AdminAuthContext,
30
+ type AdminAuthError,
31
+ adminAuthErrorResponse,
32
+ requireScope,
33
+ } from "./admin-auth.ts";
34
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
35
+ import { listGrantsForUser, revokeGrant } from "./grants.ts";
36
+
37
+ export interface AdminGrantsDeps {
38
+ db: Database;
39
+ /** Hub origin — passed through to JWT validation as the expected `iss`. */
40
+ issuer: string;
41
+ }
42
+
43
+ export interface AdminGrantListing {
44
+ user_id: string;
45
+ client_id: string;
46
+ /** Display name from `clients.client_name`. Null when the client never set one. */
47
+ client_name: string | null;
48
+ scopes: string[];
49
+ granted_at: string;
50
+ }
51
+
52
+ export async function handleListGrants(req: Request, deps: AdminGrantsDeps): Promise<Response> {
53
+ if (req.method !== "GET") {
54
+ return jsonError(405, "method_not_allowed", "use GET");
55
+ }
56
+ let ctx: AdminAuthContext;
57
+ try {
58
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
59
+ } catch (err) {
60
+ return adminAuthErrorResponse(err as AdminAuthError);
61
+ }
62
+
63
+ const url = new URL(req.url);
64
+ const vaultFilter = url.searchParams.get("vault");
65
+ if (vaultFilter !== null && !isValidVaultName(vaultFilter)) {
66
+ return jsonError(400, "invalid_request", "?vault must match [a-zA-Z0-9_-]+");
67
+ }
68
+
69
+ const grants = listGrantsForUser(deps.db, ctx.sub);
70
+ const filtered = vaultFilter
71
+ ? grants.filter((g) => grantTouchesVault(g.scopes, vaultFilter))
72
+ : grants;
73
+
74
+ // One bulk lookup keyed by client_id, instead of N getClient calls. Empty
75
+ // list short-circuits because `IN ()` is a SQL syntax error.
76
+ const names = new Map<string, string | null>();
77
+ if (filtered.length > 0) {
78
+ const placeholders = filtered.map(() => "?").join(",");
79
+ const rows = deps.db
80
+ .query<{ client_id: string; client_name: string | null }, string[]>(
81
+ `SELECT client_id, client_name FROM clients WHERE client_id IN (${placeholders})`,
82
+ )
83
+ .all(...filtered.map((g) => g.clientId));
84
+ for (const r of rows) names.set(r.client_id, r.client_name);
85
+ }
86
+
87
+ const enriched: AdminGrantListing[] = filtered.map((g) => ({
88
+ user_id: g.userId,
89
+ client_id: g.clientId,
90
+ client_name: names.get(g.clientId) ?? null,
91
+ scopes: g.scopes,
92
+ granted_at: g.grantedAt,
93
+ }));
94
+
95
+ return new Response(JSON.stringify({ grants: enriched }), {
96
+ status: 200,
97
+ headers: {
98
+ "content-type": "application/json",
99
+ "cache-control": "no-store",
100
+ },
101
+ });
102
+ }
103
+
104
+ export async function handleRevokeGrant(
105
+ req: Request,
106
+ clientId: string,
107
+ deps: AdminGrantsDeps,
108
+ ): Promise<Response> {
109
+ if (req.method !== "DELETE") {
110
+ return jsonError(405, "method_not_allowed", "use DELETE");
111
+ }
112
+ let ctx: AdminAuthContext;
113
+ try {
114
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
115
+ } catch (err) {
116
+ return adminAuthErrorResponse(err as AdminAuthError);
117
+ }
118
+
119
+ // Capture the prior scopes for the audit line — `revokeGrant` only returns
120
+ // a boolean, and we want the deleted scope set on disk in the log.
121
+ const grants = listGrantsForUser(deps.db, ctx.sub);
122
+ const target = grants.find((g) => g.clientId === clientId);
123
+ if (!target) {
124
+ return jsonError(404, "not_found", `no grant for client ${clientId}`);
125
+ }
126
+
127
+ const removed = revokeGrant(deps.db, ctx.sub, clientId);
128
+ if (!removed) {
129
+ // Race: another revoke landed between the read and the delete. Treat
130
+ // as 404 since the operator's intent (no grant for this client) is
131
+ // already satisfied.
132
+ return jsonError(404, "not_found", `no grant for client ${clientId}`);
133
+ }
134
+ console.log(
135
+ `grant revoked: client_id=${clientId} user_id=${ctx.sub} scopes=${target.scopes.join(" ")}`,
136
+ );
137
+ return new Response(null, { status: 204 });
138
+ }
139
+
140
+ const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
141
+ const VAULT_SCOPE_PREFIX = /^vault:([^:]+):/;
142
+
143
+ function isValidVaultName(name: string): boolean {
144
+ return VAULT_NAME_RE.test(name);
145
+ }
146
+
147
+ function grantTouchesVault(scopes: readonly string[], vault: string): boolean {
148
+ for (const s of scopes) {
149
+ const m = s.match(VAULT_SCOPE_PREFIX);
150
+ if (m && m[1] === vault) return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ function jsonError(status: number, error: string, description: string): Response {
156
+ return new Response(JSON.stringify({ error, error_description: description }), {
157
+ status,
158
+ headers: { "content-type": "application/json" },
159
+ });
160
+ }