@opengeni/api-router 0.5.3 → 0.5.4

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 (48) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-3HIA43CC.js → chunk-DO2G3JSB.js} +5184 -2195
  3. package/dist/chunk-DO2G3JSB.js.map +1 -0
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.js +297 -54
  6. package/dist/index.js.map +1 -1
  7. package/package.json +20 -20
  8. package/src/app.ts +415 -147
  9. package/src/auth/managed-auth.ts +32 -16
  10. package/src/http/auth.ts +8 -1
  11. package/src/http/common.ts +6 -2
  12. package/src/http/sse.ts +27 -6
  13. package/src/index.ts +196 -74
  14. package/src/integrations/oauth-client.ts +403 -120
  15. package/src/integrations/provider-domain.ts +4 -1
  16. package/src/mcp/documents.ts +173 -94
  17. package/src/mcp/server.ts +1517 -692
  18. package/src/mcp/session-view.ts +8 -2
  19. package/src/mcp/toolspace.ts +175 -84
  20. package/src/observability.ts +7 -1
  21. package/src/routes/api-keys.ts +39 -23
  22. package/src/routes/billing.ts +180 -65
  23. package/src/routes/capabilities.ts +17 -8
  24. package/src/routes/catalog-assets.ts +5 -2
  25. package/src/routes/codex.ts +244 -63
  26. package/src/routes/connections.ts +71 -33
  27. package/src/routes/documents.ts +242 -92
  28. package/src/routes/enrollments.ts +100 -70
  29. package/src/routes/environments.ts +205 -136
  30. package/src/routes/files.ts +164 -39
  31. package/src/routes/github.ts +123 -50
  32. package/src/routes/install.ts +9 -2
  33. package/src/routes/machines.ts +9 -8
  34. package/src/routes/packs.ts +141 -89
  35. package/src/routes/rigs.ts +189 -0
  36. package/src/routes/scheduled-tasks.ts +51 -9
  37. package/src/routes/sessions.ts +839 -329
  38. package/src/routes/social.ts +50 -38
  39. package/src/routes/workspace-capture.ts +238 -0
  40. package/src/routes/workspaces.ts +159 -13
  41. package/src/sandbox/access.ts +11 -3
  42. package/src/sandbox/auth-callout.ts +5 -1
  43. package/src/sandbox/channel-a.ts +104 -27
  44. package/src/sandbox/enrollment.ts +13 -3
  45. package/src/sandbox/machines.ts +68 -59
  46. package/src/sandbox/metrics-ingestion.ts +238 -17
  47. package/src/sandbox/viewer.ts +172 -46
  48. package/dist/chunk-3HIA43CC.js.map +0 -1
