@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,256 @@
1
+ /**
2
+ * Branded HTML for the hub's pre-auth surfaces: the `/login` form and the
3
+ * generic admin error page surfaced when CSRF or rate-limit gates fire on
4
+ * `/login` and `/logout`. Same privacy posture as `oauth-ui.ts` (no third-
5
+ * party fonts, inline CSS, no JS) — these pages are pre-auth and have to
6
+ * stand alone without the SPA shell.
7
+ *
8
+ * History: this file was `admin-config-ui.ts` and held the server-rendered
9
+ * `/admin/config` module-config portal (hub#46). #240 retired the portal
10
+ * post-SPA-rework; the file shed everything except the two renderers below.
11
+ * Renamed to `admin-login-ui.ts` in #241 so the filename matches the content.
12
+ *
13
+ * Pure functions — DB, sessions live in `admin-handlers.ts`.
14
+ */
15
+ import { renderCsrfHiddenInput } from "./csrf.ts";
16
+ import { escapeHtml } from "./oauth-ui.ts";
17
+
18
+ // --- shared chrome ---------------------------------------------------------
19
+
20
+ const PALETTE = {
21
+ bg: "#faf8f4",
22
+ bgSoft: "#f3f0ea",
23
+ fg: "#2c2a26",
24
+ fgMuted: "#6b6860",
25
+ fgDim: "#9a9690",
26
+ accent: "#4a7c59",
27
+ accentHover: "#3d6849",
28
+ accentSoft: "rgba(74, 124, 89, 0.08)",
29
+ border: "#e4e0d8",
30
+ borderLight: "#ece9e2",
31
+ cardBg: "#ffffff",
32
+ danger: "#a3392b",
33
+ dangerSoft: "rgba(163, 57, 43, 0.08)",
34
+ success: "#3d6849",
35
+ successSoft: "rgba(61, 104, 73, 0.08)",
36
+ } as const;
37
+
38
+ const FONT_SERIF = `Georgia, "Times New Roman", serif`;
39
+ const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
40
+ const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
41
+
42
+ function escapeAttr(s: string): string {
43
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
44
+ }
45
+
46
+ function baseDocument(title: string, body: string): string {
47
+ return `<!doctype html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="utf-8" />
51
+ <title>${escapeHtml(title)}</title>
52
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
53
+ <meta name="referrer" content="no-referrer" />
54
+ <style>${STYLES}</style>
55
+ </head>
56
+ <body>
57
+ <main>
58
+ ${body}
59
+ </main>
60
+ </body>
61
+ </html>`;
62
+ }
63
+
64
+ function header(): string {
65
+ return `
66
+ <div class="brand">
67
+ <span class="brand-mark">⌬</span>
68
+ <span class="brand-name">Parachute</span>
69
+ <span class="brand-tag">admin</span>
70
+ </div>`;
71
+ }
72
+
73
+ // --- /login ---------------------------------------------------------------
74
+
75
+ export interface AdminLoginProps {
76
+ /** Continuation path after successful login — submitted as a hidden field. */
77
+ next: string;
78
+ csrfToken: string;
79
+ errorMessage?: string;
80
+ }
81
+
82
+ export function renderAdminLogin(props: AdminLoginProps): string {
83
+ const { next, csrfToken, errorMessage } = props;
84
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
85
+ const body = `
86
+ <div class="card">
87
+ <div class="card-header">
88
+ ${header()}
89
+ <h1>Sign in</h1>
90
+ <p class="subtitle">to administer this hub</p>
91
+ </div>
92
+ ${error}
93
+ <form method="POST" action="/login" class="auth-form">
94
+ ${renderCsrfHiddenInput(csrfToken)}
95
+ <input type="hidden" name="next" value="${escapeAttr(next)}" />
96
+ <label class="field">
97
+ <span class="field-label">Username</span>
98
+ <input type="text" name="username" autocomplete="username" autofocus required />
99
+ </label>
100
+ <label class="field">
101
+ <span class="field-label">Password</span>
102
+ <input type="password" name="password" autocomplete="current-password" required />
103
+ </label>
104
+ <button type="submit" class="btn btn-primary">Sign in</button>
105
+ </form>
106
+ </div>`;
107
+ return baseDocument("Sign in to Parachute Hub admin", body);
108
+ }
109
+
110
+ // --- error page ------------------------------------------------------------
111
+
112
+ export function renderAdminError(props: { title: string; message: string }): string {
113
+ const body = `
114
+ <div class="card">
115
+ ${header()}
116
+ <h1 class="error-title">${escapeHtml(props.title)}</h1>
117
+ <p class="subtitle">${escapeHtml(props.message)}</p>
118
+ </div>`;
119
+ return baseDocument(props.title, body);
120
+ }
121
+
122
+ // --- styles ----------------------------------------------------------------
123
+
124
+ const STYLES = `
125
+ *, *::before, *::after { box-sizing: border-box; }
126
+ html, body { margin: 0; padding: 0; }
127
+ body {
128
+ font-family: ${FONT_SANS};
129
+ background: ${PALETTE.bg};
130
+ color: ${PALETTE.fg};
131
+ line-height: 1.55;
132
+ min-height: 100vh;
133
+ -webkit-font-smoothing: antialiased;
134
+ -moz-osx-font-smoothing: grayscale;
135
+ }
136
+ main {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ min-height: 100vh;
141
+ padding: 1.5rem;
142
+ }
143
+ .card {
144
+ width: 100%;
145
+ max-width: 30rem;
146
+ background: ${PALETTE.cardBg};
147
+ border: 1px solid ${PALETTE.border};
148
+ border-radius: 12px;
149
+ padding: 2rem 1.75rem;
150
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
151
+ }
152
+ .card-header { margin-bottom: 1.5rem; }
153
+ .brand {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 0.5rem;
157
+ color: ${PALETTE.accent};
158
+ font-weight: 500;
159
+ font-size: 0.95rem;
160
+ margin-bottom: 1.25rem;
161
+ }
162
+ .brand-mark { font-size: 1.1rem; line-height: 1; }
163
+ .brand-name { letter-spacing: 0.01em; }
164
+ .brand-tag {
165
+ text-transform: uppercase;
166
+ letter-spacing: 0.06em;
167
+ font-size: 0.7rem;
168
+ color: ${PALETTE.fgMuted};
169
+ border: 1px solid ${PALETTE.borderLight};
170
+ padding: 0.05rem 0.4rem;
171
+ border-radius: 999px;
172
+ }
173
+ h1 {
174
+ font-family: ${FONT_SERIF};
175
+ font-weight: 400;
176
+ font-size: 1.75rem;
177
+ line-height: 1.2;
178
+ margin: 0 0 0.4rem;
179
+ color: ${PALETTE.fg};
180
+ }
181
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
182
+
183
+ .auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
184
+ .field { display: flex; flex-direction: column; gap: 0.35rem; }
185
+ .field-label {
186
+ font-size: 0.85rem;
187
+ font-weight: 500;
188
+ color: ${PALETTE.fgMuted};
189
+ letter-spacing: 0.01em;
190
+ font-family: ${FONT_MONO};
191
+ }
192
+ input[type=text], input[type=password] {
193
+ font: inherit;
194
+ width: 100%;
195
+ padding: 0.6rem 0.75rem;
196
+ border: 1px solid ${PALETTE.border};
197
+ border-radius: 6px;
198
+ background: ${PALETTE.bg};
199
+ color: ${PALETTE.fg};
200
+ transition: border-color 0.15s ease, background 0.15s ease;
201
+ }
202
+ input[type=text]:focus, input[type=password]:focus {
203
+ outline: none;
204
+ border-color: ${PALETTE.accent};
205
+ background: ${PALETTE.cardBg};
206
+ box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
207
+ }
208
+
209
+ .btn {
210
+ font: inherit;
211
+ font-weight: 500;
212
+ padding: 0.65rem 1.25rem;
213
+ border-radius: 6px;
214
+ border: 1px solid transparent;
215
+ cursor: pointer;
216
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
217
+ min-height: 2.5rem;
218
+ }
219
+ .btn-primary {
220
+ background: ${PALETTE.accent};
221
+ color: ${PALETTE.cardBg};
222
+ margin-top: 0.4rem;
223
+ }
224
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
225
+
226
+ .error-banner {
227
+ background: ${PALETTE.dangerSoft};
228
+ border: 1px solid ${PALETTE.danger};
229
+ border-radius: 6px;
230
+ color: ${PALETTE.danger};
231
+ padding: 0.6rem 0.8rem;
232
+ margin: 0 0 1rem;
233
+ font-size: 0.9rem;
234
+ }
235
+ .error-title { color: ${PALETTE.danger}; }
236
+
237
+ @media (max-width: 480px) {
238
+ main { padding: 0.75rem; }
239
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
240
+ h1 { font-size: 1.5rem; }
241
+ }
242
+
243
+ @media (prefers-color-scheme: dark) {
244
+ body { background: #1a1815; color: #e8e4dc; }
245
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
246
+ h1 { color: #f0ece4; }
247
+ .subtitle, .field-label { color: #a8a29a; }
248
+ input[type=text], input[type=password] {
249
+ background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
250
+ }
251
+ input[type=text]:focus, input[type=password]:focus {
252
+ background: #25221d;
253
+ }
254
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
255
+ }
256
+ `;
@@ -55,7 +55,7 @@ export async function handleVaultAdminToken(
55
55
  const sid = parseSessionCookie(req.headers.get("cookie"));
56
56
  const session = sid ? findSession(deps.db, sid) : null;
57
57
  if (!session) {
58
- return jsonError(401, "unauthenticated", "no admin session — sign in at /admin/login first");
58
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
59
59
  }
60
60
  const scope = `vault:${vaultName}:admin`;
61
61
  // Per-vault audience: vault validates the JWT's `aud` claim against
package/src/api-me.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `GET /api/me` — public who-am-I endpoint for hub-served surfaces.
3
+ *
4
+ * Reads the `parachute_hub_session` cookie. If present and active, returns
5
+ * the user identity AND a CSRF token bound to the existing CSRF cookie (or
6
+ * a freshly-minted one). Otherwise returns the minimal `{ hasSession: false }`
7
+ * payload.
8
+ *
9
+ * Public — no auth required (returns "not signed in" rather than 401 when
10
+ * no session, so the SPA / discovery page can render a consistent affordance
11
+ * regardless of auth state). No CORS — same-origin only; hub-served UIs
12
+ * are same-origin.
13
+ *
14
+ * Response shape:
15
+ *
16
+ * { hasSession: false }
17
+ * { hasSession: true, user: { id, displayName }, csrf: "<token>" }
18
+ *
19
+ * `displayName` is the user's `username` today — there's no separate
20
+ * display-name field on the User shape. Surfaced under a different key
21
+ * here so a future profile-name migration can land without breaking
22
+ * SPA / discovery consumers.
23
+ *
24
+ * Why include the CSRF token only when signed in: there's nothing to
25
+ * sign-out (or otherwise mutate) without a session. Minting a token in
26
+ * the unsigned-in case would just bloat the response and prime a cookie
27
+ * the consumer has no use for.
28
+ *
29
+ * Why a dedicated endpoint rather than probing a session-gated SPA page:
30
+ * those redirect to /login when unauthenticated, which is exactly the
31
+ * wrong UX for an unconditionally-fetched "show sign-in affordance" call.
32
+ * `/api/me` cleanly returns either state without a bounce.
33
+ *
34
+ * The CSRF token returned here is the same token any same-session
35
+ * `<form>` would carry — the consumer (SPA fetch POST or
36
+ * server-rendered discovery form) submits it back as `__csrf` against
37
+ * the existing logout / mutation handlers.
38
+ */
39
+ import type { Database } from "bun:sqlite";
40
+ import { ensureCsrfToken } from "./csrf.ts";
41
+ import { findActiveSession } from "./sessions.ts";
42
+ import { getUserById } from "./users.ts";
43
+
44
+ export interface ApiMeDeps {
45
+ db: Database;
46
+ }
47
+
48
+ interface SignedInUser {
49
+ id: string;
50
+ displayName: string;
51
+ }
52
+
53
+ /**
54
+ * Discriminated union mirroring the client-side `MeResponse` shape in
55
+ * `web/ui/src/lib/api.ts`. The two early returns below (no-session,
56
+ * deleted-user) construct the `false` arm; the success path constructs
57
+ * the `true` arm. Typing it as a union (rather than an interface with
58
+ * optional fields) means the compiler refuses any future construction
59
+ * that mixes states — e.g. `{ hasSession: false, user: staleUser }`
60
+ * fails at the type-check, not just at code-review.
61
+ */
62
+ type ApiMeResponse = { hasSession: false } | { hasSession: true; user: SignedInUser; csrf: string };
63
+
64
+ export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
65
+ if (req.method !== "GET") {
66
+ return jsonError(405, "method_not_allowed", "use GET");
67
+ }
68
+
69
+ const session = findActiveSession(deps.db, req);
70
+ if (!session) {
71
+ return ok({ hasSession: false });
72
+ }
73
+
74
+ const user = getUserById(deps.db, session.userId);
75
+ if (!user) {
76
+ // Session row points at a deleted user — treat as not signed in.
77
+ // The session row should be cleaned up by some future sweep, but
78
+ // surfacing a stale identity to the UI would be worse than a
79
+ // momentary "signed out" affordance.
80
+ return ok({ hasSession: false });
81
+ }
82
+
83
+ // Mint a CSRF token (or reuse the existing cookie's). When this is the
84
+ // first request to set the cookie, attach Set-Cookie so the browser
85
+ // stores it for future logout submission.
86
+ const csrf = ensureCsrfToken(req);
87
+ const headers: Record<string, string> = {
88
+ "content-type": "application/json",
89
+ // Don't cache — session can change at any time (login, logout,
90
+ // expiry). The endpoint is cheap; revalidate on every request.
91
+ "cache-control": "no-store",
92
+ };
93
+ if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
94
+
95
+ const body: ApiMeResponse = {
96
+ hasSession: true,
97
+ user: {
98
+ id: user.id,
99
+ displayName: user.username,
100
+ },
101
+ csrf: csrf.token,
102
+ };
103
+ return new Response(JSON.stringify(body), { status: 200, headers });
104
+ }
105
+
106
+ function ok(body: ApiMeResponse): Response {
107
+ return new Response(JSON.stringify(body), {
108
+ status: 200,
109
+ headers: {
110
+ "content-type": "application/json",
111
+ "cache-control": "no-store",
112
+ },
113
+ });
114
+ }
115
+
116
+ function jsonError(status: number, error: string, description: string): Response {
117
+ return new Response(JSON.stringify({ error, error_description: description }), {
118
+ status,
119
+ headers: {
120
+ "content-type": "application/json",
121
+ "cache-control": "no-store",
122
+ },
123
+ });
124
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `POST /api/auth/mint-token` — HTTP companion to `parachute auth mint-token`.
3
+ *
4
+ * Same arg/return shape as the CLI; just the network path. Used by:
5
+ *
6
+ * - automation that doesn't have CLI access (CI runners, cloud agents)
7
+ * but does hold an operator-bearer with `parachute:host:auth` scope;
8
+ * - the future admin SPA when the operator wants to mint a one-shot
9
+ * scope-narrow token without dropping to a terminal.
10
+ *
11
+ * Auth: `Authorization: Bearer <token>` where `token`'s `scope` claim
12
+ * contains `parachute:host:auth`. The operator's local operator.token
13
+ * (admin scope-set) covers this; a narrow `--scope-set=auth` operator
14
+ * token also covers this.
15
+ *
16
+ * Why a separate endpoint instead of extending /admin/host-admin-token:
17
+ * that endpoint is session-cookie-gated for the SPA's needs and only
18
+ * mints `parachute:host:admin`. This endpoint is bearer-gated for
19
+ * automation and mints arbitrary scope/permissions tuples per request.
20
+ *
21
+ * Every successful mint writes a row to the `tokens` registry
22
+ * (`created_via='cli_mint'` — same provenance as the CLI path, since
23
+ * HTTP mint is just CLI-by-network). Powers the
24
+ * `/.well-known/parachute-revocation.json` endpoint.
25
+ */
26
+ import type { Database } from "bun:sqlite";
27
+ import { inferAudience } from "./jwt-audience.ts";
28
+ import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
29
+ import { isNonRequestableScope } from "./scope-explanations.ts";
30
+
31
+ /** Default lifetime when --expires-in / `expires_in` is omitted. Matches the CLI. */
32
+ export const API_MINT_TOKEN_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
33
+ /** Hard cap. Matches the CLI's --expires-in upper bound. */
34
+ export const API_MINT_TOKEN_MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
35
+ /** Scope required on the bearer token to call this endpoint. */
36
+ export const API_MINT_TOKEN_REQUIRED_SCOPE = "parachute:host:auth";
37
+ /** client_id stamped on minted tokens. Matches the CLI flow's value. */
38
+ export const API_MINT_TOKEN_CLIENT_ID = "parachute-hub";
39
+
40
+ export interface ApiMintTokenDeps {
41
+ db: Database;
42
+ /** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
43
+ issuer: string;
44
+ /** Test seam for time. */
45
+ now?: () => Date;
46
+ }
47
+
48
+ interface MintTokenRequest {
49
+ scope?: unknown;
50
+ audience?: unknown;
51
+ expires_in?: unknown;
52
+ subject?: unknown;
53
+ permissions?: unknown;
54
+ }
55
+
56
+ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps): Promise<Response> {
57
+ if (req.method !== "POST") {
58
+ return jsonError(405, "method_not_allowed", "use POST");
59
+ }
60
+
61
+ // 1. Bearer presence + parsing.
62
+ const auth = req.headers.get("authorization");
63
+ if (!auth || !auth.startsWith("Bearer ")) {
64
+ return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
65
+ }
66
+ const bearer = auth.slice("Bearer ".length).trim();
67
+ if (!bearer) {
68
+ return jsonError(401, "unauthenticated", "empty bearer token");
69
+ }
70
+
71
+ // 2. Bearer validation (signature, issuer, expiry, revocation).
72
+ let bearerSub: string;
73
+ let bearerScopes: string[];
74
+ try {
75
+ const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
76
+ const sub = validated.payload.sub;
77
+ if (typeof sub !== "string" || sub.length === 0) {
78
+ return jsonError(401, "unauthenticated", "bearer token has no sub claim");
79
+ }
80
+ bearerSub = sub;
81
+ bearerScopes =
82
+ typeof validated.payload.scope === "string"
83
+ ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
84
+ : [];
85
+ } catch (err) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
88
+ }
89
+
90
+ // 3. Scope gate.
91
+ if (!bearerScopes.includes(API_MINT_TOKEN_REQUIRED_SCOPE)) {
92
+ return jsonError(
93
+ 403,
94
+ "insufficient_scope",
95
+ `bearer token lacks ${API_MINT_TOKEN_REQUIRED_SCOPE}`,
96
+ );
97
+ }
98
+
99
+ // 4. Body parsing.
100
+ let body: MintTokenRequest;
101
+ try {
102
+ body = (await req.json()) as MintTokenRequest;
103
+ } catch (err) {
104
+ const msg = err instanceof Error ? err.message : String(err);
105
+ return jsonError(400, "invalid_request", `body must be valid JSON — ${msg}`);
106
+ }
107
+ if (typeof body !== "object" || body === null) {
108
+ return jsonError(400, "invalid_request", "body must be a JSON object");
109
+ }
110
+
111
+ // 5. Required + typed field extraction.
112
+ if (typeof body.scope !== "string" || body.scope.trim().length === 0) {
113
+ return jsonError(400, "invalid_request", "scope is required and must be a non-empty string");
114
+ }
115
+ const scopes = body.scope.split(/\s+/).filter((s) => s.length > 0);
116
+ if (scopes.length === 0) {
117
+ return jsonError(400, "invalid_request", "scope must contain at least one scope");
118
+ }
119
+
120
+ // Privilege-diffusion guard: mint paths cannot themselves mint tokens
121
+ // carrying non-requestable scopes (parachute:host:admin, the host:*
122
+ // narrow scopes, vault:<name>:admin). Holder of `parachute:host:auth`
123
+ // can mint vault/scribe/agent verb scopes for downstream services, but
124
+ // cannot mint another `:auth` (or any other non-requestable) without
125
+ // forced re-auth via the operator.token rotation path. Same set the
126
+ // public OAuth flow already rejects.
127
+ const blocked = scopes.filter((s) => isNonRequestableScope(s));
128
+ if (blocked.length > 0) {
129
+ return jsonError(
130
+ 400,
131
+ "invalid_scope",
132
+ `scope ${blocked.join(", ")} is not requestable via mint-token; use OAuth flow or operator rotation`,
133
+ );
134
+ }
135
+
136
+ let audience: string;
137
+ if (body.audience === undefined) {
138
+ audience = inferAudience(scopes);
139
+ } else if (typeof body.audience === "string" && body.audience.length > 0) {
140
+ audience = body.audience;
141
+ } else {
142
+ return jsonError(400, "invalid_request", "audience must be a non-empty string when present");
143
+ }
144
+
145
+ let ttlSeconds = API_MINT_TOKEN_DEFAULT_TTL_SECONDS;
146
+ if (body.expires_in !== undefined) {
147
+ if (typeof body.expires_in !== "number" || !Number.isFinite(body.expires_in)) {
148
+ return jsonError(400, "invalid_request", "expires_in must be a positive integer (seconds)");
149
+ }
150
+ if (!Number.isInteger(body.expires_in) || body.expires_in <= 0) {
151
+ return jsonError(400, "invalid_request", "expires_in must be a positive integer (seconds)");
152
+ }
153
+ if (body.expires_in > API_MINT_TOKEN_MAX_TTL_SECONDS) {
154
+ return jsonError(
155
+ 400,
156
+ "invalid_request",
157
+ `expires_in exceeds 365d cap (${API_MINT_TOKEN_MAX_TTL_SECONDS} seconds)`,
158
+ );
159
+ }
160
+ ttlSeconds = body.expires_in;
161
+ }
162
+
163
+ let subject: string;
164
+ if (body.subject === undefined) {
165
+ subject = bearerSub;
166
+ } else if (typeof body.subject === "string" && body.subject.length > 0) {
167
+ subject = body.subject;
168
+ } else {
169
+ return jsonError(400, "invalid_request", "subject must be a non-empty string when present");
170
+ }
171
+
172
+ let permissionsClaim: Record<string, unknown> | undefined;
173
+ let permissionsCanonical: string | undefined;
174
+ if (body.permissions !== undefined) {
175
+ if (
176
+ typeof body.permissions !== "object" ||
177
+ body.permissions === null ||
178
+ Array.isArray(body.permissions)
179
+ ) {
180
+ return jsonError(400, "invalid_request", "permissions must be a JSON object");
181
+ }
182
+ permissionsClaim = body.permissions as Record<string, unknown>;
183
+ permissionsCanonical = JSON.stringify(permissionsClaim);
184
+ }
185
+
186
+ // 6. Mint + register.
187
+ const minted = await signAccessToken(deps.db, {
188
+ sub: subject,
189
+ scopes,
190
+ audience,
191
+ clientId: API_MINT_TOKEN_CLIENT_ID,
192
+ issuer: deps.issuer,
193
+ ttlSeconds,
194
+ ...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
195
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
196
+ });
197
+
198
+ recordTokenMint(deps.db, {
199
+ jti: minted.jti,
200
+ createdVia: "cli_mint",
201
+ subject,
202
+ // user_id intentionally omitted — CLI-mint rows store subject only,
203
+ // matching the CLI path's shape (so HTTP and CLI mints look identical
204
+ // in the registry). The bearer's user identity is implicit via the
205
+ // bearer's own user_id (which is in its own tokens row).
206
+ clientId: API_MINT_TOKEN_CLIENT_ID,
207
+ scopes,
208
+ expiresAt: minted.expiresAt,
209
+ ...(permissionsCanonical !== undefined ? { permissions: permissionsCanonical } : {}),
210
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
211
+ });
212
+
213
+ return new Response(
214
+ JSON.stringify({
215
+ jti: minted.jti,
216
+ token: minted.token,
217
+ expires_at: minted.expiresAt,
218
+ scope: scopes.join(" "),
219
+ ...(permissionsClaim !== undefined ? { permissions: permissionsClaim } : {}),
220
+ }),
221
+ {
222
+ status: 200,
223
+ headers: {
224
+ "content-type": "application/json",
225
+ "cache-control": "no-store",
226
+ },
227
+ },
228
+ );
229
+ }
230
+
231
+ function jsonError(status: number, error: string, description: string): Response {
232
+ return new Response(JSON.stringify({ error, error_description: description }), {
233
+ status,
234
+ headers: {
235
+ "content-type": "application/json",
236
+ "cache-control": "no-store",
237
+ },
238
+ });
239
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `GET /.well-known/parachute-revocation.json` — public list of revoked,
3
+ * not-yet-expired token jtis. Resource servers (vault, scribe, agent)
4
+ * fetch this on a 60s TTL and reject any presented JWT whose jti appears.
5
+ *
6
+ * Public endpoint (no auth). The list itself is harmless to expose: it's
7
+ * a list of opaque IDs whose only utility is "this token shouldn't be
8
+ * accepted." A leaked list doesn't enable any new attack — at worst, an
9
+ * attacker learns which compromise the operator already cleaned up.
10
+ *
11
+ * Already-expired jtis are filtered out: every consumer checks `exp`
12
+ * itself, so listing expired tokens just bloats the response. The
13
+ * revocation list exists for *unexpired* tokens whose validity got cut
14
+ * short. Once `exp` passes, a row falls off the list naturally.
15
+ *
16
+ * Caching: 60s `Cache-Control: max-age=60` matches the consumer's
17
+ * polling cadence (Phase 4 wires the 60s TTL on the resource-server
18
+ * side). Shorter cache = revocation propagates faster but burns more
19
+ * CPU on this endpoint; 60s is the published convergence target.
20
+ */
21
+ import type { Database } from "bun:sqlite";
22
+ import { listActiveRevocations } from "./jwt-sign.ts";
23
+
24
+ export const REVOCATION_LIST_MOUNT = "/.well-known/parachute-revocation.json";
25
+ /** Consumer cache TTL in seconds. Resource servers should poll on this cadence. */
26
+ export const REVOCATION_LIST_CACHE_SECONDS = 60;
27
+
28
+ export interface RevocationListDeps {
29
+ db: Database;
30
+ /** Test seam for time. */
31
+ now?: () => Date;
32
+ }
33
+
34
+ interface RevocationListBody {
35
+ generated_at: string;
36
+ jtis: string[];
37
+ }
38
+
39
+ export function handleRevocationList(req: Request, deps: RevocationListDeps): Response {
40
+ if (req.method !== "GET") {
41
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
42
+ status: 405,
43
+ headers: { "content-type": "application/json" },
44
+ });
45
+ }
46
+ const now = deps.now?.() ?? new Date();
47
+ const jtis = listActiveRevocations(deps.db, now);
48
+ const body: RevocationListBody = {
49
+ generated_at: now.toISOString(),
50
+ jtis,
51
+ };
52
+ return new Response(JSON.stringify(body), {
53
+ status: 200,
54
+ headers: {
55
+ "content-type": "application/json",
56
+ "cache-control": `public, max-age=${REVOCATION_LIST_CACHE_SECONDS}`,
57
+ },
58
+ });
59
+ }