@opengeni/api-router 4.1.0 → 4.1.2-canary.36199476632001

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 (93) hide show
  1. package/dist/app.d.ts +7 -1
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
  4. package/dist/auth/managed-auth.d.ts +37 -1
  5. package/dist/auth/signup-funnel-metrics.d.ts +44 -0
  6. package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
  7. package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
  8. package/dist/http/cors.d.ts +12 -0
  9. package/dist/http/local-browser-boundary.d.ts +72 -0
  10. package/dist/http/metrics-listener.d.ts +22 -0
  11. package/dist/http/registered-route-label.d.ts +16 -0
  12. package/dist/http/request-source.d.ts +41 -3
  13. package/dist/http/user-content.d.ts +36 -0
  14. package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
  15. package/dist/index.js +90 -31
  16. package/dist/index.js.map +1 -1
  17. package/dist/integrations/github-lens-connect.d.ts +1 -1
  18. package/dist/integrations/oauth-client.d.ts +30 -0
  19. package/dist/integrations/slack-bot.d.ts +48 -2
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/documents.d.ts +2 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
  23. package/dist/mcp/server.d.ts +35 -0
  24. package/dist/model-catalog.d.ts +9 -3
  25. package/dist/observability.d.ts +3 -1
  26. package/dist/routes/billing.d.ts +27 -0
  27. package/dist/routes/browser-sessions.d.ts +3 -0
  28. package/dist/routes/client-errors.d.ts +42 -0
  29. package/dist/routes/computer-sessions.d.ts +2 -0
  30. package/dist/routes/github-browser-pages.d.ts +12 -0
  31. package/dist/routes/insights.d.ts +26 -0
  32. package/dist/sandbox/auth-callout.d.ts +8 -0
  33. package/dist/sandbox-file-artifacts.d.ts +17 -1
  34. package/package.json +19 -19
  35. package/src/app.ts +124 -12
  36. package/src/auth/managed-auth-rate-limits.ts +252 -0
  37. package/src/auth/managed-auth.ts +137 -17
  38. package/src/auth/signup-funnel-metrics.ts +164 -0
  39. package/src/http/auth.ts +6 -0
  40. package/src/http/cors.ts +37 -5
  41. package/src/http/local-browser-boundary.ts +343 -0
  42. package/src/http/metrics-listener.ts +57 -0
  43. package/src/http/registered-route-label.ts +34 -0
  44. package/src/http/request-source.ts +154 -13
  45. package/src/http/sse.ts +36 -16
  46. package/src/http/user-content.ts +158 -0
  47. package/src/http/websocket-upgrade-dispatch.ts +29 -0
  48. package/src/index.ts +22 -8
  49. package/src/integrations/atlassian.ts +7 -3
  50. package/src/integrations/fiken.ts +9 -3
  51. package/src/integrations/github-app-connect.ts +12 -2
  52. package/src/integrations/google-drive.ts +7 -3
  53. package/src/integrations/oauth-client.ts +99 -12
  54. package/src/integrations/personal-github.ts +4 -2
  55. package/src/integrations/provider-oauth.ts +10 -3
  56. package/src/integrations/slack-bot.ts +43 -1
  57. package/src/integrations/social-oauth.ts +7 -2
  58. package/src/mcp/documents.ts +9 -2
  59. package/src/mcp/files.ts +5 -1
  60. package/src/mcp/first-party-tool-permissions.ts +6 -0
  61. package/src/mcp/knowledge-entries.ts +15 -2
  62. package/src/mcp/server.ts +191 -17
  63. package/src/mcp-oauth.ts +4 -6
  64. package/src/model-catalog.ts +30 -3
  65. package/src/observability.ts +15 -1
  66. package/src/routes/billing.ts +70 -9
  67. package/src/routes/browser-sessions.ts +169 -0
  68. package/src/routes/client-errors.ts +182 -0
  69. package/src/routes/company-brain.ts +4 -0
  70. package/src/routes/computer-sessions.ts +37 -9
  71. package/src/routes/connections.ts +7 -1
  72. package/src/routes/documents.ts +6 -1
  73. package/src/routes/editable-artifacts.ts +2 -1
  74. package/src/routes/enrollments.ts +2 -13
  75. package/src/routes/files.ts +17 -3
  76. package/src/routes/github-browser-pages.ts +162 -0
  77. package/src/routes/github.ts +122 -49
  78. package/src/routes/insights.ts +70 -6
  79. package/src/routes/knowledge.ts +24 -1
  80. package/src/routes/managed-auth-session-sets.ts +26 -6
  81. package/src/routes/managed-onboarding.ts +23 -19
  82. package/src/routes/organization-memberships.ts +3 -0
  83. package/src/routes/scheduled-tasks.ts +9 -2
  84. package/src/routes/sessions.ts +89 -0
  85. package/src/routes/workspace-artifacts.ts +9 -0
  86. package/src/routes/workspace-state.ts +4 -0
  87. package/src/routes/workspaces.ts +38 -15
  88. package/src/sandbox/auth-callout.ts +62 -11
  89. package/src/sandbox/channel-a.ts +8 -0
  90. package/src/sandbox/viewer.ts +16 -3
  91. package/src/sandbox-file-artifacts.ts +37 -26
  92. package/src/site-uploads.ts +8 -1
  93. package/src/workspace-tool-gateway.ts +1 -0
