@openparachute/hub 0.5.1 → 0.5.7

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +92 -0
  3. package/src/__tests__/expose-2fa-warning.test.ts +125 -0
  4. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  5. package/src/__tests__/expose.test.ts +199 -340
  6. package/src/__tests__/hub-server.test.ts +1227 -1
  7. package/src/__tests__/install.test.ts +50 -31
  8. package/src/__tests__/lifecycle.test.ts +97 -2
  9. package/src/__tests__/module-manifest.test.ts +13 -0
  10. package/src/__tests__/notes-serve.test.ts +154 -2
  11. package/src/__tests__/oauth-handlers.test.ts +737 -1
  12. package/src/__tests__/port-assign.test.ts +41 -52
  13. package/src/__tests__/rate-limit.test.ts +190 -0
  14. package/src/__tests__/services-manifest.test.ts +367 -0
  15. package/src/__tests__/setup.test.ts +12 -9
  16. package/src/__tests__/status.test.ts +173 -0
  17. package/src/admin-handlers.ts +38 -13
  18. package/src/commands/expose-2fa-warning.ts +82 -0
  19. package/src/commands/expose-cloudflare.ts +27 -0
  20. package/src/commands/expose-public-auto.ts +3 -7
  21. package/src/commands/expose.ts +88 -173
  22. package/src/commands/install.ts +11 -13
  23. package/src/commands/lifecycle.ts +53 -4
  24. package/src/commands/status.ts +28 -1
  25. package/src/help.ts +3 -3
  26. package/src/hub-server.ts +266 -32
  27. package/src/module-manifest.ts +19 -0
  28. package/src/notes-serve.ts +70 -9
  29. package/src/oauth-handlers.ts +249 -12
  30. package/src/oauth-ui.ts +167 -0
  31. package/src/port-assign.ts +28 -35
  32. package/src/rate-limit.ts +163 -0
  33. package/src/service-spec.ts +66 -13
  34. package/src/services-manifest.ts +83 -3
  35. package/src/sessions.ts +19 -0
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { existsSync } from "node:fs";
28
+ import { homedir } from "node:os";
28
29
  import { dirname, join, resolve } from "node:path";
29
30
 
