@openparachute/hub 0.5.13-rc.42 → 0.5.13-rc.45

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.42",
3
+ "version": "0.5.13-rc.45",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -53,7 +53,9 @@ describe("renderChromeStrip", () => {
53
53
  test("includes the inlined SVG brand mark + Parachute wordmark", () => {
54
54
  const html = renderChromeStrip({});
55
55
  expect(html).toContain("<svg");
56
- expect(html).toContain("pc-chrome-mark-clip");
56
+ // Mark is rendered via the shared `src/brand.ts` helper; the chrome strip
57
+ // uses the `"chrome-1"` id suffix to namespace its `<clipPath>` id.
58
+ expect(html).toContain("pc-brand-mark-clip-chrome-1");
57
59
  expect(html).toContain(">Parachute<");
58
60
  });
59
61
 
@@ -639,6 +639,68 @@ describe("hubFetch routing", () => {
639
639
  }
640
640
  });
641
641
 
642
+ test("/.well-known/oauth-protected-resource returns RFC 9728 metadata + CORS (closes hub#393)", async () => {
643
+ const h = makeHarness();
644
+ try {
645
+ const db = openHubDb(hubDbPath(h.dir));
646
+ try {
647
+ const res = await hubFetch(h.dir, {
648
+ getDb: () => db,
649
+ issuer: "https://hub.example",
650
+ })(req("/.well-known/oauth-protected-resource"));
651
+ expect(res.status).toBe(200);
652
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
653
+ const body = (await res.json()) as Record<string, unknown>;
654
+ expect(body.resource).toBe("https://hub.example");
655
+ expect(body.authorization_servers).toEqual(["https://hub.example"]);
656
+ expect(body.bearer_methods_supported).toEqual(["header"]);
657
+ } finally {
658
+ db.close();
659
+ }
660
+ } finally {
661
+ h.cleanup();
662
+ }
663
+ });
664
+
665
+ test("unknown path returns branded HTML 404 when Accept includes text/html (closes hub#392)", async () => {
666
+ const h = makeHarness();
667
+ try {
668
+ const r = new Request("https://hub.example/this-page-does-not-exist", {
669
+ headers: { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
670
+ });
671
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(r);
672
+ expect(res.status).toBe(404);
673
+ expect(res.headers.get("content-type")).toMatch(/text\/html/);
674
+ const body = await res.text();
675
+ expect(body).toContain("Not found");
676
+ expect(body).toContain("/this-page-does-not-exist");
677
+ expect(body).toContain("Go to hub home");
678
+ // Brand mark present so it's clearly a Parachute page
679
+ expect(body).toContain("pc-brand-mark-clip-not-found");
680
+ } finally {
681
+ h.cleanup();
682
+ }
683
+ });
684
+
685
+ test("unknown path returns plain-text 404 when client doesn't accept HTML (curl-style)", async () => {
686
+ const h = makeHarness();
687
+ try {
688
+ // curl default Accept is "*/*" — no explicit text/html.
689
+ const r = new Request("https://hub.example/this-page-does-not-exist", {
690
+ headers: { accept: "*/*" },
691
+ });
692
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(r);
693
+ expect(res.status).toBe(404);
694
+ // Plain-text fallback has no explicit content-type header — that's
695
+ // fine, it's the absence of `text/html` we care about.
696
+ const ct = res.headers.get("content-type") ?? "";
697
+ expect(ct).not.toContain("text/html");
698
+ expect(await res.text()).toBe("not found");
699
+ } finally {
700
+ h.cleanup();
701
+ }
702
+ });
703
+
642
704
  // SPA mount after hub#231: single `/admin/*` mount serves vault
643
705
  // provisioning + permissions + tokens. Pre-rename `/vault` and `/hub/*`
644
706
  // SPA URLs are 301-redirected; the per-vault content proxy at
@@ -3411,8 +3473,10 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
3411
3473
  // Signed-out cluster.
3412
3474
  expect(body).toContain("Sign in");
3413
3475
  expect(body).not.toContain("Signed in as");
3414
- // Mark + wordmark from design-system.md §2.
3415
- expect(body).toContain("pc-chrome-mark-clip");
3476
+ // Mark + wordmark from design-system.md §2. Brand mark renders via the
3477
+ // shared `src/brand.ts` helper — the SVG carries the namespaced clipPath
3478
+ // id `pc-brand-mark-clip-chrome-1`.
3479
+ expect(body).toContain("pc-brand-mark-clip-chrome-1");
3416
3480
  expect(body).toContain(">Parachute<");
3417
3481
  // Home link.
3418
3482
  expect(body).toContain('<a href="/">Home</a>');
@@ -48,6 +48,18 @@ describe("renderHub", () => {
48
48
  expect(html).toContain("prefers-color-scheme: dark");
49
49
  });
50
50
 
51
+ test("renders the canonical brand mark + wordmark + tagline in the header", () => {
52
+ // The brand mark is the shared SVG from src/brand.ts, rendered with the
53
+ // `hub-home` clipPath id suffix to keep it namespaced from the chrome
54
+ // strip's own mark (which uses `chrome-1`).
55
+ expect(html).toContain("pc-brand-mark-clip-hub-home");
56
+ // Wordmark string lives in src/brand.ts as the canonical form.
57
+ expect(html).toContain("<h1>Parachute</h1>");
58
+ // Canonical tagline (picked 2026-05-25). If this fails, src/brand.ts
59
+ // CANONICAL_TAGLINE drifted from what the home actually renders.
60
+ expect(html).toContain("Truly personal computing. Your knowledge belongs with you.");
61
+ });
62
+
51
63
  test("renders two sections: Services and Admin, each with its own heading + grid", () => {
52
64
  expect(html).toContain('id="services-section"');
53
65
  expect(html).toContain('id="admin-section"');
@@ -22,6 +22,7 @@ import {
22
22
  handleRegister,
23
23
  handleRevoke,
24
24
  handleToken,
25
+ protectedResourceMetadata,
25
26
  } from "../oauth-handlers.ts";
26
27
  import type { ServicesManifest } from "../services-manifest.ts";
27
28
  import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
@@ -151,6 +152,41 @@ describe("authorizationServerMetadata", () => {
151
152
  });
152
153
  });
153
154
 
155
+ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
156
+ test("emits the required RFC 9728 fields rooted at the issuer", async () => {
157
+ const res = protectedResourceMetadata({ issuer: ISSUER });
158
+ expect(res.status).toBe(200);
159
+ expect(res.headers.get("content-type")).toMatch(/application\/json/);
160
+ const body = (await res.json()) as Record<string, unknown>;
161
+ expect(body.resource).toBe(ISSUER);
162
+ expect(body.authorization_servers).toEqual([ISSUER]);
163
+ expect(body.bearer_methods_supported).toEqual(["header"]);
164
+ expect(Array.isArray(body.scopes_supported)).toBe(true);
165
+ expect(body.resource_documentation).toMatch(/parachute\.computer/);
166
+ });
167
+
168
+ test("scopes_supported mirrors authorizationServerMetadata after the same operator-only filter", async () => {
169
+ // Same declared-scope set as the authorizationServerMetadata test; the
170
+ // resource-server view should advertise the same shape.
171
+ const declared = new Set<string>([
172
+ "vault:read",
173
+ "vault:admin",
174
+ "hub:admin",
175
+ "parachute:host:admin",
176
+ "agent:read",
177
+ ]);
178
+ const res = protectedResourceMetadata({
179
+ issuer: ISSUER,
180
+ loadDeclaredScopes: () => declared,
181
+ });
182
+ const body = (await res.json()) as Record<string, unknown>;
183
+ const scopes = body.scopes_supported as string[];
184
+ expect(scopes).toContain("vault:read");
185
+ expect(scopes).toContain("agent:read");
186
+ expect(scopes).not.toContain("parachute:host:admin");
187
+ });
188
+ });
189
+
154
190
  describe("handleAuthorizeGet", () => {
155
191
  test("renders login form when no session cookie is present", async () => {
156
192
  const { db, cleanup } = await makeDb();
@@ -209,7 +245,9 @@ describe("handleAuthorizeGet", () => {
209
245
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
210
246
  expect(res.status).toBe(200);
211
247
  const html = await res.text();
212
- expect(html).toContain("Authorize");
248
+ // Page h1: "Approve <client>?" per design-system.md §5 verb canon
249
+ // (Workstream I, 2026-05-25 — was "Authorize <client>?").
250
+ expect(html).toContain("Approve");
213
251
  expect(html).toContain("MyApp");
214
252
  expect(html).toContain("vault:read");
215
253
  expect(html).toContain('name="__action" value="consent"');
@@ -4498,8 +4536,9 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4498
4536
  expect(reentryRes.status).toBe(200);
4499
4537
  const consentHtml = await reentryRes.text();
4500
4538
  // Consent screen markers (renderConsent uses these).
4539
+ // Page h1: "Approve <client>?" per design-system.md §5 (was "Authorize").
4501
4540
  expect(consentHtml).toContain('name="__action" value="consent"');
4502
- expect(consentHtml).toContain("Authorize");
4541
+ expect(consentHtml).toContain("Approve");
4503
4542
  expect(consentHtml).toContain("RoundTrip");
4504
4543
  } finally {
4505
4544
  cleanup();
@@ -7,6 +7,7 @@ import {
7
7
  renderError,
8
8
  renderHiddenInputs,
9
9
  renderLogin,
10
+ renderNotFoundPage,
10
11
  renderUnknownClient,
11
12
  substituteVaultDisplay,
12
13
  } from "../oauth-ui.ts";
@@ -95,7 +96,9 @@ describe("renderConsent", () => {
95
96
  clientName: "MyApp",
96
97
  scopes: ["vault:read", "vault:admin"],
97
98
  });
98
- expect(html).toContain("Authorize");
99
+ // Page h1: "Approve <client>?" per design-system.md §5 verb canon
100
+ // (Workstream I, 2026-05-25 — was "Authorize <client>?").
101
+ expect(html).toContain("Approve");
99
102
  expect(html).toContain("MyApp");
100
103
  expect(html).toContain("client-abc");
101
104
  expect(html).toContain("vault:read");
@@ -235,6 +238,32 @@ describe("renderError", () => {
235
238
  });
236
239
  });
237
240
 
241
+ describe("renderNotFoundPage (closes hub#392)", () => {
242
+ test("renders a branded full-page HTML doc with a path back to /", () => {
243
+ const html = renderNotFoundPage("/some-missing-route");
244
+ expect(html).toStartWith("<!doctype html>");
245
+ expect(html).toContain("<title>Not found");
246
+ expect(html).toContain("Not found");
247
+ // Brand mark — the operator sees this is a Parachute page, not just a generic 404
248
+ expect(html).toContain("pc-brand-mark-clip-not-found");
249
+ expect(html).toContain("Parachute");
250
+ // The CTA back to hub home
251
+ expect(html).toContain('href="/"');
252
+ expect(html).toContain("Go to hub home");
253
+ });
254
+
255
+ test("echoes + escapes the requested pathname in the message", () => {
256
+ const html = renderNotFoundPage("/this-page-does-not-exist");
257
+ expect(html).toContain("/this-page-does-not-exist");
258
+ });
259
+
260
+ test("escapes hostile pathnames", () => {
261
+ const html = renderNotFoundPage('/"><script>alert(1)</script>');
262
+ expect(html).not.toContain('"><script>alert(1)</script>');
263
+ expect(html).toContain("&lt;script&gt;");
264
+ });
265
+ });
266
+
238
267
  describe("renderUnknownClient", () => {
239
268
  test("escapes the client_id into the page", () => {
240
269
  const html = renderUnknownClient({
@@ -485,7 +514,10 @@ describe("renderApprovePending unauthenticated CTAs", () => {
485
514
  approveForm: { csrfToken: CSRF, returnTo: "/oauth/authorize?client_id=client-xyz" },
486
515
  });
487
516
  expect(html).toContain('action="/oauth/authorize/approve"');
488
- expect(html).toContain("Approve and continue");
517
+ // Workstream I, 2026-05-25 — button label "Approve and continue"
518
+ // → "Approve" per design-system.md §5 (Approve is canon; the suffix
519
+ // was verbal noise implying optionality where there is none).
520
+ expect(html).toContain(">Approve</button>");
489
521
  expect(html).not.toContain("Sign in as admin to approve");
490
522
  expect(html).not.toContain("Or send this link to your hub admin");
491
523
  expect(html).not.toContain("parachute auth approve-client");
@@ -159,6 +159,45 @@ describe("isSameOriginRequest", () => {
159
159
  const req = reqWithHeaders({ origin: "not a valid url" });
160
160
  expect(isSameOriginRequest(req, BOUND)).toBe(false);
161
161
  });
162
+
163
+ test('Origin "null" falls through to Host fallback (hub#386 — no-referrer pages)', () => {
164
+ // Browsers send the literal string "null" as Origin when the form
165
+ // POST comes from a page with a restrictive referrer policy
166
+ // (`<meta name="referrer" content="no-referrer">` on hub's OAuth
167
+ // pages), from a sandboxed iframe, or from certain cross-origin
168
+ // redirect chains. The "null" signal means "I'm intentionally not
169
+ // telling you where this came from" — not "the origin is the literal
170
+ // string null." Previously the code returned false at tier 1 for
171
+ // this case, blocking legitimate operator POSTs. Caught 2026-05-26
172
+ // on Aaron's Render deploy via the rc.40 diagnostic warn.
173
+ //
174
+ // Fix: skip tier 1 when Origin is literal "null", fall through to
175
+ // Referer/Host. Host with the public Render URL is in the bound set
176
+ // → tier 3 accepts.
177
+ const req = reqWithHeaders({
178
+ origin: "null",
179
+ host: new URL(ISSUER).host,
180
+ });
181
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
182
+ });
183
+
184
+ test('Origin "null" + Host mismatch still rejects (no security regression)', () => {
185
+ // The Origin: null fall-through doesn't weaken the defense — if the
186
+ // Host header also doesn't match a bound origin, the request is
187
+ // still rejected at tier 3.
188
+ const req = reqWithHeaders({
189
+ origin: "null",
190
+ host: "attacker.example",
191
+ });
192
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
193
+ });
194
+
195
+ test('Origin "null" + no Host header rejects (defense-fails-closed)', () => {
196
+ // If Origin is null AND no Referer AND no Host, the request has no
197
+ // useful provenance signal at all — fail closed.
198
+ const req = reqWithHeaders({ origin: "null" });
199
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
200
+ });
162
201
  });
163
202
 
164
203
  describe("Referer header (fallback when Origin is absent)", () => {
@@ -22,6 +22,7 @@
22
22
  * client-side validation is a fast-feedback layer on top of the server-
23
23
  * side `validatePassword` + match-confirm + current-≠-new checks.
24
24
  */
25
+ import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
25
26
  import { renderCsrfHiddenInput } from "./csrf.ts";
26
27
  import { escapeHtml } from "./oauth-ui.ts";
27
28
  import { PASSWORD_MIN_LEN } from "./users.ts";
@@ -81,8 +82,8 @@ ${body}
81
82
  function header(): string {
82
83
  return `
83
84
  <div class="brand">
84
- <span class="brand-mark">⌬</span>
85
- <span class="brand-name">Parachute</span>
85
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "account-pw")}</span>
86
+ <span class="brand-name">${WORDMARK_TEXT}</span>
86
87
  <span class="brand-tag">account</span>
87
88
  </div>`;
88
89
  }
@@ -261,7 +262,8 @@ const STYLES = `
261
262
  font-size: 0.95rem;
262
263
  margin-bottom: 1.25rem;
263
264
  }
264
- .brand-mark { font-size: 1.1rem; line-height: 1; }
265
+ .brand-mark { display: inline-flex; line-height: 0; }
266
+ .brand-mark svg { width: 20px; height: 20px; }
265
267
  .brand-name { letter-spacing: 0.01em; }
266
268
  .brand-tag {
267
269
  text-transform: uppercase;
@@ -12,6 +12,7 @@
12
12
  *
13
13
  * Pure functions — DB, sessions live in `admin-handlers.ts`.
14
14
  */
15
+ import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
15
16
  import { renderCsrfHiddenInput } from "./csrf.ts";
16
17
  import { escapeHtml } from "./oauth-ui.ts";
17
18
 
@@ -64,8 +65,8 @@ ${body}
64
65
  function header(): string {
65
66
  return `
66
67
  <div class="brand">
67
- <span class="brand-mark">⌬</span>
68
- <span class="brand-name">Parachute</span>
68
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "admin-login")}</span>
69
+ <span class="brand-name">${WORDMARK_TEXT}</span>
69
70
  <span class="brand-tag">admin</span>
70
71
  </div>`;
71
72
  }
@@ -159,7 +160,8 @@ const STYLES = `
159
160
  font-size: 0.95rem;
160
161
  margin-bottom: 1.25rem;
161
162
  }
162
- .brand-mark { font-size: 1.1rem; line-height: 1; }
163
+ .brand-mark { display: inline-flex; line-height: 0; }
164
+ .brand-mark svg { width: 20px; height: 20px; }
163
165
  .brand-name { letter-spacing: 0.01em; }
164
166
  .brand-tag {
165
167
  text-transform: uppercase;
package/src/brand.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Canonical brand mark + tagline + wordmark for every Parachute surface.
3
+ *
4
+ * The SVG mark + tagline live here so hub-owned surfaces (`/`, `/login`,
5
+ * `/oauth/*`, admin SPA via inline-CSS shells) and the chrome-strip
6
+ * injector (workstream G) share the same source of truth. Updating the
7
+ * mark or the tagline copy happens in ONE place; every consumer follows.
8
+ *
9
+ * Source of truth for the brand mark + tagline lives in the design-system
10
+ * pattern doc (`parachute-patterns/patterns/design-system.md` §2). This file
11
+ * is the hub's vendored copy of that mark so the chrome injector + the
12
+ * server-rendered surfaces can render without fetching anything at runtime.
13
+ * Keep this file's SVG path corpus in sync with the design-system doc; the
14
+ * doc is canonical, this is a consuming copy.
15
+ *
16
+ * Aaron picked the current tagline copy on 2026-05-25.
17
+ *
18
+ * The mark itself — 37 paths, `fill="currentColor"`, viewBox 0 0 24 24.
19
+ * Renders correctly at any text color, in light + dark mode, against
20
+ * any background. Inline rather than fetched so it works on no-Google-
21
+ * Fonts surfaces (OAuth privacy posture) + survives offline.
22
+ */
23
+
24
+ const PATHS = `<path d="M23.1599 14.9453C22.7429 14.9429 22.3775 15.2985 22.375 15.7204C22.3726 16.1374 22.7282 16.5028 23.1501 16.5053C23.567 16.5077 23.9325 16.1521 23.935 15.7302C23.9374 15.3108 23.5793 14.9478 23.1599 14.9453Z" fill="currentColor"/><path d="M15.758 22.3758C15.3435 22.3562 14.9657 22.702 14.9461 23.1214C14.9265 23.5359 15.2723 23.9137 15.6917 23.9333C16.1063 23.9529 16.484 23.6071 16.5036 23.1877C16.5232 22.7731 16.1774 22.3954 15.758 22.3758Z" fill="currentColor"/><path d="M23.1208 9.08552C23.5721 9.10024 23.9375 8.76176 23.9473 8.31291C23.9571 7.86161 23.6137 7.50351 23.1649 7.49615C22.7308 7.49124 22.3825 7.81746 22.3604 8.24668C22.3383 8.70044 22.6744 9.06835 23.1208 9.08307V9.08552Z" fill="currentColor"/><path d="M8.32678 22.3598C7.87547 22.3451 7.51002 22.6836 7.50021 23.1324C7.49039 23.5837 7.83378 23.9418 8.28263 23.9492C8.73393 23.9541 9.08712 23.6058 9.08712 23.1545C9.08712 22.7032 8.75601 22.3746 8.32678 22.3598Z" fill="currentColor"/><path d="M23.1502 12.8994C23.6113 12.9019 24.0135 12.4947 24.0013 12.0361C23.9914 11.5897 23.6039 11.2095 23.16 11.207C22.6989 11.2046 22.2966 11.6117 22.3089 12.0704C22.3187 12.5143 22.7062 12.897 23.1502 12.8994Z" fill="currentColor"/><path d="M12.9002 23.1849C12.9198 22.7459 12.5568 22.3436 12.1079 22.3068C11.6542 22.2725 11.2299 22.6551 11.2078 23.1162C11.1882 23.5553 11.5512 23.9575 12 23.9943C12.4538 24.0287 12.8781 23.646 12.9002 23.1849Z" fill="currentColor"/><path d="M19.4899 20.3568C19.9829 20.3544 20.368 19.9595 20.3582 19.464C20.3508 18.9882 19.9755 18.6129 19.4997 18.6056C19.0067 18.5982 18.6118 18.9833 18.6094 19.4763C18.6094 19.9693 18.9969 20.3593 19.4899 20.3544V20.3568Z" fill="currentColor"/><path d="M0.946568 14.8555C0.483002 14.8555 0.0881117 15.243 0.0783008 15.7066C0.0684898 16.1873 0.470738 16.5994 0.951474 16.5969C1.41504 16.5969 1.80993 16.2094 1.81974 15.7458C1.82955 15.2651 1.4273 14.853 0.946568 14.8555Z" fill="currentColor"/><path d="M15.6895 1.82027C16.1678 1.83989 16.5872 1.445 16.597 0.964263C16.6044 0.500696 16.2267 0.0984479 15.7631 0.0788261C15.2848 0.0592042 14.8654 0.454094 14.8556 0.93483C14.8482 1.3984 15.2259 1.80065 15.6895 1.82027Z" fill="currentColor"/><path d="M0.928315 9.18321C1.44829 9.19302 1.84073 8.81285 1.84073 8.29532C1.84073 7.79742 1.47037 7.41479 0.974917 7.40253C0.454937 7.39272 0.0625 7.77289 0.0625 8.29042C0.0625 8.79078 0.432863 9.17095 0.928315 9.18321Z" fill="currentColor"/><path d="M8.33104 0.0630625C7.81106 0.0458934 7.41126 0.423614 7.40636 0.938689C7.399 1.43905 7.76691 1.82658 8.25991 1.84129C8.76272 1.85601 9.15761 1.50036 9.18459 1.00982C9.21157 0.489838 8.84121 0.0777789 8.33349 0.0630625H8.33104Z" fill="currentColor"/><path d="M19.483 3.67042C18.9728 3.67042 18.5362 4.1021 18.5313 4.61227C18.524 5.11999 18.9532 5.56148 19.4634 5.57374C19.9858 5.58846 20.4445 5.1347 20.4371 4.60982C20.4298 4.09965 19.9932 3.66797 19.483 3.66797V3.67042Z" fill="currentColor"/><path d="M0.976227 11.102C0.456247 11.0849 -0.00486668 11.5411 3.87869e-05 12.0611C0.00494425 12.5663 0.441531 13.0029 0.946794 13.0029C1.45206 13.0029 1.8911 12.5737 1.90091 12.066C1.91072 11.5631 1.48394 11.1192 0.976227 11.102Z" fill="currentColor"/><path d="M12.0584 4.16361e-05C11.5531 -0.00486383 11.1116 0.424365 11.1018 0.93208C11.0895 1.45206 11.5457 1.91072 12.0657 1.90091C12.571 1.8911 13.0051 1.45206 13.0002 0.946797C12.9978 0.441534 12.5636 0.0049471 12.0584 4.16361e-05Z" fill="currentColor"/><path d="M4.65891 18.5322C4.13894 18.5077 3.67046 18.9516 3.66801 19.479C3.6631 19.9867 4.09233 20.4257 4.6025 20.438C5.11022 20.4478 5.55416 20.0259 5.57133 19.5133C5.59095 19.0081 5.16908 18.5567 4.65891 18.5322Z" fill="currentColor"/><path d="M4.58641 5.65236C5.13337 5.67443 5.62637 5.21332 5.64845 4.65654C5.67052 4.10959 5.20941 3.61659 4.65264 3.59451C4.10568 3.57244 3.61268 4.03355 3.5906 4.59032C3.56853 5.13728 4.02964 5.63028 4.58641 5.65236Z" fill="currentColor"/><path d="M19.5008 16.8099C20.1017 16.8 20.5726 16.3169 20.5677 15.7159C20.5628 15.115 20.087 14.6392 19.4836 14.6367C18.8803 14.6343 18.402 15.1077 18.3946 15.7086C18.3873 16.3267 18.8803 16.8197 19.5008 16.8074V16.8099Z" fill="currentColor"/><path d="M15.7209 20.5694C16.3218 20.5694 16.8025 20.0985 16.8099 19.4976C16.8172 18.8967 16.3488 18.411 15.7478 18.3988C15.1298 18.384 14.6318 18.8746 14.6368 19.4927C14.6417 20.0936 15.1199 20.5694 15.7209 20.5719V20.5694Z" fill="currentColor"/><path d="M9.42652 19.4702C9.41916 18.8644 8.9188 18.364 8.31298 18.3518C7.69243 18.3395 7.1651 18.8546 7.16019 19.4751C7.15529 20.0981 7.67281 20.6157 8.29581 20.6157C8.9188 20.6157 9.43388 20.0908 9.42652 19.4702Z" fill="currentColor"/><path d="M19.4553 7.16016C18.8495 7.17487 18.354 7.68259 18.3516 8.28841C18.3491 8.91141 18.8666 9.42893 19.4896 9.42403C20.1126 9.41912 20.6253 8.89669 20.6154 8.27615C20.6056 7.65316 20.0734 7.14544 19.4553 7.16261V7.16016Z" fill="currentColor"/><path d="M15.7219 5.79748C16.3817 5.79748 16.9115 5.26034 16.8993 4.60055C16.887 3.95793 16.3695 3.44531 15.7244 3.44531C15.0793 3.44531 14.5348 3.98246 14.5471 4.64225C14.5593 5.28732 15.0793 5.79748 15.7219 5.79748Z" fill="currentColor"/><path d="M4.63052 16.9006C5.27559 16.8957 5.78821 16.3806 5.79557 15.738C5.80292 15.0782 5.27068 14.5435 4.6109 14.5509C3.94866 14.5582 3.42623 15.0978 3.44585 15.7576C3.46302 16.4002 3.9879 16.9055 4.63052 16.9006Z" fill="currentColor"/><path d="M12.0637 20.6756C12.7088 20.6683 13.2533 20.1115 13.246 19.4714C13.2386 18.8263 12.6818 18.2818 12.0417 18.2891C11.3966 18.2965 10.8521 18.8533 10.8594 19.4934C10.8668 20.1385 11.4211 20.683 12.0637 20.6756Z" fill="currentColor"/><path d="M19.4762 10.8594C18.8312 10.8618 18.2842 11.4137 18.2891 12.0563C18.2915 12.7014 18.8434 13.2483 19.486 13.2434C20.1311 13.241 20.6781 12.6891 20.6732 12.0465C20.6682 11.4039 20.1188 10.8569 19.4762 10.8594Z" fill="currentColor"/><path d="M8.31147 5.84627C8.98106 5.83645 9.52067 5.28459 9.51576 4.61499C9.51085 3.9454 8.9639 3.40089 8.29675 3.39844C7.62716 3.39844 7.07774 3.93804 7.07038 4.60764C7.06303 5.2944 7.6247 5.85362 8.31147 5.84627Z" fill="currentColor"/><path d="M4.64934 7.0706C3.96257 7.05588 3.39599 7.6102 3.39845 8.29942C3.39845 8.96902 3.94541 9.51597 4.615 9.51843C5.2846 9.52088 5.83646 8.98128 5.84382 8.31168C5.85118 7.64209 5.31648 7.08532 4.64689 7.0706H4.64934Z" fill="currentColor"/><path d="M12.0484 5.91679C12.7376 5.92169 13.3312 5.34285 13.3508 4.64873C13.3704 3.94479 12.7671 3.32916 12.0607 3.32425C11.3715 3.31934 10.7779 3.89819 10.7583 4.59231C10.7387 5.29625 11.3396 5.91434 12.0484 5.91679Z" fill="currentColor"/><path d="M4.58021 13.3473C5.28169 13.3743 5.90469 12.7783 5.91695 12.0695C5.92921 11.3827 5.35528 10.7818 4.66115 10.7548C3.95967 10.7278 3.33668 11.3238 3.32441 12.0327C3.31215 12.7194 3.88609 13.3203 4.58021 13.3473Z" fill="currentColor"/><path d="M15.7193 14.3359C14.9687 14.3359 14.3335 14.9761 14.3359 15.7266C14.3359 16.4772 14.9761 17.1124 15.7266 17.11C16.4772 17.11 17.1124 16.4698 17.11 15.7193C17.1075 14.9687 16.4698 14.3335 15.7193 14.3359Z" fill="currentColor"/><path d="M15.7407 9.73609C16.5428 9.72628 17.1756 9.0763 17.1658 8.27671C17.156 7.47712 16.506 6.84186 15.7064 6.85167C14.9068 6.86149 14.2716 7.51146 14.2814 8.31105C14.2912 9.11064 14.9411 9.7459 15.7407 9.73609Z" fill="currentColor"/><path d="M8.2987 14.2813C7.50156 14.2764 6.8565 14.9165 6.85159 15.7161C6.84669 16.5133 7.48685 17.1583 8.28644 17.1632C9.08358 17.1681 9.72865 16.528 9.73355 15.7284C9.73601 14.9313 9.09584 14.2862 8.2987 14.2813Z" fill="currentColor"/><path d="M8.2854 9.79467C9.12669 9.79712 9.78647 9.15696 9.79874 8.32057C9.811 7.45967 9.15857 6.79007 8.30257 6.78516C7.46128 6.78271 6.8015 7.42533 6.78923 8.25926C6.77697 9.12017 7.4294 9.78976 8.2854 9.79467Z" fill="currentColor"/><path d="M15.7268 10.5156C14.8757 10.5156 14.1644 11.2343 14.184 12.0829C14.2036 12.9242 14.9075 13.6061 15.7415 13.5914C16.5803 13.5766 17.2671 12.8801 17.2622 12.0461C17.2573 11.2097 16.5631 10.5181 15.7268 10.5156Z" fill="currentColor"/><path d="M12.0588 14.1836C11.2077 14.1787 10.4964 14.8998 10.516 15.7485C10.5356 16.5897 11.2371 17.2716 12.0686 17.2593C12.9074 17.2471 13.5942 16.553 13.5917 15.7166C13.5893 14.8802 12.8976 14.1885 12.0612 14.1836H12.0588Z" fill="currentColor"/><path d="M12.0397 6.66802C11.1568 6.67538 10.4356 7.39894 10.4258 8.28192C10.4185 9.17717 11.1666 9.92525 12.0618 9.91789C12.9448 9.91054 13.6659 9.18698 13.6757 8.304C13.6831 7.40875 12.935 6.66066 12.0397 6.66802Z" fill="currentColor"/><path d="M8.29197 13.6757C9.1725 13.6757 9.90096 12.9619 9.91813 12.074C9.9353 11.1812 9.19212 10.4282 8.29442 10.4258C7.41389 10.4258 6.68543 11.1395 6.66826 12.0274C6.65109 12.9202 7.39427 13.6732 8.29197 13.6757Z" fill="currentColor"/><path d="M12.0638 10.2891C11.068 10.2842 10.2905 11.0568 10.293 12.0526C10.293 13.0288 11.0533 13.8014 12.0222 13.8137C13.0204 13.8259 13.8077 13.0631 13.8151 12.0722C13.8225 11.074 13.0548 10.294 12.0638 10.2891Z" fill="currentColor"/>`;
25
+
26
+ /**
27
+ * Render the canonical brand mark as an inline SVG string.
28
+ *
29
+ * - `size`: width + height in CSS pixels (24 is the design system's
30
+ * default "in-line with text" size; the chrome strip uses 16; a hero
31
+ * surface might use 32 or 48).
32
+ * - `idSuffix`: appended to the SVG's internal clipPath id so multiple
33
+ * marks can coexist on the same page (one document = one DOM id
34
+ * namespace). The chrome strip uses `"chrome-1"`; hub-owned surfaces
35
+ * can use `"home"`, `"login"`, etc.
36
+ *
37
+ * Pass any size; the viewBox handles the rest. Color follows
38
+ * `currentColor`, so wrap the SVG (or its container) with a `color`
39
+ * style or text-color class to tint the mark.
40
+ */
41
+ export function brandMarkSvg(size: number, idSuffix: string): string {
42
+ const clipId = `pc-brand-mark-clip-${idSuffix}`;
43
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g clip-path="url(#${clipId})">${PATHS}</g><defs><clipPath id="${clipId}"><rect width="24" height="24" fill="white"/></clipPath></defs></svg>`;
44
+ }
45
+
46
+ /**
47
+ * The canonical Parachute tagline (Aaron 2026-05-25). Two short clauses:
48
+ * the stance, then what makes the computing personal.
49
+ *
50
+ * Compact form (one clause) for ultra-tight surfaces — favicons, 32px
51
+ * brand strips, nav bars where the second clause doesn't fit. Otherwise
52
+ * always use the full form.
53
+ */
54
+ export const CANONICAL_TAGLINE = "Truly personal computing. Your knowledge belongs with you.";
55
+ export const CANONICAL_TAGLINE_COMPACT = "Truly personal computing.";
56
+
57
+ /**
58
+ * The wordmark — bare text "Parachute" set in Instrument Serif (or the
59
+ * system-serif fallback on no-Google-Fonts surfaces). Consumers wrap
60
+ * this in a serif-styled element. No graphic asset; the typography
61
+ * carries the wordmark per design-system.md §2.
62
+ */
63
+ export const WORDMARK_TEXT = "Parachute";
@@ -38,6 +38,7 @@
38
38
  * defense is cheap and protects future refactors).
39
39
  */
40
40
 
41
+ import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
41
42
  import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
42
43
 
43
44
  /**
@@ -60,12 +61,16 @@ export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/app/notes/"];
60
61
  export const MAX_INJECT_SIZE_BYTES = 256 * 1024;
61
62
 
62
63
  /**
63
- * The 16×16 SVG brand mark (workstream G uses the chrome-nav recommended
64
- * size). Sourced verbatim from `parachute-patterns/patterns/design-system.md`
65
- * §2 `fill="currentColor"` so the mark inherits the surrounding text
66
- * color and renders correctly in dark mode.
64
+ * The 16×16 SVG brand mark for the chrome nav. Rendered via the shared
65
+ * `./brand.ts` so hub home, login, OAuth surfaces, and this chrome strip
66
+ * all draw from one source. The clipId suffix `"chrome-1"` keeps this
67
+ * mark's internal `<clipPath>` id distinct from any other mark on the
68
+ * same document (e.g. a hub-owned surface rendering its own mark in its
69
+ * header AND chrome injected over the top — they deduplicate via the
70
+ * `pc-chrome` class check today, but the unique-id discipline is cheap
71
+ * insurance against future refactors).
67
72
  */
68
- const BRAND_MARK_SVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g clip-path="url(#pc-chrome-mark-clip-1)"><path d="M23.1599 14.9453C22.7429 14.9429 22.3775 15.2985 22.375 15.7204C22.3726 16.1374 22.7282 16.5028 23.1501 16.5053C23.567 16.5077 23.9325 16.1521 23.935 15.7302C23.9374 15.3108 23.5793 14.9478 23.1599 14.9453Z" fill="currentColor"/><path d="M15.758 22.3758C15.3435 22.3562 14.9657 22.702 14.9461 23.1214C14.9265 23.5359 15.2723 23.9137 15.6917 23.9333C16.1063 23.9529 16.484 23.6071 16.5036 23.1877C16.5232 22.7731 16.1774 22.3954 15.758 22.3758Z" fill="currentColor"/><path d="M23.1208 9.08552C23.5721 9.10024 23.9375 8.76176 23.9473 8.31291C23.9571 7.86161 23.6137 7.50351 23.1649 7.49615C22.7308 7.49124 22.3825 7.81746 22.3604 8.24668C22.3383 8.70044 22.6744 9.06835 23.1208 9.08307V9.08552Z" fill="currentColor"/><path d="M8.32678 22.3598C7.87547 22.3451 7.51002 22.6836 7.50021 23.1324C7.49039 23.5837 7.83378 23.9418 8.28263 23.9492C8.73393 23.9541 9.08712 23.6058 9.08712 23.1545C9.08712 22.7032 8.75601 22.3746 8.32678 22.3598Z" fill="currentColor"/><path d="M23.1502 12.8994C23.6113 12.9019 24.0135 12.4947 24.0013 12.0361C23.9914 11.5897 23.6039 11.2095 23.16 11.207C22.6989 11.2046 22.2966 11.6117 22.3089 12.0704C22.3187 12.5143 22.7062 12.897 23.1502 12.8994Z" fill="currentColor"/><path d="M12.9002 23.1849C12.9198 22.7459 12.5568 22.3436 12.1079 22.3068C11.6542 22.2725 11.2299 22.6551 11.2078 23.1162C11.1882 23.5553 11.5512 23.9575 12 23.9943C12.4538 24.0287 12.8781 23.646 12.9002 23.1849Z" fill="currentColor"/><path d="M19.4899 20.3568C19.9829 20.3544 20.368 19.9595 20.3582 19.464C20.3508 18.9882 19.9755 18.6129 19.4997 18.6056C19.0067 18.5982 18.6118 18.9833 18.6094 19.4763C18.6094 19.9693 18.9969 20.3593 19.4899 20.3544V20.3568Z" fill="currentColor"/><path d="M0.946568 14.8555C0.483002 14.8555 0.0881117 15.243 0.0783008 15.7066C0.0684898 16.1873 0.470738 16.5994 0.951474 16.5969C1.41504 16.5969 1.80993 16.2094 1.81974 15.7458C1.82955 15.2651 1.4273 14.853 0.946568 14.8555Z" fill="currentColor"/><path d="M15.6895 1.82027C16.1678 1.83989 16.5872 1.445 16.597 0.964263C16.6044 0.500696 16.2267 0.0984479 15.7631 0.0788261C15.2848 0.0592042 14.8654 0.454094 14.8556 0.93483C14.8482 1.3984 15.2259 1.80065 15.6895 1.82027Z" fill="currentColor"/><path d="M0.928315 9.18321C1.44829 9.19302 1.84073 8.81285 1.84073 8.29532C1.84073 7.79742 1.47037 7.41479 0.974917 7.40253C0.454937 7.39272 0.0625 7.77289 0.0625 8.29042C0.0625 8.79078 0.432863 9.17095 0.928315 9.18321Z" fill="currentColor"/><path d="M8.33104 0.0630625C7.81106 0.0458934 7.41126 0.423614 7.40636 0.938689C7.399 1.43905 7.76691 1.82658 8.25991 1.84129C8.76272 1.85601 9.15761 1.50036 9.18459 1.00982C9.21157 0.489838 8.84121 0.0777789 8.33349 0.0630625H8.33104Z" fill="currentColor"/><path d="M19.483 3.67042C18.9728 3.67042 18.5362 4.1021 18.5313 4.61227C18.524 5.11999 18.9532 5.56148 19.4634 5.57374C19.9858 5.58846 20.4445 5.1347 20.4371 4.60982C20.4298 4.09965 19.9932 3.66797 19.483 3.66797V3.67042Z" fill="currentColor"/><path d="M0.976227 11.102C0.456247 11.0849 -0.00486668 11.5411 3.87869e-05 12.0611C0.00494425 12.5663 0.441531 13.0029 0.946794 13.0029C1.45206 13.0029 1.8911 12.5737 1.90091 12.066C1.91072 11.5631 1.48394 11.1192 0.976227 11.102Z" fill="currentColor"/><path d="M12.0584 4.16361e-05C11.5531 -0.00486383 11.1116 0.424365 11.1018 0.93208C11.0895 1.45206 11.5457 1.91072 12.0657 1.90091C12.571 1.8911 13.0051 1.45206 13.0002 0.946797C12.9978 0.441534 12.5636 0.0049471 12.0584 4.16361e-05Z" fill="currentColor"/><path d="M4.65891 18.5322C4.13894 18.5077 3.67046 18.9516 3.66801 19.479C3.6631 19.9867 4.09233 20.4257 4.6025 20.438C5.11022 20.4478 5.55416 20.0259 5.57133 19.5133C5.59095 19.0081 5.16908 18.5567 4.65891 18.5322Z" fill="currentColor"/><path d="M4.58641 5.65236C5.13337 5.67443 5.62637 5.21332 5.64845 4.65654C5.67052 4.10959 5.20941 3.61659 4.65264 3.59451C4.10568 3.57244 3.61268 4.03355 3.5906 4.59032C3.56853 5.13728 4.02964 5.63028 4.58641 5.65236Z" fill="currentColor"/><path d="M19.5008 16.8099C20.1017 16.8 20.5726 16.3169 20.5677 15.7159C20.5628 15.115 20.087 14.6392 19.4836 14.6367C18.8803 14.6343 18.402 15.1077 18.3946 15.7086C18.3873 16.3267 18.8803 16.8197 19.5008 16.8074V16.8099Z" fill="currentColor"/><path d="M15.7209 20.5694C16.3218 20.5694 16.8025 20.0985 16.8099 19.4976C16.8172 18.8967 16.3488 18.411 15.7478 18.3988C15.1298 18.384 14.6318 18.8746 14.6368 19.4927C14.6417 20.0936 15.1199 20.5694 15.7209 20.5719V20.5694Z" fill="currentColor"/><path d="M9.42652 19.4702C9.41916 18.8644 8.9188 18.364 8.31298 18.3518C7.69243 18.3395 7.1651 18.8546 7.16019 19.4751C7.15529 20.0981 7.67281 20.6157 8.29581 20.6157C8.9188 20.6157 9.43388 20.0908 9.42652 19.4702Z" fill="currentColor"/><path d="M19.4553 7.16016C18.8495 7.17487 18.354 7.68259 18.3516 8.28841C18.3491 8.91141 18.8666 9.42893 19.4896 9.42403C20.1126 9.41912 20.6253 8.89669 20.6154 8.27615C20.6056 7.65316 20.0734 7.14544 19.4553 7.16261V7.16016Z" fill="currentColor"/><path d="M15.7219 5.79748C16.3817 5.79748 16.9115 5.26034 16.8993 4.60055C16.887 3.95793 16.3695 3.44531 15.7244 3.44531C15.0793 3.44531 14.5348 3.98246 14.5471 4.64225C14.5593 5.28732 15.0793 5.79748 15.7219 5.79748Z" fill="currentColor"/><path d="M4.63052 16.9006C5.27559 16.8957 5.78821 16.3806 5.79557 15.738C5.80292 15.0782 5.27068 14.5435 4.6109 14.5509C3.94866 14.5582 3.42623 15.0978 3.44585 15.7576C3.46302 16.4002 3.9879 16.9055 4.63052 16.9006Z" fill="currentColor"/><path d="M12.0637 20.6756C12.7088 20.6683 13.2533 20.1115 13.246 19.4714C13.2386 18.8263 12.6818 18.2818 12.0417 18.2891C11.3966 18.2965 10.8521 18.8533 10.8594 19.4934C10.8668 20.1385 11.4211 20.683 12.0637 20.6756Z" fill="currentColor"/><path d="M19.4762 10.8594C18.8312 10.8618 18.2842 11.4137 18.2891 12.0563C18.2915 12.7014 18.8434 13.2483 19.486 13.2434C20.1311 13.241 20.6781 12.6891 20.6732 12.0465C20.6682 11.4039 20.1188 10.8569 19.4762 10.8594Z" fill="currentColor"/><path d="M8.31147 5.84627C8.98106 5.83645 9.52067 5.28459 9.51576 4.61499C9.51085 3.9454 8.9639 3.40089 8.29675 3.39844C7.62716 3.39844 7.07774 3.93804 7.07038 4.60764C7.06303 5.2944 7.6247 5.85362 8.31147 5.84627Z" fill="currentColor"/><path d="M4.64934 7.0706C3.96257 7.05588 3.39599 7.6102 3.39845 8.29942C3.39845 8.96902 3.94541 9.51597 4.615 9.51843C5.2846 9.52088 5.83646 8.98128 5.84382 8.31168C5.85118 7.64209 5.31648 7.08532 4.64689 7.0706H4.64934Z" fill="currentColor"/><path d="M12.0484 5.91679C12.7376 5.92169 13.3312 5.34285 13.3508 4.64873C13.3704 3.94479 12.7671 3.32916 12.0607 3.32425C11.3715 3.31934 10.7779 3.89819 10.7583 4.59231C10.7387 5.29625 11.3396 5.91434 12.0484 5.91679Z" fill="currentColor"/><path d="M4.58021 13.3473C5.28169 13.3743 5.90469 12.7783 5.91695 12.0695C5.92921 11.3827 5.35528 10.7818 4.66115 10.7548C3.95967 10.7278 3.33668 11.3238 3.32441 12.0327C3.31215 12.7194 3.88609 13.3203 4.58021 13.3473Z" fill="currentColor"/><path d="M15.7193 14.3359C14.9687 14.3359 14.3335 14.9761 14.3359 15.7266C14.3359 16.4772 14.9761 17.1124 15.7266 17.11C16.4772 17.11 17.1124 16.4698 17.11 15.7193C17.1075 14.9687 16.4698 14.3335 15.7193 14.3359Z" fill="currentColor"/><path d="M15.7407 9.73609C16.5428 9.72628 17.1756 9.0763 17.1658 8.27671C17.156 7.47712 16.506 6.84186 15.7064 6.85167C14.9068 6.86149 14.2716 7.51146 14.2814 8.31105C14.2912 9.11064 14.9411 9.7459 15.7407 9.73609Z" fill="currentColor"/><path d="M8.2987 14.2813C7.50156 14.2764 6.8565 14.9165 6.85159 15.7161C6.84669 16.5133 7.48685 17.1583 8.28644 17.1632C9.08358 17.1681 9.72865 16.528 9.73355 15.7284C9.73601 14.9313 9.09584 14.2862 8.2987 14.2813Z" fill="currentColor"/><path d="M8.2854 9.79467C9.12669 9.79712 9.78647 9.15696 9.79874 8.32057C9.811 7.45967 9.15857 6.79007 8.30257 6.78516C7.46128 6.78271 6.8015 7.42533 6.78923 8.25926C6.77697 9.12017 7.4294 9.78976 8.2854 9.79467Z" fill="currentColor"/><path d="M15.7268 10.5156C14.8757 10.5156 14.1644 11.2343 14.184 12.0829C14.2036 12.9242 14.9075 13.6061 15.7415 13.5914C16.5803 13.5766 17.2671 12.8801 17.2622 12.0461C17.2573 11.2097 16.5631 10.5181 15.7268 10.5156Z" fill="currentColor"/><path d="M12.0588 14.1836C11.2077 14.1787 10.4964 14.8998 10.516 15.7485C10.5356 16.5897 11.2371 17.2716 12.0686 17.2593C12.9074 17.2471 13.5942 16.553 13.5917 15.7166C13.5893 14.8802 12.8976 14.1885 12.0612 14.1836H12.0588Z" fill="currentColor"/><path d="M12.0397 6.66802C11.1568 6.67538 10.4356 7.39894 10.4258 8.28192C10.4185 9.17717 11.1666 9.92525 12.0618 9.91789C12.9448 9.91054 13.6659 9.18698 13.6757 8.304C13.6831 7.40875 12.935 6.66066 12.0397 6.66802Z" fill="currentColor"/><path d="M8.29197 13.6757C9.1725 13.6757 9.90096 12.9619 9.91813 12.074C9.9353 11.1812 9.19212 10.4282 8.29442 10.4258C7.41389 10.4258 6.68543 11.1395 6.66826 12.0274C6.65109 12.9202 7.39427 13.6732 8.29197 13.6757Z" fill="currentColor"/><path d="M12.0638 10.2891C11.068 10.2842 10.2905 11.0568 10.293 12.0526C10.293 13.0288 11.0533 13.8014 12.0222 13.8137C13.0204 13.8259 13.8077 13.0631 13.8151 12.0722C13.8225 11.074 13.0548 10.294 12.0638 10.2891Z" fill="currentColor"/></g><defs><clipPath id="pc-chrome-mark-clip-1"><rect width="24" height="24" fill="white"/></clipPath></defs></svg>`;
73
+ const BRAND_MARK_SVG = brandMarkSvg(16, "chrome-1");
69
74
 
70
75
  /**
71
76
  * Canonical CSS for the chrome strip + token shim. Inlined into a single
@@ -186,7 +191,7 @@ export function renderChromeStrip(opts: ChromeStripOptions): string {
186
191
  opts.displayName && opts.csrfToken
187
192
  ? renderSignedInCluster(opts.displayName, opts.csrfToken)
188
193
  : renderSignedOutCluster(opts.nextPath ?? "/");
189
- return `<style>${CHROME_STYLE}</style><header class="pc-chrome" role="banner"><a href="/" class="pc-chrome-brand"><span class="pc-chrome-mark">${BRAND_MARK_SVG}</span><span class="pc-chrome-wordmark">Parachute</span></a><nav class="pc-chrome-nav" aria-label="primary"><a href="/">Home</a></nav><div class="pc-chrome-auth">${authCluster}</div></header>`;
194
+ return `<style>${CHROME_STYLE}</style><header class="pc-chrome" role="banner"><a href="/" class="pc-chrome-brand"><span class="pc-chrome-mark">${BRAND_MARK_SVG}</span><span class="pc-chrome-wordmark">${WORDMARK_TEXT}</span></a><nav class="pc-chrome-nav" aria-label="primary"><a href="/">Home</a></nav><div class="pc-chrome-auth">${authCluster}</div></header>`;
190
195
  }
191
196
 
192
197
  function renderSignedInCluster(displayName: string, csrfToken: string): string {
@@ -287,6 +287,12 @@ export async function serve(opts: ServeOpts = {}): Promise<{
287
287
  const server = Bun.serve({
288
288
  port,
289
289
  hostname,
290
+ // Hold idle keep-alive connections for Bun's maximum 255s so reverse-
291
+ // proxy edges (Render, Cloudflare, fly.io) don't race us when reusing
292
+ // pooled connections. See `src/hub-server.ts` for the full rationale —
293
+ // this is the active code path for `bun src/cli.ts serve` (the Docker
294
+ // CMD), so the fix has to land here too. Closes hub#399.
295
+ idleTimeout: 255,
290
296
  fetch: hubFetch(WELL_KNOWN_DIR, {
291
297
  getDb: () => db,
292
298
  issuer,
package/src/hub-server.ts CHANGED
@@ -165,7 +165,9 @@ import {
165
165
  handleRegister,
166
166
  handleRevoke,
167
167
  handleToken,
168
+ protectedResourceMetadata,
168
169
  } from "./oauth-handlers.ts";
170
+ import { renderNotFoundPage } from "./oauth-ui.ts";
169
171
  import { buildHubBoundOrigins } from "./origin-check.ts";
170
172
  import { clearPid, writePid } from "./process-state.ts";
171
173
  import { isHttpsRequest } from "./request-protocol.ts";
@@ -1512,6 +1514,23 @@ export function hubFetch(
1512
1514
  return new Response(res.body, { status: res.status, headers: merged });
1513
1515
  }
1514
1516
 
1517
+ if (pathname === "/.well-known/oauth-protected-resource") {
1518
+ // RFC 9728 — companion to oauth-authorization-server. MCP clients
1519
+ // (since 2025-06-18 spec) probe this to discover scopes + the
1520
+ // authorization server. Same wildcard CORS shape. Closes hub#393.
1521
+ const corsHeaders = {
1522
+ "access-control-allow-origin": "*",
1523
+ "access-control-allow-methods": "GET, OPTIONS",
1524
+ };
1525
+ if (req.method === "OPTIONS") {
1526
+ return new Response(null, { status: 204, headers: corsHeaders });
1527
+ }
1528
+ const res = protectedResourceMetadata(oauthDeps(req));
1529
+ const merged = new Headers(res.headers);
1530
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
1531
+ return new Response(res.body, { status: res.status, headers: merged });
1532
+ }
1533
+
1515
1534
  // OAuth surface — every handler return is wrapped in `applyCorsHeaders`
1516
1535
  // so third-party SPAs can fetch these endpoints cross-origin (the entire
1517
1536
  // point of OAuth DCR: arbitrary SPAs register → authorize → exchange
@@ -1953,6 +1972,18 @@ export function hubFetch(
1953
1972
  const proxied = await proxyToService(req, manifestPath);
1954
1973
  if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
1955
1974
 
1975
+ // Branded fall-through 404 (closes hub#392) — the operator who mistyped
1976
+ // a URL sees a clear "not found" page with a path back home, not the
1977
+ // browser's default empty-body chrome. Only HTML clients get the
1978
+ // rendered page; non-HTML callers (curl, API probes) still see the
1979
+ // shorter "not found" text so log noise stays low.
1980
+ const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
1981
+ if (wantsHtml) {
1982
+ return new Response(renderNotFoundPage(pathname), {
1983
+ status: 404,
1984
+ headers: { "content-type": "text/html; charset=utf-8" },
1985
+ });
1986
+ }
1956
1987
  return new Response("not found", { status: 404 });
1957
1988
  };
1958
1989
  }
@@ -2021,6 +2052,18 @@ if (import.meta.main) {
2021
2052
  Bun.serve({
2022
2053
  port,
2023
2054
  hostname,
2055
+ // Hold idle connections open for 255 seconds (Bun's max) instead of
2056
+ // the 10-second default. When the hub sits behind a reverse-proxy edge
2057
+ // that pools keep-alive connections (Render, Cloudflare, fly proxy,
2058
+ // etc.), the edge's idle timeout is longer than our default — so the
2059
+ // proxy reaches into the pool, sends a request on a connection we
2060
+ // just closed, and returns 502 (Bad Gateway) to the client. The bug
2061
+ // is invisible to us (no log, no restart, deployStatus=live) and
2062
+ // manifests as a 5–15% "random" 502 rate under steady probing.
2063
+ // Canonical fix on Node is `keepAliveTimeout > edge.idle_timeout`;
2064
+ // Bun's equivalent is this. 255s comfortably exceeds Render's edge
2065
+ // pool TTL (community-observed ~120s). Closes hub#399.
2066
+ idleTimeout: 255,
2024
2067
  fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
2025
2068
  });
2026
2069
  // Register PID + port from the running hub itself so any startup path
package/src/hub.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { brandMarkSvg, CANONICAL_TAGLINE, WORDMARK_TEXT } from "./brand.ts";
3
4
  import { CONFIG_DIR } from "./config.ts";
4
5
  import { CSRF_FIELD_NAME } from "./csrf.ts";
5
6
 
@@ -221,6 +222,15 @@ const HTML_TEMPLATE = `<!doctype html>
221
222
  color/decoration. */
222
223
  border-bottom: none;
223
224
  }
225
+ .brand-mark {
226
+ display: inline-flex;
227
+ color: var(--accent);
228
+ margin: 0 0 1.25rem;
229
+ }
230
+ .brand-mark svg {
231
+ width: clamp(48px, 7vw, 64px);
232
+ height: auto;
233
+ }
224
234
  h1 {
225
235
  font-family: var(--serif);
226
236
  font-weight: 400;
@@ -232,7 +242,9 @@ const HTML_TEMPLATE = `<!doctype html>
232
242
  .tagline {
233
243
  color: var(--fg-muted);
234
244
  font-size: 1.1rem;
245
+ line-height: 1.5;
235
246
  margin: 0;
247
+ max-width: 28rem;
236
248
  }
237
249
  .section {
238
250
  margin-bottom: 3rem;
@@ -355,8 +367,9 @@ const HTML_TEMPLATE = `<!doctype html>
355
367
  <main>
356
368
  <header>
357
369
  <!--AUTH-INDICATOR-->
358
- <h1>Parachute</h1>
359
- <p class="tagline">Your personal-computing modules.</p>
370
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(56, "hub-home")}</span>
371
+ <h1>${WORDMARK_TEXT}</h1>
372
+ <p class="tagline">${CANONICAL_TAGLINE}</p>
360
373
  </header>
361
374
 
362
375
  <section class="section" id="get-started-section" hidden>