@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/auth/managed-auth.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
67
|
+
return false;
|
|
36
68
|
}
|
|
37
|
-
|
|
69
|
+
return isConfiguredBrowserOrigin(url.origin, input);
|
|
38
70
|
}
|