30
31
  interface Args {
@@ -67,16 +68,76 @@ export function normalizeMount(raw: string): string {
67
68
  return raw.replace(/\/+$/, "");
68
69
  }
69
70
 
70
- function resolveNotesDist(): string {
71
- const pkgPath = Bun.resolveSync("@openparachute/notes/package.json", process.cwd());
72
- const root = dirname(pkgPath);
73
- const dist = join(root, "dist");
74
- if (!existsSync(dist)) {
75
- throw new Error(
76
- `@openparachute/notes is installed but has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
77
- );
71
+ /**
72
+ * Candidate base directories that `Bun.resolveSync` walks from when looking
73
+ * for `@openparachute/notes/package.json`. Order matters:
74
+ *
75
+ * 1. `process.cwd()` — works when notes-serve is invoked from inside the
76
+ * notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
77
+ * any project that depends on `@openparachute/notes`.
78
+ * 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
79
+ * layout. This is where `bun add -g @openparachute/notes` lands the
80
+ * package, and where `bun link @openparachute/notes` symlinks it.
81
+ * 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
82
+ *
83
+ * Hub itself does NOT depend on `@openparachute/notes`, so when
84
+ * `parachute start notes` is run from the hub repo dir, the cwd-relative
85
+ * resolve walks ancestral node_modules and finds nothing. Bun does not
86
+ * auto-consult the global install dir, so bun-linked installs fail to
87
+ * resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
88
+ * `/notes/` because of this — fixed by trying the global install dirs.
89
+ *
90
+ * Exported (and parameterized via `cwd`/`home`) so tests can drive the
91
+ * resolution order against a real fixture install without monkey-patching
92
+ * `Bun.resolveSync`.
93
+ */
94
+ export function notesDistCandidates(cwd: string, home: string): string[] {
95
+ return [cwd, join(home, ".bun/install/global/node_modules"), join(home, ".bun/install/global")];
96
+ }
97
+
98
+ export interface ResolveNotesDistDeps {
99
+ cwd?: string;
100
+ home?: string;
101
+ /** Override `Bun.resolveSync` for tests. */
102
+ resolveSync?: (specifier: string, base: string) => string;
103
+ existsSync?: (path: string) => boolean;
104
+ }
105
+
106
+ export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
107
+ const cwd = deps.cwd ?? process.cwd();
108
+ const home = deps.home ?? homedir();
109
+ const resolveSync = deps.resolveSync ?? Bun.resolveSync;
110
+ const exists = deps.existsSync ?? existsSync;
111
+ const candidates = notesDistCandidates(cwd, home);
112
+ const resolveErrors: string[] = [];
113
+ for (const base of candidates) {
114
+ let pkgPath: string;
115
+ try {
116
+ pkgPath = resolveSync("@openparachute/notes/package.json", base);
117
+ } catch (err) {
118
+ resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
119
+ continue;
120
+ }
121
+ const root = dirname(pkgPath);
122
+ const dist = join(root, "dist");
123
+ if (!exists(dist)) {
124
+ // Found the package but it has no dist/. This is a hard error
125
+ // (package shipped without a prebuilt bundle); don't fall through to
126
+ // other candidates — they'd resolve to the same package and report
127
+ // the same problem.
128
+ throw new Error(
129
+ `@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
130
+ );
131
+ }
132
+ return dist;
78
133
  }
79
- return dist;
134
+ throw new Error(
135
+ `Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
136
+ );
137
+ }
138
+
139
+ function resolveNotesDist(): string {
140
+ return resolveNotesDistFrom();
80
141
  }
81
142
 
82
143
  function mimeFor(path: string): string | undefined {
@@ -8,6 +8,7 @@
8
8
  * - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
9
9
  * - GET /oauth/authorize (login → consent → code)
10
10
  * - POST /oauth/authorize (form posts: login + consent)
11
+ * - POST /oauth/authorize/approve (operator-driven inline DCR approval, #208)
11
12
  * - POST /oauth/token (grant_type=authorization_code | refresh_token)
12
13
  * - POST /oauth/register (RFC 7591 DCR)
13
14
  * - POST /oauth/revoke (RFC 7009 token revocation)
@@ -35,6 +36,7 @@ import {
35
36
  type ClientStatus,
36
37
  type OAuthClient,
37
38
  type RegisteredClient,
39
+ approveClient,
38
40
  getClient,
39
41
  isValidRedirectUri,
40
42
  registerClient,
@@ -53,7 +55,13 @@ import {
53
55
  signAccessToken,
54
56
  signRefreshToken,
55
57
  } from "./jwt-sign.ts";
56
- import { type AuthorizeFormParams, renderConsent, renderError, renderLogin } from "./oauth-ui.ts";
58
+ import {
59
+ type AuthorizeFormParams,
60
+ renderApprovePending,
61
+ renderConsent,
62
+ renderError,
63
+ renderLogin,
64
+ } from "./oauth-ui.ts";
57
65
  import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
58
66
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
59
67
  import {
@@ -64,6 +72,7 @@ import {
64
72
  SESSION_TTL_MS,
65
73
  buildSessionCookie,
66
74
  createSession,
75
+ findActiveSession,
67
76
  findSession,
68
77
  parseSessionCookie,
69
78
  } from "./sessions.ts";
@@ -291,12 +300,63 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
291
300
  };
292
301
  }
293
302
 
294
- /** HTML response for pending clients hitting /oauth/authorize. */
295
- function pendingClientHtml(): Response {
296
- return htmlError(
297
- "App not yet approved",
298
- "This client_id is registered but has not been approved by the hub operator. Ask the operator to run `parachute auth approve-client` for this app, then try again.",
303
+ /**
304
+ * "App not yet approved" page (#74) for /oauth/authorize. When the request
305
+ * carries a valid operator session AND a same-origin Origin/Referer, render
306
+ * the inline approve form (#208) so one click flips the client to `approved`
307
+ * and the OAuth flow re-enters at consent. Otherwise fall back to the
308
+ * pre-#208 CLI-only message ("ask operator to run `parachute auth
309
+ * approve-client <id>`").
310
+ *
311
+ * The session-bound approve gate mirrors the same-origin DCR auto-approve
312
+ * gate on `/oauth/register` (#199, #200): valid session cookie + matching
313
+ * Origin/Referer = trusted operator action. Cross-origin or session-less
314
+ * GETs see the CLI-fallback message; the button never renders for them, so
315
+ * the POST handler can't be tricked into approving via a hand-crafted form
316
+ * either (CSRF token won't match).
317
+ *
318
+ * The form's `return_to` carries the original `/oauth/authorize?...` URL so
319
+ * the post-approve redirect lands the operator back on the same flow with
320
+ * the now-approved client. The POST handler validates `return_to` is a
321
+ * hub-relative path before following it (open-redirect defense).
322
+ */
323
+ function pendingClientResponse(
324
+ db: Database,
325
+ req: Request,
326
+ client: OAuthClient,
327
+ authorizeUrl: URL,
328
+ deps: OAuthDeps,
329
+ ): Response {
330
+ const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
331
+ .split(" ")
332
+ .filter((s) => s.length > 0);
333
+ const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
334
+ const sameOrigin = originMatchesIssuer(req, deps.issuer);
335
+ const csrf = ensureCsrfToken(req);
336
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
337
+ if (session && sameOrigin) {
338
+ const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
339
+ return htmlResponse(
340
+ renderApprovePending({
341
+ clientName: client.clientName ?? client.clientId,
342
+ clientId: client.clientId,
343
+ redirectUris: client.redirectUris,
344
+ requestedScopes,
345
+ approveForm: { csrfToken: csrf.token, returnTo },
346
+ }),
347
+ 403,
348
+ extra,
349
+ );
350
+ }
351
+ return htmlResponse(
352
+ renderApprovePending({
353
+ clientName: client.clientName ?? client.clientId,
354
+ clientId: client.clientId,
355
+ redirectUris: client.redirectUris,
356
+ requestedScopes,
357
+ }),
299
358
  403,
359
+ extra,
300
360
  );
301
361
  }
302
362
 
@@ -345,7 +405,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
345
405
  // matched it against a registered client. Render an HTML error.
346
406
  return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
347
407
  }
348
- if (client.status !== "approved") return pendingClientHtml();
408
+ if (client.status !== "approved") {
409
+ return pendingClientResponse(db, req, client, url, deps);
410
+ }
349
411
  try {
350
412
  requireRegisteredRedirectUri(client, parsed.redirectUri);
351
413
  } catch {
@@ -536,7 +598,18 @@ async function handleConsentSubmit(
536
598
  if (!client) {
537
599
  return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
538
600
  }
539
- if (client.status !== "approved") return pendingClientHtml();
601
+ if (client.status !== "approved") {
602
+ // Defensive: consent only renders for approved clients, so a non-approved
603
+ // status here means the row was unapproved between render and submit (or
604
+ // the form was hand-crafted). The approve UI requires a known authorize
605
+ // URL to round-trip via `return_to`, which we don't reconstruct here —
606
+ // surface the static error and let the operator restart from the SPA.
607
+ return htmlError(
608
+ "App not yet approved",
609
+ `This client_id is registered but has not been approved. Run \`parachute auth approve-client ${client.clientId}\` from a terminal, then try again.`,
610
+ 403,
611
+ );
612
+ }
540
613
  try {
541
614
  requireRegisteredRedirectUri(client, params.redirectUri);
542
615
  } catch {
@@ -602,6 +675,104 @@ async function handleConsentSubmit(
602
675
  return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
603
676
  }
604
677
 
678
+ /**
679
+ * POST /oauth/authorize/approve — operator-driven inline approval of a
680
+ * pending DCR client (closes #208). The cross-origin SPA case the
681
+ * same-origin DCR auto-approve (#199, #200) doesn't cover: an SPA on a
682
+ * different origin can't ride the cookie path during DCR, so its
683
+ * freshly-registered client_id lands `pending` and the operator hits
684
+ * "App not yet approved" on /oauth/authorize. This endpoint flips that
685
+ * client to `approved` in one click and redirects back into the OAuth flow.
686
+ *
687
+ * Three-belt security model. All three must pass:
688
+ *
689
+ * 1. Valid CSRF token (double-submit cookie). Defends against a malicious
690
+ * cross-origin POST that rides the session cookie's SameSite=Lax.
691
+ * Token was minted at GET render time and embedded in the form.
692
+ * 2. Active operator session (`findActiveSession`). The operator must be
693
+ * logged into this hub from the browser submitting the form — no
694
+ * session means no operator authority to approve anything.
695
+ * 3. Origin/Referer matches the issuer (`originMatchesIssuer`). Same
696
+ * shape as the DCR auto-approve gate (#199, #200): a same-origin POST
697
+ * proves the form was rendered by *this hub*, not a forged page.
698
+ *
699
+ * `return_to` validation: the form embeds the original authorize URL so
700
+ * the post-approve redirect lands the operator back on `/oauth/authorize`
701
+ * with the now-approved client. We refuse anything that doesn't start with
702
+ * `/oauth/authorize?` — open-redirect defense, plus a hand-crafted form
703
+ * trying to use this endpoint as a generic redirect-after-approve gadget
704
+ * shouldn't succeed at smuggling an off-path target.
705
+ */
706
+ export async function handleApproveClientPost(
707
+ db: Database,
708
+ req: Request,
709
+ deps: OAuthDeps,
710
+ ): Promise<Response> {
711
+ const form = await req.formData();
712
+ const formCsrf = form.get(CSRF_FIELD_NAME);
713
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
714
+ return htmlError(
715
+ "Invalid form submission",
716
+ "The form's CSRF token did not match. Reload the page and try again.",
717
+ 403,
718
+ );
719
+ }
720
+ const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
721
+ if (!session) {
722
+ return htmlError(
723
+ "Sign in required",
724
+ "You must be signed in to this hub to approve an app. Sign in and try again.",
725
+ 401,
726
+ );
727
+ }
728
+ if (!originMatchesIssuer(req, deps.issuer)) {
729
+ return htmlError(
730
+ "Cross-origin request rejected",
731
+ "The approve form must be submitted from this hub's own origin.",
732
+ 403,
733
+ );
734
+ }
735
+ const clientId = String(form.get("client_id") ?? "");
736
+ if (!clientId) {
737
+ return htmlError("Invalid form submission", "Missing client_id.", 400);
738
+ }
739
+ const client = getClient(db, clientId);
740
+ if (!client) {
741
+ return htmlError("Unknown application", "This client_id is not registered with this hub.", 404);
742
+ }
743
+ // Validate return_to BEFORE the DB mutation: if an authenticated operator
744
+ // submits a hand-crafted form with a bad return_to, we refuse without
745
+ // committing the client to `approved`. Practical risk is low (all three
746
+ // belts already passed), but ordering matters — validate, then mutate.
747
+ const returnTo = String(form.get("return_to") ?? "");
748
+ if (!isSafeAuthorizeReturnTo(returnTo)) {
749
+ return htmlError(
750
+ "Invalid form submission",
751
+ "The return_to value is not a hub-relative /oauth/authorize URL.",
752
+ 400,
753
+ );
754
+ }
755
+ approveClient(db, clientId);
756
+ return redirectResponse(returnTo);
757
+ }
758
+
759
+ /**
760
+ * Validate a form-submitted `return_to` value. Must be a hub-relative URL
761
+ * (no scheme, no double-slash) targeting `/oauth/authorize` with a query
762
+ * string — anything else is either an open-redirect attempt or a misuse of
763
+ * the endpoint. Empty string is rejected (the form always supplies one).
764
+ */
765
+ function isSafeAuthorizeReturnTo(value: string): boolean {
766
+ if (!value) return false;
767
+ // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
768
+ // single-slash root-relative paths are allowed.
769
+ if (!value.startsWith("/") || value.startsWith("//")) return false;
770
+ // Must target the authorize endpoint with a query string. The OAuth flow
771
+ // re-enters via GET /oauth/authorize?<original-params>; anything off-path
772
+ // is a misuse.
773
+ return value.startsWith("/oauth/authorize?");
774
+ }
775
+
605
776
  function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
