@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.
- package/dist/app.d.ts +7 -1
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth.d.ts +37 -1
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
- package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-source.d.ts +41 -3
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.js +90 -31
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +30 -0
- package/dist/integrations/slack-bot.d.ts +48 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
- package/dist/mcp/server.d.ts +35 -0
- package/dist/model-catalog.d.ts +9 -3
- package/dist/observability.d.ts +3 -1
- package/dist/routes/billing.d.ts +27 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +42 -0
- package/dist/routes/computer-sessions.d.ts +2 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +19 -19
- package/src/app.ts +124 -12
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth.ts +137 -17
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/http/auth.ts +6 -0
- package/src/http/cors.ts +37 -5
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-source.ts +154 -13
- package/src/http/sse.ts +36 -16
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +22 -8
- package/src/integrations/atlassian.ts +7 -3
- package/src/integrations/fiken.ts +9 -3
- package/src/integrations/github-app-connect.ts +12 -2
- package/src/integrations/google-drive.ts +7 -3
- package/src/integrations/oauth-client.ts +99 -12
- package/src/integrations/personal-github.ts +4 -2
- package/src/integrations/provider-oauth.ts +10 -3
- package/src/integrations/slack-bot.ts +43 -1
- package/src/integrations/social-oauth.ts +7 -2
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/files.ts +5 -1
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +191 -17
- package/src/mcp-oauth.ts +4 -6
- package/src/model-catalog.ts +30 -3
- package/src/observability.ts +15 -1
- package/src/routes/billing.ts +70 -9
- package/src/routes/browser-sessions.ts +169 -0
- package/src/routes/client-errors.ts +182 -0
- package/src/routes/company-brain.ts +4 -0
- package/src/routes/computer-sessions.ts +37 -9
- package/src/routes/connections.ts +7 -1
- package/src/routes/documents.ts +6 -1
- package/src/routes/editable-artifacts.ts +2 -1
- package/src/routes/enrollments.ts +2 -13
- package/src/routes/files.ts +17 -3
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +122 -49
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +24 -1
- package/src/routes/managed-auth-session-sets.ts +26 -6
- package/src/routes/managed-onboarding.ts +23 -19
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/scheduled-tasks.ts +9 -2
- package/src/routes/sessions.ts +89 -0
- package/src/routes/workspace-artifacts.ts +9 -0
- package/src/routes/workspace-state.ts +4 -0
- package/src/routes/workspaces.ts +38 -15
- package/src/sandbox/auth-callout.ts +62 -11
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/site-uploads.ts +8 -1
- 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 ??
|
|
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
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|