package/src/app.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { registerConnectCallbackReturns } from "./integrations/connect-callback-return";
2
2
  import { registerFeedbackRoutes } from "./routes/feedback";
3
+ import {
4
+ CLIENT_ERRORS_PATH,
5
+ isClientErrorReportRequest,
6
+ registerClientErrorRoutes,
7
+ } from "./routes/client-errors";
3
8
  import { codemodeSessionRequest } from "./codemode";
4
9
  import { SiteSessionPathError, OrganizationIntegrationDeniedError } from "@opengeni/contracts";
5
10
  import { registerModelConnectionAccessRoutes } from "./routes/model-connection-access";
6
11
  import {
7
12
  canonicalizeConfiguredModelId,
13
+ codeSearchDeploymentPolicy,
8
14
  configuredAllowedModels,
9
15
  configuredAllowedReasoningEfforts,
10
16
  configuredModels,
@@ -44,6 +50,7 @@ import {
44
50
  ConnectAttemptConflictError,
45
51
  ConnectAttemptNotFoundError,
46
52
  configureChildLifecycleNotices,
53
+ configureCodeSearchDeploymentPolicy,
47
54
  configureWorkspaceControlRequestLockTimeoutMs,
48
55
  dbSql,
49
56
  getManagedAuthSessionSetSnapshot,
@@ -66,7 +73,12 @@ import { cors } from "hono/cors";
66
73
  import { getCookie } from "hono/cookie";
67
74
  import { HTTPException } from "hono/http-exception";
68
75
  import { ApiHttpError, workspaceControlBusyHttpError } from "./http/api-error";
76
+ import { replaceTrustedClientAddressHeader } from "./http/request-source";
69
77
  import type { ContentfulStatusCode } from "hono/utils/http-status";
78
+ import {
79
+ boundedRegisteredRouteLabel,
80
+ registeredHandlerRoutePath,
81
+ } from "./http/registered-route-label";
70
82
  import type { ApiRouteDeps, AppDependencies } from "@opengeni/core";
71
83
  import {
72
84
  CodexCompactionV2ProviderLockedError,
@@ -144,7 +156,16 @@ import {
144
156
  BrowserControlTransportError,
145
157
  } from "@opengeni/runtime/sandbox";
146
158
  import { requireAccessKey } from "./http/auth";
159
+ import {
160
+ publicListenerServesMetrics,
161
+ registerPrometheusMetricsRoute,
162
+ } from "./http/metrics-listener";
147
163
  import { allowedCorsOrigin } from "./http/cors";
164
+ import {
165
+ createLocalBrowserBoundary,
166
+ localBrowserRequestHost,
167
+ markLocalInternalDispatch,
168
+ } from "./http/local-browser-boundary";
148
169
  import { withAccessGrantSessionRlsContext } from "./access-grant-rls";
149
170
  import { registerCapabilityRoutes } from "./routes/capabilities";
150
171
  import { registerCatalogAssetRoutes } from "./routes/catalog-assets";
@@ -294,11 +315,17 @@ export function createAppComposition(deps: AppDependencies): {
294
315
  // Pause) run inside API-originated db commands; install the boot-validated
295
316
  // rollout flag once for this process.
296
317
  configureChildLifecycleNotices({ enabled: deps.settings.childLifecycleNoticesEnabled });
318
+ // Sessions created by this process freeze their code_search decision from
319
+ // the boot-parsed deployment policy.
320
+ configureCodeSearchDeploymentPolicy(codeSearchDeploymentPolicy(deps.settings));
297
321
  const managedEmailTransport =
298
322
  deps.managedEmailTransport ?? createManagedEmailTransport(deps.settings);
299
323
  assertManagedEmailTransportMetadata(managedEmailTransport);
324
+ const observability =
325
+ deps.observability ?? createObservability(deps.settings, { component: "api" });
300
326
  const managedAuth =
301
- deps.managedAuth ?? createManagedAuth(deps.settings, deps.db, managedEmailTransport);
327
+ deps.managedAuth ??
328
+ createManagedAuth(deps.settings, deps.db, managedEmailTransport, { observability });
302
329
  const managedAuthSessionAdapter =
303
330
  deps.managedAuthSessionAdapter ??
304
331
  (managedAuth ? createBetterAuthSessionAdapter(managedAuth, deps.db) : null);
@@ -373,8 +400,6 @@ export function createAppComposition(deps: AppDependencies): {
373
400
  // concrete for routes; it throws SandboxResumeError when sandboxBackend=none.
374
401
  const sandboxClient = deps.sandboxClient ?? createApiSandboxClient(deps.settings);
375
402
  const resumeBoxById = deps.resumeBoxById ?? makeResumeBoxById(sandboxClient);
376
- const observability =
377
- deps.observability ?? createObservability(deps.settings, { component: "api" });
378
403
  if (
379
404
  managedAuth &&
380
405
  deps.settings.managedAuthSessionSetMode !== "legacy" &&
@@ -435,6 +460,39 @@ export function createAppComposition(deps: AppDependencies): {
435
460
  await next();
436
461
  });
437
462
 
463
+ // Unauthenticated local mode: admit only requests addressed to this computer
464
+ // and, when a browser sent them, from this stack's web app (see
465
+ // http/local-browser-boundary.ts). Runs before CORS so a refused preflight
466
+ // carries no CORS grant, and logs each distinct refused Host or Origin once
467
+ // because the browser shows only a generic CORS error. Null outside local
468
+ // development.
469
+ const localBrowserBoundary = createLocalBrowserBoundary(deps.settings, {
470
+ warn: (message, attributes) => observability.warn(message, attributes),
471
+ });
472
+ if (localBrowserBoundary) {
473
+ app.use("*", async (c, next) => {
474
+ const rejection = localBrowserBoundary.rejection(c.req.raw);
475
+ if (rejection) {
476
+ throw new ApiHttpError(rejection.status, {
477
+ code: "forbidden",
478
+ message: rejection.message,
479
+ retryable: false,
480
+ details: { code: rejection.code },
481
+ });
482
+ }
483
+ await next();
484
+ });
485
+ }
486
+
487
+ // Better Auth keys its rate limits and session addresses on a request
488
+ // header. Drop any caller-supplied copy everywhere and stamp the trusted
489
+ // source address on managed-auth routes before any route derives a Better
490
+ // Auth request from this one.
491
+ app.use("*", async (c, next) => {
492
+ replaceTrustedClientAddressHeader(c, deps.settings, c.req.path.startsWith("/v1/auth/"));
493
+ await next();
494
+ });
495
+
438
496
  app.use("*", async (c, next) => {
439
497
  const oauthToken = mcpOAuthBearerToken(c.req.raw);
440
498
  if (
@@ -498,8 +556,25 @@ export function createAppComposition(deps: AppDependencies): {
498
556
  allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin) ? origin : null,
499
557
  });
500
558
 
559
+ const localCors = localBrowserBoundary
560
+ ? cors({
561
+ ...corsHeaders,
562
+ credentials: true,
563
+ origin: (origin, c) =>
564
+ localBrowserBoundary.originAllowed(origin, localBrowserRequestHost(c.req.raw))
565
+ ? origin
566
+ : null,
567
+ })
568
+ : null;
569
+
501
570
  app.use("*", (c, next) => {
502
571
  const origin = c.req.header("origin");
572
+ if (localCors) {
573
+ // The boundary above already refused every other origin. Local mode
574
+ // never answers with wildcard CORS: a request without credentials acts
575
+ // as the local user, so any site could otherwise read its responses.
576
+ return origin ? localCors(c, next) : next();
577
+ }
503
578
  const middleware =
504
579
  origin && allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin)
505
580
  ? credentialedCors
@@ -516,7 +591,14 @@ export function createAppComposition(deps: AppDependencies): {
516
591
  // Git packfiles must stay streaming and can legitimately exceed the JSON
517
592
  // request ceiling. The exact closed broker routes apply their own method,
518
593
  // content-type, authority, and idle-deadline checks.
519
- if (isPersonalGitHubGitBrokerRequest(c.req.method, new URL(c.req.url).pathname)) {
594
+ const pathname = new URL(c.req.url).pathname;
595
+ if (isPersonalGitHubGitBrokerRequest(c.req.method, pathname)) {
596
+ await next();
597
+ return;
598
+ }
599
+ // The anonymous web error beacon enforces its own 512-byte limit on the
600
+ // streamed body; the generic ceiling would buffer far more first.
601
+ if (isClientErrorReportRequest(c.req.method, pathname)) {
520
602
  await next();
521
603
  return;
522
604
  }
@@ -539,7 +621,7 @@ export function createAppComposition(deps: AppDependencies): {
539
621
 
540
622
  app.use("*", async (c, next) => {
541
623
  const url = new URL(c.req.url);
542
- const route = routeLabel(url.pathname);
624
+ const route = routeLabel(url.pathname, registeredHandlerRoutePath(c));
543
625
  const correlationId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
544
626
  const start = performance.now();
545
627
  const span = observability.startSpan(
@@ -896,14 +978,14 @@ export function createAppComposition(deps: AppDependencies): {
896
978
  return c.json(result, result.ok ? 200 : 503);
897
979
  });
898
980
 
899
- app.get("/metrics", async (c) =>
900
- c.text(await observability.prometheusMetrics(), 200, {
901
- "content-type": "text/plain; version=0.0.4; charset=utf-8",
902
- }),
903
- );
981
+ if (publicListenerServesMetrics(deps.settings)) {
982
+ registerPrometheusMetricsRoute(app, observability);
983
+ }
904
984
 
905
985
  registerMcpOAuthRoutes(app, routeDeps);
906
986
 
987
+ registerClientErrorRoutes(app, { observability, settings: deps.settings });
988
+
907
989
  app.get("/v1/config/client", async (c) => {
908
990
  c.header("cache-control", "no-store");
909
991
  const resolvedCatalog = await resolveCatalogSettings(deps.db, deps.settings);
@@ -934,6 +1016,7 @@ export function createAppComposition(deps: AppDependencies): {
934
1016
  name: server.name ?? server.id,
935
1017
  })),
936
1018
  firstPartyMcpTools: resolveFirstPartyMcpToolPolicy(deps.settings),
1019
+ codeSearch: codeSearchDeploymentPolicy(deps.settings),
937
1020
  fileUploads: {
938
1021
  enabled: objectStorage !== null,
939
1022
  maxSizeBytes: objectStorage?.maxSinglePutSizeBytes ?? 5_000_000_000,
@@ -969,6 +1052,7 @@ export function createAppComposition(deps: AppDependencies): {
969
1052
  billingMode: deps.settings.billingMode,
970
1053
  managedAuthSessionSetMode: deps.settings.managedAuthSessionSetMode,
971
1054
  auth: clientAuthConfig(deps.settings),
1055
+ documentationUrl: deps.settings.documentationUrl,
972
1056
  analytics: clientAnalyticsConfig(deps.settings),
973
1057
  // Channel-A structured services (P4.4) ride exec/readFile/createEditor,
974
1058
  // available on every real backend; `none` has no box so they are all off.
@@ -1232,7 +1316,9 @@ export function createAppComposition(deps: AppDependencies): {
1232
1316
  } catch (error) {
1233
1317
  throw codemodeHttpError(error);
1234
1318
  }
1235
- return app.fetch(forwarded);
1319
+ // The forwarded request drops the caller's Host and names a non-sandbox
1320
+ // path; the request it was built from already passed the local boundary.
1321
+ return app.fetch(markLocalInternalDispatch(forwarded));
1236
1322
  });
1237
1323
 
1238
1324
  app.get("/v1/workspaces/:workspaceId/codemode/catalog", async (c) => {
@@ -1816,6 +1902,21 @@ const routeLabelPatterns: Array<{
1816
1902
  },
1817
1903
  { pattern: /^\/healthz$/, label: "/healthz" },
1818
1904
  { pattern: /^\/readyz$/, label: "/readyz" },
1905
+ // Better Auth answers these behind one `/v1/auth/*` registration, so the
1906
+ // provider endpoints need an explicit closed set to stay distinguishable.
1907
+ {
1908
+ pattern:
1909
+ /^\/v1\/auth\/(sign-up\/email|sign-in\/email|sign-in\/social|sign-out|send-verification-email|verify-email|request-password-reset|reset-password|error|ok)$/,
1910
+ label: (match) => `/v1/auth/${match[1]}`,
1911
+ },
1912
+ { pattern: /^\/v1\/auth\/reset-password\/[^/]+$/, label: "/v1/auth/reset-password/:token" },
1913
+ {
1914
+ pattern: /^\/v1\/auth\/callback\/([^/]+)$/,
1915
+ label: (match) =>
1916
+ match[1] === "google" || match[1] === "github"
1917
+ ? `/v1/auth/callback/${match[1]}`
1918
+ : "/v1/auth/callback/:providerId",
1919
+ },
1819
1920
  { pattern: /^\/traffic-readyz$/, label: "/traffic-readyz" },
1820
1921
  {
1821
1922
  pattern: /^\/v1\/workspaces\/[^/]+\/codex\/connect\/start$/,
@@ -1871,6 +1972,7 @@ const routeLabelPatterns: Array<{
1871
1972
  },
1872
1973
  { pattern: /^\/metrics$/, label: "/metrics" },
1873
1974
  { pattern: /^\/v1\/config\/client$/, label: "/v1/config/client" },
1975
+ { pattern: /^\/v1\/client-errors$/, label: "/v1/client-errors" },
1874
1976
  { pattern: /^\/v1\/billing$/, label: "/v1/billing" },
1875
1977
  { pattern: /^\/v1\/billing\/checkout$/, label: "/v1/billing/checkout" },
1876
1978
  { pattern: /^\/v1\/billing\/usage$/, label: "/v1/billing/usage" },
@@ -2629,7 +2731,13 @@ const routeLabelPatterns: Array<{
2629
2731
  },
2630
2732
  ];
2631
2733
 
2632
- export function routeLabel(pathname: string): string {
2734
+ /**
2735
+ * Bounded route label for metrics, spans, and request logs. Explicit patterns
2736
+ * keep established label spellings stable; any other request answered by a
2737
+ * registered handler uses that handler's code-owned path template (see
2738
+ * `registeredHandlerRoutePath`). Only unregistered paths fall into `unknown`.
2739
+ */
2740
+ export function routeLabel(pathname: string, registeredRoutePath?: string | null): string {
2633
2741
  if (/^\/v1\/workspaces\/[^/]+\/transcriptions$/.test(pathname))
2634
2742
  return "/v1/workspaces/:workspaceId/transcriptions";
2635
2743
  const transcription = pathname.match(
@@ -2650,6 +2758,8 @@ export function routeLabel(pathname: string): string {
2650
2758
  return typeof candidate.label === "string" ? candidate.label : candidate.label(match);
2651
2759
  }
2652
2760
  }
2761
+ const registered = boundedRegisteredRouteLabel(registeredRoutePath);
2762
+ if (registered) return registered;
2653
2763
  return pathname.startsWith("/v1/") ? "/v1/unknown" : "/unknown";
2654
2764
  }
2655
2765
 
@@ -2680,6 +2790,8 @@ export function isApiContractProtectedMutation(method: string, pathname: string)
2680
2790
  pathname === "/v1/integrations/slack/commands" ||
2681
2791
  pathname === "/v1/integrations/slack/interactions" ||
2682
2792
  pathname.startsWith("/v1/github/") ||
2793
+ // A stale tab must still report the error that follows a rollout.
2794
+ pathname === CLIENT_ERRORS_PATH ||
2683
2795
  pathname === "/v1/enrollments/device/start" ||
2684
2796
  pathname === "/v1/enrollments/device/poll" ||
2685
2797
  pathname === "/v1/enrollments/token/exchange"
@@ -0,0 +1,252 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import type { Database } from "@opengeni/db";
4
+ import { APIError, createAuthMiddleware } from "better-auth/api";
5
+ import { sql } from "drizzle-orm";
6
+
7
+ import {
8
+ requestSourceRateLimitKey,
9
+ TRUSTED_CLIENT_ADDRESS_HEADER,
10
+ UNKNOWN_REQUEST_SOURCE_ADDRESS,
11
+ } from "../http/request-source";
12
+
13
+ export type ManagedAuthClientRateLimitRule = { window: number; max: number };
14
+
15
+ /**
16
+ * Better Auth's per-client-address limits, keyed by endpoint path relative to
17
+ * `/v1/auth`. The client address is the trusted source address stamped by the
18
+ * API (see `TRUSTED_CLIENT_ADDRESS_HEADER`), never a caller-supplied header.
19
+ *
20
+ * Better Auth keys each counter on `(client address, path)` and resets it only
21
+ * after `window` seconds pass without an admitted request. Its database store
22
+ * prunes rows idle for longer than 60 s, so no window here exceeds 60 s; a
23
+ * longer window would silently shrink to that pruning horizon. The limits are
24
+ * sized for launch traffic that shares one address (office and carrier NAT)
25
+ * while bounding scripted credential guessing and mail sending from one
26
+ * source. Per-email throttles below cap the same abuse across many addresses.
27
+ *
28
+ * Email sign-in and sign-up allow 20 per minute, at least Better Auth's
29
+ * previous default (3 per 10 s, about 18 per minute). Until the edge forwards
30
+ * real client addresses (for example ingress-nginx behind a cloud load
31
+ * balancer with `externalTrafficPolicy: Cluster`), every user shares a few
32
+ * node addresses, and these two limits then bound the whole deployment's
33
+ * email sign-in and sign-up rate; they must not fall below that default.
34
+ */
35
+ export const MANAGED_AUTH_CLIENT_RATE_LIMIT_RULES = {
36
+ "/sign-in/email": { window: 60, max: 20 },
37
+ "/sign-up/email": { window: 60, max: 20 },
38
+ "/sign-in/social": { window: 60, max: 20 },
39
+ "/callback/*": { window: 60, max: 30 },
40
+ "/request-password-reset": { window: 60, max: 5 },
41
+ "/send-verification-email": { window: 60, max: 5 },
42
+ "/verify-email": { window: 60, max: 20 },
43
+ "/reset-password": { window: 60, max: 10 },
44
+ "/reset-password/*": { window: 60, max: 20 },
45
+ } as const satisfies Record<string, ManagedAuthClientRateLimitRule>;
46
+
47
+ export type ManagedAuthEmailThrottle = {
48
+ purpose: "sign_in" | "sign_up" | "password_reset" | "verification_email";
49
+ windowSeconds: number;
50
+ /** Attempts one client address (an IPv6 /64) may make against one email. */
51
+ perSourceMax: number;
52
+ /** Attempts all client addresses together may make against one email. */
53
+ perEmailMax: number;
54
+ };
55
+
56
+ /**
57
+ * Per-email throttles for the endpoints that verify a password or send mail
58
+ * to an address. They bound password guessing against one account and mail
59
+ * bombing of one inbox regardless of how many client addresses an attacker
60
+ * rotates through. Every attempt counts, including successful sign-ins.
61
+ *
62
+ * Each email has two fixed windows. The tight one is per (email, client
63
+ * address); an attempt it refuses never reaches the looser per-email window.
64
+ * One source can therefore spend only its own share of an email's budget:
65
+ * locking a person out of email/password sign-in, sign-up, password reset, or
66
+ * verification mail takes at least `perEmailMax / perSourceMax` (5) distinct
67
+ * client addresses (IPv6: /64 blocks) inside the window, while one address
68
+ * can exhaust that email's budget only for requests from the same address.
69
+ * Social sign-in is never throttled per email. The accepted cost is that a
70
+ * distributed attacker may make up to `perEmailMax` attempts per window
71
+ * against one email, and can deny that email's password sign-in for the rest
72
+ * of the window once it spends them.
73
+ */
74
+ export const MANAGED_AUTH_EMAIL_THROTTLES = {
75
+ "/sign-in/email": {
76
+ purpose: "sign_in",
77
+ windowSeconds: 15 * 60,
78
+ perSourceMax: 10,
79
+ perEmailMax: 50,
80
+ },
81
+ "/sign-up/email": {
82
+ purpose: "sign_up",
83
+ windowSeconds: 60 * 60,
84
+ perSourceMax: 5,
85
+ perEmailMax: 25,
86
+ },
87
+ "/request-password-reset": {
88
+ purpose: "password_reset",
89
+ windowSeconds: 60 * 60,
90
+ perSourceMax: 5,
91
+ perEmailMax: 25,
92
+ },
93
+ "/send-verification-email": {
94
+ purpose: "verification_email",
95
+ windowSeconds: 60 * 60,
96
+ perSourceMax: 5,
97
+ perEmailMax: 25,
98
+ },
99
+ } as const satisfies Record<string, ManagedAuthEmailThrottle>;
100
+
101
+ export const MANAGED_AUTH_EMAIL_THROTTLE_KEY_PREFIX = "opengeni:email-throttle:v1:";
102
+
103
+ /** A per-email throttle refusal; an ordinary Better Auth 429 on the wire. */
104
+ export class ManagedAuthEmailThrottleError extends APIError {
105
+ readonly retryAfterSeconds: number;
106
+
107
+ constructor(retryAfterSeconds: number) {
108
+ super(
109
+ "TOO_MANY_REQUESTS",
110
+ { code: "TOO_MANY_REQUESTS", message: "Too many requests. Please try again later." },
111
+ { "X-Retry-After": String(retryAfterSeconds) },
112
+ );
113
+ this.retryAfterSeconds = retryAfterSeconds;
114
+ }
115
+ }
116
+
117
+ export function managedAuthEmailThrottleFor(path: string): ManagedAuthEmailThrottle | null {
118
+ return Object.hasOwn(MANAGED_AUTH_EMAIL_THROTTLES, path)
119
+ ? MANAGED_AUTH_EMAIL_THROTTLES[path as keyof typeof MANAGED_AUTH_EMAIL_THROTTLES]
120
+ : null;
121
+ }
122
+
123
+ /**
124
+ * Content-free counter key for one email. The address is normalized the way
125
+ * Better Auth stores it and keyed-hashed so the rate-limit table never holds
126
+ * an email.
127
+ */
128
+ export function managedAuthEmailThrottleKey(
129
+ secret: string,
130
+ purpose: ManagedAuthEmailThrottle["purpose"],
131
+ email: string,
132
+ ): string {
133
+ const digest = createHmac("sha256", secret)
134
+ .update(`opengeni:managed-auth:email-throttle:v1\n${purpose}\n${normalizedEmail(email)}`)
135
+ .digest("hex");
136
+ return `${MANAGED_AUTH_EMAIL_THROTTLE_KEY_PREFIX}${purpose}:${digest}`;
137
+ }
138
+
139
+ /**
140
+ * Content-free counter key for one (email, client address) pair; `source` is
141
+ * a {@link requestSourceRateLimitKey}. Neither value is stored.
142
+ */
143
+ export function managedAuthEmailSourceThrottleKey(
144
+ secret: string,
145
+ purpose: ManagedAuthEmailThrottle["purpose"],
146
+ email: string,
147
+ source: string,
148
+ ): string {
149
+ const digest = createHmac("sha256", secret)
150
+ .update(
151
+ `opengeni:managed-auth:email-source-throttle:v1\n${purpose}\n${normalizedEmail(email)}\n${source}`,
152
+ )
153
+ .digest("hex");
154
+ return `${MANAGED_AUTH_EMAIL_THROTTLE_KEY_PREFIX}${purpose}:source:${digest}`;
155
+ }
156
+
157
+ /**
158
+ * The limiter source for a Better Auth request: the trusted address the API
159
+ * stamped on it, as a rate-limit key. The API strips caller-supplied copies,
160
+ * so a request without one (an in-process call without a transport peer)
161
+ * shares the unknown-source bucket.
162
+ */
163
+ export function managedAuthThrottleSource(headers: Headers | null | undefined): string {
164
+ const stamped = headers?.get(TRUSTED_CLIENT_ADDRESS_HEADER)?.trim();
165
+ return stamped && isIP(stamped) !== 0
166
+ ? requestSourceRateLimitKey(stamped.toLowerCase())
167
+ : UNKNOWN_REQUEST_SOURCE_ADDRESS;
168
+ }
169
+
170
+ function normalizedEmail(email: string): string {
171
+ return email.trim().toLowerCase();
172
+ }
173
+
174
+ /**
175
+ * Atomically count one attempt in a fixed window shared by every API replica.
176
+ *
177
+ * Rows live in Better Auth's `auth_rate_limits` table. For these keys
178
+ * `last_request` holds the window deadline (epoch milliseconds) rather than
179
+ * the last request time: Better Auth prunes rows whose `last_request` is more
180
+ * than 60 s in the past, so storing the deadline keeps an active window alive
181
+ * and lets pruning reclaim it 60 s after it expires. Database time is the only
182
+ * clock, so replicas with skewed clocks agree.
183
+ */
184
+ export async function consumeManagedAuthEmailThrottle(
185
+ db: Pick<Database, "execute">,
186
+ input: { key: string; windowSeconds: number; max: number },
187
+ ): Promise<{ allowed: boolean; retryAfterSeconds: number }> {
188
+ const windowMs = input.windowSeconds * 1_000;
189
+ const result = await db.execute(sql`
190
+ insert into auth_rate_limits as bucket (id, key, count, last_request)
191
+ values (
192
+ ${input.key},
193
+ ${input.key},
194
+ 1,
195
+ floor(extract(epoch from clock_timestamp()) * 1000)::bigint + ${windowMs}::bigint
196
+ )
197
+ on conflict (key) do update set
198
+ count = case
199
+ when bucket.last_request <= excluded.last_request - ${windowMs}::bigint then 1
200
+ else least(bucket.count + 1, ${input.max + 1}::integer)
201
+ end,
202
+ last_request = case
203
+ when bucket.last_request <= excluded.last_request - ${windowMs}::bigint
204
+ then excluded.last_request
205
+ else bucket.last_request
206
+ end
207
+ returning
208
+ bucket.count as count,
209
+ greatest(
210
+ 1,
211
+ ceil((bucket.last_request - extract(epoch from clock_timestamp()) * 1000) / 1000.0)
212
+ )::integer as retry_after_seconds
213
+ `);
214
+ const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows) as
215
+ | Array<{ count: number | string; retry_after_seconds: number | string }>
216
+ | undefined;
217
+ const row = rows?.[0];
218
+ if (!row) throw new Error("managed auth email throttle returned no row");
219
+ return {
220
+ allowed: Number(row.count) <= input.max,
221
+ retryAfterSeconds: Number(row.retry_after_seconds),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Better Auth `hooks.before` middleware. It runs after Better Auth's own
227
+ * per-client-address limiter and for both HTTP requests and server-side
228
+ * `auth.api.*` calls, so the product session-set sign-in is covered too. The
229
+ * per-(email, address) window is consumed first; only an attempt it admits
230
+ * counts toward the email's shared window.
231
+ */
232
+ export function createManagedAuthEmailThrottleHook(db: Pick<Database, "execute">, secret: string) {
233
+ return createAuthMiddleware(async (ctx) => {
234
+ const throttle = managedAuthEmailThrottleFor(ctx.path);
235
+ if (!throttle) return;
236
+ const email = (ctx.body as { email?: unknown } | undefined)?.email;
237
+ if (typeof email !== "string" || !email.trim()) return;
238
+ const source = managedAuthThrottleSource(ctx.headers);
239
+ const perSource = await consumeManagedAuthEmailThrottle(db, {
240
+ key: managedAuthEmailSourceThrottleKey(secret, throttle.purpose, email, source),
241
+ windowSeconds: throttle.windowSeconds,
242
+ max: throttle.perSourceMax,
243
+ });
244
+ if (!perSource.allowed) throw new ManagedAuthEmailThrottleError(perSource.retryAfterSeconds);
245
+ const perEmail = await consumeManagedAuthEmailThrottle(db, {
246
+ key: managedAuthEmailThrottleKey(secret, throttle.purpose, email),
247
+ windowSeconds: throttle.windowSeconds,
248
+ max: throttle.perEmailMax,
249
+ });
250
+ if (!perEmail.allowed) throw new ManagedAuthEmailThrottleError(perEmail.retryAfterSeconds);
251
+ });
252
+ }