@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
@@ -12,14 +12,21 @@ import {
12
12
  getCanonicalHumanExactLoginBindingForAuthUser,
13
13
  synchronizeCanonicalHumanLoginBindings,
14
14
  } from "@opengeni/db/canonical-human-identities";
15
+ import type { Observability } from "@opengeni/observability";
15
16
  import { betterAuth } from "better-auth";
16
17
  import { createEmailVerificationToken } from "better-auth/api";
17
18
  import { hashPassword, verifyPassword } from "better-auth/crypto";
18
19
  import { sql } from "drizzle-orm";
19
- import { Pool } from "pg";
20
+ import { Pool, type PoolConfig } from "pg";
20
21
 
22
+ import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../http/request-source";
21
23
  import { decideCanonicalHumanSessionAdmission } from "./canonical-human-session-admission";
24
+ import {
25
+ createManagedAuthEmailThrottleHook,
26
+ MANAGED_AUTH_CLIENT_RATE_LIMIT_RULES,
27
+ } from "./managed-auth-rate-limits";
22
28
  import { deliverManagedSignInNotification } from "./managed-sign-in-notifications";
29
+ import { createSignupFunnelMetrics } from "./signup-funnel-metrics";
23
30
  import {
24
31
  currentManagedAuthProviderId,
25
32
  currentManagedAuthAttemptId,
@@ -28,6 +35,10 @@ import {
28
35
  currentManagedSignInConnectIntent,
29
36
  } from "./managed-auth-attempt-context";
30
37
 
38
+ // Only used when no Better Auth secret is configured (local development);
39
+ // the email throttle key is a content-free digest either way.
40
+ const MANAGED_AUTH_EMAIL_THROTTLE_FALLBACK_KEY = "opengeni:managed-auth:email-throttle:local";
41
+
31
42
  // `ManagedAuth` (the Better Auth `Auth<any>` alias) is owned by @opengeni/core
32
43
  // (`managed-auth-type.ts`) — `dependencies.ts`/`access` reference it as a
33
44
  // type-only slot. We re-export it from this construction site so existing
@@ -72,16 +83,94 @@ export async function verifyManagedAuthPassword(password: string, hash: string):
72
83
  return await verifyPassword({ password, hash });
73
84
  }
74
85
 
