@openparachute/hub 0.5.13-rc.46 → 0.5.13-rc.47

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.5.13-rc.46",
3
+ "version": "0.5.13-rc.47",
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": {
@@ -5,7 +5,9 @@ import { join } from "node:path";
5
5
  import { registerClient } from "../clients.ts";
6
6
  import {
7
7
  findGrant,
8
+ findGrantByClientName,
8
9
  isCoveredByGrant,
10
+ isCoveredByGrantForClientName,
9
11
  listGrantsForUser,
10
12
  recordGrant,
11
13
  revokeGrant,
@@ -162,3 +164,144 @@ describe("grants module (#75)", () => {
162
164
  }
163
165
  });
164
166
  });
167
+
168
+ describe("findGrantByClientName / isCoveredByGrantForClientName (hub#409)", () => {
169
+ test("returns the most recent grant across any client_id with the matching name", async () => {
170
+ // Closes hub#409: CLI MCP clients re-DCR each session, each landing
171
+ // fresh client_ids. Operator approves once by name; future DCRs of
172
+ // the same name should auto-trust.
173
+ const h = await harness();
174
+ try {
175
+ // First DCR: client_name="claude-code", scope a+b
176
+ const reg1 = registerClient(h.db, {
177
+ redirectUris: ["https://app.example/cb"],
178
+ clientName: "claude-code",
179
+ });
180
+ recordGrant(h.db, h.userId, reg1.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
181
+ // Second DCR: same client_name="claude-code", fresh client_id, no grant yet
182
+ const reg2 = registerClient(h.db, {
183
+ redirectUris: ["https://app.example/cb"],
184
+ clientName: "claude-code",
185
+ });
186
+ // findGrantByClientName should return the prior grant
187
+ const grant = findGrantByClientName(h.db, h.userId, "claude-code");
188
+ expect(grant).not.toBeNull();
189
+ expect(grant?.clientId).toBe(reg1.client.clientId);
190
+ expect(grant?.scopes).toEqual(["a", "b"]);
191
+ } finally {
192
+ h.cleanup();
193
+ }
194
+ });
195
+
196
+ test("returns null when no client with that name has any grant", async () => {
197
+ const h = await harness();
198
+ try {
199
+ registerClient(h.db, {
200
+ redirectUris: ["https://app.example/cb"],
201
+ clientName: "claude-code",
202
+ });
203
+ // No grants recorded
204
+ expect(findGrantByClientName(h.db, h.userId, "claude-code")).toBeNull();
205
+ } finally {
206
+ h.cleanup();
207
+ }
208
+ });
209
+
210
+ test("returns null when client_name is empty string", async () => {
211
+ const h = await harness();
212
+ try {
213
+ expect(findGrantByClientName(h.db, h.userId, "")).toBeNull();
214
+ } finally {
215
+ h.cleanup();
216
+ }
217
+ });
218
+
219
+ test("returns null for a different user (per-user isolation)", async () => {
220
+ const h = await harness();
221
+ try {
222
+ const reg = registerClient(h.db, {
223
+ redirectUris: ["https://app.example/cb"],
224
+ clientName: "claude-code",
225
+ });
226
+ recordGrant(h.db, h.userId, reg.client.clientId, ["a"]);
227
+ // Another user — should NOT see the grant. (hub is single-user-by-
228
+ // default; pass allowMulti for the test.)
229
+ const other = await createUser(h.db, "other-user", "pw", { allowMulti: true });
230
+ expect(findGrantByClientName(h.db, other.id, "claude-code")).toBeNull();
231
+ } finally {
232
+ h.cleanup();
233
+ }
234
+ });
235
+
236
+ test("picks the most recent when multiple clients share the name", async () => {
237
+ const h = await harness();
238
+ try {
239
+ const reg1 = registerClient(h.db, {
240
+ redirectUris: ["https://app.example/cb"],
241
+ clientName: "claude-code",
242
+ });
243
+ const reg2 = registerClient(h.db, {
244
+ redirectUris: ["https://app.example/cb"],
245
+ clientName: "claude-code",
246
+ });
247
+ const reg3 = registerClient(h.db, {
248
+ redirectUris: ["https://app.example/cb"],
249
+ clientName: "claude-code",
250
+ });
251
+ recordGrant(h.db, h.userId, reg1.client.clientId, ["a"], new Date("2026-04-01T00:00:00Z"));
252
+ recordGrant(h.db, h.userId, reg3.client.clientId, ["a", "c"], new Date("2026-04-15T00:00:00Z"));
253
+ recordGrant(h.db, h.userId, reg2.client.clientId, ["a", "b"], new Date("2026-04-10T00:00:00Z"));
254
+ const grant = findGrantByClientName(h.db, h.userId, "claude-code");
255
+ // Most recent = reg3's grant (2026-04-15)
256
+ expect(grant?.clientId).toBe(reg3.client.clientId);
257
+ expect(grant?.scopes).toEqual(["a", "c"]);
258
+ } finally {
259
+ h.cleanup();
260
+ }
261
+ });
262
+
263
+ test("isCoveredByGrantForClientName: subset of stored scopes → true", async () => {
264
+ const h = await harness();
265
+ try {
266
+ const reg = registerClient(h.db, {
267
+ redirectUris: ["https://app.example/cb"],
268
+ clientName: "claude-code",
269
+ });
270
+ recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read", "vault:default:write"]);
271
+ expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read"])).toBe(true);
272
+ expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(true);
273
+ } finally {
274
+ h.cleanup();
275
+ }
276
+ });
277
+
278
+ test("isCoveredByGrantForClientName: superset of stored scopes → false", async () => {
279
+ const h = await harness();
280
+ try {
281
+ const reg = registerClient(h.db, {
282
+ redirectUris: ["https://app.example/cb"],
283
+ clientName: "claude-code",
284
+ });
285
+ recordGrant(h.db, h.userId, reg.client.clientId, ["vault:default:read"]);
286
+ // Asking for write — not previously granted
287
+ expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:write"])).toBe(false);
288
+ expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", ["vault:default:read", "vault:default:write"])).toBe(false);
289
+ } finally {
290
+ h.cleanup();
291
+ }
292
+ });
293
+
294
+ test("isCoveredByGrantForClientName: empty scopes → false (matches isCoveredByGrant contract)", async () => {
295
+ const h = await harness();
296
+ try {
297
+ const reg = registerClient(h.db, {
298
+ redirectUris: ["https://app.example/cb"],
299
+ clientName: "claude-code",
300
+ });
301
+ recordGrant(h.db, h.userId, reg.client.clientId, ["a"]);
302
+ expect(isCoveredByGrantForClientName(h.db, h.userId, "claude-code", [])).toBe(false);
303
+ } finally {
304
+ h.cleanup();
305
+ }
306
+ });
307
+ });
@@ -13,6 +13,7 @@ import {
13
13
  setSetting,
14
14
  } from "../hub-settings.ts";
15
15
  import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
16
+ import { findGrant, recordGrant } from "../grants.ts";
16
17
  import {
17
18
  authorizationServerMetadata,
18
19
  buildServicesCatalog,
@@ -6392,3 +6393,198 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
6392
6393
  }
6393
6394
  });
