@openparachute/hub 0.5.13-rc.38 → 0.5.13-rc.39
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__/admin-clients.test.ts +161 -0
- package/src/__tests__/hub-server.test.ts +45 -7
- package/src/__tests__/hub.test.ts +5 -4
- package/src/__tests__/well-known.test.ts +39 -1
- package/src/admin-clients.ts +89 -13
- package/src/hub-server.ts +17 -7
- package/src/hub.ts +41 -45
- package/src/oauth-handlers.ts +6 -1
- package/src/well-known.ts +51 -15
- package/web/ui/dist/assets/{index-CG229ge6.js → index-C17QvwDb.js} +14 -14
- package/web/ui/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -298,4 +298,165 @@ describe("handleApproveClient", () => {
|
|
|
298
298
|
const res = await handleApproveClient(req, id, { db: harness.db, issuer: ISSUER });
|
|
299
299
|
expect(res.status).toBe(405);
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
// Workstream D — OAuth resume via `return_to`. The SPA approve page
|
|
303
|
+
// can pass a hub-relative authorize URL as JSON body; the response
|
|
304
|
+
// echoes it as `redirect_to` so the SPA can navigate the browser there
|
|
305
|
+
// and resume the parked OAuth flow. The pre-D no-body shape continues
|
|
306
|
+
// to work (no `redirect_to` field, share-link dead-end case).
|
|
307
|
+
describe("workstream D — return_to / redirect_to", () => {
|
|
308
|
+
function jsonApproveReq(clientId: string, bearer: string, body: unknown): Request {
|
|
309
|
+
return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
authorization: `Bearer ${bearer}`,
|
|
313
|
+
"content-type": "application/json",
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify(body),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
test("echoes a same-origin /oauth/authorize?... return_to as redirect_to", async () => {
|
|
320
|
+
const { bearer } = await makeOperatorBearer();
|
|
321
|
+
const id = regPending();
|
|
322
|
+
const returnTo =
|
|
323
|
+
"/oauth/authorize?client_id=" +
|
|
324
|
+
encodeURIComponent(id) +
|
|
325
|
+
"&response_type=code&scope=vault%3Awork%3Aread";
|
|
326
|
+
const res = await handleApproveClient(
|
|
327
|
+
jsonApproveReq(id, bearer, { return_to: returnTo }),
|
|
328
|
+
id,
|
|
329
|
+
{ db: harness.db, issuer: ISSUER },
|
|
330
|
+
);
|
|
331
|
+
expect(res.status).toBe(200);
|
|
332
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
333
|
+
expect(body.redirect_to).toBe(returnTo);
|
|
334
|
+
expect(body.status).toBe("approved");
|
|
335
|
+
expect(getClient(harness.db, id)?.status).toBe("approved");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("omits redirect_to entirely when return_to is missing (share-link case preserved)", async () => {
|
|
339
|
+
const { bearer } = await makeOperatorBearer();
|
|
340
|
+
const id = regPending();
|
|
341
|
+
// No body — the pre-D shape. The endpoint must continue to work.
|
|
342
|
+
const res = await handleApproveClient(approveReq(id, bearer), id, {
|
|
343
|
+
db: harness.db,
|
|
344
|
+
issuer: ISSUER,
|
|
345
|
+
});
|
|
346
|
+
expect(res.status).toBe(200);
|
|
347
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
348
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
349
|
+
expect(body.status).toBe("approved");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("drops an off-origin return_to (scheme-relative) silently, still approves", async () => {
|
|
353
|
+
const { bearer } = await makeOperatorBearer();
|
|
354
|
+
const id = regPending();
|
|
355
|
+
const res = await handleApproveClient(
|
|
356
|
+
jsonApproveReq(id, bearer, { return_to: "//evil.example/oauth/authorize?foo=1" }),
|
|
357
|
+
id,
|
|
358
|
+
{ db: harness.db, issuer: ISSUER },
|
|
359
|
+
);
|
|
360
|
+
expect(res.status).toBe(200);
|
|
361
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
362
|
+
// No redirect_to — server refuses to echo a bad value. The client
|
|
363
|
+
// is still approved (we don't fail an otherwise-legitimate approve
|
|
364
|
+
// over a malformed return_to).
|
|
365
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
366
|
+
expect(getClient(harness.db, id)?.status).toBe("approved");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("drops a non-authorize return_to (off-path) silently", async () => {
|
|
370
|
+
const { bearer } = await makeOperatorBearer();
|
|
371
|
+
const id = regPending();
|
|
372
|
+
const res = await handleApproveClient(
|
|
373
|
+
jsonApproveReq(id, bearer, { return_to: "/admin/vaults" }),
|
|
374
|
+
id,
|
|
375
|
+
{ db: harness.db, issuer: ISSUER },
|
|
376
|
+
);
|
|
377
|
+
expect(res.status).toBe(200);
|
|
378
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
379
|
+
// `/admin/vaults` is same-origin but isn't a `/oauth/authorize?...`
|
|
380
|
+
// URL — the server-side gate is "authorize URL only" so the SPA
|
|
381
|
+
// can't be used as a redirect gadget for arbitrary in-SPA navigation.
|
|
382
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("drops absolute URL return_to silently", async () => {
|
|
386
|
+
const { bearer } = await makeOperatorBearer();
|
|
387
|
+
const id = regPending();
|
|
388
|
+
const res = await handleApproveClient(
|
|
389
|
+
jsonApproveReq(id, bearer, {
|
|
390
|
+
return_to: "https://evil.example/oauth/authorize?foo=1",
|
|
391
|
+
}),
|
|
392
|
+
id,
|
|
393
|
+
{ db: harness.db, issuer: ISSUER },
|
|
394
|
+
);
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
397
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("non-JSON body is treated as 'no return_to' (no parser explosion)", async () => {
|
|
401
|
+
const { bearer } = await makeOperatorBearer();
|
|
402
|
+
const id = regPending();
|
|
403
|
+
// text/plain body — pre-D / unknown clients send anything. The
|
|
404
|
+
// endpoint must NOT throw on parse and must NOT echo a redirect_to.
|
|
405
|
+
const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
authorization: `Bearer ${bearer}`,
|
|
409
|
+
"content-type": "text/plain",
|
|
410
|
+
},
|
|
411
|
+
body: "garbage",
|
|
412
|
+
});
|
|
413
|
+
const res = await handleApproveClient(req, id, {
|
|
414
|
+
db: harness.db,
|
|
415
|
+
issuer: ISSUER,
|
|
416
|
+
});
|
|
417
|
+
expect(res.status).toBe(200);
|
|
418
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
419
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("malformed JSON body is treated as 'no return_to'", async () => {
|
|
423
|
+
const { bearer } = await makeOperatorBearer();
|
|
424
|
+
const id = regPending();
|
|
425
|
+
const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: {
|
|
428
|
+
authorization: `Bearer ${bearer}`,
|
|
429
|
+
"content-type": "application/json",
|
|
430
|
+
},
|
|
431
|
+
body: "{not json",
|
|
432
|
+
});
|
|
433
|
+
const res = await handleApproveClient(req, id, {
|
|
434
|
+
db: harness.db,
|
|
435
|
+
issuer: ISSUER,
|
|
436
|
+
});
|
|
437
|
+
expect(res.status).toBe(200);
|
|
438
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
439
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("re-approve with return_to echoes redirect_to (idempotent path)", async () => {
|
|
443
|
+
// The OAuth resume flow can legitimately race: operator opens the
|
|
444
|
+
// approve link, an automated path approves the same client, then
|
|
445
|
+
// operator clicks. We still want the redirect to fire so the
|
|
446
|
+
// operator's flow resumes — not dead-end on already_approved.
|
|
447
|
+
const { bearer } = await makeOperatorBearer();
|
|
448
|
+
const id = regPending();
|
|
449
|
+
approveClient(harness.db, id);
|
|
450
|
+
const returnTo = `/oauth/authorize?client_id=${encodeURIComponent(id)}&response_type=code&scope=vault%3Awork%3Aread`;
|
|
451
|
+
const res = await handleApproveClient(
|
|
452
|
+
jsonApproveReq(id, bearer, { return_to: returnTo }),
|
|
453
|
+
id,
|
|
454
|
+
{ db: harness.db, issuer: ISSUER },
|
|
455
|
+
);
|
|
456
|
+
expect(res.status).toBe(200);
|
|
457
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
458
|
+
expect(body.already_approved).toBe(true);
|
|
459
|
+
expect(body.redirect_to).toBe(returnTo);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
301
462
|
});
|
|
@@ -370,14 +370,16 @@ describe("hubFetch routing", () => {
|
|
|
370
370
|
}
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
-
test("/.well-known/parachute.json: uiUrl
|
|
373
|
+
test("/.well-known/parachute.json: vault entry uiUrl is prefixed with the per-instance mount path", async () => {
|
|
374
374
|
const h = makeHarness();
|
|
375
375
|
try {
|
|
376
376
|
const vaultWithDir: ServiceEntry = { ...vaultEntry("default"), installDir: "/fake/vault" };
|
|
377
377
|
writeManifest({ services: [vaultWithDir] }, h.manifestPath);
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
378
|
+
// Workstream C (patterns#96 + vault PR 367): vault declares
|
|
379
|
+
// `uiUrl: "/admin/"` as a per-instance path. loadServiceUiMetadata
|
|
380
|
+
// no longer skips vault entries; buildWellKnown prefixes the
|
|
381
|
+
// declared path with the per-instance mount on emission, yielding
|
|
382
|
+
// `/vault/default/admin/` for a vault mounted at `/vault/default`.
|
|
381
383
|
const res = await hubFetch(h.dir, {
|
|
382
384
|
manifestPath: h.manifestPath,
|
|
383
385
|
readModuleManifest: async () => ({
|
|
@@ -386,12 +388,48 @@ describe("hubFetch routing", () => {
|
|
|
386
388
|
port: 1940,
|
|
387
389
|
paths: ["/vault/default"],
|
|
388
390
|
health: "/health",
|
|
389
|
-
uiUrl: "/
|
|
391
|
+
uiUrl: "/admin/",
|
|
390
392
|
}),
|
|
391
393
|
})(req("/.well-known/parachute.json"));
|
|
392
394
|
const body = (await res.json()) as { services: Array<{ name: string; uiUrl?: string }> };
|
|
393
|
-
const vaultSvc = body.services.find((s) => s.name === "parachute-vault");
|
|
394
|
-
expect(vaultSvc).
|
|
395
|
+
const vaultSvc = body.services.find((s) => s.name === "parachute-vault-default");
|
|
396
|
+
expect(vaultSvc?.uiUrl).toMatch(/\/vault\/default\/admin\/$/);
|
|
397
|
+
} finally {
|
|
398
|
+
h.cleanup();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("/.well-known/parachute.json: vault with multiple paths emits one row per instance with its own prefixed uiUrl", async () => {
|
|
403
|
+
const h = makeHarness();
|
|
404
|
+
try {
|
|
405
|
+
const multi: ServiceEntry = {
|
|
406
|
+
name: "parachute-vault",
|
|
407
|
+
port: 1940,
|
|
408
|
+
paths: ["/vault/default", "/vault/techne"],
|
|
409
|
+
health: "/vault/default/health",
|
|
410
|
+
version: "0.4.8",
|
|
411
|
+
installDir: "/fake/vault",
|
|
412
|
+
};
|
|
413
|
+
writeManifest({ services: [multi] }, h.manifestPath);
|
|
414
|
+
const res = await hubFetch(h.dir, {
|
|
415
|
+
manifestPath: h.manifestPath,
|
|
416
|
+
readModuleManifest: async () => ({
|
|
417
|
+
name: "vault",
|
|
418
|
+
manifestName: "parachute-vault",
|
|
419
|
+
port: 1940,
|
|
420
|
+
paths: ["/vault/default", "/vault/techne"],
|
|
421
|
+
health: "/health",
|
|
422
|
+
uiUrl: "/admin/",
|
|
423
|
+
}),
|
|
424
|
+
})(req("/.well-known/parachute.json"));
|
|
425
|
+
const body = (await res.json()) as {
|
|
426
|
+
services: Array<{ name: string; path?: string; uiUrl?: string }>;
|
|
427
|
+
};
|
|
428
|
+
const vaultRows = body.services.filter((s) => s.name === "parachute-vault");
|
|
429
|
+
expect(vaultRows.length).toBe(2);
|
|
430
|
+
const uiUrls = vaultRows.map((r) => r.uiUrl).sort();
|
|
431
|
+
expect(uiUrls[0]).toMatch(/\/vault\/default\/admin\/$/);
|
|
432
|
+
expect(uiUrls[1]).toMatch(/\/vault\/techne\/admin\/$/);
|
|
395
433
|
} finally {
|
|
396
434
|
h.cleanup();
|
|
397
435
|
}
|
|
@@ -78,10 +78,11 @@ describe("renderHub", () => {
|
|
|
78
78
|
expect(html).not.toContain("['notes', 'scribe', 'agent']");
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
test("Services skip rule emerges from data, not name-checks (
|
|
82
|
-
// The previous `isVaultName` hardcoded skip is gone — vault
|
|
83
|
-
//
|
|
84
|
-
// modules (current or future)
|
|
81
|
+
test("Services skip rule emerges from data, not name-checks (any service without uiUrl skipped)", () => {
|
|
82
|
+
// The previous `isVaultName` hardcoded skip is gone — vault now
|
|
83
|
+
// declares uiUrl per workstream C (patterns#96), so it renders too;
|
|
84
|
+
// API-only modules (current or future) without uiUrl get omitted
|
|
85
|
+
// for free under the same data-driven rule.
|
|
85
86
|
expect(html).toContain("if (!svc || !svc.uiUrl) continue;");
|
|
86
87
|
// The function definition is gone (the comment may still mention the
|
|
87
88
|
// name as historical context — we only care about the active code).
|
|
@@ -324,7 +324,7 @@ describe("buildWellKnown", () => {
|
|
|
324
324
|
expect(svc?.uiUrl).toBe("https://notes.example.com/app");
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
test("uiUrl absent when the resolver returns undefined (
|
|
327
|
+
test("uiUrl absent when the resolver returns undefined (API-only service)", () => {
|
|
328
328
|
const doc = buildWellKnown({
|
|
329
329
|
services: [vault, notes],
|
|
330
330
|
canonicalOrigin: "https://x.example",
|
|
@@ -336,6 +336,44 @@ describe("buildWellKnown", () => {
|
|
|
336
336
|
expect(notesSvc?.uiUrl).toBe("https://x.example/notes");
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
// Workstream C (patterns#96): vault declares `uiUrl: "/admin/"` as a
|
|
340
|
+
// per-instance path. buildWellKnown applies the per-instance mount-path
|
|
341
|
+
// prefix on emission, yielding one tile per vault instance pointing at
|
|
342
|
+
// `<origin>/vault/<name>/admin/`. Non-vault uiUrl behavior is unchanged.
|
|
343
|
+
test("vault uiUrl is prefixed with the per-instance mount path (single instance)", () => {
|
|
344
|
+
const doc = buildWellKnown({
|
|
345
|
+
services: [vault],
|
|
346
|
+
canonicalOrigin: "https://x.example",
|
|
347
|
+
uiUrlFor: () => "/admin/",
|
|
348
|
+
});
|
|
349
|
+
const svc = doc.services.find((s) => s.name === "parachute-vault");
|
|
350
|
+
expect(svc?.uiUrl).toBe("https://x.example/vault/default/admin/");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("vault uiUrl is prefixed per-instance for multi-path vault entries", () => {
|
|
354
|
+
const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
|
|
355
|
+
const doc = buildWellKnown({
|
|
356
|
+
services: [multi],
|
|
357
|
+
canonicalOrigin: "https://x.example",
|
|
358
|
+
uiUrlFor: () => "/admin/",
|
|
359
|
+
});
|
|
360
|
+
const rows = doc.services.filter((s) => s.name === "parachute-vault");
|
|
361
|
+
expect(rows.length).toBe(2);
|
|
362
|
+
const uiUrls = rows.map((r) => r.uiUrl).sort();
|
|
363
|
+
expect(uiUrls[0]).toBe("https://x.example/vault/default/admin/");
|
|
364
|
+
expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("vault uiUrl absolute URL still passes through verbatim (no prefix)", () => {
|
|
368
|
+
const doc = buildWellKnown({
|
|
369
|
+
services: [vault],
|
|
370
|
+
canonicalOrigin: "https://x.example",
|
|
371
|
+
uiUrlFor: () => "https://vault.example.com/admin",
|
|
372
|
+
});
|
|
373
|
+
const svc = doc.services.find((s) => s.name === "parachute-vault");
|
|
374
|
+
expect(svc?.uiUrl).toBe("https://vault.example.com/admin");
|
|
375
|
+
});
|
|
376
|
+
|
|
339
377
|
test("displayName resolver overrides services.json displayName", () => {
|
|
340
378
|
const notesWithName: ServiceEntry = { ...notes, displayName: "FromServicesJson" };
|
|
341
379
|
const doc = buildWellKnown({
|
package/src/admin-clients.ts
CHANGED
|
@@ -18,6 +18,33 @@
|
|
|
18
18
|
* the API path logs because cross-machine "who approved this" is the
|
|
19
19
|
* audit-grade signal we'd want when the operator approves from a browser
|
|
20
20
|
* rather than a terminal they own.
|
|
21
|
+
*
|
|
22
|
+
* ## OAuth resume via `return_to` (workstream D, AUDIT-UI-UX.md §5 row D)
|
|
23
|
+
*
|
|
24
|
+
* The SPA approve page (`web/ui/src/routes/ApproveClient.tsx`) was a
|
|
25
|
+
* documented dead-end pre-D: it flipped the client to approved, then told
|
|
26
|
+
* the operator to "return to the app and retry" — the parked OAuth flow
|
|
27
|
+
* had no way to resume.
|
|
28
|
+
*
|
|
29
|
+
* D adds the affordance, not a behaviour change for existing callers. If
|
|
30
|
+
* the POST body carries a `return_to` JSON field that's a hub-relative
|
|
31
|
+
* `/oauth/authorize?...` URL, the response echoes it back as `redirect_to`
|
|
32
|
+
* and the SPA navigates the browser there to resume the flow. Callers
|
|
33
|
+
* that don't pass `return_to` (the "share this link with another admin"
|
|
34
|
+
* case the unauth pending-client CTA renders) get the unchanged response
|
|
35
|
+
* shape; the SPA renders its dead-end success state and the deep-link
|
|
36
|
+
* UX is preserved.
|
|
37
|
+
*
|
|
38
|
+
* Two cases, one route — `return_to` is the discriminator. The pattern
|
|
39
|
+
* doc is `parachute-patterns/patterns/oauth-dcr-approval.md` §"SPA
|
|
40
|
+
* approve page (two cases, one route)".
|
|
41
|
+
*
|
|
42
|
+
* Validation reuses `isSafeAuthorizeReturnTo` from oauth-handlers.ts so
|
|
43
|
+
* the SPA endpoint and the inline `/oauth/authorize/approve` endpoint
|
|
44
|
+
* apply the same gate — single source of truth for "what's a valid OAuth
|
|
45
|
+
* resume target?" Off-origin or non-authorize values are silently dropped
|
|
46
|
+
* (the response omits `redirect_to`) rather than 4xx'ing — a bad
|
|
47
|
+
* `return_to` shouldn't block an otherwise-legitimate approve.
|
|
21
48
|
*/
|
|
22
49
|
import type { Database } from "bun:sqlite";
|
|
23
50
|
import {
|
|
@@ -28,6 +55,7 @@ import {
|
|
|
28
55
|
} from "./admin-auth.ts";
|
|
29
56
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
30
57
|
import { approveClient, getClient } from "./clients.ts";
|
|
58
|
+
import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
|
|
31
59
|
|
|
32
60
|
export interface AdminClientsDeps {
|
|
33
61
|
db: Database;
|
|
@@ -102,6 +130,11 @@ export async function handleApproveClient(
|
|
|
102
130
|
} catch (err) {
|
|
103
131
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
104
132
|
}
|
|
133
|
+
// Parse the body OPTIONALLY — pre-D callers send no body at all, so a
|
|
134
|
+
// missing / empty / non-JSON body is fine. Only fish out `return_to` when
|
|
135
|
+
// the caller actually provided a parseable JSON object; everything else
|
|
136
|
+
// is treated as "no return_to specified," same as pre-D.
|
|
137
|
+
const returnTo = await readReturnTo(req);
|
|
105
138
|
const before = getClient(deps.db, clientId);
|
|
106
139
|
if (!before) {
|
|
107
140
|
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
@@ -123,20 +156,63 @@ export async function handleApproveClient(
|
|
|
123
156
|
`client approved: client_id=${clientId} client_name=${before.clientName ?? ""} approver_sub=${ctx.sub}`,
|
|
124
157
|
);
|
|
125
158
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
159
|
+
// Only echo `redirect_to` when the caller's `return_to` passed the gate.
|
|
160
|
+
// Bad / missing values just drop off the response — the SPA falls back
|
|
161
|
+
// to its dead-end success state. We don't 4xx an otherwise-legitimate
|
|
162
|
+
// approve over a bad return_to (the client is now approved either way).
|
|
163
|
+
const body: ApproveClientResponse = {
|
|
164
|
+
client_id: clientId,
|
|
165
|
+
status: "approved",
|
|
166
|
+
already_approved: !wasPending,
|
|
167
|
+
};
|
|
168
|
+
if (returnTo !== null && isSafeAuthorizeReturnTo(returnTo)) {
|
|
169
|
+
body.redirect_to = returnTo;
|
|
170
|
+
}
|
|
171
|
+
return new Response(JSON.stringify(body), {
|
|
172
|
+
status: 200,
|
|
173
|
+
headers: {
|
|
174
|
+
"content-type": "application/json",
|
|
175
|
+
"cache-control": "no-store",
|
|
138
176
|
},
|
|
139
|
-
);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface ApproveClientResponse {
|
|
181
|
+
client_id: string;
|
|
182
|
+
status: "approved";
|
|
183
|
+
already_approved: boolean;
|
|
184
|
+
/**
|
|
185
|
+
* Hub-relative `/oauth/authorize?...` URL the SPA should navigate to
|
|
186
|
+
* after approving, to resume a parked OAuth flow. Only present when the
|
|
187
|
+
* POST body's `return_to` passed `isSafeAuthorizeReturnTo`. Absent for
|
|
188
|
+
* the share-link case (no `return_to` provided) so the SPA's dead-end
|
|
189
|
+
* success state still renders.
|
|
190
|
+
*/
|
|
191
|
+
redirect_to?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pull `return_to` out of the request body if present. Tolerant by design:
|
|
196
|
+
* pre-D callers (and tests, and curl probes) send no body or a non-JSON
|
|
197
|
+
* body, and the endpoint MUST continue to work in those shapes. Any parse
|
|
198
|
+
* failure or missing field returns null; the response omits `redirect_to`
|
|
199
|
+
* accordingly.
|
|
200
|
+
*
|
|
201
|
+
* Only `application/json` bodies are inspected — keeping the format
|
|
202
|
+
* restricted to JSON matches the existing API conventions (the SPA's
|
|
203
|
+
* other admin POSTs use JSON throughout) and avoids parser ambiguity
|
|
204
|
+
* over form-encoded variants on a deliberately optional field.
|
|
205
|
+
*/
|
|
206
|
+
async function readReturnTo(req: Request): Promise<string | null> {
|
|
207
|
+
const ct = req.headers.get("content-type") ?? "";
|
|
208
|
+
if (!ct.toLowerCase().includes("application/json")) return null;
|
|
209
|
+
try {
|
|
210
|
+
const body = (await req.json()) as { return_to?: unknown };
|
|
211
|
+
if (typeof body?.return_to !== "string") return null;
|
|
212
|
+
return body.return_to;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
140
216
|
}
|
|
141
217
|
|
|
142
218
|
function jsonError(status: number, error: string, description: string): Response {
|
package/src/hub-server.ts
CHANGED
|
@@ -719,12 +719,24 @@ async function loadManagementUrls(
|
|
|
719
719
|
}
|
|
720
720
|
|
|
721
721
|
/**
|
|
722
|
-
* For each
|
|
722
|
+
* For each `ServiceEntry` with a known `installDir`, read its
|
|
723
723
|
* `.parachute/module.json` and surface the optional `uiUrl` and
|
|
724
724
|
* `displayName`. Returns two `name → value` maps keyed by services.json
|
|
725
|
-
* entry name.
|
|
726
|
-
*
|
|
727
|
-
*
|
|
725
|
+
* entry name.
|
|
726
|
+
*
|
|
727
|
+
* Vaults are NOT skipped — as of patterns#96 (workstream C) vault declares
|
|
728
|
+
* its own `uiUrl: "/admin/"` (multi-instance form). `buildWellKnown`
|
|
729
|
+
* applies the per-instance mount-path prefix for vault rows so each
|
|
730
|
+
* instance gets a discovery tile pointing at `/vault/<name>/admin/`. The
|
|
731
|
+
* earlier "vaults browse via Notes — no tile" rule retired with PR 1 of
|
|
732
|
+
* workstream C; operators administer per-vault tokens / config / MCP via
|
|
733
|
+
* the vault admin SPA, which is a different audience from Notes' content
|
|
734
|
+
* browse. See [`module-ui-declaration.md` §"Use vs admin"](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/module-ui-declaration.md#use-vs-admin--both-can-be-true).
|
|
735
|
+
*
|
|
736
|
+
* `loadManagementUrls` continues to handle vault's `managementUrl` for
|
|
737
|
+
* the hub admin SPA's vault-list "Manage" link — a different surface
|
|
738
|
+
* (admin SPA, not discovery), even when the target path happens to
|
|
739
|
+
* collide (`/admin/` for both).
|
|
728
740
|
*
|
|
729
741
|
* Why read at request time and not from services.json: services own the
|
|
730
742
|
* write side of services.json (`upsertService` replaces the whole entry
|
|
@@ -746,9 +758,7 @@ async function loadServiceUiMetadata(
|
|
|
746
758
|
const displayNames = new Map<string, string>();
|
|
747
759
|
await Promise.all(
|
|
748
760
|
services.map(async (s) => {
|
|
749
|
-
|
|
750
|
-
// operator-facing user UI of their own (content browses via Notes).
|
|
751
|
-
if (isVaultEntry(s) || !s.installDir) return;
|
|
761
|
+
if (!s.installDir) return;
|
|
752
762
|
try {
|
|
753
763
|
const m = await read(s.installDir);
|
|
754
764
|
if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
|
package/src/hub.ts
CHANGED
|
@@ -9,15 +9,17 @@ import { CSRF_FIELD_NAME } from "./csrf.ts";
|
|
|
9
9
|
* The page is split into two sections, organized by **ownership**:
|
|
10
10
|
*
|
|
11
11
|
* - **Services** — surfaces provided by the modules running on this
|
|
12
|
-
* hub. Browse notes (the Notes PWA); transcribe audio (Scribe);
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
12
|
+
* hub. Browse notes (the Notes PWA); transcribe audio (Scribe);
|
|
13
|
+
* administer a vault (Vault admin SPA); run agents (Agent). Each
|
|
14
|
+
* entry points at the service's own UI; the service owns what's
|
|
15
|
+
* behind the link (use, config, admin — whatever it chooses to
|
|
16
|
+
* surface). Entries are dynamic, derived from
|
|
17
|
+
* `/.well-known/parachute.json`; only installed services show up.
|
|
18
|
+
* Vault declares `uiUrl` per workstream C (patterns#96, 2026-05-25)
|
|
19
|
+
* — the earlier "vault has no tile because content browses via
|
|
20
|
+
* Notes" rule split: Notes covers end-user content browsing; the
|
|
21
|
+
* vault tile covers operator admin (per-vault tokens, config, MCP).
|
|
22
|
+
* Both deserve discovery presence.
|
|
21
23
|
*
|
|
22
24
|
* - **Admin** — hub-owned admin surfaces for cross-cutting host
|
|
23
25
|
* concerns. Always visible: even with zero vaults installed, an
|
|
@@ -455,17 +457,25 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
455
457
|
/**
|
|
456
458
|
* Render the "Get started" section (hub#342) above the Services grid.
|
|
457
459
|
*
|
|
458
|
-
*
|
|
459
|
-
* installed:
|
|
460
|
+
* One hardcoded target, conditional on its prerequisite being installed:
|
|
460
461
|
* - "Open Notes" → /app/notes/ (requires parachute-app installed;
|
|
461
462
|
* App auto-bootstraps Notes-as-UI per parachute-app §17, so the
|
|
462
463
|
* mere presence of App means /app/notes/ is live)
|
|
463
|
-
* - "Browse Vault" → /vault/<first-vault-name>/admin/ (requires
|
|
464
|
-
* parachute-vault installed; uses the first vault's name from
|
|
465
|
-
* its services.json mount path tail)
|
|
466
464
|
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
465
|
+
* The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
|
|
466
|
+
* once vault declared uiUrl in its module.json (per patterns#96). With
|
|
467
|
+
* the multi-instance prefix lifted into buildWellKnown, every vault
|
|
468
|
+
* instance now renders its own tile in the Services grid below — one
|
|
469
|
+
* per instance, with the actual instance name in the discovery doc
|
|
470
|
+
* rather than a hub-side guess at "first vault."
|
|
471
|
+
*
|
|
472
|
+
* Notes stays here because Notes is the end-user CTA (the audience
|
|
473
|
+
* that needs the strongest above-the-fold prompt); the Services grid
|
|
474
|
+
* positions it equally with admin tiles, which underweights the
|
|
475
|
+
* "browse my content" path for a returning operator.
|
|
476
|
+
*
|
|
477
|
+
* If the prerequisite is not met (fresh install pre-wizard, no app)
|
|
478
|
+
* the section stays hidden. The hardcoded shape mirrors the wizard's
|
|
469
479
|
* own done-screen "Start using your vault" tile — same architectural
|
|
470
480
|
* shape (single obvious entry point) at a different surface.
|
|
471
481
|
*
|
|
@@ -478,11 +488,6 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
478
488
|
if (!getStartedGrid || !getStartedSection) return;
|
|
479
489
|
const tiles = [];
|
|
480
490
|
const hasApp = services.some((s) => s && s.name === 'parachute-app');
|
|
481
|
-
// services[] is fanned out per-vault for parachute-vault rows (see
|
|
482
|
-
// well-known.ts emitVaultRows) — "path" is the per-instance mount
|
|
483
|
-
// "/vault/<name>". Pick the first vault entry; the order matches
|
|
484
|
-
// services.json's paths[] order, so this is deterministic.
|
|
485
|
-
const vault = services.find((s) => s && s.name === 'parachute-vault');
|
|
486
491
|
if (hasApp) {
|
|
487
492
|
tiles.push({
|
|
488
493
|
title: 'Open Notes',
|
|
@@ -490,25 +495,6 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
490
495
|
href: '/app/notes/',
|
|
491
496
|
});
|
|
492
497
|
}
|
|
493
|
-
if (vault) {
|
|
494
|
-
// vault.path is the per-instance mount "/vault/<name>". Extract
|
|
495
|
-
// the tail as the display name; mirror the wizard's own
|
|
496
|
-
// firstVaultName() shape.
|
|
497
|
-
let vaultName = 'default';
|
|
498
|
-
if (typeof vault.path === 'string' && vault.path.startsWith('/vault/')) {
|
|
499
|
-
// Character class for the slash so the template literal can't eat
|
|
500
|
-
// the backslash-escape — \/ collapses to / inside backticks, and
|
|
501
|
-
// /\/+$/ degenerates to a // line comment that breaks the whole
|
|
502
|
-
// IIFE. [/]+$ keeps the same semantics with no escape needed.
|
|
503
|
-
const tail = vault.path.slice('/vault/'.length).replace(/[/]+$/, '');
|
|
504
|
-
if (tail.length > 0) vaultName = tail;
|
|
505
|
-
}
|
|
506
|
-
tiles.push({
|
|
507
|
-
title: 'Browse Vault',
|
|
508
|
-
desc: "Open your vault's admin UI — content, settings, MCP.",
|
|
509
|
-
href: '/vault/' + encodeURIComponent(vaultName) + '/admin/',
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
498
|
if (tiles.length === 0) {
|
|
513
499
|
getStartedSection.setAttribute('hidden', '');
|
|
514
500
|
return;
|
|
@@ -520,11 +506,21 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
520
506
|
|
|
521
507
|
function renderServices(services) {
|
|
522
508
|
// Render one tile per service that declares a uiUrl. Entries without
|
|
523
|
-
// uiUrl are intentionally omitted —
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
509
|
+
// uiUrl are intentionally omitted — API-only modules with no
|
|
510
|
+
// operator surface. The shortName-collapse below picks the first row
|
|
511
|
+
// per service short-name; for multi-instance services, this matters
|
|
512
|
+
// only when the services.json entry name is shared across instances.
|
|
513
|
+
//
|
|
514
|
+
// Today: vault registers as parachute-vault with multiple paths[] —
|
|
515
|
+
// shortName is "vault", one tile points at the first instance's admin.
|
|
516
|
+
// Same effective behavior the retired hardcoded "Browse Vault" tile
|
|
517
|
+
// had (workstream C, 2026-05-25).
|
|
518
|
+
//
|
|
519
|
+
// If a future multi-vault setup ever uses distinct entry names per
|
|
520
|
+
// instance (e.g. parachute-vault-default, parachute-vault-techne),
|
|
521
|
+
// the shortName collapse will yield "vault-default" and "vault-techne"
|
|
522
|
+
// and the operator gets one tile per named vault automatically — no
|
|
523
|
+
// disambiguation code needed.
|
|
528
524
|
const byShort = new Map();
|
|
529
525
|
for (const svc of services) {
|
|
530
526
|
if (!svc || !svc.uiUrl) continue;
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -1251,8 +1251,13 @@ export async function handleApproveClientPost(
|
|
|
1251
1251
|
* (no scheme, no double-slash) targeting `/oauth/authorize` with a query
|
|
1252
1252
|
* string — anything else is either an open-redirect attempt or a misuse of
|
|
1253
1253
|
* the endpoint. Empty string is rejected (the form always supplies one).
|
|
1254
|
+
*
|
|
1255
|
+
* Exported so the SPA approve-client endpoint (`handleApproveClient` in
|
|
1256
|
+
* admin-clients.ts) can apply the same gate when echoing a `return_to` back
|
|
1257
|
+
* to the caller — workstream D. Single helper = single shape of "what's a
|
|
1258
|
+
* valid OAuth-resume target?" for the whole hub.
|
|
1254
1259
|
*/
|
|
1255
|
-
function isSafeAuthorizeReturnTo(value: string): boolean {
|
|
1260
|
+
export function isSafeAuthorizeReturnTo(value: string): boolean {
|
|
1256
1261
|
if (!value) return false;
|
|
1257
1262
|
// Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
|
|
1258
1263
|
// single-slash root-relative paths are allowed.
|