86
+ type ManagedAuthPoolObservability = Pick<Observability, "warn" | "incrementCounter">;
87
+
88
+ /**
89
+ * Bounded pool settings for Better Auth's dedicated `pg` pool. A connection
90
+ * attempt fails after 10 s instead of queueing requests indefinitely while the
91
+ * database is unreachable, and connections recycle like the main postgres-js
92
+ * pool so a pooler- or failover-replaced backend is not held forever.
93
+ */
94
+ export const MANAGED_AUTH_DATABASE_POOL_OPTIONS = {
95
+ max: 10,
96
+ connectionTimeoutMillis: 10_000,
97
+ idleTimeoutMillis: 30_000,
98
+ maxLifetimeSeconds: 1_800,
99
+ keepAlive: true,
100
+ } as const satisfies PoolConfig;
101
+
102
+ /**
103
+ * Better Auth's `pg` pool. When the server closes a connection (a restart,
104
+ * failover, or maintenance window) `pg` emits `error`: on the pool for an idle
105
+ * client, and on the client itself for one checked out by Better Auth's query
106
+ * layer. Without listeners either becomes an uncaught exception and the API's
107
+ * fatal process boundary exits. Both listeners record one content-free signal
108
+ * and keep serving. The pool has already discarded a failed idle client and
109
+ * opens a fresh connection for the next checkout; a checked-out client's
110
+ * in-flight query already rejects to its caller, and the pool discards the
111
+ * dead client on release. An idle client's error also reaches its own
112
+ * listener, so that listener records only while the client is checked out,
113
+ * and at most once per checkout.
114
+ */
115
+ export function createManagedAuthDatabasePool(
116
+ databaseUrl: string,
117
+ observability?: ManagedAuthPoolObservability,
118
+ ): Pool {
119
+ const pool = new Pool({ connectionString: databaseUrl, ...MANAGED_AUTH_DATABASE_POOL_OPTIONS });
120
+ const checkedOut = new WeakSet<object>();
121
+ pool.on("acquire", (client) => checkedOut.add(client));
122
+ pool.on("release", (_error, client) => checkedOut.delete(client));
123
+ pool.on("connect", (client) => {
124
+ client.on("error", () => {
125
+ // One lost connection can emit twice (the server's termination
126
+ // message, then the socket end), so count it once.
127
+ if (checkedOut.delete(client)) {
128
+ recordManagedAuthPoolError(observability, "checked_out_connection_failed");
129
+ }
130
+ });
131
+ });
132
+ pool.on("error", () => recordManagedAuthPoolError(observability, "idle_connection_discarded"));
133
+ return pool;
134
+ }
135
+
136
+ export const MANAGED_AUTH_DATABASE_POOL_ERRORS_METRIC = {
137
+ name: "opengeni_managed_auth_database_pool_errors_total",
138
+ help: "Managed-auth database connections the server closed or lost, by pool state.",
139
+ } as const;
140
+
141
+ function recordManagedAuthPoolError(
142
+ observability: ManagedAuthPoolObservability | undefined,
143
+ outcome: "idle_connection_discarded" | "checked_out_connection_failed",
144
+ ): void {
145
+ try {
146
+ observability?.incrementCounter({
147
+ ...MANAGED_AUTH_DATABASE_POOL_ERRORS_METRIC,
148
+ labels: { outcome },
149
+ });
150
+ observability?.warn("Managed auth database connection failed", {
151
+ dependency: "managed_auth_database",
152
+ outcome,
153
+ });
154
+ } catch {
155
+ // An observer failure must not turn a recovered pool error into a crash.
156
+ }
157
+ }
158
+
75
159
  export function createManagedAuth(
76
160
  settings: Settings,
77
161
  db: Database,
78
162
  managedEmailTransport: ManagedEmailTransport,
163
+ options: { observability?: ManagedAuthPoolObservability } = {},
79
164
  ): ManagedAuth | null {
80
165
  if (settings.productAccessMode !== "managed") {
81
166
  return null;
82
167
  }
168
+ // Content-free sign-up funnel counters; recording never fails an auth flow.
169
+ const funnel = options.observability
170
+ ? createSignupFunnelMetrics(options.observability)
171
+ : undefined;
83
172
  const requireEmailVerification = managedAuthRequiresEmailVerification(settings);
84
- const pool = new Pool({ connectionString: settings.databaseUrl });
173
+ const pool = createManagedAuthDatabasePool(settings.databaseUrl, options.observability);
85
174
  return betterAuth({
86
175
  appName: "OpenGeni",
87
176
  baseURL: betterAuthBaseUrl(settings),
@@ -89,7 +178,22 @@ export function createManagedAuth(
89
178
  secret: settings.betterAuthSecret,
90
179
  database: pool,
91
180
  trustedOrigins: betterAuthTrustedOrigins(settings),
181
+ hooks: {
182
+ before: createManagedAuthEmailThrottleHook(
183
+ db,
184
+ settings.betterAuthSecret ?? MANAGED_AUTH_EMAIL_THROTTLE_FALLBACK_KEY,
185
+ ),
186
+ },
92
187
  advanced: {
188
+ // Better Auth reads client addresses only from request headers. The API
189
+ // strips this header from every inbound request and re-stamps it on
190
+ // managed-auth routes with the trusted source address (transport peer,
191
+ // or the forwarded client when OPENGENI_API_TRUSTED_PROXY_HOPS declares
192
+ // a proxy chain), so callers cannot choose their rate-limit bucket or
193
+ // recorded session address.
194
+ ipAddress: {
195
+ ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER],
196
+ },
93
197
  useSecureCookies: settings.publicBaseUrl?.startsWith("https://") ?? false,
94
198
  ...(settings.betterAuthCookieDomain
95
199
  ? {
@@ -110,6 +214,7 @@ export function createManagedAuth(
110
214
  fields: {
111
215
  lastRequest: "last_request",
112
216
  },
217
+ customRules: MANAGED_AUTH_CLIENT_RATE_LIMIT_RULES,
113
218
  },
114
219
  user: {
115
220
  modelName: "auth_users",
@@ -240,13 +345,10 @@ export function createManagedAuth(
240
345
  onExistingUserSignUp: async ({ user }) => {
241
346
  if (requireEmailVerification && !user.emailVerified) {
242
347
  const url = await verificationUrl(settings, user.email);
243
- await sendManagedAuthEmail(managedEmailTransport, {
244
- kind: "email_verification",
245
- to: user.email,
246
- subject: "Verify your OpenGeni email",
247
- text: `Verify your OpenGeni email: ${url}`,
248
- html: `<p>Verify your OpenGeni email:</p><p><a href="${escapeHtml(url)}">Verify email</a></p>`,
249
- });
348
+ await sendManagedAuthEmail(
349
+ managedEmailTransport,
350
+ emailVerificationMessage(user.email, url),
351
+ );
250
352
  }
251
353
  },
252
354
  sendResetPassword: async ({ user, url }) => {
@@ -261,16 +363,19 @@ export function createManagedAuth(
261
363
  },
262
364
  emailVerification: {
263
365
  sendOnSignUp: requireEmailVerification,
366
+ // The first successful verification link click signs the user in (a
367
+ // reused link creates no session). Only legacy mode: session-set modes
368
+ // bind sign-in to an isolated browser transaction, and the provider
369
+ // session this would create is discarded there by design.
370
+ autoSignInAfterVerification: settings.managedAuthSessionSetMode === "legacy",
264
371
  sendVerificationEmail: async ({ user, url }) => {
265
- await sendManagedAuthEmail(managedEmailTransport, {
266
- kind: "email_verification",
267
- to: user.email,
268
- subject: "Verify your OpenGeni email",
269
- text: `Verify your OpenGeni email: ${url}`,
270
- html: `<p>Verify your OpenGeni email:</p><p><a href="${escapeHtml(url)}">Verify email</a></p>`,
271
- });
372
+ await sendManagedAuthEmail(
373
+ managedEmailTransport,
374
+ emailVerificationMessage(user.email, url),
375
+ );
272
376
  },
273
377
  afterEmailVerification: async (user) => {
378
+ funnel?.recordEmailVerified();
274
379
  await ensureManagedAccessForUser(db, {
275
380
  userId: user.id,
276
381
  email: user.email,
@@ -394,6 +499,7 @@ export function createManagedAuth(
394
499
  };
395
500
  },
396
501
  after: async (session) => {
502
+ funnel?.recordSignIn();
397
503
  recordCurrentManagedAuthSession(session.id);
398
504
  if (!shouldDiscardCurrentManagedAuthProviderSession()) return;
399
505
  await db.execute(sql`delete from auth_sessions where id = ${session.id}`);
@@ -404,7 +510,8 @@ export function createManagedAuth(
404
510
  create: {
405
511
  before: async (user) =>
406
512
  managedAuthUserCreateAdmission(settings, user, currentManagedAuthProviderId()),
407
- after: async (user) => {
513
+ after: async (user, context) => {
514
+ await funnel?.recordSignUp(context);
408
515
  if (!user.emailVerified) return;
409
516
  await ensureManagedAccessForUser(db, {
410
517
  userId: user.id,
@@ -570,6 +677,19 @@ export async function sendManagedAuthEmail(
570
677
  if (result.status !== "sent") throw new Error(`managed email ${result.status}`);
571
678
  }
572
679
 
680
+ // Verification can sign the clicker in (autoSignInAfterVerification), so an
681
+ // unsolicited verification email must say plainly that it can be ignored.
682
+ function emailVerificationMessage(to: string, url: string): Omit<ManagedEmailMessage, "from"> {
683
+ const ignore = "If you did not create an OpenGeni account, ignore this email.";
684
+ return {
685
+ kind: "email_verification",
686
+ to,
687
+ subject: "Verify your OpenGeni email",
688
+ text: `Verify your OpenGeni email: ${url}\n\n${ignore}`,
689
+ html: `<p>Verify your OpenGeni email:</p><p><a href="${escapeHtml(url)}">Verify email</a></p><p>${ignore}</p>`,
690
+ };
691
+ }
692
+
573
693
  async function verificationUrl(settings: Settings, email: string): Promise<string> {
574
694
  if (!settings.betterAuthSecret) {
575
695
  throw new Error(
@@ -0,0 +1,164 @@
1
+ import {
2
+ SIGNUP_ACQUISITION_SOURCES,
3
+ signupAcquisitionSource,
4
+ type SignupAcquisitionSource,
5
+ } from "@opengeni/contracts";
6
+ import type { Observability } from "@opengeni/observability";
7
+ import { getOAuthState } from "better-auth/api";
8
+
9
+ import {
10
+ currentManagedAuthProviderId,
11
+ shouldDiscardCurrentManagedAuthProviderSession,
12
+ } from "./managed-auth-attempt-context";
13
+
14
+ /**
15
+ * Content-free sign-up funnel counters. Every label is a closed set: no user,
16
+ * email, organization, workspace, or campaign value ever becomes a label.
17
+ */
18
+ export const AUTH_FUNNEL_EVENTS = ["sign_up", "email_verified", "sign_in"] as const;
19
+ export type AuthFunnelEvent = (typeof AUTH_FUNNEL_EVENTS)[number];
20
+ export const AUTH_FUNNEL_METHODS = ["email", "google", "github", "other"] as const;
21
+ export type AuthFunnelMethod = (typeof AUTH_FUNNEL_METHODS)[number];
22
+ export const ORGANIZATION_SETUP_OUTCOMES = ["created", "failed"] as const;
23
+ export type OrganizationSetupOutcome = (typeof ORGANIZATION_SETUP_OUTCOMES)[number];
24
+
25
+ const AUTH_EVENTS_METRIC = {
26
+ name: "opengeni_auth_events_total",
27
+ help: "Managed authentication funnel events (sign-up, email verification, sign-in) by closed method.",
28
+ } as const;
29
+ const ORGANIZATION_SETUP_METRIC = {
30
+ name: "opengeni_organization_setup_total",
31
+ help: "Self-service post-sign-in organization setup outcomes.",
32
+ } as const;
33
+ const SIGNUP_ACQUISITION_METRIC = {
34
+ name: "opengeni_signup_acquisition_total",
35
+ help: "New managed users by normalized first-touch acquisition source.",
36
+ } as const;
37
+
38
+ /** The only observability surface funnel telemetry uses. */
39
+ export type SignupFunnelObservability = Pick<Observability, "incrementCounter">;
40
+
41
+ /** Better Auth request-body / OAuth-state key that carries first-touch attribution. */
42
+ export const SIGNUP_ATTRIBUTION_AUTH_FIELD = "opengeniAttribution";
43
+
44
+ export type SignupFunnelMetrics = {
45
+ /** A managed auth user was created (Better Auth `user.create.after`). */
46
+ recordSignUp(authContext: unknown): Promise<void>;
47
+ /** A managed user verified their email address. */
48
+ recordEmailVerified(): void;
49
+ /**
50
+ * A provider session was created that is not an internally discarded one.
51
+ * In the legacy session-set mode this includes the session the first
52
+ * successful email-verification click creates: that is the new user's first
53
+ * sign-in, so it is counted rather than folded into `email_verified`.
54
+ */
55
+ recordSignIn(): void;
56
+ };
57
+
58
+ export function authFunnelMethod(providerId: string | null | undefined): AuthFunnelMethod {
59
+ if (providerId === "credential") return "email";
60
+ if (providerId === "google" || providerId === "github") return providerId;
61
+ return "other";
62
+ }
63
+
64
+ /**
65
+ * Publish every closed series at zero so dashboards and alerts can tell a quiet
66
+ * funnel from missing instrumentation. Never throws: a metric-registry failure
67
+ * must not stop managed auth (and with it API startup) from composing.
68
+ */
69
+ export function registerSignupFunnelMetricBaselines(
70
+ observability: SignupFunnelObservability,
71
+ ): void {
72
+ const baseline = (
73
+ metric: { name: string; help: string },
74
+ labels: Record<string, string>,
75
+ ): void => {
76
+ try {
77
+ observability.incrementCounter({ ...metric, labels, amount: 0 });
78
+ } catch {
79
+ // Telemetry only; the series appears on its first real increment.
80
+ }
81
+ };
82
+ for (const event of AUTH_FUNNEL_EVENTS) {
83
+ for (const method of AUTH_FUNNEL_METHODS) baseline(AUTH_EVENTS_METRIC, { event, method });
84
+ }
85
+ for (const outcome of ORGANIZATION_SETUP_OUTCOMES) {
86
+ baseline(ORGANIZATION_SETUP_METRIC, { outcome });
87
+ }
88
+ for (const source of SIGNUP_ACQUISITION_SOURCES) baseline(SIGNUP_ACQUISITION_METRIC, { source });
89
+ }
90
+
91
+ export function createSignupFunnelMetrics(
92
+ observability: SignupFunnelObservability,
93
+ ): SignupFunnelMetrics {
94
+ registerSignupFunnelMetricBaselines(observability);
95
+ const authEvent = (event: AuthFunnelEvent, method: AuthFunnelMethod) => {
96
+ observability.incrementCounter({ ...AUTH_EVENTS_METRIC, labels: { event, method } });
97
+ };
98
+ return {
99
+ recordSignUp: async (authContext) => {
100
+ try {
101
+ authEvent("sign_up", authFunnelMethod(currentManagedAuthProviderId()));
102
+ const source: SignupAcquisitionSource = signupAcquisitionSource(
103
+ await signupAttributionFromAuthContext(authContext),
104
+ );
105
+ observability.incrementCounter({ ...SIGNUP_ACQUISITION_METRIC, labels: { source } });
106
+ } catch {
107
+ // Funnel telemetry must never fail account creation.
108
+ }
109
+ },
110
+ recordEmailVerified: () => {
111
+ try {
112
+ authEvent("email_verified", "email");
113
+ } catch {
114
+ /* Telemetry only. */
115
+ }
116
+ },
117
+ recordSignIn: () => {
118
+ try {
119
+ // Session-set mode discards the provider session of a non-callback
120
+ // Better Auth call; the product transaction then creates the real one.
121
+ if (shouldDiscardCurrentManagedAuthProviderSession()) return;
122
+ authEvent("sign_in", authFunnelMethod(currentManagedAuthProviderId()));
123
+ } catch {
124
+ /* Telemetry only. */
125
+ }
126
+ },
127
+ };
128
+ }
129
+
130
+ /** Count one self-service organization setup request outcome. */
131
+ export function recordOrganizationSetupOutcome(
132
+ observability: SignupFunnelObservability | undefined,
133
+ outcome: OrganizationSetupOutcome,
134
+ ): void {
135
+ try {
136
+ observability?.incrementCounter({ ...ORGANIZATION_SETUP_METRIC, labels: { outcome } });
137
+ } catch {
138
+ // Telemetry must never change the setup response.
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Read untrusted first-touch attribution from the Better Auth endpoint that is
144
+ * creating the user: the email sign-up body, or the server-side OAuth state
145
+ * (`additionalData`) that the social start stored for its callback.
146
+ */
147
+ export async function signupAttributionFromAuthContext(authContext: unknown): Promise<unknown> {
148
+ const body =
149
+ authContext && typeof authContext === "object"
150
+ ? (authContext as { body?: unknown }).body
151
+ : undefined;
152
+ if (body && typeof body === "object" && SIGNUP_ATTRIBUTION_AUTH_FIELD in body) {
153
+ return (body as Record<string, unknown>)[SIGNUP_ATTRIBUTION_AUTH_FIELD];
154
+ }
155
+ try {
156
+ const state: unknown = await getOAuthState();
157
+ if (state && typeof state === "object" && SIGNUP_ATTRIBUTION_AUTH_FIELD in state) {
158
+ return (state as Record<string, unknown>)[SIGNUP_ATTRIBUTION_AUTH_FIELD];
159
+ }
160
+ } catch {
161
+ // Not inside a Better Auth OAuth callback.
162
+ }
163
+ return undefined;
164
+ }
package/src/http/auth.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolveFirstPartyDelegationSecret, type Settings } from "@opengeni/config";
2
2
  import { verifyDelegatedAccessToken } from "@opengeni/contracts";
3
3
  import type { Context, MiddlewareHandler } from "hono";
4
+ import { CLIENT_ERRORS_PATH } from "../routes/client-errors";
4
5
  import { installExactPaths, isInstallRedirectPath } from "../routes/install";
5
6
  import {
6
7
  isMcpOAuthPublicProtocolPath,
@@ -47,6 +48,11 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
47
48
  if (path === "/v1/config/client") {
48
49
  return true;
49
50
  }
51
+ // The web error beacon must count failures before sign-in. Its payload is a
52
+ // closed, content-free report and the route bounds its own admission.
53
+ if (c.req.method === "POST" && path === CLIENT_ERRORS_PATH) {
54
+ return true;
55
+ }
50
56
  if (path === "/v1/auth" || path.startsWith("/v1/auth/")) {
51
57
  return true;
52
58
  }
package/src/http/cors.ts CHANGED
@@ -26,13 +26,45 @@ export function validateInteractionRequestOrigin(
26
26
  ) {
27
27
  throw new HTTPException(400, { message: "invalid request origin" });
28
28
  }
29
- if (
30
- allowedCorsOrigin(input.corsAllowOriginRegex, url.origin) ||
29
+ if (isConfiguredBrowserOrigin(url.origin, input)) {
30
+ return url.origin;
31
+ }
32
+ throw new HTTPException(403, { message: "request origin is not allowed" });
33
+ }
34
+
35
+ type BrowserOriginSettings = {
36
+ corsAllowOriginRegex: string;
37
+ publicBaseUrl?: string | undefined;
38
+ webBaseUrl?: string | undefined;
39
+ };
40
+
41
+ function isConfiguredBrowserOrigin(origin: string, input: BrowserOriginSettings): boolean {
42
+ return (
43
+ allowedCorsOrigin(input.corsAllowOriginRegex, origin) ||
31
44
  [input.publicBaseUrl, input.webBaseUrl].some((baseUrl) =>
32
- baseUrl ? new URL(baseUrl).origin === url.origin : false,
45
+ baseUrl ? new URL(baseUrl).origin === origin : false,
33
46
  )
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Whether a present `Origin` header names a browser origin this deployment
52
+ * serves its web app from: the CORS allowlist, the public base URL, or the
53
+ * separate web base URL. `null` and malformed values are never allowed.
54
+ */
55
+ export function isAllowedBrowserOrigin(value: string, input: BrowserOriginSettings): boolean {
56
+ let url: URL;
57
+ try {
58
+ url = new URL(value);
59
+ } catch {
60
+ return false;
61
+ }
62
+ if (
63
+ url.origin === "null" ||
64
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
65
+ url.origin !== value
34
66
  ) {
35
- return url.origin;
67
+ return false;
36
68
  }
37
- throw new HTTPException(403, { message: "request origin is not allowed" });
69
+ return isConfiguredBrowserOrigin(url.origin, input);
38
70
  }