6394
6395
  });
6396
+
6397
+ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", () => {
6398
+ test("happy path: session + same-origin + prior grant for client_name → 302 to redirect_uri with code", async () => {
6399
+ // The exact scenario hub#409 closes: operator approved "claude-code"
6400
+ // last session; this session, Claude re-DCRs a fresh client_id with
6401
+ // the same client_name; operator should NOT see the approve-pending
6402
+ // screen — the flow goes straight to the authorize-code redirect.
6403
+ const { db, cleanup } = await makeDb();
6404
+ try {
6405
+ const user = await createUser(db, "owner", "pw");
6406
+ const session = createSession(db, { userId: user.id });
6407
+ // 1. Prior client + grant (the "previously approved" state)
6408
+ const prior = registerClient(db, {
6409
+ redirectUris: ["https://app.example/cb"],
6410
+ status: "approved",
6411
+ clientName: "claude-code",
6412
+ });
6413
+ recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
6414
+ // 2. Fresh DCR — same client_name, fresh client_id, status=pending
6415
+ const fresh = registerClient(db, {
6416
+ redirectUris: ["https://app.example/cb"],
6417
+ status: "pending",
6418
+ clientName: "claude-code",
6419
+ });
6420
+ const { challenge } = makePkce();
6421
+ const req = new Request(
6422
+ authorizeUrl({
6423
+ client_id: fresh.client.clientId,
6424
+ redirect_uri: "https://app.example/cb",
6425
+ response_type: "code",
6426
+ code_challenge: challenge,
6427
+ code_challenge_method: "S256",
6428
+ scope: "vault:default:read",
6429
+ state: "trust-by-name",
6430
+ }),
6431
+ {
6432
+ headers: {
6433
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
6434
+ origin: ISSUER,
6435
+ },
6436
+ },
6437
+ );
6438
+ const res = handleAuthorizeGet(db, req, {
6439
+ issuer: ISSUER,
6440
+ loadServicesManifest: fixtureLoadServicesManifest,
6441
+ });
6442
+ // 302 to redirect_uri with code — NOT a 403 with approve-pending HTML.
6443
+ expect(res.status).toBe(302);
6444
+ const loc = new URL(res.headers.get("location") ?? "");
6445
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
6446
+ expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
6447
+ expect(loc.searchParams.get("state")).toBe("trust-by-name");
6448
+ // The fresh client_id is now approved
6449
+ const after = getClient(db, fresh.client.clientId);
6450
+ expect(after?.status).toBe("approved");
6451
+ // A grant was recorded for the new client_id
6452
+ expect(findGrant(db, user.id, fresh.client.clientId)).not.toBeNull();
6453
+ } finally {
6454
+ cleanup();
6455
+ }
6456
+ });
6457
+
6458
+ test("falls through to approve-pending when requested scope is NOT covered by prior grant (superset)", async () => {
6459
+ const { db, cleanup } = await makeDb();
6460
+ try {
6461
+ const user = await createUser(db, "owner", "pw");
6462
+ const session = createSession(db, { userId: user.id });
6463
+ const prior = registerClient(db, {
6464
+ redirectUris: ["https://app.example/cb"],
6465
+ status: "approved",
6466
+ clientName: "claude-code",
6467
+ });
6468
+ // Prior grant covers READ only
6469
+ recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
6470
+ const fresh = registerClient(db, {
6471
+ redirectUris: ["https://app.example/cb"],
6472
+ status: "pending",
6473
+ clientName: "claude-code",
6474
+ });
6475
+ const { challenge } = makePkce();
6476
+ // Asking for WRITE — not in prior grant
6477
+ const req = new Request(
6478
+ authorizeUrl({
6479
+ client_id: fresh.client.clientId,
6480
+ redirect_uri: "https://app.example/cb",
6481
+ response_type: "code",
6482
+ code_challenge: challenge,
6483
+ code_challenge_method: "S256",
6484
+ scope: "vault:default:write",
6485
+ }),
6486
+ {
6487
+ headers: {
6488
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
6489
+ origin: ISSUER,
6490
+ },
6491
+ },
6492
+ );
6493
+ const res = handleAuthorizeGet(db, req, {
6494
+ issuer: ISSUER,
6495
+ loadServicesManifest: fixtureLoadServicesManifest,
6496
+ });
6497
+ // Approve-pending render — 403 — because the new scope wasn't trusted
6498
+ expect(res.status).toBe(403);
6499
+ expect(await res.text()).toContain("App not yet approved");
6500
+ // The fresh client_id stays pending
6501
+ expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
6502
+ } finally {
6503
+ cleanup();
6504
+ }
6505
+ });
6506
+
6507
+ test("falls through when no session (unauthenticated client re-DCR can't ride a session's trust)", async () => {
6508
+ const { db, cleanup } = await makeDb();
6509
+ try {
6510
+ const user = await createUser(db, "owner", "pw");
6511
+ const prior = registerClient(db, {
6512
+ redirectUris: ["https://app.example/cb"],
6513
+ status: "approved",
6514
+ clientName: "claude-code",
6515
+ });
6516
+ recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
6517
+ const fresh = registerClient(db, {
6518
+ redirectUris: ["https://app.example/cb"],
6519
+ status: "pending",
6520
+ clientName: "claude-code",
6521
+ });
6522
+ const { challenge } = makePkce();
6523
+ // No session cookie
6524
+ const req = new Request(
6525
+ authorizeUrl({
6526
+ client_id: fresh.client.clientId,
6527
+ redirect_uri: "https://app.example/cb",
6528
+ response_type: "code",
6529
+ code_challenge: challenge,
6530
+ code_challenge_method: "S256",
6531
+ scope: "vault:default:read",
6532
+ }),
6533
+ { headers: { origin: ISSUER } },
6534
+ );
6535
+ const res = handleAuthorizeGet(db, req, {
6536
+ issuer: ISSUER,
6537
+ loadServicesManifest: fixtureLoadServicesManifest,
6538
+ });
6539
+ expect(res.status).toBe(403);
6540
+ expect(await res.text()).toContain("App not yet approved");
6541
+ expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
6542
+ } finally {
6543
+ cleanup();
6544
+ }
6545
+ });
6546
+
6547
+ test("falls through when client_name is missing/empty (can't match a prior grant)", async () => {
6548
+ const { db, cleanup } = await makeDb();
6549
+ try {
6550
+ const user = await createUser(db, "owner", "pw");
6551
+ const session = createSession(db, { userId: user.id });
6552
+ const prior = registerClient(db, {
6553
+ redirectUris: ["https://app.example/cb"],
6554
+ status: "approved",
6555
+ clientName: "claude-code",
6556
+ });
6557
+ recordGrant(db, user.id, prior.client.clientId, ["vault:default:read"]);
6558
+ // Fresh DCR omits client_name
6559
+ const fresh = registerClient(db, {
6560
+ redirectUris: ["https://app.example/cb"],
6561
+ status: "pending",
6562
+ });
6563
+ const { challenge } = makePkce();
6564
+ const req = new Request(
6565
+ authorizeUrl({
6566
+ client_id: fresh.client.clientId,
6567
+ redirect_uri: "https://app.example/cb",
6568
+ response_type: "code",
6569
+ code_challenge: challenge,
6570
+ code_challenge_method: "S256",
6571
+ scope: "vault:default:read",
6572
+ }),
6573
+ {
6574
+ headers: {
6575
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
6576
+ origin: ISSUER,
6577
+ },
6578
+ },
6579
+ );
6580
+ const res = handleAuthorizeGet(db, req, {
6581
+ issuer: ISSUER,
6582
+ loadServicesManifest: fixtureLoadServicesManifest,
6583
+ });
6584
+ expect(res.status).toBe(403);
6585
+ expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
6586
+ } finally {
6587
+ cleanup();
6588
+ }
6589
+ });
6590
+ });
package/src/grants.ts CHANGED
@@ -117,6 +117,77 @@ export function isCoveredByGrant(
117
117
  return true;
118
118
  }
