@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `POST /api/auth/revoke-token` — HTTP companion to `parachute auth
3
+ * revoke-token <jti>` (hub#221) and the missing piece behind the future
4
+ * admin UI's revoke action.
5
+ *
6
+ * Same auth shape as `POST /api/auth/mint-token`: bearer-gated on
7
+ * `parachute:host:auth` (admin scope-set tokens carry it as a superset;
8
+ * narrow `--scope-set auth` operator tokens carry it directly). Closes
9
+ * hub#220.
10
+ *
11
+ * Body: `{ jti: string }`.
12
+ *
13
+ * Responses (matching the OAuth 2.0 error-shape vocabulary used by
14
+ * mint-token and the rest of the hub's bearer-protected admin API):
15
+ *
16
+ * - 200 `{ jti, revoked_at }` — success. Idempotent: re-revoking an
17
+ * already-revoked jti returns the existing `revoked_at` and 200,
18
+ * same as the CLI's exit-0-with-existing-timestamp behavior.
19
+ * - 400 `invalid_request` — missing/malformed body, missing jti.
20
+ * - 401 `unauthenticated` — missing or invalid bearer.
21
+ * - 403 `insufficient_scope` — bearer lacks `parachute:host:auth`.
22
+ * - 404 `not_found` — no `tokens` row matches the jti.
23
+ * - 405 `method_not_allowed` — non-POST.
24
+ *
25
+ * Identity field in audit-friendly success: not echoed in the response
26
+ * body (the JSON shape is intentionally minimal — `jti` + `revoked_at`
27
+ * is all a UI consumer needs); operator-side audit lives in hub logs.
28
+ * Mirrors the CLI's design where `identity=` was added for stdout but
29
+ * the wire response stays narrow.
30
+ */
31
+ import type { Database } from "bun:sqlite";
32
+ import { findTokenRowByJti, revokeTokenByJti, validateAccessToken } from "./jwt-sign.ts";
33
+
34
+ /** Scope required on the bearer token to call this endpoint. */
35
+ export const API_REVOKE_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
36
+
37
+ export interface ApiRevokeTokenDeps {
38
+ db: Database;
39
+ /** Hub origin — used to validate the bearer's `iss`. */
40
+ issuer: string;
41
+ /** Test seam for time. */
42
+ now?: () => Date;
43
+ }
44
+
45
+ interface RevokeTokenRequest {
46
+ jti?: unknown;
47
+ }
48
+
49
+ export async function handleApiRevokeToken(
50
+ req: Request,
51
+ deps: ApiRevokeTokenDeps,
52
+ ): Promise<Response> {
53
+ if (req.method !== "POST") {
54
+ return jsonError(405, "method_not_allowed", "use POST");
55
+ }
56
+
57
+ // 1. Bearer presence + parsing.
58
+ const auth = req.headers.get("authorization");
59
+ if (!auth || !auth.startsWith("Bearer ")) {
60
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
61
+ }
62
+ const bearer = auth.slice("Bearer ".length).trim();
63
+ if (!bearer) {
64
+ return jsonError(401, "unauthenticated", "empty bearer token");
65
+ }
66
+
67
+ // 2. Bearer validation (signature, issuer, expiry, hub-side revocation).
68
+ let bearerScopes: string[];
69
+ try {
70
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
71
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
72
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
73
+ }
74
+ bearerScopes =
75
+ typeof validated.payload.scope === "string"
76
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
77
+ : [];
78
+ } catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
81
+ }
82
+
83
+ // 3. Scope gate.
84
+ if (!bearerScopes.includes(API_REVOKE_TOKEN_REQUIRED_SCOPE)) {
85
+ return jsonError(
86
+ 403,
87
+ "insufficient_scope",
88
+ `bearer token lacks ${API_REVOKE_TOKEN_REQUIRED_SCOPE}`,
89
+ );
90
+ }
91
+
92
+ // 4. Body parsing + field extraction.
93
+ let body: RevokeTokenRequest;
94
+ try {
95
+ body = (await req.json()) as RevokeTokenRequest;
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return jsonError(400, "invalid_request", `body must be valid JSON — ${msg}`);
99
+ }
100
+ if (typeof body !== "object" || body === null) {
101
+ return jsonError(400, "invalid_request", "body must be a JSON object");
102
+ }
103
+ if (typeof body.jti !== "string" || body.jti.length === 0) {
104
+ return jsonError(400, "invalid_request", "jti is required and must be a non-empty string");
105
+ }
106
+ const jti = body.jti;
107
+
108
+ // 5. Lookup + revoke. Order: row-existence first (404 if missing), then
109
+ // attempt revoke. Idempotent: if already revoked, surface the existing
110
+ // revoked_at — same CLI semantics from hub#221.
111
+ const existing = findTokenRowByJti(deps.db, jti);
112
+ if (!existing) {
113
+ return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
114
+ }
115
+ if (existing.revokedAt) {
116
+ return ok({ jti, revoked_at: existing.revokedAt });
117
+ }
118
+
119
+ const now = deps.now?.() ?? new Date();
120
+ const flipped = revokeTokenByJti(deps.db, jti, now);
121
+ if (!flipped) {
122
+ // Race: row vanished or was concurrently revoked between our lookup
123
+ // and the UPDATE. Re-read to surface the now-current revoked_at if
124
+ // someone else won. If still nothing, 404 (the row genuinely went
125
+ // away — a concurrent prune, perhaps).
126
+ const reRead = findTokenRowByJti(deps.db, jti);
127
+ if (reRead?.revokedAt) {
128
+ return ok({ jti, revoked_at: reRead.revokedAt });
129
+ }
130
+ return jsonError(404, "not_found", `no token with jti ${jti} found in registry`);
131
+ }
132
+ return ok({ jti, revoked_at: now.toISOString() });
133
+ }
134
+
135
+ function ok(body: { jti: string; revoked_at: string }): Response {
136
+ return new Response(JSON.stringify(body), {
137
+ status: 200,
138
+ headers: {
139
+ "content-type": "application/json",
140
+ "cache-control": "no-store",
141
+ },
142
+ });
143
+ }
144
+
145
+ function jsonError(status: number, error: string, description: string): Response {
146
+ return new Response(JSON.stringify({ error, error_description: description }), {
147
+ status,
148
+ headers: {
149
+ "content-type": "application/json",
150
+ "cache-control": "no-store",
151
+ },
152
+ });
153
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `GET /api/auth/tokens` — paginated list of the hub's `tokens` registry,
3
+ * for the future admin UI's token-management view (Phase 2 of hub#212).
4
+ *
5
+ * Same auth shape as the rest of `/api/auth/*`: bearer-gated on
6
+ * `parachute:host:auth`. The list is intentionally rich — every column
7
+ * the registry holds is surfaced, since the consumer (admin UI) needs
8
+ * status pills, sort, filter, and per-row revoke actions, all of which
9
+ * key off these fields.
10
+ *
11
+ * Wire shape:
12
+ *
13
+ * GET /api/auth/tokens?revoked=true|false|all&subject=...&cursor=...
14
+ * →
15
+ * {
16
+ * "tokens": [
17
+ * {
18
+ * "jti": "...",
19
+ * "user_id": "..." | null,
20
+ * "subject": "..." | null,
21
+ * "client_id": "...",
22
+ * "scopes": [...],
23
+ * "expires_at": "ISO-8601",
24
+ * "revoked_at": "ISO-8601" | null,
25
+ * "created_at": "ISO-8601",
26
+ * "created_via": "oauth_refresh" | "cli_mint" | "operator_mint",
27
+ * "permissions": "<json-string>" | null
28
+ * }
29
+ * ],
30
+ * "next_cursor": "<opaque>" | null
31
+ * }
32
+ *
33
+ * Pagination is opaque cursor (newest-first; cursor encodes the previous
34
+ * page's last `(created_at, jti)` composite). Page size is a hardcoded
35
+ * 50 — see `listTokens` in `jwt-sign.ts`.
36
+ *
37
+ * Filter semantics:
38
+ * - `revoked=true` — only revoked rows.
39
+ * - `revoked=false` — only un-revoked rows.
40
+ * - `revoked=all` (or omitted) — all rows.
41
+ * - `subject=<value>` — exact match against either `user_id` (OAuth
42
+ * rows) or `subject` (CLI / operator / service mint rows). The
43
+ * consumer doesn't need to know which column to query; the helper
44
+ * handles both.
45
+ * - `created_via=<value>` — narrow by mint provenance. One of
46
+ * `oauth_refresh` (OAuth refresh-token rotation), `operator_mint`
47
+ * (operator-token rotation via `parachute auth rotate-operator`),
48
+ * or `cli_mint` (CLI / `POST /api/auth/mint-token`). Powers the
49
+ * admin UI's "by source" filter pills (hub#212 Phase F).
50
+ *
51
+ * Why bearer-gated rather than session-cookie-gated: matches the rest
52
+ * of `/api/auth/*` (mint-token, revoke-token), so an automation client
53
+ * holding a `parachute:host:auth` bearer can read the registry without
54
+ * juggling browser session state. The admin UI mints its bearer via
55
+ * the same `getHostAdminToken()` helper that powers the existing
56
+ * `/vaults` and `/api/grants` calls.
57
+ */
58
+ import type { Database } from "bun:sqlite";
59
+ import { type TokenCreatedVia, listTokens, validateAccessToken } from "./jwt-sign.ts";
60
+
61
+ /** Scope required on the bearer token to call this endpoint. */
62
+ export const API_TOKENS_REQUIRED_SCOPE = "parachute:host:auth";
63
+
64
+ export interface ApiTokensDeps {
65
+ db: Database;
66
+ /** Hub origin — used to validate the bearer's `iss`. */
67
+ issuer: string;
68
+ }
69
+
70
+ interface TokenWireShape {
71
+ jti: string;
72
+ user_id: string | null;
73
+ subject: string | null;
74
+ client_id: string;
75
+ scopes: string[];
76
+ expires_at: string;
77
+ revoked_at: string | null;
78
+ created_at: string;
79
+ created_via: string;
80
+ /**
81
+ * Parsed `permissions` claim — JSON object as the UI consumer expects.
82
+ * `scopes` is similarly parsed from its space-separated wire form to an
83
+ * array at this boundary; folding `permissions` parsing here keeps the
84
+ * contract uniform (consumers receive native objects, not raw strings).
85
+ * Stored as a JSON string in the DB; if the row's permissions value is
86
+ * malformed (shouldn't happen — `recordTokenMint` validates on write,
87
+ * but defense-in-depth), surface as `null` rather than crashing the
88
+ * list response.
89
+ */
90
+ permissions: Record<string, unknown> | null;
91
+ }
92
+
93
+ interface TokensListResponse {
94
+ tokens: TokenWireShape[];
95
+ next_cursor: string | null;
96
+ }
97
+
98
+ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promise<Response> {
99
+ if (req.method !== "GET") {
100
+ return jsonError(405, "method_not_allowed", "use GET");
101
+ }
102
+
103
+ // 1. Bearer presence + parsing.
104
+ const auth = req.headers.get("authorization");
105
+ if (!auth || !auth.startsWith("Bearer ")) {
106
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
107
+ }
108
+ const bearer = auth.slice("Bearer ".length).trim();
109
+ if (!bearer) {
110
+ return jsonError(401, "unauthenticated", "empty bearer token");
111
+ }
112
+
113
+ // 2. Bearer validation.
114
+ let bearerScopes: string[];
115
+ try {
116
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
117
+ if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
118
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
119
+ }
120
+ bearerScopes =
121
+ typeof validated.payload.scope === "string"
122
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
123
+ : [];
124
+ } catch (err) {
125
+ const msg = err instanceof Error ? err.message : String(err);
126
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
127
+ }
128
+
129
+ // 3. Scope gate.
130
+ if (!bearerScopes.includes(API_TOKENS_REQUIRED_SCOPE)) {
131
+ return jsonError(403, "insufficient_scope", `bearer token lacks ${API_TOKENS_REQUIRED_SCOPE}`);
132
+ }
133
+
134
+ // 4. Query-string parsing. All filters are optional; defaults match
135
+ // listTokens (`revoked=all`, no subject filter, default page size).
136
+ const url = new URL(req.url);
137
+ const revokedParam = url.searchParams.get("revoked");
138
+ let revoked: "true" | "false" | "all" | undefined;
139
+ if (revokedParam === "true" || revokedParam === "false" || revokedParam === "all") {
140
+ revoked = revokedParam;
141
+ } else if (revokedParam !== null) {
142
+ return jsonError(400, "invalid_request", "revoked must be one of: true | false | all");
143
+ }
144
+ const subjectParam = url.searchParams.get("subject");
145
+ const subject =
146
+ typeof subjectParam === "string" && subjectParam.length > 0 ? subjectParam : undefined;
147
+ const createdViaParam = url.searchParams.get("created_via");
148
+ let createdVia: TokenCreatedVia | undefined;
149
+ if (
150
+ createdViaParam === "oauth_refresh" ||
151
+ createdViaParam === "operator_mint" ||
152
+ createdViaParam === "cli_mint"
153
+ ) {
154
+ createdVia = createdViaParam;
155
+ } else if (createdViaParam !== null) {
156
+ return jsonError(
157
+ 400,
158
+ "invalid_request",
159
+ "created_via must be one of: oauth_refresh | operator_mint | cli_mint",
160
+ );
161
+ }
162
+ const cursor = url.searchParams.get("cursor");
163
+
164
+ // 5. Query.
165
+ const page = listTokens(deps.db, {
166
+ filter: {
167
+ ...(revoked ? { revoked } : {}),
168
+ ...(subject ? { subject } : {}),
169
+ ...(createdVia ? { createdVia } : {}),
170
+ },
171
+ cursor,
172
+ });
173
+
174
+ const body: TokensListResponse = {
175
+ tokens: page.rows.map((r) => ({
176
+ jti: r.jti,
177
+ user_id: r.userId,
178
+ subject: r.subject,
179
+ client_id: r.clientId,
180
+ scopes: r.scopes,
181
+ expires_at: r.expiresAt,
182
+ revoked_at: r.revokedAt,
183
+ created_at: r.createdAt,
184
+ created_via: r.createdVia,
185
+ permissions: parsePermissions(r.permissions),
186
+ })),
187
+ next_cursor: page.nextCursor,
188
+ };
189
+
190
+ return new Response(JSON.stringify(body), {
191
+ status: 200,
192
+ headers: {
193
+ "content-type": "application/json",
194
+ "cache-control": "no-store",
195
+ },
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Parse a row's `permissions` JSON-string column into the wire shape's
201
+ * native object. `null`/empty stays `null`. Malformed JSON (defense-in-depth;
202
+ * `recordTokenMint` validates on the write side) also surfaces as `null`
203
+ * rather than crashing the list response.
204
+ */
205
+ function parsePermissions(raw: string | null): Record<string, unknown> | null {
206
+ if (!raw) return null;
207
+ try {
208
+ const parsed = JSON.parse(raw) as unknown;
209
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
210
+ return parsed as Record<string, unknown>;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ function jsonError(status: number, error: string, description: string): Response {
217
+ return new Response(JSON.stringify({ error, error_description: description }), {
218
+ status,
219
+ headers: {
220
+ "content-type": "application/json",
221
+ "cache-control": "no-store",
222
+ },
223
+ });
224
+ }