@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21

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.
Files changed (75) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -2,18 +2,24 @@ 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,
11
+ findReapableClients,
9
12
  getClient,
10
13
  isValidRedirectUri,
11
14
  listClientsByStatus,
15
+ reapClient,
12
16
  registerClient,
13
17
  requireRegisteredRedirectUri,
14
18
  verifyClientSecret,
15
19
  } from "../clients.ts";
20
+ import { findGrant, recordGrant } from "../grants.ts";
16
21
  import { hubDbPath, openHubDb } from "../hub-db.ts";
22
+ import { createUser } from "../users.ts";
17
23
 
18
24
  function makeDb() {
19
25
  const configDir = mkdtempSync(join(tmpdir(), "phub-clients-"));
@@ -151,7 +157,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
151
157
  const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
152
158
 
153
159
  test("expands a loopback-rooted URI onto every other hub origin", () => {
154
- const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
160
+ const out = expandRedirectUrisForHubOrigins(
161
+ [`${LOOPBACK}/surface/notes/oauth/callback`],
162
+ hubOrigins,
163
+ );
155
164
  // Original is preserved + the public + localhost variants are added.
156
165
  expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
157
166
  expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
@@ -188,10 +197,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
188
197
  });
189
198
 
190
199
  test("single known hub origin → no expansion (submitted set returned as-is)", () => {
191
- const out = expandRedirectUrisForHubOrigins(
192
- [`${LOOPBACK}/surface/notes/`],
193
- [LOOPBACK],
194
- );
200
+ const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
195
201
  expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
196
202
  });
197
203
 
@@ -222,9 +228,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
222
228
  const r = registerClient(db, { redirectUris: expanded });
223
229
  // The public-origin variant now matches exactly at authorize time — the
224
230
  // off-localhost sign-in that surface#118 broke.
225
- expect(
226
- requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
227
- ).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
231
+ expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
232
+ `${PUBLIC}/surface/notes/oauth/callback`,
233
+ );
228
234
  // A truly-unregistered URI is still rejected — strict match unchanged.
229
235
  expect(() =>
230
236
  requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
@@ -341,6 +347,298 @@ describe("approval gate (#74)", () => {
341
347
  });
342
348
  });
343
349
 