119
119
 
120
+ /**
121
+ * Find the most-recent grant for a user across any client matching the
122
+ * given client_name. Used to support "trust an app by name" — once a
123
+ * user approves a `client_name` like `"claude-code"`, future DCRs with
124
+ * the same name auto-trust without re-asking. Returns null when no
125
+ * grant exists for any client of this name.
126
+ *
127
+ * Why: CLI MCP clients (Claude Code et al.) re-DCR on every `mcp add`
128
+ * (or every session), each landing a fresh `client_id`. Strict
129
+ * (user, client_id) grants force re-approval every time even though
130
+ * the operator has approved the same app many times before. Matching
131
+ * by client_name reflects the operator's actual mental model — "I
132
+ * approved Claude" — not the protocol's mental model — "I approved
133
+ * this specific client_id."
134
+ *
135
+ * Tradeoff: an attacker who can register a client with a known-trusted
136
+ * name (e.g. `"claude-code"`) gets auto-trust on first authorize. The
137
+ * defenses we kept:
138
+ * 1. Admin-scope flows still show consent (handled by the caller,
139
+ * not this helper).
140
+ * 2. The audit log records each auto-trust event with both client_ids
141
+ * (the original trusted one + the freshly auto-trusted one).
142
+ * 3. The Permissions admin SPA shows trusted client_names so the
143
+ * operator can revoke trust by name.
144
+ *
145
+ * Closes hub#409 (Aaron 2026-05-26: "asking for approval every time…
146
+ * once we've approved something like Claude once it should not need
147
+ * admin approval every other time").
148
+ */
149
+ export function findGrantByClientName(
150
+ db: Database,
151
+ userId: string,
152
+ clientName: string,
153
+ ): Grant | null {
154
+ if (!clientName) return null;
155
+ const row = db
156
+ .prepare(
157
+ `SELECT g.user_id, g.client_id, g.scopes, g.granted_at
158
+ FROM grants g
159
+ JOIN clients c ON g.client_id = c.client_id
160
+ WHERE g.user_id = ? AND c.client_name = ?
161
+ ORDER BY g.granted_at DESC
162
+ LIMIT 1`,
163
+ )
164
+ .get(userId, clientName) as GrantRow | undefined;
165
+ return row ? rowToGrant(row) : null;
166
+ }
167
+
168
+ /**
169
+ * Test whether `requestedScopes` is covered by ANY grant for the given
170
+ * client_name + user. The client_name-keyed counterpart to
171
+ * `isCoveredByGrant`. Used by /oauth/authorize to skip BOTH the
172
+ * approve-pending screen + the consent screen when the operator has
173
+ * previously approved a same-named client with sufficient scopes.
174
+ */
175
+ export function isCoveredByGrantForClientName(
176
+ db: Database,
177
+ userId: string,
178
+ clientName: string,
179
+ requestedScopes: readonly string[],
180
+ ): boolean {
181
+ if (requestedScopes.length === 0) return false;
182
+ const grant = findGrantByClientName(db, userId, clientName);
183
+ if (!grant) return false;
184
+ const granted = new Set(grant.scopes);
185
+ for (const s of requestedScopes) {
186
+ if (!granted.has(s)) return false;
187
+ }
188
+ return true;
189
+ }
190
+
120
191
  /** All grants for a user, ordered most-recent first. Used by `parachute auth list-grants`. */
