@openparachute/hub 0.5.13-rc.43 → 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 +1 -1
- package/src/__tests__/chrome-strip.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +66 -2
- package/src/__tests__/hub.test.ts +12 -0
- package/src/__tests__/oauth-handlers.test.ts +36 -0
- package/src/__tests__/oauth-ui.test.ts +27 -0
- package/src/__tests__/origin-check.test.ts +39 -0
- package/src/account-change-password-ui.ts +5 -3
- package/src/admin-login-ui.ts +5 -3
- package/src/brand.ts +63 -0
- package/src/chrome-strip.ts +11 -6
- package/src/commands/serve.ts +6 -0
- package/src/hub-server.ts +43 -0
- package/src/hub.ts +15 -2
- package/src/oauth-handlers.ts +42 -0
- package/src/oauth-ui.ts +57 -11
- package/src/origin-check.ts +15 -1
- package/src/setup-wizard.ts +5 -3
- package/web/ui/dist/assets/index-7DtAXz7y.css +1 -0
- package/web/ui/dist/assets/index-Dzrbe6EP.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CGPyOfGK.css +0 -1
- package/web/ui/dist/assets/index-DNTukKZw.js +0 -61
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -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";
|
|
@@ -237,6 +238,32 @@ describe("renderError", () => {
|
|
|
237
238
|
});
|
|
238
239
|
});
|
|
239
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("<script>");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
240
267
|
describe("renderUnknownClient", () => {
|
|
241
268
|
test("escapes the client_id into the page", () => {
|
|
242
269
|
const html = renderUnknownClient({
|
|
@@ -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"
|
|
85
|
-
<span class="brand-name"
|
|
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 {
|
|
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;
|
package/src/admin-login-ui.ts
CHANGED
|
@@ -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"
|
|
68
|
-
<span class="brand-name"
|
|
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 {
|
|
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";
|
package/src/chrome-strip.ts
CHANGED
|
@@ -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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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 =
|
|
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"
|
|
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 {
|
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
-
<
|
|
359
|
-
<
|
|
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>
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -348,6 +348,48 @@ function oauthErrorRedirect(
|
|
|
348
348
|
return redirectResponse(u.toString());
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
// --- /.well-known/oauth-protected-resource ---------------------------------
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* RFC 9728 (April 2025) — OAuth 2.0 Protected Resource Metadata. The
|
|
355
|
+
* resource-server-side companion to `authorizationServerMetadata`. MCP
|
|
356
|
+
* clients (since the 2025-06-18 spec draft) probe this endpoint to
|
|
357
|
+
* discover which authorization server signs tokens for the resource,
|
|
358
|
+
* which scopes the resource accepts, and how tokens are presented.
|
|
359
|
+
*
|
|
360
|
+
* Hub-as-resource posture: hub itself is the protected resource. The
|
|
361
|
+
* authorization server is also hub (the issuer). The advertised scopes
|
|
362
|
+
* mirror `authorizationServerMetadata.scopes_supported` — same set, same
|
|
363
|
+
* filtering (operator-only scopes hidden per RFC 8414 §2 framing).
|
|
364
|
+
*
|
|
365
|
+
* Per-vault metadata could also live at
|
|
366
|
+
* `/vault/<name>/.well-known/oauth-protected-resource` to scope the
|
|
367
|
+
* advertised resource indicator and scope subset to one vault. Deferred
|
|
368
|
+
* until an MCP client actually probes that path — today the spec
|
|
369
|
+
* accepts the hub-level form for the resource indicator.
|
|
370
|
+
*
|
|
371
|
+
* Closes hub#393.
|
|
372
|
+
*/
|
|
373
|
+
export function protectedResourceMetadata(deps: OAuthDeps): Response {
|
|
374
|
+
const iss = deps.issuer;
|
|
375
|
+
const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
|
|
376
|
+
return jsonResponse({
|
|
377
|
+
resource: iss,
|
|
378
|
+
authorization_servers: [iss],
|
|
379
|
+
scopes_supported: Array.from(declared).filter(isRequestableScope),
|
|
380
|
+
bearer_methods_supported: ["header"],
|
|
381
|
+
resource_documentation: "https://parachute.computer",
|
|
382
|
+
// Intentional omission: `resource_signing_alg_values_supported` +
|
|
383
|
+
// `signed_metadata`. Hub serves the resource metadata document
|
|
384
|
+
// unsigned today — MCP clients that probe for a signed metadata
|
|
385
|
+
// JWT will fall back to verifying the resource via the
|
|
386
|
+
// authorization-server's JWKS-signed access tokens. When the signed
|
|
387
|
+
// metadata path lands here (likely once a downstream MCP client
|
|
388
|
+
// requires it for offline verification), add the alg list + the
|
|
389
|
+
// `signed_metadata` JWT alongside.
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
351
393
|
// --- /.well-known/oauth-authorization-server -------------------------------
|
|
352
394
|
|
|
353
395
|
export function authorizationServerMetadata(deps: OAuthDeps): Response {
|