@openparachute/hub 0.5.7 → 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 (60) 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 +70 -323
  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 +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +338 -65
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +266 -5
  20. package/src/__tests__/operator-token.test.ts +379 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/status.test.ts +199 -0
  23. package/src/__tests__/well-known.test.ts +69 -0
  24. package/src/admin-clients.ts +139 -0
  25. package/src/admin-handlers.ts +32 -254
  26. package/src/admin-host-admin-token.ts +25 -10
  27. package/src/admin-login-ui.ts +256 -0
  28. package/src/admin-vault-admin-token.ts +1 -1
  29. package/src/api-me.ts +124 -0
  30. package/src/api-mint-token.ts +239 -0
  31. package/src/api-revocation-list.ts +59 -0
  32. package/src/api-revoke-token.ts +153 -0
  33. package/src/api-tokens.ts +224 -0
  34. package/src/commands/auth.ts +408 -51
  35. package/src/commands/expose-2fa-warning.ts +6 -6
  36. package/src/commands/status.ts +74 -10
  37. package/src/csrf.ts +6 -3
  38. package/src/help.ts +10 -4
  39. package/src/hub-db.ts +63 -0
  40. package/src/hub-server.ts +426 -97
  41. package/src/hub.ts +272 -149
  42. package/src/install-source.ts +291 -0
  43. package/src/jwt-sign.ts +265 -5
  44. package/src/module-manifest.ts +48 -10
  45. package/src/oauth-handlers.ts +183 -54
  46. package/src/oauth-ui.ts +23 -2
  47. package/src/operator-token.ts +272 -18
  48. package/src/origin-check.ts +127 -0
  49. package/src/rate-limit.ts +5 -2
  50. package/src/scope-explanations.ts +33 -2
  51. package/src/sessions.ts +1 -1
  52. package/src/well-known.ts +54 -1
  53. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  54. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  55. package/web/ui/dist/index.html +2 -2
  56. package/src/__tests__/admin-config.test.ts +0 -281
  57. package/src/admin-config-ui.ts +0 -534
  58. package/src/admin-config.ts +0 -226
  59. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  60. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -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
+ }