606
777
  return {
607
778
  clientId: String(form.get("client_id") ?? ""),
@@ -1064,20 +1235,72 @@ interface RegisterRequestBody {
1064
1235
  token_endpoint_auth_method?: string;
1065
1236
  }
1066
1237
 
1238
+ /**
1239
+ * CSRF defense for the cookie-based DCR auto-approve path (closes #199).
1240
+ *
1241
+ * Compares the request's `Origin` (or `Referer` as fallback) against the
1242
+ * configured issuer origin. URL.origin compares scheme + host + port —
1243
+ * port-only mismatches reject. A request with neither header is treated as
1244
+ * suspicious and rejected: cookie-bearing POSTs from same-origin browsers
1245
+ * always send Origin (per Fetch standard) and almost always send Referer,
1246
+ * so a header-stripped request is more likely a curl probe or a privacy
1247
+ * extension on a third-party site than a legitimate same-origin caller.
1248
+ *
1249
+ * SameSite=Lax on the session cookie (sessions.ts:buildSessionCookie) is the
1250
+ * browser-side defense layer; this function is the server-side belt.
1251
+ */
1252
+ function originMatchesIssuer(req: Request, issuer: string): boolean {
1253
+ const origin = req.headers.get("origin");
1254
+ if (origin) {
1255
+ try {
1256
+ return new URL(origin).origin === new URL(issuer).origin;
1257
+ } catch {
1258
+ return false;
1259
+ }
1260
+ }
1261
+ const referer = req.headers.get("referer");
1262
+ if (referer) {
1263
+ try {
1264
+ return new URL(referer).origin === new URL(issuer).origin;
1265
+ } catch {
1266
+ return false;
1267
+ }
1268
+ }
1269
+ return false;
1270
+ }
1271
+
1067
1272
  /**
1068
1273
  * POST /oauth/register — RFC 7591 Dynamic Client Registration.
1069
1274
  *
1070
1275
  * Approval gate (closes #74). New rows land as `pending` by default and
1071
1276
  * cannot participate in OAuth flows until an operator runs
1072
- * `parachute auth approve-client <id>`. The single bypass is presenting an
1073
- * `Authorization: Bearer <operator-token>` whose token carries the
1074
- * `hub:admin` scope — the install-time path used by first-party modules so
1075
- * `parachute install vault` can self-register without a human follow-up.
1277
+ * `parachute auth approve-client <id>`. Two bypass paths:
1278
+ *
1279
+ * 1. **Operator-bearer** (#74). `Authorization: Bearer <operator-token>` whose
1280
+ * token carries the `hub:admin` scope — the install-time path used by
1281
+ * first-party modules so `parachute install vault` can self-register
1282
+ * without a human follow-up.
1283
+ * 2. **Operator-session** (#199). A valid `parachute_hub_session` cookie
1284
+ * plus a same-origin `Origin`/`Referer` header. The browser path: an
1285
+ * operator hitting their own SPA from their own browser is by definition
1286
+ * operator-authenticated, so re-requiring approval is friction without
1287
+ * benefit. CSRF defense is `originMatchesIssuer` + the cookie's
1288
+ * `SameSite=Lax` attribute.
1076
1289
  *
1077
1290
  * If a bearer is presented but invalid or insufficient, we reject with the
1078
1291
  * RFC 6750 shape rather than silently downgrading to the public path: a
1079
1292
  * caller who tried to authenticate but failed wants to know why, not get
1080
1293
  * `pending` back and wonder why their module can't OAuth.
1294
+ *
1295
+ * Access-control matrix:
1296
+ * no auth → pending
1297
+ * bearer (hub:admin) → approved (#74)
1298
+ * bearer (other scope) → 403 insufficient_scope
1299
+ * bearer (malformed) → 401 invalid_token
1300
+ * session cookie + same-origin → approved (#199)
1301
+ * session cookie + cross-origin → pending (CSRF defense)
1302
+ * session cookie + no Origin/Referer → pending
1303
+ * expired/unknown session → pending
1081
1304
  */
1082
1305
  export async function handleRegister(
1083
1306
  db: Database,
@@ -1124,6 +1347,20 @@ export async function handleRegister(
1124
1347
  throw err;
1125
1348
  }
1126
1349
  }
1350
+ // Operator-session auto-approve (closes #199). The browser path:
1351
+ // operator-authenticated SPA on the hub's own origin can self-register a
1352
+ // client without dropping to a terminal. Two gates: (1) a live (un-expired)
1353
+ // session row keyed by the cookie, (2) Origin/Referer matches the issuer
1354
+ // origin so a cross-site forgery can't ride the cookie. Quietly stays
1355
+ // `pending` on any failure — unlike the bearer path, we don't surface an
1356
+ // error, because absence of session/origin is the *normal* unauthenticated
1357
+ // public-DCR shape.
1358
+ if (status === "pending") {
1359
+ const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
1360
+ if (session && originMatchesIssuer(req, deps.issuer)) {
1361
+ status = "approved";
1362
+ }
1363
+ }
1127
1364
  const confidential = body.token_endpoint_auth_method === "client_secret_post";
1128
1365
  const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
1129
1366
  let registered: RegisteredClient;
package/src/oauth-ui.ts CHANGED
@@ -97,6 +97,33 @@ export interface ErrorViewProps {
97
97
  status: number;
98
98
  }
99
99
 
100
+ /**
101
+ * Props for the "App not yet approved" view rendered when an unapproved
102
+ * client lands on `/oauth/authorize`. When `session` is true the operator is
103
+ * authenticated to this hub from the browser making the request, so we render
104
+ * an inline approve form (closes #208). When false we fall back to the
105
+ * pre-#208 CLI-only message.
106
+ */
107
+ export interface ApprovePendingViewProps {
108
+ /** Display name to show — falls back to client_id when no name was supplied at DCR. */
109
+ clientName: string;
110
+ clientId: string;
111
+ redirectUris: string[];
112
+ /** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
113
+ requestedScopes: string[];
114
+ /**
115
+ * When set, render the inline approve form. The form posts to
116
+ * `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
117
+ * server will redirect to after the approve commits — the original
118
+ * `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
119
+ * approved client and lands on the consent screen.
120
+ */
121
+ approveForm?: {
122
+ csrfToken: string;
123
+ returnTo: string;
124
+ };
125
+ }
126
+
100
127
  export function renderLogin(props: LoginViewProps): string {
101
128
  const { params, errorMessage, csrfToken } = props;
102
129
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
@@ -204,6 +231,79 @@ function renderVaultPicker(picker: VaultPicker): string {
204
231
  </section>`;
205
232
  }
206
233
 
234
+ /**
235
+ * "App not yet approved" page (#74). When the request carries a valid
236
+ * operator session (#208), render the inline approve form so one click lands
237
+ * the client as `approved` and re-enters the OAuth flow at consent. Without
238
+ * a session, fall back to the original CLI-only message — anyone hitting
239
+ * /oauth/authorize unauthenticated to the hub itself can't be trusted to
240
+ * approve a DCR client from the browser, so they need to drop to a terminal
241
+ * and run `parachute auth approve-client <id>`.
242
+ *
243
+ * The CLI fallback hint is shown in BOTH branches: a button-equipped operator
244
+ * may still want the CLI invocation handy (different machine, scriptable
245
+ * context). The button is the easy path; the CLI is always-available.
246
+ */
247
+ export function renderApprovePending(props: ApprovePendingViewProps): string {
248
+ const { clientName, clientId, redirectUris, requestedScopes, approveForm } = props;
249
+ const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
250
+ const scopeRows =
251
+ requestedScopes.length === 0
252
+ ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
253
+ : requestedScopes.map(renderScopeRow).join("\n");
254
+ const formSection = approveForm
255
+ ? `
256
+ <form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
257
+ ${renderCsrfHiddenInput(approveForm.csrfToken)}
258
+ <input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
259
+ <input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
260
+ <button type="submit" class="btn btn-primary">Approve and continue</button>
261
+ </form>
262
+ <p class="approve-cli-hint">
263
+ Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
264
+ </p>`
265
+ : `
266
+ <p class="approve-cli-hint">
267
+ Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
268
+ from a terminal, then try again.
269
+ </p>`;
270
+ const body = `
271
+ <div class="card">
272
+ <div class="card-header">
273
+ <div class="brand">
274
+ <span class="brand-mark">⌬</span>
275
+ <span class="brand-name">Parachute</span>
276
+ </div>
277
+ <h1>App not yet approved</h1>
278
+ <p class="subtitle">
279
+ ${escapeHtml(clientName)} is registered with this hub but hasn't been approved yet.
280
+ Review the details below before approving.
281
+ </p>
282
+ </div>
283
+ <section class="approve-meta">
284
+ <h2 class="scopes-title">Application</h2>
285
+ <p class="approve-meta-row">
286
+ <span class="approve-meta-label">name</span>
287
+ <code class="approve-meta-value">${escapeHtml(clientName)}</code>
288
+ </p>
289
+ <p class="approve-meta-row">
290
+ <span class="approve-meta-label">client_id</span>
291
+ <code class="approve-meta-value">${escapeHtml(clientId)}</code>
292
+ </p>
293
+ <div class="approve-meta-row approve-meta-row-block">
294
+ <span class="approve-meta-label">redirect_uris</span>
295
+ <ul class="approve-redirect-list">${redirectList}</ul>
296
+ </div>
297
+ </section>
298
+ <section class="scopes">
299
+ <h2 class="scopes-title">Permissions requested</h2>
300
+ <ul class="scope-list">${scopeRows}</ul>
301
+ </section>
302
+ ${formSection}
303
+ </div>`;
304
+ return baseDocument("App not yet approved", body);
305
+ }
306
+
207
307
  export function renderError(props: ErrorViewProps): string {
208
308
  const body = `
209
309
  <div class="card">
@@ -542,6 +642,73 @@ const STYLES = `
542
642
  .vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
543
643
  .vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
544
644
 
645
+ .approve-meta {
646
+ margin: 0 0 1.25rem;
647
+ padding: 0.75rem 0.85rem;
648
+ border: 1px solid ${PALETTE.borderLight};
649
+ border-radius: 6px;
650
+ background: ${PALETTE.bgSoft};
651
+ }
652
+ .approve-meta .scopes-title { margin-bottom: 0.5rem; }
653
+ .approve-meta-row {
654
+ margin: 0 0 0.4rem;
655
+ display: flex;
656
+ gap: 0.5rem;
657
+ align-items: baseline;
658
+ flex-wrap: wrap;
659
+ }
660
+ .approve-meta-row:last-child { margin-bottom: 0; }
661
+ .approve-meta-row-block { flex-direction: column; gap: 0.25rem; }
662
+ .approve-meta-label {
663
+ text-transform: uppercase;
664
+ letter-spacing: 0.05em;
665
+ font-size: 0.7rem;
666
+ color: ${PALETTE.fgDim};
667
+ }
668
+ .approve-meta-value {
669
+ font-family: ${FONT_MONO};
670
+ font-size: 0.82rem;
671
+ background: ${PALETTE.cardBg};
672
+ padding: 0.1rem 0.4rem;
673
+ border-radius: 4px;
674
+ color: ${PALETTE.fg};
675
+ word-break: break-all;
676
+ }
677
+ .approve-redirect-list {
678
+ list-style: none;
679
+ margin: 0;
680
+ padding: 0;
681
+ display: flex;
682
+ flex-direction: column;
683
+ gap: 0.25rem;
684
+ }
685
+ .approve-redirect-list li code {
686
+ font-family: ${FONT_MONO};
687
+ font-size: 0.82rem;
688
+ background: ${PALETTE.cardBg};
689
+ padding: 0.1rem 0.4rem;
690
+ border-radius: 4px;
691
+ color: ${PALETTE.fg};
692
+ word-break: break-all;
693
+ }
694
+ .approve-form { gap: 0; }
695
+ .approve-cli-hint {
696
+ margin-top: 1rem;
697
+ padding-top: 0.85rem;
698
+ border-top: 1px solid ${PALETTE.borderLight};
699
+ color: ${PALETTE.fgMuted};
700
+ font-size: 0.85rem;
701
+ }
702
+ .approve-cli-hint code {
703
+ font-family: ${FONT_MONO};
704
+ font-size: 0.8rem;
705
+ background: ${PALETTE.bgSoft};
706
+ padding: 0.1rem 0.4rem;
707
+ border-radius: 4px;
708
+ color: ${PALETTE.fg};
709
+ word-break: break-all;
710
+ }
711
+
545
712
  .badge {
546
713
  display: inline-block;
547
714
  font-size: 0.7rem;