350
+ describe("deleteClient cascade (hub#640 RFC 7592 deregistration)", () => {
351
+ test("returns false for an unknown client_id (nothing to delete)", () => {
352
+ const { db, cleanup } = makeDb();
353
+ try {
354
+ expect(deleteClient(db, "no-such-client")).toBe(false);
355
+ } finally {
356
+ cleanup();
357
+ }
358
+ });
359
+
360
+ test("deletes the client row and reports true", () => {
361
+ const { db, cleanup } = makeDb();
362
+ try {
363
+ const r = registerClient(db, { redirectUris: ["https://app.example/cb"] });
364
+ expect(getClient(db, r.client.clientId)).not.toBeNull();
365
+ expect(deleteClient(db, r.client.clientId)).toBe(true);
366
+ expect(getClient(db, r.client.clientId)).toBeNull();
367
+ } finally {
368
+ cleanup();
369
+ }
370
+ });
371
+
372
+ test("cascades dependent grants + auth_codes (FK ON, no ON DELETE CASCADE)", async () => {
373
+ const { db, cleanup } = makeDb();
374
+ try {
375
+ const user = await createUser(db, "owner", "pw");
376
+ const r = registerClient(db, {
377
+ redirectUris: ["https://app.example/cb"],
378
+ scopes: ["vault:work:read"],
379
+ });
380
+ const clientId = r.client.clientId;
381
+
382
+ // Plant a live grant + auth_code that reference the client. Without the
383
+ // cascade, the bare DELETE FROM clients would throw a FK violation while
384
+ // these rows exist (PRAGMA foreign_keys = ON).
385
+ recordGrant(db, user.id, clientId, ["vault:work:read"]);
386
+ const ac = issueAuthCode(db, {
387
+ clientId,
388
+ userId: user.id,
389
+ redirectUri: "https://app.example/cb",
390
+ scopes: ["vault:work:read"],
391
+ codeChallenge: "x".repeat(43),
392
+ codeChallengeMethod: "S256",
393
+ });
394
+ // Sanity: the dependents are present before the delete.
395
+ expect(findGrant(db, user.id, clientId)).not.toBeNull();
396
+ expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).not.toBeNull();
397
+
398
+ // Delete cascades — no FK throw, true returned.
399
+ expect(deleteClient(db, clientId)).toBe(true);
400
+
401
+ // Client + both dependents are gone.
402
+ expect(getClient(db, clientId)).toBeNull();
403
+ expect(findGrant(db, user.id, clientId)).toBeNull();
404
+ expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).toBeNull();
405
+ // The user row is untouched — only client-keyed rows cascade.
406
+ expect(db.query("SELECT id FROM users WHERE id = ?").get(user.id)).not.toBeNull();
407
+ } finally {
408
+ cleanup();
409
+ }
410
+ });
411
+
412
+ test("transactional: deleting one client leaves a sibling client's grants intact", async () => {
413
+ const { db, cleanup } = makeDb();
414
+ try {
415
+ const user = await createUser(db, "owner", "pw");
416
+ const keep = registerClient(db, { redirectUris: ["https://keep.example/cb"] });
417
+ const drop = registerClient(db, { redirectUris: ["https://drop.example/cb"] });
418
+ recordGrant(db, user.id, keep.client.clientId, ["vault:work:read"]);
419
+ recordGrant(db, user.id, drop.client.clientId, ["vault:work:read"]);
420
+
421
+ expect(deleteClient(db, drop.client.clientId)).toBe(true);
422
+
423
+ // The kept client + its grant survive; only the dropped client's
424
+ // grant cascaded.
425
+ expect(getClient(db, keep.client.clientId)).not.toBeNull();
426
+ expect(findGrant(db, user.id, keep.client.clientId)).not.toBeNull();
427
+ expect(getClient(db, drop.client.clientId)).toBeNull();
428
+ expect(findGrant(db, user.id, drop.client.clientId)).toBeNull();
429
+ } finally {
430
+ cleanup();
431
+ }
432
+ });
433
+ });
434
+
435
+ describe("findReapableClients / reapClient — the GC safety gate (hub#640)", () => {
436
+ const DAY_MS = 24 * 60 * 60 * 1000;
437
+ // A fixed "now" so age math is deterministic. All planted clients register
438
+ // 60 days before this unless a test overrides.
439
+ const NOW = new Date("2026-06-27T00:00:00Z");
440
+ const now = () => NOW;
441
+ const OLD = new Date(NOW.getTime() - 60 * DAY_MS); // 60d old → past 30d floor
442
+ const oldNow = () => OLD;
443
+
444
+ /** Plant a `tokens` row with precise expiry/revocation for a client. */
445
+ function plantToken(
446
+ db: ReturnType<typeof openHubDb>,
447
+ opts: {
448
+ clientId: string;
449
+ userId: string;
450
+ jti: string;
451
+ expiresAt: string;
452
+ revokedAt?: string | null;
453
+ },
454
+ ): void {
455
+ db.prepare(
456
+ `INSERT INTO tokens
457
+ (jti, user_id, client_id, scopes, refresh_token_hash, family_id,
458
+ expires_at, revoked_at, created_at, created_via, subject)
459
+ VALUES (?, ?, ?, '', NULL, NULL, ?, ?, ?, 'oauth_refresh', NULL)`,
460
+ ).run(
461
+ opts.jti,
462
+ opts.userId,
463
+ opts.clientId,
464
+ opts.expiresAt,
465
+ opts.revokedAt ?? null,
466
+ OLD.toISOString(),
467
+ );
468
+ }
469
+
470
+ test("a genuinely-dead old client IS reaped (no grants, only expired/revoked tokens, no live codes)", async () => {
471
+ const { db, cleanup } = makeDb();
472
+ try {
473
+ const user = await createUser(db, "owner", "pw");
474
+ const dead = registerClient(db, { redirectUris: ["https://dead.example/cb"], now: oldNow });
475
+ const id = dead.client.clientId;
476
+ // An expired token + a revoked token — both dead.
477
+ plantToken(db, {
478
+ clientId: id,
479
+ userId: user.id,
480
+ jti: "jti-expired",
481
+ expiresAt: new Date(NOW.getTime() - DAY_MS).toISOString(),
482
+ });
483
+ plantToken(db, {
484
+ clientId: id,
485
+ userId: user.id,
486
+ jti: "jti-revoked",
487
+ expiresAt: new Date(NOW.getTime() + DAY_MS).toISOString(), // unexpired...
488
+ revokedAt: new Date(NOW.getTime() - DAY_MS).toISOString(), // ...but revoked → dead
489
+ });
490
+
491
+ const reapable = findReapableClients(db, { now });
492
+ expect(reapable.map((c) => c.clientId)).toEqual([id]);
493
+ expect(reapable[0]?.ageDays).toBe(60);
494
+
495
+ // reapClient removes the client AND its dead token rows.
496
+ expect(reapClient(db, id)).toBe(true);
497
+ expect(getClient(db, id)).toBeNull();
498
+ expect(db.query("SELECT jti FROM tokens WHERE client_id = ?").all(id)).toEqual([]);
499
+ } finally {
500
+ cleanup();
501
+ }
502
+ });
503
+
504
+ test("a client WITH a live grant is NEVER reaped, even when old", async () => {
505
+ const { db, cleanup } = makeDb();
506
+ try {
507
+ const user = await createUser(db, "owner", "pw");
508
+ const c = registerClient(db, { redirectUris: ["https://granted.example/cb"], now: oldNow });
509
+ recordGrant(db, user.id, c.client.clientId, ["vault:work:read"]);
510
+ const reapable = findReapableClients(db, { now });
511
+ expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
512
+ } finally {
513
+ cleanup();
514
+ }
515
+ });
516
+
517
+ test("a client with a live (unexpired, unrevoked) token is NEVER reaped, even when old", async () => {
518
+ const { db, cleanup } = makeDb();
519
+ try {
520
+ const user = await createUser(db, "owner", "pw");
521
+ const c = registerClient(db, { redirectUris: ["https://live.example/cb"], now: oldNow });
522
+ plantToken(db, {
523
+ clientId: c.client.clientId,
524
+ userId: user.id,
525
+ jti: "jti-live",
526
+ expiresAt: new Date(NOW.getTime() + 7 * DAY_MS).toISOString(),
527
+ revokedAt: null,
528
+ });
529
+ const reapable = findReapableClients(db, { now });
530
+ expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
531
+ } finally {
532
+ cleanup();
533
+ }
534
+ });
535
+
536
+ test("a client with an unexpired auth_code is NEVER reaped (in-flight OAuth)", async () => {
537
+ const { db, cleanup } = makeDb();
538
+ try {
539
+ const user = await createUser(db, "owner", "pw");
540
+ const c = registerClient(db, { redirectUris: ["https://inflight.example/cb"], now: oldNow });
541
+ // issueAuthCode mints a code expiring AUTH_CODE_TTL after `now` → unexpired.
542
+ issueAuthCode(db, {
543
+ clientId: c.client.clientId,
544
+ userId: user.id,
545
+ redirectUri: "https://inflight.example/cb",
546
+ scopes: ["vault:work:read"],
547
+ codeChallenge: "x".repeat(43),
548
+ codeChallengeMethod: "S256",
549
+ now,
550
+ });
551
+ const reapable = findReapableClients(db, { now });
552
+ expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
553
+ } finally {
554
+ cleanup();
555
+ }
556
+ });
557
+
558
+ test("a freshly-registered client is NEVER reaped, even with zero grants/tokens/codes", () => {
559
+ const { db, cleanup } = makeDb();
560
+ try {
561
+ // Registered 5 days ago — inside the default 30d floor.
562
+ const fresh = registerClient(db, {
563
+ redirectUris: ["https://fresh.example/cb"],
564
+ now: () => new Date(NOW.getTime() - 5 * DAY_MS),
565
+ });
566
+ const reapable = findReapableClients(db, { now });
567
+ expect(reapable.map((x) => x.clientId)).not.toContain(fresh.client.clientId);
568
+ } finally {
569
+ cleanup();
570
+ }
571
+ });
572
+
573
+ test("an expired auth_code does NOT protect a client (only LIVE codes do)", async () => {
574
+ const { db, cleanup } = makeDb();
575
+ try {
576
+ const user = await createUser(db, "owner", "pw");
577
+ const c = registerClient(db, { redirectUris: ["https://stale.example/cb"], now: oldNow });
578
+ // Code issued 60d ago → long expired.
579
+ issueAuthCode(db, {
580
+ clientId: c.client.clientId,
581
+ userId: user.id,
582
+ redirectUri: "https://stale.example/cb",
583
+ scopes: ["vault:work:read"],
584
+ codeChallenge: "x".repeat(43),
585
+ codeChallengeMethod: "S256",
586
+ now: oldNow,
587
+ });
588
+ const reapable = findReapableClients(db, { now });
589
+ expect(reapable.map((x) => x.clientId)).toContain(c.client.clientId);
590
+ } finally {
591
+ cleanup();
592
+ }
593
+ });
594
+
595
+ test("--older-than threshold is honored (a 20d-old client falls under a 10d floor but not 30d)", () => {
596
+ const { db, cleanup } = makeDb();
597
+ try {
598
+ const c = registerClient(db, {
599
+ redirectUris: ["https://midage.example/cb"],
600
+ now: () => new Date(NOW.getTime() - 20 * DAY_MS),
601
+ });
602
+ // Default 30d floor → not yet reapable.
603
+ expect(findReapableClients(db, { now }).map((x) => x.clientId)).not.toContain(
604
+ c.client.clientId,
605
+ );
606
+ // 10d floor → now reapable.
607
+ expect(
608
+ findReapableClients(db, { now, olderThanMs: 10 * DAY_MS }).map((x) => x.clientId),
609
+ ).toContain(c.client.clientId);
610
+ } finally {
611
+ cleanup();
612
+ }
613
+ });
614
+
615
+ test("reapClient on a still-protected client deletes nothing the gate excluded (callers pass only gated ids)", async () => {
616
+ // Belt-and-suspenders: reapClient itself doesn't re-check the gate (the
617
+ // caller is contractually responsible). This asserts the realistic flow —
618
+ // a live client is simply NOT in the findReapableClients output, so the
619
+ // apply loop never calls reapClient on it.
620
+ const { db, cleanup } = makeDb();
621
+ try {
622
+ const user = await createUser(db, "owner", "pw");
623
+ const live = registerClient(db, { redirectUris: ["https://keep.example/cb"], now: oldNow });
624
+ recordGrant(db, user.id, live.client.clientId, ["vault:work:read"]);
625
+ const dead = registerClient(db, { redirectUris: ["https://gone.example/cb"], now: oldNow });
626
+
627
+ const reapable = findReapableClients(db, { now });
628
+ const ids = reapable.map((x) => x.clientId);
629
+ expect(ids).toEqual([dead.client.clientId]);
630
+ for (const c of reapable) reapClient(db, c.clientId);
631
+
632
+ // Live client + its grant survive; only the dead one is gone.
633
+ expect(getClient(db, live.client.clientId)).not.toBeNull();
634
+ expect(findGrant(db, user.id, live.client.clientId)).not.toBeNull();
635
+ expect(getClient(db, dead.client.clientId)).toBeNull();
636
+ } finally {
637
+ cleanup();
638
+ }
639
+ });
640
+ });
641
+
344
642
  describe("isValidRedirectUri", () => {
345
643
  test("accepts http and https", () => {
346
644
  expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
@@ -352,4 +650,24 @@ describe("isValidRedirectUri", () => {
352
650
  expect(isValidRedirectUri("/relative")).toBe(false);
353
651
  expect(isValidRedirectUri("not a url")).toBe(false);
354
652
  });
653
+ // hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
654
+ test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
655
+ expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
656
+ expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
657
+ expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
658
+ });
659
+ test("rejects control chars in the raw input (hub#663)", () => {
660
+ // Control chars must be caught on the RAW string — URL parsing would
661
+ // otherwise strip a trailing \r\n and the smuggled value would pass.
662
+ expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
663
+ expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
664
+ expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
665
+ });
666
+ test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
667
+ // Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
668
+ // all register clean URIs — these must keep passing.
669
+ expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
670
+ expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
671
+ expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
672
+ });
355
673
  });
@@ -258,8 +258,10 @@ describe("installConnectorService — Linux systemd", () => {
258
258
  platform: "linux",
259
259
  getuid: () => 1000,
260
260
  userName: () => "op",
261
- // enable-linger FAIL, then daemon-reload OK, enable --now OK.
261
+ // #528 probe: show-user → Linger=no (off, so we proceed to enable);
262
+ // then enable-linger FAIL, daemon-reload OK, enable --now OK.
262
263
  runResults: [
264
+ { code: 0, stdout: "Linger=no\n", stderr: "" },
263
265
  { code: 1, stdout: "", stderr: "Failed to enable linger" },
264
266
  { code: 0, stdout: "", stderr: "" },
265
267
  { code: 0, stdout: "", stderr: "" },
@@ -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
+ });