@openparachute/hub 0.7.4-rc.10 → 0.7.4-rc.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.10",
3
+ "version": "0.7.4-rc.12",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -12,8 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
12
  import { mkdtempSync, rmSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
- import { handleApproveClient, handleGetClient } from "../admin-clients.ts";
15
+ import { handleApproveClient, handleDeleteClient, handleGetClient } from "../admin-clients.ts";
16
16
  import { approveClient, getClient, registerClient } from "../clients.ts";
17
+ import { findGrant, recordGrant } from "../grants.ts";
17
18
  import { hubDbPath, openHubDb } from "../hub-db.ts";
18
19
  import { signAccessToken } from "../jwt-sign.ts";
19
20
  import { createUser } from "../users.ts";
@@ -83,6 +84,23 @@ function approveReq(clientId: string, bearer?: string, method = "POST"): Request
83
84
  return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, init);
84
85
  }
85
86
 
87
+ // DELETE hits the TOP-LEVEL /oauth/clients/<id> prefix (the path the surface
88
+ // remove-flow calls), not the /api/... admin prefix the GET/approve use.
89
+ function deleteReq(clientId: string, bearer?: string, method = "DELETE"): Request {
90
+ const init: RequestInit = { method };
91
+ if (bearer) init.headers = { authorization: `Bearer ${bearer}` };
92
+ return new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(clientId)}`, init);
93
+ }
94
+
95
+ function regApproved(name?: string): string {
96
+ const r = registerClient(harness.db, {
97
+ redirectUris: ["https://app.example/cb"],
98
+ scopes: ["vault:work:read"],
99
+ ...(name !== undefined ? { clientName: name } : {}),
100
+ });
101
+ return r.client.clientId;
102
+ }
103
+
86
104
  describe("handleGetClient", () => {
87
105
  test("401 without Bearer", async () => {
88
106
  const id = regPending("App");
@@ -460,3 +478,87 @@ describe("handleApproveClient", () => {
460
478
  });
461
479
  });
462
480
  });
481
+
482
+ // hub#640 — RFC 7592 client deregistration. The surface fires DELETE on the
483
+ // top-level /oauth/clients/<id> path; the handler is operator-bearer-gated,
484
+ // returns 204 on delete (cascading grants + auth_codes), 404 when absent.
485
+ describe("handleDeleteClient", () => {
486
+ test("401 without Bearer (row survives)", async () => {
487
+ const id = regApproved("App");
488
+ const res = await handleDeleteClient(deleteReq(id), id, {
489
+ db: harness.db,
490
+ issuer: ISSUER,
491
+ });
492
+ expect(res.status).toBe(401);
493
+ expect(getClient(harness.db, id)).not.toBeNull();
494
+ });
495
+
496
+ test("403 with the wrong scope (row survives)", async () => {
497
+ const { bearer } = await makeOperatorBearer(["parachute:host:auth"]);
498
+ const id = regApproved("App");
499
+ const res = await handleDeleteClient(deleteReq(id, bearer), id, {
500
+ db: harness.db,
501
+ issuer: ISSUER,
502
+ });
503
+ expect(res.status).toBe(403);
504
+ expect(getClient(harness.db, id)).not.toBeNull();
505
+ });
506
+
507
+ test("204 on an existing client + cascade gone + audit line", async () => {
508
+ const { bearer, userId } = await makeOperatorBearer();
509
+ const id = regApproved("Notes");
510
+ // Plant a grant so the cascade has something to remove.
511
+ recordGrant(harness.db, userId, id, ["vault:work:read"]);
512
+ expect(findGrant(harness.db, userId, id)).not.toBeNull();
513
+
514
+ const logs: string[] = [];
515
+ const originalLog = console.log;
516
+ console.log = (...args: unknown[]) => {
517
+ logs.push(args.map(String).join(" "));
518
+ };
519
+ let res: Response;
520
+ try {
521
+ res = await handleDeleteClient(deleteReq(id, bearer), id, {
522
+ db: harness.db,
523
+ issuer: ISSUER,
524
+ });
525
+ } finally {
526
+ console.log = originalLog;
527
+ }
528
+ expect(res.status).toBe(204);
529
+ // 204 carries no body.
530
+ expect(await res.text()).toBe("");
531
+ // Client + its grant are gone.
532
+ expect(getClient(harness.db, id)).toBeNull();
533
+ expect(findGrant(harness.db, userId, id)).toBeNull();
534
+
535
+ const line = logs.find((l) => l.startsWith("client deleted:"));
536
+ expect(line).toBeDefined();
537
+ expect(line).toContain(`client_id=${id}`);
538
+ expect(line).toContain("client_name=Notes");
539
+ expect(line).toContain(`remover_sub=${userId}`);
540
+ });
541
+
542
+ test("404 on an absent client_id", async () => {
543
+ const { bearer } = await makeOperatorBearer();
544
+ const res = await handleDeleteClient(deleteReq("nope", bearer), "nope", {
545
+ db: harness.db,
546
+ issuer: ISSUER,
547
+ });
548
+ expect(res.status).toBe(404);
549
+ const body = (await res.json()) as Record<string, unknown>;
550
+ expect(body.error).toBe("not_found");
551
+ });
552
+
553
+ test("405 on a non-DELETE method", async () => {
554
+ const { bearer } = await makeOperatorBearer();
555
+ const id = regApproved();
556
+ const res = await handleDeleteClient(deleteReq(id, bearer, "GET"), id, {
557
+ db: harness.db,
558
+ issuer: ISSUER,
559
+ });
560
+ expect(res.status).toBe(405);
561
+ // Row untouched.
562
+ expect(getClient(harness.db, id)).not.toBeNull();
563
+ });
564
+ });
@@ -849,6 +849,75 @@ describe("parachute auth pending-clients / approve-client", () => {
849
849
  });
850
850
  });
851
851
 
852
+ // hub#640 — RFC 7592 deregistration from the terminal.
853
+ describe("parachute auth revoke-client", () => {
854
+ test("revoke-client without an arg is a usage error", async () => {
855
+ const tmp = makeTmp();
856
+ try {
857
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
858
+ const { code, stderr } = await captureOutput(() => auth(["revoke-client"], deps));
859
+ expect(code).toBe(1);
860
+ expect(stderr).toContain("missing client_id");
861
+ } finally {
862
+ tmp.cleanup();
863
+ }
864
+ });
865
+
866
+ test("revoke-client <unknown> exits 1 with a friendly message", async () => {
867
+ const tmp = makeTmp();
868
+ try {
869
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
870
+ const { code, stderr } = await captureOutput(() => auth(["revoke-client", "no-such"], deps));
871
+ expect(code).toBe(1);
872
+ expect(stderr).toContain("no OAuth client");
873
+ } finally {
874
+ tmp.cleanup();
875
+ }
876
+ });
877
+
878
+ test("revoke-client deletes the client + cascades its grant + emits audit line", async () => {
879
+ const tmp = makeTmp();
880
+ try {
881
+ const db = openHubDb(tmp.dbPath);
882
+ let userId: string;
883
+ let clientId: string;
884
+ try {
885
+ const user = await createUser(db, "owner", "pw");
886
+ userId = user.id;
887
+ clientId = registerClient(db, {
888
+ redirectUris: ["https://app.example/cb"],
889
+ clientName: "MyApp",
890
+ }).client.clientId;
891
+ recordGrant(db, userId, clientId, ["vault:work:read"]);
892
+ expect(findGrant(db, userId, clientId)).not.toBeNull();
893
+ } finally {
894
+ db.close();
895
+ }
896
+ const deps: AuthDeps = { dbPath: tmp.dbPath };
897
+ const { code, stdout } = await captureOutput(() => auth(["revoke-client", clientId], deps));
898
+ expect(code).toBe(0);
899
+ expect(stdout).toContain("Deregistered OAuth client");
900
+ // Audit line for greppability (matches the route's shape, remover_sub=cli).
901
+ expect(stdout).toContain(`client deleted: client_id=${clientId}`);
902
+ expect(stdout).toContain("client_name=MyApp");
903
+ expect(stdout).toContain("remover_sub=cli");
904
+
905
+ // Verify the cascade actually landed in the db.
906
+ const db2 = openHubDb(tmp.dbPath);
907
+ try {
908
+ expect(
909
+ db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(clientId),
910
+ ).toBeNull();
911
+ expect(findGrant(db2, userId, clientId)).toBeNull();
912
+ } finally {
913
+ db2.close();
914
+ }
915
+ } finally {
916
+ tmp.cleanup();
917
+ }
918
+ });
919
+ });
920
+
852
921
  // closes #75 — operator-facing controls for the OAuth consent skip-list.
853
922
  describe("parachute auth list-grants / revoke-grant", () => {
854
923
  test("list-grants shows the seeding hint when no users exist", async () => {
@@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { issueAuthCode } from "../auth-codes.ts";
5
6
  import {
6
7
  InvalidRedirectUriError,
7
8
  approveClient,
9
+ deleteClient,
8
10
  expandRedirectUrisForHubOrigins,
9
11
  getClient,
10
12
  isValidRedirectUri,
@@ -13,7 +15,9 @@ import {
13
15
  requireRegisteredRedirectUri,
14
16
  verifyClientSecret,
15
17
  } from "../clients.ts";
18
+ import { findGrant, recordGrant } from "../grants.ts";
16
19
  import { hubDbPath, openHubDb } from "../hub-db.ts";
20
+ import { createUser } from "../users.ts";
17
21
 
18
22
  function makeDb() {
19
23
  const configDir = mkdtempSync(join(tmpdir(), "phub-clients-"));
@@ -341,6 +345,91 @@ describe("approval gate (#74)", () => {
341
345
  });
342
346
  });
343
347
 
348
+ describe("deleteClient cascade (hub#640 RFC 7592 deregistration)", () => {
349
+ test("returns false for an unknown client_id (nothing to delete)", () => {
350
+ const { db, cleanup } = makeDb();
351
+ try {
352
+ expect(deleteClient(db, "no-such-client")).toBe(false);
353
+ } finally {
354
+ cleanup();
355
+ }
356
+ });
357
+
358
+ test("deletes the client row and reports true", () => {
359
+ const { db, cleanup } = makeDb();
360
+ try {
361
+ const r = registerClient(db, { redirectUris: ["https://app.example/cb"] });
362
+ expect(getClient(db, r.client.clientId)).not.toBeNull();
363
+ expect(deleteClient(db, r.client.clientId)).toBe(true);
364
+ expect(getClient(db, r.client.clientId)).toBeNull();
365
+ } finally {
366
+ cleanup();
367
+ }
368
+ });
369
+
370
+ test("cascades dependent grants + auth_codes (FK ON, no ON DELETE CASCADE)", async () => {
371
+ const { db, cleanup } = makeDb();
372
+ try {
373
+ const user = await createUser(db, "owner", "pw");
374
+ const r = registerClient(db, {
375
+ redirectUris: ["https://app.example/cb"],
376
+ scopes: ["vault:work:read"],
377
+ });
378
+ const clientId = r.client.clientId;
379
+
380
+ // Plant a live grant + auth_code that reference the client. Without the
381
+ // cascade, the bare DELETE FROM clients would throw a FK violation while
382
+ // these rows exist (PRAGMA foreign_keys = ON).
383
+ recordGrant(db, user.id, clientId, ["vault:work:read"]);
384
+ const ac = issueAuthCode(db, {
385
+ clientId,
386
+ userId: user.id,
387
+ redirectUri: "https://app.example/cb",
388
+ scopes: ["vault:work:read"],
389
+ codeChallenge: "x".repeat(43),
390
+ codeChallengeMethod: "S256",
391
+ });
392
+ // Sanity: the dependents are present before the delete.
393
+ expect(findGrant(db, user.id, clientId)).not.toBeNull();
394
+ expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).not.toBeNull();
395
+
396
+ // Delete cascades — no FK throw, true returned.
397
+ expect(deleteClient(db, clientId)).toBe(true);
398
+
399
+ // Client + both dependents are gone.
400
+ expect(getClient(db, clientId)).toBeNull();
401
+ expect(findGrant(db, user.id, clientId)).toBeNull();
402
+ expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).toBeNull();
403
+ // The user row is untouched — only client-keyed rows cascade.
404
+ expect(db.query("SELECT id FROM users WHERE id = ?").get(user.id)).not.toBeNull();
405
+ } finally {
406
+ cleanup();
407
+ }
408
+ });
409
+
410
+ test("transactional: deleting one client leaves a sibling client's grants intact", async () => {
411
+ const { db, cleanup } = makeDb();
412
+ try {
413
+ const user = await createUser(db, "owner", "pw");
414
+ const keep = registerClient(db, { redirectUris: ["https://keep.example/cb"] });
415
+ const drop = registerClient(db, { redirectUris: ["https://drop.example/cb"] });
416
+ recordGrant(db, user.id, keep.client.clientId, ["vault:work:read"]);
417
+ recordGrant(db, user.id, drop.client.clientId, ["vault:work:read"]);
418
+
419
+ expect(deleteClient(db, drop.client.clientId)).toBe(true);
420
+
421
+ // The kept client + its grant survive; only the dropped client's
422
+ // grant cascaded.
423
+ expect(getClient(db, keep.client.clientId)).not.toBeNull();
424
+ expect(findGrant(db, user.id, keep.client.clientId)).not.toBeNull();
425
+ expect(getClient(db, drop.client.clientId)).toBeNull();
426
+ expect(findGrant(db, user.id, drop.client.clientId)).toBeNull();
427
+ } finally {
428
+ cleanup();
429
+ }
430
+ });
431
+ });
432
+
344
433
  describe("isValidRedirectUri", () => {
345
434
  test("accepts http and https", () => {
346
435
  expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { getClient, registerClient } from "../clients.ts";
5
6
  import {
6
7
  CORS_PREFLIGHT_HEADERS,
7
8
  CORS_RESPONSE_HEADERS,
@@ -11,7 +12,9 @@ import {
11
12
  } from "../cors.ts";
12
13
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
14
  import { hubFetch } from "../hub-server.ts";
15
+ import { signAccessToken } from "../jwt-sign.ts";
14
16
  import { writeManifest } from "../services-manifest.ts";
17
+ import { createUser } from "../users.ts";
15
18
 
16
19
  const GITCOIN_BRAIN_ORIGIN = "https://unforced-dev.github.io";
17
20
  const EXAMPLE_ORIGIN = "https://example.com";
@@ -51,10 +54,12 @@ describe("cors helper module", () => {
51
54
  expect(CORS_RESPONSE_HEADERS["access-control-expose-headers"]).toContain("WWW-Authenticate");
52
55
  });
53
56
 
54
- test("CORS_PREFLIGHT_HEADERS announces GET + POST + OPTIONS and standard request headers", () => {
57
+ test("CORS_PREFLIGHT_HEADERS announces GET + POST + DELETE + OPTIONS and standard request headers", () => {
55
58
  const methods = CORS_PREFLIGHT_HEADERS["access-control-allow-methods"] ?? "";
56
59
  expect(methods).toContain("GET");
57
60
  expect(methods).toContain("POST");
61
+ // DELETE for RFC 7592 client deregistration (hub#640).
62
+ expect(methods).toContain("DELETE");
58
63
  expect(methods).toContain("OPTIONS");
59
64
  const headers = CORS_PREFLIGHT_HEADERS["access-control-allow-headers"] ?? "";
60
65
  expect(headers).toContain("Authorization");
@@ -585,3 +590,135 @@ describe("hubFetch CORS scope discipline — out-of-scope routes stay same-origi
585
590
  }
586
591
  });
587
592
  });
593
+
594
+ // hub#640 — confirm the TOP-LEVEL DELETE /oauth/clients/<id> route is wired
595
+ // into the real dispatch (not just the handler in isolation). This is the
596
+ // load-bearing "right prefix" check: the surface remove-flow fires DELETE at
597
+ // exactly this path, and before this route the hub 404'd it. Goes through
598
+ // hubFetch so the dispatch order + the path-prefix branch are exercised.
599
+ describe("hub#640 DELETE /oauth/clients/<id> dispatch (RFC 7592)", () => {
600
+ async function operatorBearer(db: ReturnType<typeof openHubDb>): Promise<string> {
601
+ const user = await createUser(db, "owner", "pw");
602
+ const minted = await signAccessToken(db, {
603
+ sub: user.id,
604
+ scopes: ["parachute:host:admin"],
605
+ audience: "hub",
606
+ clientId: "parachute-hub-spa",
607
+ issuer: ISSUER,
608
+ ttlSeconds: 600,
609
+ });
610
+ return minted.token;
611
+ }
612
+
613
+ test("204 + row gone with a valid operator Bearer (the surface remove-flow path)", async () => {
614
+ const h = makeHarness();
615
+ try {
616
+ writeManifest({ services: [] }, h.manifestPath);
617
+ const db = openHubDb(hubDbPath(h.dir));
618
+ try {
619
+ const bearer = await operatorBearer(db);
620
+ const id = registerClient(db, {
621
+ redirectUris: ["https://app.example/cb"],
622
+ clientName: "Notes",
623
+ }).client.clientId;
624
+
625
+ const res = await hubFetch(h.dir, {
626
+ getDb: () => db,
627
+ issuer: ISSUER,
628
+ manifestPath: h.manifestPath,
629
+ })(
630
+ new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(id)}`, {
631
+ method: "DELETE",
632
+ headers: { authorization: `Bearer ${bearer}` },
633
+ }),
634
+ );
635
+ expect(res.status).toBe(204);
636
+ expect(getClient(db, id)).toBeNull();
637
+ } finally {
638
+ db.close();
639
+ }
640
+ } finally {
641
+ h.cleanup();
642
+ }
643
+ });
644
+
645
+ test("401 without a Bearer — the route is auth-gated, not open", async () => {
646
+ const h = makeHarness();
647
+ try {
648
+ writeManifest({ services: [] }, h.manifestPath);
649
+ const db = openHubDb(hubDbPath(h.dir));
650
+ try {
651
+ const id = registerClient(db, {
652
+ redirectUris: ["https://app.example/cb"],
653
+ }).client.clientId;
654
+ const res = await hubFetch(h.dir, {
655
+ getDb: () => db,
656
+ issuer: ISSUER,
657
+ manifestPath: h.manifestPath,
658
+ })(
659
+ new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(id)}`, {
660
+ method: "DELETE",
661
+ }),
662
+ );
663
+ expect(res.status).toBe(401);
664
+ // Row survives an unauthenticated DELETE.
665
+ expect(getClient(db, id)).not.toBeNull();
666
+ } finally {
667
+ db.close();
668
+ }
669
+ } finally {
670
+ h.cleanup();
671
+ }
672
+ });
673
+
674
+ test("404 for an absent client_id through dispatch (matches surface 'not_found')", async () => {
675
+ const h = makeHarness();
676
+ try {
677
+ writeManifest({ services: [] }, h.manifestPath);
678
+ const db = openHubDb(hubDbPath(h.dir));
679
+ try {
680
+ const bearer = await operatorBearer(db);
681
+ const res = await hubFetch(h.dir, {
682
+ getDb: () => db,
683
+ issuer: ISSUER,
684
+ manifestPath: h.manifestPath,
685
+ })(
686
+ new Request(`${ISSUER}/oauth/clients/no-such-client`, {
687
+ method: "DELETE",
688
+ headers: { authorization: `Bearer ${bearer}` },
689
+ }),
690
+ );
691
+ expect(res.status).toBe(404);
692
+ // The surface keys off a JSON 404 → hubDeleteStatus "not_found".
693
+ const body = (await res.json()) as Record<string, unknown>;
694
+ expect(body.error).toBe("not_found");
695
+ } finally {
696
+ db.close();
697
+ }
698
+ } finally {
699
+ h.cleanup();
700
+ }
701
+ });
702
+
703
+ test("OPTIONS preflight on the DELETE path advertises DELETE in Allow-Methods", async () => {
704
+ const h = makeHarness();
705
+ try {
706
+ writeManifest({ services: [] }, h.manifestPath);
707
+ const db = openHubDb(hubDbPath(h.dir));
708
+ try {
709
+ const res = await hubFetch(h.dir, {
710
+ getDb: () => db,
711
+ issuer: ISSUER,
712
+ manifestPath: h.manifestPath,
713
+ })(preflight("/oauth/clients/some-id", EXAMPLE_ORIGIN));
714
+ expect(res.status).toBe(204);
715
+ expect(res.headers.get("access-control-allow-methods")).toContain("DELETE");
716
+ expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
717
+ } finally {
718
+ db.close();
719
+ }
720
+ } finally {
721
+ h.cleanup();
722
+ }
723
+ });
724
+ });
@@ -353,6 +353,88 @@ describe("handleAuthorizeGet", () => {
353
353
  }
354
354
  });
355
355
 
356
+ // hub#314 — same-hub vs external trust marker reaches the rendered consent
357
+ // screen via `client.sameHub`. An unnamed `vault:read` request from a
358
+ // same-hub client falls through to consent (the auto-approve gate requires
359
+ // `!hasUnnamedVault`), so we can assert the marker on the GET render.
360
+ test("renders the EXTERNAL trust marker for a third-party DCR client", async () => {
361
+ const { db, cleanup } = await makeDb();
362
+ try {
363
+ const user = await createUser(db, "owner", "pw");
364
+ const session = createSession(db, { userId: user.id });
365
+ // registerClient defaults sameHub:false → external (third-party DCR).
366
+ const reg = registerClient(db, {
367
+ redirectUris: ["https://app.example/cb"],
368
+ clientName: "ThirdPartyApp",
369
+ });
370
+ const { challenge } = makePkce();
371
+ const req = new Request(
372
+ authorizeUrl({
373
+ client_id: reg.client.clientId,
374
+ redirect_uri: "https://app.example/cb",
375
+ response_type: "code",
376
+ code_challenge: challenge,
377
+ code_challenge_method: "S256",
378
+ scope: "vault:read",
379
+ }),
380
+ {
381
+ headers: {
382
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
383
+ },
384
+ },
385
+ );
386
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
387
+ expect(res.status).toBe(200);
388
+ const html = await res.text();
389
+ // Element form — the `.badge-trust-*` CSS class names are always in the
390
+ // inlined <style>; the rendered ELEMENT only appears when the marker fires.
391
+ expect(html).toContain('class="badge badge-trust-external"');
392
+ expect(html).toContain("third-party app that registered itself");
393
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
394
+ } finally {
395
+ cleanup();
396
+ }
397
+ });
398
+
399
+ test("renders the FIRST-PARTY trust marker for a same-hub client", async () => {
400
+ const { db, cleanup } = await makeDb();
401
+ try {
402
+ const user = await createUser(db, "owner", "pw");
403
+ const session = createSession(db, { userId: user.id });
404
+ const reg = registerClient(db, {
405
+ redirectUris: ["https://app.example/cb"],
406
+ clientName: "FirstPartyApp",
407
+ sameHub: true,
408
+ });
409
+ const { challenge } = makePkce();
410
+ const req = new Request(
411
+ authorizeUrl({
412
+ client_id: reg.client.clientId,
413
+ redirect_uri: "https://app.example/cb",
414
+ response_type: "code",
415
+ code_challenge: challenge,
416
+ code_challenge_method: "S256",
417
+ // Unnamed vault verb → bypasses the same-hub auto-approve gate
418
+ // (`!hasUnnamedVault`) and falls through to the consent render.
419
+ scope: "vault:read",
420
+ }),
421
+ {
422
+ headers: {
423
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
424
+ },
425
+ },
426
+ );
427
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
428
+ expect(res.status).toBe(200);
429
+ const html = await res.text();
430
+ expect(html).toContain('class="badge badge-trust-same-hub"');
431
+ expect(html).toContain("Registered through this hub");
432
+ expect(html).not.toContain('class="badge badge-trust-external"');
433
+ } finally {
434
+ cleanup();
435
+ }
436
+ });
437
+
356
438
  test("rejects unknown client_id with 400", async () => {
357
439
  const { db, cleanup } = await makeDb();
358
440
  try {
@@ -10142,6 +10224,62 @@ describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
10142
10224
  }
10143
10225
  });
10144
10226
 
10227
+ // GET render (hub#703, folded into hub#314): a user with MIXED authority —
10228
+ // admin on vault A (role=write → holds admin) but only read on vault B
10229
+ // (direct INSERT role=read) — does NOT see the selector. The user could pick
10230
+ // either vault, but doesn't own (hold admin on) EVERY pickable vault, so the
10231
+ // `userHoldsAdminOnPickable` predicate (`assignedVaults.every(v => verbs
10232
+ // includes "admin")`) fails on vault B and the selector is suppressed. The
10233
+ // suppression logic already ships + is correct (oauth-handlers.ts ~2963 +
10234
+ // ~1226); this test closes the coverage gap with no code change.
10235
+ test("selector NOT rendered for a mixed-authority user (admin on A, read-only on B)", async () => {
10236
+ const { db, cleanup } = await makeDb();
10237
+ try {
10238
+ await createUser(db, "owner", "pw");
10239
+ const mixed = await createUser(db, "mixed", "pw", { allowMulti: true });
10240
+ // Vault A ("work"): role=write → vaultVerbsForRole maps to [read,write,
10241
+ // admin], so the user holds admin on A. (setUserVaults hardcodes write.)
10242
+ setUserVaults(db, mixed.id, ["work"]);
10243
+ // Vault B ("other"): direct INSERT role=read → holds read only, NOT admin.
10244
+ // setUserVaults DELETEs first, so this INSERT must come after it to keep A.
10245
+ db.prepare(
10246
+ "INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
10247
+ ).run(mixed.id, "other", new Date().toISOString());
10248
+ const session = createSession(db, { userId: mixed.id });
10249
+ const reg = registerClient(db, {
10250
+ redirectUris: ["https://app.example/cb"],
10251
+ status: "approved",
10252
+ });
10253
+ const { challenge } = makePkce();
10254
+ const res = handleAuthorizeGet(
10255
+ db,
10256
+ new Request(
10257
+ authorizeUrl({
10258
+ client_id: reg.client.clientId,
10259
+ redirect_uri: "https://app.example/cb",
10260
+ response_type: "code",
10261
+ code_challenge: challenge,
10262
+ code_challenge_method: "S256",
10263
+ scope: "vault:read",
10264
+ }),
10265
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
10266
+ ),
10267
+ selDeps,
10268
+ );
10269
+ expect(res.status).toBe(200);
10270
+ const html = await res.text();
10271
+ // Suppressed: the `.every(v => verbs includes "admin")` check fails on B.
10272
+ expect(html).not.toContain("Access level");
10273
+ expect(html).not.toContain('name="verb_select"');
10274
+ // Sanity: the multi-vault picker DID render (two assigned vaults), so the
10275
+ // suppression is specifically the verb selector, not the whole flow.
10276
+ expect(html).toContain('name="vault_pick" value="work"');
10277
+ expect(html).toContain('name="vault_pick" value="other"');
10278
+ } finally {
10279
+ cleanup();
10280
+ }
10281
+ });
10282
+
10145
10283
  // GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
10146
10284
  // see the selector — they can't authorize a vault scope at all.
10147
10285
  test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
@@ -306,6 +306,58 @@ describe("renderConsent", () => {
306
306
  expect(html).not.toContain("Access level");
307
307
  expect(html).not.toContain('name="verb_select"');
308
308
  });
309
+
310
+ // hub#314 — same-hub vs external trust marker. The `.badge-trust-*` class
311
+ // names are always present in the inlined <style> block, so assertions target
312
+ // the RENDERED ELEMENT form (`class="badge badge-trust-*"`) + the copy text,
313
+ // which only appear in the consent body when the marker actually renders.
314
+ test("renders the first-party trust marker for a same-hub client", () => {
315
+ const html = renderConsent({
316
+ params: PARAMS,
317
+ csrfToken: CSRF,
318
+ clientId: "c",
319
+ clientName: "App",
320
+ scopes: ["vault:read"],
321
+ sameHub: true,
322
+ });
323
+ expect(html).toContain('class="badge badge-trust-same-hub"');
324
+ expect(html).toContain(">First-party<");
325
+ expect(html).toContain("Registered through this hub");
326
+ // The external badge / copy must NOT appear for a same-hub client.
327
+ expect(html).not.toContain('class="badge badge-trust-external"');
328
+ expect(html).not.toContain("third-party app that registered itself");
329
+ });
330
+
331
+ test("renders the external trust marker for a third-party DCR client", () => {
332
+ const html = renderConsent({
333
+ params: PARAMS,
334
+ csrfToken: CSRF,
335
+ clientId: "c",
336
+ clientName: "App",
337
+ scopes: ["vault:read"],
338
+ sameHub: false,
339
+ });
340
+ expect(html).toContain('class="badge badge-trust-external"');
341
+ expect(html).toContain(">External<");
342
+ expect(html).toContain("third-party app that registered itself");
343
+ // The first-party badge / copy must NOT appear for an external client.
344
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
345
+ expect(html).not.toContain("Registered through this hub");
346
+ });
347
+
348
+ test("renders no trust marker when provenance is unknown (sameHub omitted)", () => {
349
+ const html = renderConsent({
350
+ params: PARAMS,
351
+ csrfToken: CSRF,
352
+ clientId: "c",
353
+ clientName: "App",
354
+ scopes: ["vault:read"],
355
+ // sameHub omitted → undefined → no badge
356
+ });
357
+ expect(html).not.toContain('class="trust-marker');
358
+ expect(html).not.toContain('class="badge badge-trust-same-hub"');
359
+ expect(html).not.toContain('class="badge badge-trust-external"');
360
+ });
309
361
  });
310
362
 
311
363
  describe("renderError", () => {
@@ -4,8 +4,11 @@
4
4
  * without round-tripping through the `/oauth/authorize` flow (whose
5
5
  * `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
6
6
  *
7
- * GET /api/oauth/clients/<client_id> client details
8
- * POST /api/oauth/clients/<client_id>/approve flip status to approved
7
+ * GET /api/oauth/clients/<client_id> client details
8
+ * POST /api/oauth/clients/<client_id>/approve flip status to approved
9
+ * DELETE /oauth/clients/<client_id> deregister (RFC 7592) — note
10
+ * the TOP-LEVEL prefix, see
11
+ * handleDeleteClient
9
12
  *
10
13
  * Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
11
14
  * /api/auth/tokens, etc.). The SPA mints one via the session cookie at
@@ -54,7 +57,7 @@ import {
54
57
  requireScope,
55
58
  } from "./admin-auth.ts";
56
59
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
57
- import { approveClient, getClient } from "./clients.ts";
60
+ import { approveClient, deleteClient, getClient } from "./clients.ts";
58
61
  import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
59
62
 
60
63
  export interface AdminClientsDeps {
@@ -177,6 +180,55 @@ export async function handleApproveClient(
177
180
  });
178
181
  }
179
182
 
183
+ /**
184
+ * RFC 7592 Dynamic Client Registration *deletion* (deregistration).
185
+ *
186
+ * DELETE /oauth/clients/<client_id> remove the client + its cascade
187
+ *
188
+ * Mounted at the TOP-LEVEL `/oauth/clients/` prefix (NOT under `/api/...`)
189
+ * because that's the path parachute-surface's remove-flow actually calls
190
+ * (`packages/surface-host/src/dcr.ts` → `DELETE <hub>/oauth/clients/<id>`),
191
+ * carrying the operator token as a Bearer. Before this route existed the
192
+ * hub 404'd every such DELETE, so every Notes/Claude reconnect orphaned a
193
+ * `clients` row in the operator's DB (closes hub#640, 4/5 boxes — the GC
194
+ * reaper for legacy orphans is a separate follow-up).
195
+ *
196
+ * Auth mirrors `handleGetClient`: `parachute:host:admin` Bearer via
197
+ * `requireScope`. Returns 204 (no content) on a successful delete, 404 when
198
+ * the client isn't registered — the same shape the surface already tolerates
199
+ * (`hubDeleteStatus: "ok"` on 200/204, `"not_found"` on a JSON 404).
200
+ *
201
+ * Audit: emits a `client deleted: ...` line in the same `key=value` shape as
202
+ * the `client approved: ...` line, so cross-machine "who removed this client"
203
+ * is greppable in hub.log.
204
+ */
205
+ export async function handleDeleteClient(
206
+ req: Request,
207
+ clientId: string,
208
+ deps: AdminClientsDeps,
209
+ ): Promise<Response> {
210
+ if (req.method !== "DELETE") {
211
+ return jsonError(405, "method_not_allowed", "use DELETE");
212
+ }
213
+ let ctx: AdminAuthContext;
214
+ try {
215
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
216
+ } catch (err) {
217
+ return adminAuthErrorResponse(err as AdminAuthError);
218
+ }
219
+ // Capture the name BEFORE deleting so the audit line can carry it.
220
+ const before = getClient(deps.db, clientId);
221
+ const removed = deleteClient(deps.db, clientId);
222
+ if (!removed) {
223
+ return jsonError(404, "not_found", `no client registered with id ${clientId}`);
224
+ }
225
+ console.log(
226
+ `client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=${ctx.sub}`,
227
+ );
228
+ // 204 No Content — RFC 7592 §2.3 prescribes 204 for a successful delete.
229
+ return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
230
+ }
231
+
180
232
  interface ApproveClientResponse {
181
233
  client_id: string;
182
234
  status: "approved";
package/src/clients.ts CHANGED
@@ -203,6 +203,41 @@ export function getClient(db: Database, clientId: string): OAuthClient | null {
203
203
  return row ? rowToClient(row) : null;
204
204
  }
205
205
 
206
+ /**
207
+ * Delete an OAuth client and everything that references it. Returns true when
208
+ * a client row was removed, false when no such client existed.
209
+ *
210
+ * Backs the RFC 7592 `DELETE /oauth/clients/<id>` deregistration endpoint
211
+ * (closes hub#640): parachute-surface fires a best-effort DELETE on every
212
+ * Notes/Claude remove-flow, so without this helper + route every reconnect
213
+ * orphaned a `clients` row in the operator's hub DB.
214
+ *
215
+ * CASCADE-BY-HAND. `auth_codes.client_id` and `grants.client_id` both
216
+ * `REFERENCES clients(client_id)` with NO `ON DELETE CASCADE`, and the hub
217
+ * opens its DB with `PRAGMA foreign_keys = ON` — so a bare
218
+ * `DELETE FROM clients` while a dependent auth_code or grant row exists
219
+ * throws a FOREIGN KEY constraint violation. We delete the dependents FIRST,
220
+ * then the client row, all inside a single transaction so a mid-cascade
221
+ * failure rolls the whole thing back (no half-deleted client). Modelled on
222
+ * `grants.revokeGrant` + the vault-delete cascade in `admin-vaults`.
223
+ *
224
+ * Note on tokens: access tokens already minted for this client are NOT
225
+ * touched here (the `tokens` registry is keyed by jti, not client_id, and
226
+ * carries no FK to `clients`). They expire on their own; an operator who
227
+ * wants to kill live sessions runs `/oauth/revoke` separately. Same posture
228
+ * `revokeGrant` documents.
229
+ */
230
+ export function deleteClient(db: Database, clientId: string): boolean {
231
+ return db.transaction(() => {
232
+ // Delete dependents first — FK ON, no cascade, so the client row can't
233
+ // go while an auth_code or grant still points at it.
234
+ db.prepare("DELETE FROM auth_codes WHERE client_id = ?").run(clientId);
235
+ db.prepare("DELETE FROM grants WHERE client_id = ?").run(clientId);
236
+ const res = db.prepare("DELETE FROM clients WHERE client_id = ?").run(clientId);
237
+ return res.changes > 0;
238
+ })();
239
+ }
240
+
206
241
  /**
207
242
  * Returns the registered redirect URI matching `candidate` exactly, or throws.
208
243
  * RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { join } from "node:path";
27
27
  import { createInterface } from "node:readline/promises";
28
- import { approveClient, getClient, listClientsByStatus } from "../clients.ts";
28
+ import { approveClient, deleteClient, getClient, listClientsByStatus } from "../clients.ts";
29
29
  import { CONFIG_DIR } from "../config.ts";
30
30
  import { readExposeState } from "../expose-state.ts";
31
31
  import { listGrantsForUser, revokeGrant } from "../grants.ts";
@@ -96,6 +96,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
96
96
  "revoke-token",
97
97
  "pending-clients",
98
98
  "approve-client",
99
+ "revoke-client",
99
100
  "list-grants",
100
101
  "revoke-grant",
101
102
  ]);
@@ -135,6 +136,9 @@ Usage:
135
136
  exits 0.
136
137
  parachute auth pending-clients List OAuth clients awaiting approval
137
138
  parachute auth approve-client <id> Approve a pending OAuth client
139
+ parachute auth revoke-client <id> Deregister (delete) an OAuth client,
140
+ cascading its grants + auth codes
141
+ (RFC 7592 deregistration)
138
142
  parachute auth list-grants [--username <name>]
139
143
  Show OAuth scope grants on record
140
144
  parachute auth revoke-grant <client_id> [--username <name>]
@@ -619,6 +623,44 @@ function runApproveClient(args: readonly string[], deps: AuthDeps): number {
619
623
  }
620
624
  }
621
625
 
626
+ /**
627
+ * `parachute auth revoke-client <id>` — RFC 7592 deregistration from the
628
+ * terminal. The CLI complement to the `DELETE /oauth/clients/<id>` route
629
+ * parachute-surface fires automatically; an operator runs this to clean up
630
+ * an orphaned / stale client by hand (closes hub#640). Calls the
631
+ * `deleteClient` cascade helper directly (same db, no round-trip through the
632
+ * HTTP route) and emits the same `client deleted:` audit line the route does
633
+ * so browser-driven and terminal-driven deletions are uniformly greppable in
634
+ * hub.log. `remover_sub=cli` distinguishes the terminal path (which has no
635
+ * authenticated JWT subject) from the route's `remover_sub=<jwt sub>`.
636
+ */
637
+ function runRevokeClient(args: readonly string[], deps: AuthDeps): number {
638
+ const clientId = args[0];
639
+ if (!clientId) {
640
+ console.error("parachute auth revoke-client: missing client_id argument");
641
+ console.error("usage: parachute auth revoke-client <client_id>");
642
+ return 1;
643
+ }
644
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
645
+ try {
646
+ // Read the name before deleting so the audit line + confirmation can
647
+ // carry it. getClient also gives us the "exists?" answer for the 404 path.
648
+ const before = getClient(db, clientId);
649
+ const removed = deleteClient(db, clientId);
650
+ if (!removed) {
651
+ console.error(`no OAuth client registered with client_id "${clientId}"`);
652
+ return 1;
653
+ }
654
+ console.log(
655
+ `client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=cli`,
656
+ );
657
+ console.log(`Deregistered OAuth client "${clientId}" (grants + auth codes cascaded).`);
658
+ return 0;
659
+ } finally {
660
+ db.close();
661
+ }
662
+ }
663
+
622
664
  interface UsernameFlag {
623
665
  username?: string;
624
666
  rest: string[];
@@ -1405,6 +1447,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
1405
1447
  return 1;
1406
1448
  }
1407
1449
  }
1450
+ if (sub === "revoke-client") {
1451
+ try {
1452
+ return runRevokeClient(args.slice(1), normalized);
1453
+ } catch (err) {
1454
+ const msg = err instanceof Error ? err.message : String(err);
1455
+ console.error(`parachute auth revoke-client: ${msg}`);
1456
+ return 1;
1457
+ }
1458
+ }
1408
1459
  if (sub === "list-grants") {
1409
1460
  try {
1410
1461
  return runListGrants(args.slice(1), normalized);
package/src/cors.ts CHANGED
@@ -89,8 +89,9 @@
89
89
  * leaking the wrong ACAO and breaking CORS in unpredictable ways.
90
90
  * Critical for cache correctness.
91
91
  *
92
- * Access-Control-Allow-Methods: GET, POST, OPTIONS
93
- * The union of methods the in-scope route family supports. Per-route
92
+ * Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
93
+ * The union of methods the in-scope route family supports (DELETE for the
94
+ * RFC 7592 `DELETE /oauth/clients/<id>` deregistration, hub#640). Per-route
94
95
  * could be narrower (e.g. /oauth/token is POST-only), but advertising
95
96
  * the union is the simpler shape and browsers don't enforce a per-route
96
97
  * check anyway — the *actual* request method gates execution at the
@@ -137,7 +138,10 @@ const CORS_STATIC_RESPONSE_HEADERS: Readonly<Record<string, string>> = {
137
138
  * `corsPreflightResponse`.
138
139
  */
139
140
  const CORS_STATIC_PREFLIGHT_HEADERS: Readonly<Record<string, string>> = {
140
- "access-control-allow-methods": "GET, POST, OPTIONS",
141
+ // DELETE is in the union for RFC 7592 client deregistration
142
+ // (`DELETE /oauth/clients/<id>`, hub#640). A cross-origin browser caller
143
+ // (vs the server-side surface daemon) would otherwise fail the preflight.
144
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
141
145
  "access-control-allow-headers": "Authorization, Content-Type, X-Requested-With",
142
146
  "access-control-max-age": "86400",
143
147
  };
package/src/hub-server.ts CHANGED
@@ -171,7 +171,7 @@ import {
171
171
  handleOAuthGrantCallback,
172
172
  } from "./admin-agent-grants.ts";
173
173
  import { handleAgentToken } from "./admin-agent-token.ts";
174
- import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
174
+ import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
175
175
  import {
176
176
  type ConnectionsDeps,
177
177
  handleConnections,
@@ -2767,6 +2767,33 @@ export function hubFetch(
2767
2767
  return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
2768
2768
  }
2769
2769
 
2770
+ // RFC 7592 client deregistration: DELETE /oauth/clients/<id> (hub#640).
2771
+ // Mounted at this TOP-LEVEL `/oauth/clients/` prefix — NOT under
2772
+ // `/api/oauth/clients/` — because that's the path parachute-surface's
2773
+ // remove-flow actually calls (`packages/surface-host/src/dcr.ts` fires a
2774
+ // best-effort DELETE on every Notes/Claude reconnect, carrying the
2775
+ // operator token as a Bearer). Without it the hub 404'd every such
2776
+ // DELETE and orphaned a `clients` row per reconnect. Operator-bearer-
2777
+ // gated (parachute:host:admin) inside handleDeleteClient; 204 on delete,
2778
+ // 404 if absent. CORS-wrapped + OPTIONS-preflighted like its OAuth
2779
+ // siblings (the top-of-dispatch isCorsAllowedRoute("/oauth/") preempts
2780
+ // the preflight). The GET/approve sub-paths stay on `/api/oauth/clients/`
2781
+ // (the SPA-facing admin surface) below.
2782
+ if (pathname.startsWith("/oauth/clients/")) {
2783
+ if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
2784
+ const clientId = decodeURIComponent(pathname.slice("/oauth/clients/".length));
2785
+ if (!clientId || clientId.includes("/")) {
2786
+ return applyCorsHeaders(req, new Response("not found", { status: 404 }));
2787
+ }
2788
+ return applyCorsHeaders(
2789
+ req,
2790
+ await handleDeleteClient(req, clientId, {
2791
+ db: getDb(),
2792
+ issuer: oauthDeps(req).issuer,
2793
+ }),
2794
+ );
2795
+ }
2796
+
2770
2797
  // Agent-connector OAuth-client callback (Phase 4b-2). The operator's
2771
2798
  // browser is redirected here by a REMOTE issuer after consenting to a
2772
2799
  // `kind:mcp` grant. Standalone server-rendered route — NOT under /admin/*,
@@ -2984,6 +2984,11 @@ function consentProps(
2984
2984
  blockApproveForStaleAssignment:
2985
2985
  staleAssignedVault !== undefined && (unnamedVerbs.length > 0 || hasNamedStaleVaultScope),
2986
2986
  userCanAuthorizeRequest,
2987
+ // hub#314 — surface the client's provenance (same-hub first-party vs
2988
+ // external third-party DCR) so the operator sees the trust level on the
2989
+ // consent screen. Clean DB-backed signal: the `same_hub` column written
2990
+ // at DCR time (bearer hub:admin / same-origin session → true).
2991
+ sameHub: client.sameHub,
2987
2992
  };
2988
2993
  }
2989
2994
 
package/src/oauth-ui.ts CHANGED
@@ -162,6 +162,21 @@ export interface ConsentViewProps {
162
162
  * the unnamed verb(s) on the picked vault; it never touches any other scope.
163
163
  */
164
164
  ownerVerbSelector?: OwnerVerbSelector;
165
+ /**
166
+ * hub#314 — same-hub vs external trust marker. True when the requesting
167
+ * client was registered through this hub's own flow / first-party install
168
+ * (`OAuthClient.sameHub` — bearer `hub:admin` OR session-cookie +
169
+ * same-origin DCR). False for a third-party Dynamic Client Registration
170
+ * (an external app, e.g. Claude.ai, that self-registered). Drives a small
171
+ * trust badge in the consent card header so the operator knows the trust
172
+ * level of the app they're approving before they click Approve.
173
+ *
174
+ * Omitted / undefined → no badge (provenance unknown; only the GET-handler
175
+ * call site, which always has the client row, populates it). Provenance is
176
+ * a clean DB-backed signal — see the `same_hub` column on `clients` and the
177
+ * `consentProps` call site in `oauth-handlers.ts`.
178
+ */
179
+ sameHub?: boolean;
165
180
  }
166
181
 
167
182
  export interface OwnerVerbSelector {
@@ -354,6 +369,7 @@ export function renderConsent(props: ConsentViewProps): string {
354
369
  blockApproveForStaleAssignment,
355
370
  userCanAuthorizeRequest,
356
371
  ownerVerbSelector,
372
+ sameHub,
357
373
  } = props;
358
374
  // Substitute unnamed `vault:<verb>` rows with the resolved named form so
359
375
  // the operator sees the scope shape that will appear in the token. Raw
@@ -418,6 +434,24 @@ export function renderConsent(props: ConsentViewProps): string {
418
434
  before authorizing vault access.
419
435
  </p>`
420
436
  : "";
437
+ // hub#314 — same-hub vs external trust marker. `sameHub === true` means the
438
+ // client was registered through this hub's own flow (first-party / operator-
439
+ // authenticated DCR); `false` means a third-party app self-registered via
440
+ // public Dynamic Client Registration. `undefined` → no badge (provenance
441
+ // unknown to the caller). The badge sits in the header so the operator sees
442
+ // the trust level before reading the scope list.
443
+ const trustMarker =
444
+ sameHub === undefined
445
+ ? ""
446
+ : sameHub
447
+ ? `<p class="trust-marker trust-marker-same-hub">
448
+ <span class="badge badge-trust-same-hub">First-party</span>
449
+ <span class="trust-marker-text">Registered through this hub.</span>
450
+ </p>`
451
+ : `<p class="trust-marker trust-marker-external">
452
+ <span class="badge badge-trust-external">External</span>
453
+ <span class="trust-marker-text">A third-party app that registered itself. Approve only if you recognise it.</span>
454
+ </p>`;
421
455
  const body = `
422
456
  <div class="card">
423
457
  <div class="card-header">
@@ -429,6 +463,7 @@ export function renderConsent(props: ConsentViewProps): string {
429
463
  <p class="subtitle">
430
464
  This app is requesting access to your Parachute account.
431
465
  </p>
466
+ ${trustMarker}
432
467
  <p class="client-meta">
433
468
  <span class="client-meta-label">client_id</span>
434
469
  <code>${escapeHtml(clientId)}</code>
@@ -1579,6 +1614,22 @@ const STYLES = `
1579
1614
  .badge-send { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
1580
1615
  .badge-admin { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
1581
1616
 
1617
+ /* hub#314 — same-hub vs external trust marker on the consent header. The
1618
+ first-party badge uses the accent (calm/trusted); external uses the danger
1619
+ tint so a third-party DCR client stands out without being alarmist. */
1620
+ .trust-marker {
1621
+ display: flex;
1622
+ align-items: baseline;
1623
+ gap: 0.45rem;
1624
+ flex-wrap: wrap;
1625
+ margin: 0.75rem 0 0;
1626
+ font-size: 0.85rem;
1627
+ color: ${PALETTE.fgMuted};
1628
+ }
1629
+ .trust-marker-text { flex: 1; min-width: 12rem; }
1630
+ .badge-trust-same-hub { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
1631
+ .badge-trust-external { background: ${PALETTE.dangerSoft}; color: ${PALETTE.danger}; }
1632
+
1582
1633
  @media (max-width: 480px) {
1583
1634
  main { padding: 0.75rem; }
1584
1635
  .card { padding: 1.5rem 1.25rem; border-radius: 10px; }