121
192
  export function listGrantsForUser(db: Database, userId: string): Grant[] {
122
193
  const rows = db
@@ -44,7 +44,7 @@ import {
44
44
  verifyClientSecret,
45
45
  } from "./clients.ts";
46
46
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
47
- import { isCoveredByGrant, recordGrant } from "./grants.ts";
47
+ import { isCoveredByGrant, isCoveredByGrantForClientName, recordGrant } from "./grants.ts";
48
48
  import { consumeFirstClientAutoApproveWindow } from "./hub-settings.ts";
49
49
  import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
50
50
  import {
@@ -506,6 +506,73 @@ function pendingClientResponse(
506
506
  const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
507
507
  const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
508
508
  const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
509
+
510
+ // Trust-by-client_name auto-approve (closes hub#409). When the requesting
511
+ // user has previously approved a client with the SAME client_name AND the
512
+ // current request's scopes are covered by that prior grant, auto-promote
513
+ // this pending client to approved + carry on as if status had been
514
+ // approved from the start.
515
+ //
516
+ // Motivation: CLI MCP clients (Claude Code et al.) re-DCR each session,
517
+ // each landing a fresh client_id. Strict (user, client_id) approval forces
518
+ // the operator to click Approve every single time even though they
519
+ // already approved the same client by name on every prior session. Aaron
520
+ // 2026-05-26: "once we've approved something like claude once it should
521
+ // not need admin approval every other time."
522
+ //
523
+ // Constraints (security guardrails kept):
524
+ // 1. Requires an active operator session — anonymous DCR can't ride
525
+ // another operator's prior trust.
526
+ // 2. Requires same-origin — defends against an attacker registering a
527
+ // malicious "claude-code" client on a different hub and tricking
528
+ // the operator into authorizing it.
529
+ // 3. Requires a non-empty client_name — DCR allows omitting it, in
530
+ // which case the prior-grant lookup has nothing to match against.
531
+ // 4. Requires scope coverage — a strict superset (the new request asks
532
+ // for scopes the prior grant didn't cover) falls through to the
533
+ // approve-pending screen so the operator explicitly approves the
534
+ // addition.
535
+ // 5. Non-admin scopes only — `*:admin` scopes (hub:admin, vault:*:admin
536
+ // if it ever becomes requestable) require explicit per-session
537
+ // consent. This guard mirrors the same-hub-auto-trust gate's
538
+ // treatment of admin scopes (handleAuthorizeGet ~line 854).
539
+ // NOTE: `scopeIsAdmin` has a documented blind spot for
540
+ // module-declared admin scopes (e.g. a hypothetical `runner:admin`
541
+ // registered via a module manifest's scopes.defines). See
542
+ // `src/scope-explanations.ts:191`. A future module that makes a
543
+ // module-admin scope requestable via public DCR would silently
544
+ // bypass this guard. Worth a tighter scope-classification helper
545
+ // when that becomes a real risk.
546
+ if (
547
+ session &&
548
+ sameOrigin &&
549
+ client.clientName &&
550
+ requestedScopes.length > 0 &&
551
+ !requestedScopes.some(scopeIsAdmin) &&
552
+ isCoveredByGrantForClientName(db, session.userId, client.clientName, requestedScopes)
553
+ ) {
554
+ console.log(
555
+ `[oauth] auto-approved pending client by prior client_name trust client_id=${client.clientId} client_name=${JSON.stringify(client.clientName)} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#409)`,
556
+ );
557
+ approveClient(db, client.clientId);
558
+ // Re-record the grant for this fresh client_id so the standard
559
+ // (user, client_id) consent-skip path also fires on the IMMEDIATE
560
+ // continuation below — without this, the very next /oauth/authorize
561
+ // dispatch would re-enter the "is grant covered?" check against the
562
+ // new client_id, find nothing (we matched by name, not id), and
563
+ // render the consent screen anyway.
564
+ recordGrant(db, session.userId, client.clientId, requestedScopes, deps.now?.() ?? new Date());
565
+ // Fall through to the standard approved-client flow: re-fetch the
566
+ // refreshed row + let handleAuthorizeGet continue past the
567
+ // status-check + into the consent-skip / same-hub auto-trust path.
568
+ const refreshed = getClient(db, client.clientId);
569
+ if (refreshed && refreshed.status === "approved") {
570
+ return handleAuthorizeGet(db, req, deps);
571
+ }
572
+ // If for some reason the refresh failed, fall through to render the
573
+ // approve-pending page (defensive — should never happen given the
574
+ // approveClient call just above).
575
+ }
509
576
  const csrf = ensureCsrfToken(req);
510
577
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
511
578
  // Hub-relative URL of the original `/oauth/authorize?...` request. Used in