@openparachute/hub 0.5.13-rc.37 → 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__/oauth-handlers.test.ts +35 -13
- package/src/__tests__/oauth-ui.test.ts +31 -1
- 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 +42 -12
- package/src/oauth-ui.ts +66 -8
- 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).
|
|
@@ -3970,13 +3970,20 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3970
3970
|
});
|
|
3971
3971
|
}
|
|
3972
3972
|
|
|
3973
|
-
test("session absent →
|
|
3974
|
-
// Approval-UX rc.19: the unauthenticated viewer no
|
|
3975
|
-
//
|
|
3976
|
-
//
|
|
3977
|
-
//
|
|
3978
|
-
//
|
|
3979
|
-
// the
|
|
3973
|
+
test("session absent → Sign-in CTA preserves the authorize URL through login + shareable deep link", async () => {
|
|
3974
|
+
// Approval-UX rc.19: the unauthenticated viewer sees no CLI hint —
|
|
3975
|
+
// the web approval path (#277) is the canonical recovery now. The
|
|
3976
|
+
// primary Sign-in CTA wires `/login?next=<authorize URL>` so post-
|
|
3977
|
+
// login the operator lands BACK on the same `/oauth/authorize?...`
|
|
3978
|
+
// request — now authenticated, they see the inline approve form, one
|
|
3979
|
+
// click resumes the OAuth flow through consent → redirect_uri. The
|
|
3980
|
+
// shareable secondary deep link still points at the SPA approve page
|
|
3981
|
+
// (it's for sharing with another admin, not for the in-flight flow).
|
|
3982
|
+
//
|
|
3983
|
+
// Pre-fix the Sign-in CTA also pointed at the SPA approve page —
|
|
3984
|
+
// approving the client but discarding the authorize URL params, so
|
|
3985
|
+
// the calling app (e.g. Claude.ai MCP) was never told and the user
|
|
3986
|
+
// looped on retry. Caught by Aaron on the Render deploy.
|
|
3980
3987
|
const { db, cleanup } = await makeDb();
|
|
3981
3988
|
try {
|
|
3982
3989
|
const reg = registerClient(db, {
|
|
@@ -3984,19 +3991,34 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3984
3991
|
clientName: "MyApp",
|
|
3985
3992
|
status: "pending",
|
|
3986
3993
|
});
|
|
3987
|
-
const
|
|
3994
|
+
const authorizePath = pendingAuthorizeUrl(reg.client.clientId);
|
|
3995
|
+
const req = new Request(authorizePath);
|
|
3988
3996
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
3989
3997
|
expect(res.status).toBe(403);
|
|
3990
3998
|
const html = await res.text();
|
|
3991
3999
|
expect(html).toContain("App not yet approved");
|
|
3992
|
-
// Primary CTA: Sign-in link wired to
|
|
3993
|
-
// the
|
|
4000
|
+
// Primary CTA: Sign-in link wired to round-trip the operator back
|
|
4001
|
+
// to the original /oauth/authorize?... URL after login (resumes the
|
|
4002
|
+
// OAuth flow rather than dead-ending at the SPA approve page).
|
|
3994
4003
|
expect(html).toContain("Sign in as admin to approve");
|
|
3995
|
-
const
|
|
4004
|
+
const requestUrl = new URL(authorizePath);
|
|
4005
|
+
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
|
|
4006
|
+
const expectedLoginHref = `/login?next=${encodeURIComponent(returnTo)}`;
|
|
4007
|
+
expect(html).toContain(`href="${expectedLoginHref}"`);
|
|
4008
|
+
// Sanity: the next= target carries the authorize path + the
|
|
4009
|
+
// client_id + state so the flow can resume verbatim post-login.
|
|
4010
|
+
expect(returnTo).toContain("/oauth/authorize");
|
|
4011
|
+
expect(returnTo).toContain(encodeURIComponent(reg.client.clientId));
|
|
4012
|
+
expect(returnTo).toContain("state=rt-208");
|
|
4013
|
+
// The legacy SPA approve path is NOT what the Sign-in CTA points
|
|
4014
|
+
// at any more (regression guard for the fix).
|
|
4015
|
+
const legacyHref = `/login?next=${encodeURIComponent(
|
|
3996
4016
|
`/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
3997
4017
|
)}`;
|
|
3998
|
-
expect(html).toContain(`href="${
|
|
3999
|
-
// Secondary CTA: shareable, fully-qualified deep link + Copy button
|
|
4018
|
+
expect(html).not.toContain(`href="${legacyHref}"`);
|
|
4019
|
+
// Secondary CTA: shareable, fully-qualified deep link + Copy button
|
|
4020
|
+
// — still points at the SPA approve page (no OAuth flow context to
|
|
4021
|
+
// preserve for the share-with-another-admin case).
|
|
4000
4022
|
expect(html).toContain(
|
|
4001
4023
|
`${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
4002
4024
|
);
|
|
@@ -408,13 +408,43 @@ describe("renderApprovePending unauthenticated CTAs", () => {
|
|
|
408
408
|
hubOrigin: "https://hub.example.com",
|
|
409
409
|
};
|
|
410
410
|
|
|
411
|
-
test("
|
|
411
|
+
test("without loginNextUrl: Sign in CTA falls back to /admin/approve-client/<id>", () => {
|
|
412
|
+
// Back-compat fallback for callers that don't have an in-flight OAuth
|
|
413
|
+
// URL to resume (e.g. direct share-page entry). The SPA approve path
|
|
414
|
+
// approves the client but discards any OAuth context — fine here
|
|
415
|
+
// because there's no OAuth flow to discard.
|
|
412
416
|
const html = renderApprovePending(COMMON);
|
|
413
417
|
expect(html).toContain("Sign in as admin to approve");
|
|
414
418
|
const expectedHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
|
|
415
419
|
expect(html).toContain(`href="${expectedHref}"`);
|
|
416
420
|
});
|
|
417
421
|
|
|
422
|
+
test("with loginNextUrl set: Sign in CTA preserves the authorize URL through login", () => {
|
|
423
|
+
// The OAuth-flow entry case (the bug fix): the page is rendered for an
|
|
424
|
+
// `/oauth/authorize?...` GET, the operator isn't signed in, the CTA
|
|
425
|
+
// sends them through `/login?next=<authorize URL>` so post-login they
|
|
426
|
+
// land BACK on the OAuth flow — now authenticated, see the inline
|
|
427
|
+
// approve form, click once, OAuth flow resumes through consent →
|
|
428
|
+
// redirect_uri callback. Without this the SPA approves the client but
|
|
429
|
+
// the calling app (Claude MCP) is never told and the user loops on
|
|
430
|
+
// retry. Caught when Aaron tested via Claude.ai's MCP connector
|
|
431
|
+
// against the Render deploy.
|
|
432
|
+
const authorizeUrl =
|
|
433
|
+
"/oauth/authorize?client_id=client-xyz&redirect_uri=https%3A%2F%2Fapp.example%2Fcb" +
|
|
434
|
+
"&response_type=code&code_challenge=abc&code_challenge_method=S256&state=rt";
|
|
435
|
+
const html = renderApprovePending({ ...COMMON, loginNextUrl: authorizeUrl });
|
|
436
|
+
expect(html).toContain("Sign in as admin to approve");
|
|
437
|
+
const expectedHref = `/login?next=${encodeURIComponent(authorizeUrl)}`;
|
|
438
|
+
expect(html).toContain(`href="${expectedHref}"`);
|
|
439
|
+
// Sanity: the legacy SPA approve path is NOT what `next` points at.
|
|
440
|
+
const legacyHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
|
|
441
|
+
expect(html).not.toContain(`href="${legacyHref}"`);
|
|
442
|
+
// The shareable deep link (secondary CTA, not Sign-in) still uses
|
|
443
|
+
// the SPA approve path — it's for sharing with another admin who
|
|
444
|
+
// isn't in an OAuth flow.
|
|
445
|
+
expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
|
|
446
|
+
});
|
|
447
|
+
|
|
418
448
|
test("renders fully-qualified shareable deep link + Copy button + clipboard JS", () => {
|
|
419
449
|
const html = renderApprovePending(COMMON);
|
|
420
450
|
expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
|
|
@@ -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);
|