@@ -12,7 +12,9 @@ import {
12
12
  encryptEnvironmentValue,
13
13
  getConnectionMetadata,
14
14
  isPrivateAddress,
15
+ listConnectionsMetadata,
15
16
  loadIntegrationOAuthClient,
17
+ normalizeBearerScheme,
16
18
  storeIntegrationOAuthClient,
17
19
  updateConnection,
18
20
  type Database,
@@ -65,12 +67,13 @@ type AuthorizationServerMetadata = {
65
67
  tokenEndpoint: string;
66
68
  registrationEndpoint?: string;
67
69
  clientIdMetadataDocumentSupported: boolean;
70
+ tokenEndpointAuthMethodsSupported: string[];
68
71
  codeChallengeMethodsSupported: string[];
69
72
  raw: Record<string, unknown>;
70
73
  };
71
74
 
72
75
  type OAuthClientRegistration = {
73
- method: "operator" | "cimd" | "dcr";
76
+ method: "operator" | "manual" | "cimd" | "dcr";
74
77
  issuer: string;
75
78
  authorizationServer: string;
76
79
  clientId: string;
@@ -94,6 +97,7 @@ type OAuthStatePayload = {
94
97
  issuer: string;
95
98
  clientRegistrationMethod: OAuthClientRegistration["method"];
96
99
  tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
100
+ encryptedClientSecret?: string;
97
101
  returnPath: string;
98
102
  connectionId?: string;
99
103
  connectionVersion?: number;
@@ -129,23 +133,40 @@ export async function startMcpOAuth(
129
133
  ): Promise<OAuthStartResponse> {
130
134
  const { db, settings } = deps;
131
135
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
132
- const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
136
+ const providerDomain = canonicalProviderDomain(
137
+ context.payload.providerDomain ?? new URL(mcpUrl).hostname,
138
+ );
133
139
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
134
140
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
135
141
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
136
142
  const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
137
- const existing = context.payload.connectionId
138
- ? await getConnectionMetadata(db, context.workspaceId, context.payload.connectionId, context.subjectId)
139
- : null;
143
+ const existing = await existingOAuthConnectionForStart(db, {
144
+ workspaceId: context.workspaceId,
145
+ subjectId: context.subjectId,
146
+ providerDomain,
147
+ connectionId: context.payload.connectionId,
148
+ });
140
149
  if (context.payload.connectionId && !existing) {
141
150
  throw new HTTPException(404, { message: "connection not found" });
142
151
  }
143
152
 
144
153
  const discovery = await discoverMcpOAuth(mcpUrl, settings);
145
154
  const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
146
- const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
147
155
  const verifier = randomPkceVerifier();
148
- const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
156
+ const authorizeScopes = chooseAuthorizeScopes(
157
+ context.payload.requestedScopes,
158
+ discovery.challenge.scope,
159
+ discovery.prm.scopesSupported,
160
+ );
161
+ const client = await registerOAuthClient(
162
+ db,
163
+ settings,
164
+ discovery.as,
165
+ metadataUrl,
166
+ redirectUri,
167
+ authorizeScopes,
168
+ context.payload.oauthClient,
169
+ );
149
170
  const key = requireEnvironmentEncryption(settings);
150
171
  const state = createSignedState(requireIntegrationsStateSecret(settings), {
151
172
  accountId: context.accountId,
@@ -163,6 +184,9 @@ export async function startMcpOAuth(
163
184
  issuer: client.issuer,
164
185
  clientRegistrationMethod: client.method,
165
186
  tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
187
+ ...(client.method === "manual" && client.clientSecret
188
+ ? { encryptedClientSecret: encryptEnvironmentValue(key, client.clientSecret) }
189
+ : {}),
166
190
  returnPath,
167
191
  ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
168
192
  });
@@ -189,14 +213,20 @@ export async function completeMcpOAuthCallback(
189
213
  const { db, settings, observability } = deps;
190
214
  let state: OAuthStatePayload | null = null;
191
215
  if (!input.state) {
192
- const error = new OAuthCallbackStageError("state_verify", "state_invalid", new Error("missing OAuth state"));
216
+ const error = new OAuthCallbackStageError(
217
+ "state_verify",
218
+ "state_invalid",
219
+ new Error("missing OAuth state"),
220
+ );
193
221
  logOAuthCallbackFailure(observability, error, state);
194
222
  return { redirectTo: callbackReturnPath("/integrations", "error", { reason: error.reason }) };
195
223
  }
196
224
  try {
197
225
  state = readOAuthState(input.state, settings);
198
226
  if (!input.code) {
199
- return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
227
+ return {
228
+ redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }),
229
+ };
200
230
  }
201
231
  const consumed = await consumeIntegrationOAuthStateNonce(db, {
202
232
  accountId: state.accountId,
@@ -212,7 +242,11 @@ export async function completeMcpOAuthCallback(
212
242
  } catch (error) {
213
243
  const staged = new OAuthCallbackStageError("state_verify", "state_invalid", error);
214
244
  logOAuthCallbackFailure(observability, staged, state);
215
- return { redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", { reason: staged.reason }) };
245
+ return {
246
+ redirectTo: callbackReturnPath(state?.returnPath ?? "/integrations", "error", {
247
+ reason: staged.reason,
248
+ }),
249
+ };
216
250
  }
217
251
 
218
252
  try {
@@ -221,14 +255,16 @@ export async function completeMcpOAuthCallback(
221
255
  const key = requireEnvironmentEncryption(settings);
222
256
  const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
223
257
  const client = await clientForState(db, settings, state);
224
- const token = await stage("token_exchange", "token_exchange_failed", () => exchangeAuthorizationCode(settings, {
225
- code: input.code!,
226
- verifier,
227
- redirectUri,
228
- resource: state.resource,
229
- tokenEndpoint: state.tokenEndpoint,
230
- client,
231
- }));
258
+ const token = await runCallbackStage("token_exchange", "token_exchange_failed", () =>
259
+ exchangeAuthorizationCode(settings, {
260
+ code: input.code!,
261
+ verifier,
262
+ redirectUri,
263
+ resource: state.resource,
264
+ tokenEndpoint: state.tokenEndpoint,
265
+ client,
266
+ }),
267
+ );
232
268
  const verification = await verifyMcpToolsListNonFatal(observability, settings, state, token);
233
269
  const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
234
270
  const credential = credentialBundle(token, state, client);
@@ -244,35 +280,39 @@ export async function completeMcpOAuthCallback(
244
280
  ...(verification.tools ? { mcpTools: verification.tools } : {}),
245
281
  };
246
282
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
247
- const connection = await stage("persist", "persist_failed", () => state.connectionId
248
- ? updateConnection(db, {
249
- workspaceId: state.workspaceId,
250
- connectionId: state.connectionId,
251
- visibleToSubjectId: state.subjectId,
252
- expectedVersion: state.connectionVersion,
253
- providerDomain: state.providerDomain,
254
- kind: "oauth2",
255
- status: "active",
256
- credentialEncrypted,
257
- grantedScopes: scopes,
258
- expiresAt: token.expiresAt,
259
- metadata,
260
- updatedBySubjectId: state.subjectId,
261
- })
262
- : createConnection(db, {
263
- accountId: state.accountId,
264
- workspaceId: state.workspaceId,
265
- subjectId: null,
266
- providerDomain: state.providerDomain,
267
- kind: "oauth2",
268
- credentialEncrypted,
269
- grantedScopes: scopes,
270
- expiresAt: token.expiresAt,
271
- metadata,
272
- createdBySubjectId: state.subjectId,
273
- }));
283
+ const connection = await runCallbackStage("persist", "persist_failed", () =>
284
+ state.connectionId
285
+ ? updateConnection(db, {
286
+ workspaceId: state.workspaceId,
287
+ connectionId: state.connectionId,
288
+ visibleToSubjectId: state.subjectId,
289
+ expectedVersion: state.connectionVersion,
290
+ providerDomain: state.providerDomain,
291
+ kind: "oauth2",
292
+ status: "active",
293
+ credentialEncrypted,
294
+ grantedScopes: scopes,
295
+ expiresAt: token.expiresAt,
296
+ metadata,
297
+ updatedBySubjectId: state.subjectId,
298
+ })
299
+ : createConnection(db, {
300
+ accountId: state.accountId,
301
+ workspaceId: state.workspaceId,
302
+ subjectId: null,
303
+ providerDomain: state.providerDomain,
304
+ kind: "oauth2",
305
+ credentialEncrypted,
306
+ grantedScopes: scopes,
307
+ expiresAt: token.expiresAt,
308
+ metadata,
309
+ createdBySubjectId: state.subjectId,
310
+ }),
311
+ );
274
312
  if (!connection) {
275
- throw new HTTPException(409, { message: "connection changed during OAuth reconnect; start again" });
313
+ throw new HTTPException(409, {
314
+ message: "connection changed during OAuth reconnect; start again",
315
+ });
276
316
  }
277
317
  // Carry the canonical providerDomain (not just the id) so the SPA can build
278
318
  // the enable connectionRef straight from the redirect, without a listConnections
@@ -286,9 +326,10 @@ export async function completeMcpOAuthCallback(
286
326
  }),
287
327
  };
288
328
  } catch (error) {
289
- const staged = error instanceof OAuthCallbackStageError
290
- ? error
291
- : new OAuthCallbackStageError("persist", "persist_failed", error);
329
+ const staged =
330
+ error instanceof OAuthCallbackStageError
331
+ ? error
332
+ : new OAuthCallbackStageError("persist", "persist_failed", error);
292
333
  logOAuthCallbackFailure(observability, staged, state);
293
334
  return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: staged.reason }) };
294
335
  }
@@ -301,30 +342,46 @@ export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl
301
342
  export function requireIntegrationsStateSecret(settings: Settings): string {
302
343
  const secret = settings.integrationsStateSecret?.trim();
303
344
  if (!secret) {
304
- throw new HTTPException(503, { message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET" });
345
+ throw new HTTPException(503, {
346
+ message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET",
347
+ });
305
348
  }
306
349
  return secret;
307
350
  }
308
351
 
309
- async function discoverMcpOAuth(resource: string, settings: Settings): Promise<{
352
+ async function discoverMcpOAuth(
353
+ resource: string,
354
+ settings: Settings,
355
+ ): Promise<{
310
356
  challenge: WwwAuthenticateChallenge;
311
357
  prm: ProtectedResourceMetadata;
312
358
  as: AuthorizationServerMetadata;
313
359
  }> {
314
360
  const challenge = await probeMcpChallenge(resource, settings);
315
- const prm = await discoverProtectedResourceMetadata(resource, settings, challenge.resourceMetadata);
361
+ const prm = await discoverProtectedResourceMetadata(
362
+ resource,
363
+ settings,
364
+ challenge.resourceMetadata,
365
+ );
316
366
  const authorizationServer = prm.authorizationServers[0];
317
367
  if (!authorizationServer) {
318
- throw new HTTPException(422, { message: "MCP protected resource metadata did not advertise an authorization server" });
368
+ throw new HTTPException(422, {
369
+ message: "MCP protected resource metadata did not advertise an authorization server",
370
+ });
319
371
  }
320
372
  const as = await discoverAuthorizationServerMetadata(authorizationServer, settings);
321
373
  if (!as.codeChallengeMethodsSupported.includes("S256")) {
322
- throw new HTTPException(422, { message: "authorization server does not support required PKCE S256" });
374
+ throw new HTTPException(422, {
375
+ message: "authorization server does not support required PKCE S256",
376
+ });
323
377
  }
324
378
  return { challenge, prm, as };
325
379
  }
326
380
 
327
- async function probeMcpChallenge(resource: string, settings: Settings): Promise<WwwAuthenticateChallenge> {
381
+ async function probeMcpChallenge(
382
+ resource: string,
383
+ settings: Settings,
384
+ ): Promise<WwwAuthenticateChallenge> {
328
385
  const response = await fetchOAuth(resource, settings, {
329
386
  method: "GET",
330
387
  headers: { accept: "application/json" },
@@ -368,7 +425,10 @@ async function discoverProtectedResourceMetadata(
368
425
  throw new HTTPException(422, { message: "could not discover MCP protected resource metadata" });
369
426
  }
370
427
 
371
- async function discoverAuthorizationServerMetadata(authorizationServer: string, settings: Settings): Promise<AuthorizationServerMetadata> {
428
+ async function discoverAuthorizationServerMetadata(
429
+ authorizationServer: string,
430
+ settings: Settings,
431
+ ): Promise<AuthorizationServerMetadata> {
372
432
  const candidates = uniqueStrings([
373
433
  authorizationServer,
374
434
  ...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
@@ -395,12 +455,17 @@ async function discoverAuthorizationServerMetadata(authorizationServer: string,
395
455
  authorizationEndpoint,
396
456
  tokenEndpoint,
397
457
  clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
458
+ tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
398
459
  codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
399
460
  raw: payload,
400
- ...(stringValue(payload.registration_endpoint) ? { registrationEndpoint: stringValue(payload.registration_endpoint)! } : {}),
461
+ ...(stringValue(payload.registration_endpoint)
462
+ ? { registrationEndpoint: stringValue(payload.registration_endpoint)! }
463
+ : {}),
401
464
  };
402
465
  }
403
- throw new HTTPException(422, { message: "could not discover OAuth authorization server metadata" });
466
+ throw new HTTPException(422, {
467
+ message: "could not discover OAuth authorization server metadata",
468
+ });
404
469
  }
405
470
 
406
471
  async function registerOAuthClient(
@@ -409,6 +474,8 @@ async function registerOAuthClient(
409
474
  as: AuthorizationServerMetadata,
410
475
  metadataUrl: string,
411
476
  redirectUri: string,
477
+ scopes: string[],
478
+ manual: OAuthStartRequest["oauthClient"],
412
479
  ): Promise<OAuthClientRegistration> {
413
480
  const operator = operatorClientForAs(settings, as);
414
481
  if (operator) {
@@ -423,15 +490,41 @@ async function registerOAuthClient(
423
490
  tokenEndpointAuthMethod: "none",
424
491
  };
425
492
  }
493
+ if (manual) {
494
+ return {
495
+ method: "manual",
496
+ issuer: as.issuer,
497
+ authorizationServer: as.authorizationServer,
498
+ clientId: manual.clientId,
499
+ ...(manual.clientSecret ? { clientSecret: manual.clientSecret } : {}),
500
+ tokenEndpointAuthMethod: tokenAuthMethod(
501
+ manual.tokenEndpointAuthMethod,
502
+ Boolean(manual.clientSecret),
503
+ ),
504
+ };
505
+ }
506
+ return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes);
507
+ }
508
+
509
+ async function getOrCreateDynamicClientRegistration(
510
+ db: Database,
511
+ settings: Settings,
512
+ as: AuthorizationServerMetadata,
513
+ redirectUri: string,
514
+ scopes: string[],
515
+ ): Promise<OAuthClientRegistration> {
426
516
  const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
427
- if (storedClient) {
517
+ if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, scopes)) {
428
518
  return {
429
519
  method: "dcr",
430
520
  issuer: storedClient.issuer,
431
521
  authorizationServer: storedClient.authorizationServer,
432
522
  clientId: storedClient.clientId,
433
523
  ...(storedClient.clientSecret ? { clientSecret: storedClient.clientSecret } : {}),
434
- tokenEndpointAuthMethod: tokenAuthMethod(storedClient.tokenEndpointAuthMethod, Boolean(storedClient.clientSecret)),
524
+ tokenEndpointAuthMethod: tokenAuthMethod(
525
+ storedClient.tokenEndpointAuthMethod,
526
+ Boolean(storedClient.clientSecret),
527
+ ),
435
528
  };
436
529
  }
437
530
  if (!as.registrationEndpoint) {
@@ -439,29 +532,56 @@ async function registerOAuthClient(
439
532
  message: "manual OAuth client credentials are required for this authorization server",
440
533
  });
441
534
  }
442
- const dcr = await dynamicClientRegistration(settings, as, redirectUri);
535
+ const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes);
443
536
  const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
444
- const storedWinner = await storeIntegrationOAuthClient(db, {
537
+ const storeInput = {
445
538
  issuer: as.issuer,
446
539
  authorizationServer: as.authorizationServer,
447
540
  clientId: dcr.clientId,
448
- clientSecretEncrypted: dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
541
+ clientSecretEncrypted:
542
+ dcr.clientSecret && key ? encryptEnvironmentValue(key, dcr.clientSecret) : null,
449
543
  tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
450
- metadata: {
451
- registrationEndpoint: as.registrationEndpoint,
452
- registeredAt: new Date().toISOString(),
453
- },
454
- });
544
+ metadata: registrationMetadata(as, scopes),
545
+ };
546
+ const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
455
547
  if (storedWinner.clientId !== dcr.clientId) {
456
548
  const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
457
549
  if (!winner) {
458
- throw new HTTPException(422, { message: "OAuth client registration could not be loaded after a registration race" });
550
+ throw new HTTPException(422, {
551
+ message: "OAuth client registration could not be loaded after a registration race",
552
+ });
459
553
  }
460
554
  return dcrRegistrationFromStored(winner);
461
555
  }
462
556
  return dcr;
463
557
  }
464
558
 
559
+ function storedDcrClientSatisfiesPolicy(
560
+ stored: { metadata: Record<string, unknown> },
561
+ scopes: string[],
562
+ ): boolean {
563
+ return registeredScopesMatch(stored.metadata, scopes);
564
+ }
565
+
566
+ function registeredScopesMatch(metadata: Record<string, unknown>, scopes: string[]): boolean {
567
+ return stableScopeKey(stringArray(metadata.registeredScopes)) === stableScopeKey(scopes);
568
+ }
569
+
570
+ function stableScopeKey(scopes: string[]): string {
571
+ return uniqueStrings(scopes).sort().join(" ");
572
+ }
573
+
574
+ function registrationMetadata(
575
+ as: AuthorizationServerMetadata,
576
+ scopes: string[],
577
+ ): Record<string, unknown> {
578
+ return {
579
+ registrationEndpoint: as.registrationEndpoint,
580
+ registeredAt: new Date().toISOString(),
581
+ registeredScopes: uniqueStrings(scopes),
582
+ };
583
+ }
584
+
465
585
  function dcrRegistrationFromStored(stored: {
466
586
  issuer: string;
467
587
  authorizationServer: string;
@@ -475,11 +595,17 @@ function dcrRegistrationFromStored(stored: {
475
595
  authorizationServer: stored.authorizationServer,
476
596
  clientId: stored.clientId,
477
597
  ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
478
- tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret)),
598
+ tokenEndpointAuthMethod: tokenAuthMethod(
599
+ stored.tokenEndpointAuthMethod,
600
+ Boolean(stored.clientSecret),
601
+ ),
479
602
  };
480
603
  }
481
604
 
482
- function operatorClientForAs(settings: Settings, as: AuthorizationServerMetadata): OAuthClientRegistration | null {
605
+ function operatorClientForAs(
606
+ settings: Settings,
607
+ as: AuthorizationServerMetadata,
608
+ ): OAuthClientRegistration | null {
483
609
  const entry = operatorClientEntryFor(settings, [as.issuer, as.authorizationServer]);
484
610
  if (!entry) {
485
611
  return null;
@@ -490,7 +616,10 @@ function operatorClientForAs(settings: Settings, as: AuthorizationServerMetadata
490
616
  authorizationServer: as.authorizationServer,
491
617
  clientId: entry.clientId,
492
618
  ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
493
- tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret)),
619
+ tokenEndpointAuthMethod: tokenAuthMethod(
620
+ entry.tokenEndpointAuthMethod,
621
+ Boolean(entry.clientSecret),
622
+ ),
494
623
  };
495
624
  }
496
625
 
@@ -499,7 +628,9 @@ function operatorClientEntryFor(
499
628
  candidates: string[],
500
629
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
501
630
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
502
- const exactKeys = uniqueStrings(candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]));
631
+ const exactKeys = uniqueStrings(
632
+ candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
633
+ );
503
634
  for (const key of exactKeys) {
504
635
  const entry = configured[key];
505
636
  if (entry) {
@@ -523,9 +654,12 @@ async function dynamicClientRegistration(
523
654
  settings: Settings,
524
655
  as: AuthorizationServerMetadata,
525
656
  redirectUri: string,
657
+ scopes: string[],
526
658
  ): Promise<OAuthClientRegistration> {
527
659
  if (!as.registrationEndpoint) {
528
- throw new HTTPException(422, { message: "authorization server does not support dynamic client registration" });
660
+ throw new HTTPException(422, {
661
+ message: "authorization server does not support dynamic client registration",
662
+ });
529
663
  }
530
664
  await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
531
665
  const response = await fetchOAuth(as.registrationEndpoint, settings, {
@@ -537,15 +671,20 @@ async function dynamicClientRegistration(
537
671
  token_endpoint_auth_method: "none",
538
672
  grant_types: ["authorization_code", "refresh_token"],
539
673
  response_types: ["code"],
674
+ ...(scopes.length ? { scope: scopes.join(" ") } : {}),
540
675
  }),
541
676
  });
542
677
  if (!response.ok) {
543
- throw new HTTPException(422, { message: `dynamic client registration failed with HTTP ${response.status}` });
678
+ throw new HTTPException(422, {
679
+ message: `dynamic client registration failed with HTTP ${response.status}`,
680
+ });
544
681
  }
545
- const payload = await response.json() as Record<string, unknown>;
682
+ const payload = (await response.json()) as Record<string, unknown>;
546
683
  const clientId = stringValue(payload.client_id);
547
684
  if (!clientId) {
548
- throw new HTTPException(422, { message: "dynamic client registration response did not include client_id" });
685
+ throw new HTTPException(422, {
686
+ message: "dynamic client registration response did not include client_id",
687
+ });
549
688
  }
550
689
  const clientSecret = stringValue(payload.client_secret);
551
690
  return {
@@ -554,10 +693,37 @@ async function dynamicClientRegistration(
554
693
  authorizationServer: as.authorizationServer,
555
694
  clientId,
556
695
  ...(clientSecret ? { clientSecret } : {}),
557
- tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.token_endpoint_auth_method), Boolean(clientSecret)),
696
+ tokenEndpointAuthMethod: tokenAuthMethod(
697
+ stringValue(payload.token_endpoint_auth_method),
698
+ Boolean(clientSecret),
699
+ ),
558
700
  };
559
701
  }
560
702
 
703
+ async function existingOAuthConnectionForStart(
704
+ db: Database,
705
+ input: {
706
+ workspaceId: string;
707
+ subjectId: string;
708
+ providerDomain: string;
709
+ connectionId?: string | undefined;
710
+ },
711
+ ) {
712
+ if (input.connectionId) {
713
+ return await getConnectionMetadata(db, input.workspaceId, input.connectionId, input.subjectId);
714
+ }
715
+ const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
716
+ return (
717
+ visible.find(
718
+ (connection) =>
719
+ connection.subjectId === null &&
720
+ connection.kind === "oauth2" &&
721
+ connection.status === "active" &&
722
+ connection.providerDomain === input.providerDomain,
723
+ ) ?? null
724
+ );
725
+ }
726
+
561
727
  function buildAuthorizationUrl(input: {
562
728
  endpoint: string;
563
729
  clientId: string;
@@ -582,7 +748,10 @@ function buildAuthorizationUrl(input: {
582
748
  }
583
749
 
584
750
  function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
585
- const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<string, unknown> | null;
751
+ const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
752
+ string,
753
+ unknown
754
+ > | null;
586
755
  if (!payload) {
587
756
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
588
757
  }
@@ -601,13 +770,19 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
601
770
  resource,
602
771
  requestedScopes: stringArray(payload.requestedScopes),
603
772
  authorizeScopes: stringArray(payload.authorizeScopes),
604
- encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
773
+ encryptedPkceVerifier: requiredString(
774
+ payload.encryptedPkceVerifier,
775
+ "state.encryptedPkceVerifier",
776
+ ),
605
777
  clientId: requiredString(payload.clientId, "state.clientId"),
606
778
  tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
607
779
  authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
608
780
  issuer: requiredString(payload.issuer, "state.issuer"),
609
781
  clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
610
782
  tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
783
+ ...(stringValue(payload.encryptedClientSecret)
784
+ ? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! }
785
+ : {}),
611
786
  returnPath: safeReturnPath(stringValue(payload.returnPath) ?? "/integrations"),
612
787
  nonce: requiredString(payload.nonce, "state.nonce"),
613
788
  iat,
@@ -621,7 +796,11 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
621
796
  };
622
797
  }
623
798
 
624
- async function clientForState(db: Database, settings: Settings, state: OAuthStatePayload): Promise<OAuthClientRegistration> {
799
+ async function clientForState(
800
+ db: Database,
801
+ settings: Settings,
802
+ state: OAuthStatePayload,
803
+ ): Promise<OAuthClientRegistration> {
625
804
  if (state.clientRegistrationMethod === "cimd") {
626
805
  return {
627
806
  method: "cimd",
@@ -631,6 +810,19 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
631
810
  tokenEndpointAuthMethod: "none",
632
811
  };
633
812
  }
813
+ if (state.clientRegistrationMethod === "manual") {
814
+ const key = requireEnvironmentEncryption(settings);
815
+ return {
816
+ method: "manual",
817
+ issuer: state.issuer,
818
+ authorizationServer: state.authorizationServer,
819
+ clientId: state.clientId,
820
+ ...(state.encryptedClientSecret
821
+ ? { clientSecret: decryptEnvironmentValue(key, state.encryptedClientSecret) }
822
+ : {}),
823
+ tokenEndpointAuthMethod: state.tokenEndpointAuthMethod,
824
+ };
825
+ }
634
826
  if (state.clientRegistrationMethod === "dcr") {
635
827
  const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
636
828
  if (!stored || stored.clientId !== state.clientId) {
@@ -642,12 +834,17 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
642
834
  authorizationServer: stored.authorizationServer,
643
835
  clientId: stored.clientId,
644
836
  ...(stored.clientSecret ? { clientSecret: stored.clientSecret } : {}),
645
- tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret)),
837
+ tokenEndpointAuthMethod: tokenAuthMethod(
838
+ stored.tokenEndpointAuthMethod,
839
+ Boolean(stored.clientSecret),
840
+ ),
646
841
  };
647
842
  }
648
843
  const entry = operatorClientEntryFor(settings, [state.issuer, state.authorizationServer]);
649
844
  if (!entry || entry.clientId !== state.clientId) {
650
- throw new HTTPException(400, { message: "operator OAuth client credentials are no longer available" });
845
+ throw new HTTPException(400, {
846
+ message: "operator OAuth client credentials are no longer available",
847
+ });
651
848
  }
652
849
  return {
653
850
  method: "operator",
@@ -655,7 +852,10 @@ async function clientForState(db: Database, settings: Settings, state: OAuthStat
655
852
  authorizationServer: state.authorizationServer,
656
853
  clientId: entry.clientId,
657
854
  ...(entry.clientSecret ? { clientSecret: entry.clientSecret } : {}),
658
- tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret)),
855
+ tokenEndpointAuthMethod: tokenAuthMethod(
856
+ entry.tokenEndpointAuthMethod,
857
+ Boolean(entry.clientSecret),
858
+ ),
659
859
  };
660
860
  }
661
861
 
@@ -677,19 +877,35 @@ async function exchangeAuthorizationCode(
677
877
  body.set("redirect_uri", input.redirectUri);
678
878
  body.set("code_verifier", input.verifier);
679
879
  body.set("resource", input.resource);
680
- body.set("client_id", input.client.clientId);
681
- const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
880
+ const headers: Record<string, string> = {
881
+ "content-type": "application/x-www-form-urlencoded",
882
+ accept: "application/json",
883
+ };
682
884
  if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_post") {
885
+ body.set("client_id", input.client.clientId);
683
886
  body.set("client_secret", input.client.clientSecret);
684
- } else if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_basic") {
887
+ } else if (
888
+ input.client.clientSecret &&
889
+ input.client.tokenEndpointAuthMethod === "client_secret_basic"
890
+ ) {
685
891
  headers.authorization = `Basic ${Buffer.from(`${input.client.clientId}:${input.client.clientSecret}`).toString("base64")}`;
892
+ } else {
893
+ body.set("client_id", input.client.clientId);
686
894
  }
687
- const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
895
+ const response = await fetchOAuth(input.tokenEndpoint, settings, {
896
+ method: "POST",
897
+ headers,
898
+ body,
899
+ });
688
900
  if (!response.ok) {
689
901
  const oauthError = await oauthErrorFromResponse(response);
690
- throw new OAuthCallbackStageError("token_exchange", oauthError ?? "token_exchange_failed", new Error(`OAuth token endpoint returned HTTP ${response.status}`));
902
+ throw new OAuthCallbackStageError(
903
+ "token_exchange",
904
+ oauthError ?? "token_exchange_failed",
905
+ new Error(`OAuth token endpoint returned HTTP ${response.status}`),
906
+ );
691
907
  }
692
- const payload = await response.json() as Record<string, unknown>;
908
+ const payload = (await response.json()) as Record<string, unknown>;
693
909
  const accessToken = stringValue(payload.access_token);
694
910
  if (!accessToken) {
695
911
  throw new Error("OAuth token response did not include access_token");
@@ -699,13 +915,15 @@ async function exchangeAuthorizationCode(
699
915
  tokenType: stringValue(payload.token_type) ?? "Bearer",
700
916
  expiresAt: expiresAtFromTokenResponse(payload),
701
917
  raw: payload,
702
- ...(stringValue(payload.refresh_token) ? { refreshToken: stringValue(payload.refresh_token)! } : {}),
918
+ ...(stringValue(payload.refresh_token)
919
+ ? { refreshToken: stringValue(payload.refresh_token)! }
920
+ : {}),
703
921
  ...(stringValue(payload.scope) ? { scopeText: stringValue(payload.scope)! } : {}),
704
922
  };
705
923
  }
706
924
 
707
- async function stage<T>(
708
- stage: OAuthCallbackStage,
925
+ async function runCallbackStage<T>(
926
+ callbackStage: OAuthCallbackStage,
709
927
  fallbackReason: string,
710
928
  fn: () => Promise<T>,
711
929
  ): Promise<T> {
@@ -715,7 +933,7 @@ async function stage<T>(
715
933
  if (error instanceof OAuthCallbackStageError) {
716
934
  throw error;
717
935
  }
718
- throw new OAuthCallbackStageError(stage, fallbackReason, error);
936
+ throw new OAuthCallbackStageError(callbackStage, fallbackReason, error);
719
937
  }
720
938
  }
721
939
 
@@ -781,7 +999,10 @@ async function oauthErrorFromResponse(response: Response): Promise<string | null
781
999
  if (!contentType.toLowerCase().includes("application/json")) {
782
1000
  return null;
783
1001
  }
784
- const payload = await response.clone().json().catch(() => null) as Record<string, unknown> | null;
1002
+ const payload = (await response
1003
+ .clone()
1004
+ .json()
1005
+ .catch(() => null)) as Record<string, unknown> | null;
785
1006
  const error = stringValue(payload?.error);
786
1007
  if (!error || !/^[a-zA-Z0-9_.-]{1,80}$/.test(error)) {
787
1008
  return null;
@@ -789,17 +1010,29 @@ async function oauthErrorFromResponse(response: Response): Promise<string | null
789
1010
  return error;
790
1011
  }
791
1012
 
792
- async function verifyMcpToolsList(settings: Settings, resource: string, token: TokenResponse): Promise<Array<{ name: string; description?: string }>> {
1013
+ async function verifyMcpToolsList(
1014
+ settings: Settings,
1015
+ resource: string,
1016
+ token: TokenResponse,
1017
+ ): Promise<Array<{ name: string; description?: string }>> {
793
1018
  await assertOAuthFetchAllowed(resource, settings);
794
- const client = new Client({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
1019
+ const client = new Client(
1020
+ { name: "opengeni-integration-verify", version: "0.1.0" },
1021
+ { capabilities: {} },
1022
+ );
795
1023
  try {
796
1024
  const transport = new StreamableHTTPClientTransport(new URL(resource), {
797
1025
  requestInit: {
798
- headers: { authorization: `${token.tokenType} ${token.accessToken}` },
1026
+ headers: {
1027
+ authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
1028
+ },
799
1029
  },
800
1030
  fetch: (url, init) => fetchOAuth(url.toString(), settings, init),
801
1031
  });
802
- await client.connect(transport as unknown as Transport, { timeout: 10_000, maxTotalTimeout: 10_000 });
1032
+ await client.connect(transport as unknown as Transport, {
1033
+ timeout: 10_000,
1034
+ maxTotalTimeout: 10_000,
1035
+ });
803
1036
  const listed = await client.listTools(undefined, { timeout: 10_000, maxTotalTimeout: 10_000 });
804
1037
  return listed.tools.map((tool) => ({
805
1038
  name: tool.name,
@@ -822,7 +1055,9 @@ async function verifyMcpToolsListNonFatal(
822
1055
  tools?: Array<{ name: string; description?: string }>;
823
1056
  }> {
824
1057
  try {
825
- const tools = await stage("tools_list", "tools_list_failed", () => verifyMcpToolsList(settings, state.mcpUrl, token));
1058
+ const tools = await runCallbackStage("tools_list", "tools_list_failed", () =>
1059
+ verifyMcpToolsList(settings, state.mcpUrl, token),
1060
+ );
826
1061
  return {
827
1062
  metadata: {
828
1063
  status: "ok",
@@ -832,9 +1067,10 @@ async function verifyMcpToolsListNonFatal(
832
1067
  tools,
833
1068
  };
834
1069
  } catch (error) {
835
- const staged = error instanceof OAuthCallbackStageError
836
- ? error
837
- : new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
1070
+ const staged =
1071
+ error instanceof OAuthCallbackStageError
1072
+ ? error
1073
+ : new OAuthCallbackStageError("tools_list", "tools_list_failed", error);
838
1074
  logOAuthVerificationWarning(observability, staged, state);
839
1075
  return {
840
1076
  metadata: {
@@ -846,7 +1082,11 @@ async function verifyMcpToolsListNonFatal(
846
1082
  }
847
1083
  }
848
1084
 
849
- function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client: OAuthClientRegistration): Record<string, unknown> {
1085
+ function credentialBundle(
1086
+ token: TokenResponse,
1087
+ state: OAuthStatePayload,
1088
+ client: OAuthClientRegistration,
1089
+ ): Record<string, unknown> {
850
1090
  return {
851
1091
  access_token: token.accessToken,
852
1092
  ...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
@@ -854,14 +1094,27 @@ function credentialBundle(token: TokenResponse, state: OAuthStatePayload, client
854
1094
  ...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
855
1095
  resource: state.resource,
856
1096
  mcp_url: state.mcpUrl,
857
- ...(token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {}),
1097
+ ...(token.scopeText
1098
+ ? { scope: token.scopeText }
1099
+ : state.authorizeScopes.length
1100
+ ? { scope: state.authorizeScopes.join(" ") }
1101
+ : {}),
858
1102
  token_endpoint: state.tokenEndpoint,
859
1103
  client_id: client.clientId,
860
- ...(client.clientSecret ? { client_secret: client.clientSecret, token_endpoint_auth_method: client.tokenEndpointAuthMethod } : {}),
1104
+ ...(client.clientSecret
1105
+ ? {
1106
+ client_secret: client.clientSecret,
1107
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
1108
+ }
1109
+ : {}),
861
1110
  };
862
1111
  }
863
1112
 
864
- function callbackReturnPath(returnPath: string, status: "success" | "error", params: Record<string, string>): string {
1113
+ function callbackReturnPath(
1114
+ returnPath: string,
1115
+ status: "success" | "error",
1116
+ params: Record<string, string>,
1117
+ ): string {
865
1118
  const url = new URL(returnPath, "https://opengeni.local");
866
1119
  url.searchParams.set("integration_oauth", status);
867
1120
  for (const [key, value] of Object.entries(params)) {
@@ -887,7 +1140,9 @@ function canonicalMcpResource(value: string | undefined): string {
887
1140
  function canonicalOAuthResource(value: string): string {
888
1141
  const trimmed = value.trim();
889
1142
  if (!trimmed) {
890
- throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
1143
+ throw new HTTPException(422, {
1144
+ message: "MCP protected resource metadata advertised an invalid resource",
1145
+ });
891
1146
  }
892
1147
  try {
893
1148
  const url = new URL(trimmed);
@@ -897,7 +1152,9 @@ function canonicalOAuthResource(value: string): string {
897
1152
  }
898
1153
  return trimmed;
899
1154
  } catch {
900
- throw new HTTPException(422, { message: "MCP protected resource metadata advertised an invalid resource" });
1155
+ throw new HTTPException(422, {
1156
+ message: "MCP protected resource metadata advertised an invalid resource",
1157
+ });
901
1158
  }
902
1159
  }
903
1160
 
@@ -924,7 +1181,12 @@ async function fetchJsonObject(url: string, settings: Settings): Promise<Record<
924
1181
  return payload as Record<string, unknown>;
925
1182
  }
926
1183
 
927
- async function fetchOAuth(rawUrl: string, settings: Settings, init: RequestInit = {}, hop = 0): Promise<Response> {
1184
+ async function fetchOAuth(
1185
+ rawUrl: string,
1186
+ settings: Settings,
1187
+ init: RequestInit = {},
1188
+ hop = 0,
1189
+ ): Promise<Response> {
928
1190
  await assertOAuthFetchAllowed(rawUrl, settings);
929
1191
  const response = await fetch(rawUrl, { ...init, redirect: "manual" });
930
1192
  if (response.status < 300 || response.status >= 400) {
@@ -951,20 +1213,29 @@ async function assertOAuthFetchAllowed(rawUrl: string, settings: Settings): Prom
951
1213
  if (!["https:", "http:"].includes(url.protocol)) {
952
1214
  throw new HTTPException(422, { message: "OAuth discovery only supports http and https URLs" });
953
1215
  }
954
- if (settings.integrationsAllowPrivateNetworkTargets || ["local", "test"].includes(settings.environment)) {
1216
+ if (
1217
+ settings.integrationsAllowPrivateNetworkTargets ||
1218
+ ["local", "test"].includes(settings.environment)
1219
+ ) {
955
1220
  return;
956
1221
  }
957
1222
  if (url.protocol !== "https:") {
958
- throw new HTTPException(422, { message: "OAuth discovery targets must use https outside local/test" });
1223
+ throw new HTTPException(422, {
1224
+ message: "OAuth discovery targets must use https outside local/test",
1225
+ });
959
1226
  }
960
1227
  const hostname = url.hostname.toLowerCase();
961
1228
  if (hostname === "localhost" || hostname.endsWith(".localhost")) {
962
1229
  throw new HTTPException(422, { message: "OAuth discovery may not target localhost" });
963
1230
  }
964
1231
  const literal = isIP(hostname);
965
- const addresses = literal ? [hostname] : (await lookup(hostname, { all: true })).map((entry) => entry.address);
1232
+ const addresses = literal
1233
+ ? [hostname]
1234
+ : (await lookup(hostname, { all: true })).map((entry) => entry.address);
966
1235
  if (addresses.some(isPrivateAddress)) {
967
- throw new HTTPException(422, { message: "OAuth discovery may not target private network addresses" });
1236
+ throw new HTTPException(422, {
1237
+ message: "OAuth discovery may not target private network addresses",
1238
+ });
968
1239
  }
969
1240
  }
970
1241
 
@@ -982,7 +1253,9 @@ function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {
982
1253
  let match: RegExpExecArray | null;
983
1254
  while ((match = re.exec(paramsText)) !== null) {
984
1255
  const raw = match[2]!;
985
- params[match[1]!.toLowerCase()] = raw.startsWith("\"") ? raw.slice(1, -1).replace(/\\"/g, "\"") : raw;
1256
+ params[match[1]!.toLowerCase()] = raw.startsWith('"')
1257
+ ? raw.slice(1, -1).replace(/\\"/g, '"')
1258
+ : raw;
986
1259
  }
987
1260
  return {
988
1261
  ...(params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {}),
@@ -1001,7 +1274,11 @@ function wellKnownCandidates(rawUrl: string, name: string): string[] {
1001
1274
  ]);
1002
1275
  }
1003
1276
 
1004
- function chooseAuthorizeScopes(requested: string[] | undefined, challenged: string[] | undefined, supported: string[]): string[] {
1277
+ function chooseAuthorizeScopes(
1278
+ requested: string[] | undefined,
1279
+ challenged: string[] | undefined,
1280
+ supported: string[],
1281
+ ): string[] {
1005
1282
  if (requested?.length) {
1006
1283
  return uniqueStrings(requested);
1007
1284
  }
@@ -1018,7 +1295,10 @@ function grantedScopes(scopeText: string | undefined, fallback: string[]): strin
1018
1295
  return fallback;
1019
1296
  }
1020
1297
 
1021
- function tokenAuthMethod(raw: string | undefined, hasSecret: boolean): OAuthClientRegistration["tokenEndpointAuthMethod"] {
1298
+ function tokenAuthMethod(
1299
+ raw: string | undefined,
1300
+ hasSecret: boolean,
1301
+ ): OAuthClientRegistration["tokenEndpointAuthMethod"] {
1022
1302
  if (raw === "client_secret_post" || raw === "client_secret_basic") {
1023
1303
  return raw;
1024
1304
  }
@@ -1026,7 +1306,7 @@ function tokenAuthMethod(raw: string | undefined, hasSecret: boolean): OAuthClie
1026
1306
  }
1027
1307
 
1028
1308
  function registrationMethod(value: unknown): OAuthClientRegistration["method"] {
1029
- if (value === "operator" || value === "cimd" || value === "dcr") {
1309
+ if (value === "operator" || value === "manual" || value === "cimd" || value === "dcr") {
1030
1310
  return value;
1031
1311
  }
1032
1312
  throw new HTTPException(400, { message: "invalid OAuth state" });
@@ -1038,7 +1318,8 @@ function expiresAtFromTokenResponse(payload: Record<string, unknown>): Date | nu
1038
1318
  const parsed = new Date(expiresAt);
1039
1319
  return Number.isNaN(parsed.getTime()) ? null : parsed;
1040
1320
  }
1041
- const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
1321
+ const expiresIn =
1322
+ typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
1042
1323
  if (Number.isFinite(expiresIn) && expiresIn > 0) {
1043
1324
  return new Date(Date.now() + expiresIn * 1000);
1044
1325
  }
@@ -1058,7 +1339,9 @@ function uniqueStrings(values: string[]): string[] {
1058
1339
  }
1059
1340
 
1060
1341
  function stringArray(value: unknown): string[] {
1061
- return Array.isArray(value) ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string")) : [];
1342
+ return Array.isArray(value)
1343
+ ? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string"))
1344
+ : [];
1062
1345
  }
1063
1346
 
1064
1347
  function stringValue(value: unknown): string | undefined {