@opengeni/api-router 2.3.2-canary.2 → 2.4.2-canary.0

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 (39) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +4 -0
  3. package/dist/auth/managed-auth-session-adapter.d.ts +4 -0
  4. package/dist/{chunk-IBV7Z6F4.js → chunk-L7GVVSSQ.js} +2996 -373
  5. package/dist/chunk-L7GVVSSQ.js.map +1 -0
  6. package/dist/fatal-process-boundary.d.ts +25 -0
  7. package/dist/http/sse.d.ts +2 -0
  8. package/dist/index.d.ts +5 -1
  9. package/dist/index.js +168 -6
  10. package/dist/index.js.map +1 -1
  11. package/dist/mcp/receipts.d.ts +9 -0
  12. package/dist/organization-recovery-notifications.d.ts +41 -0
  13. package/dist/routes/managed-auth-session-sets.d.ts +19 -0
  14. package/dist/routes/organization-recovery.d.ts +19 -0
  15. package/dist/routes/workspaces.d.ts +1 -0
  16. package/package.json +18 -18
  17. package/src/app.ts +133 -1
  18. package/src/auth/managed-auth-attempt-context.ts +24 -0
  19. package/src/auth/managed-auth-session-adapter.ts +205 -0
  20. package/src/auth/managed-auth.ts +52 -2
  21. package/src/fatal-process-boundary.ts +231 -0
  22. package/src/http/sse.ts +7 -0
  23. package/src/index.ts +25 -5
  24. package/src/integrations/slack-interactions.ts +30 -17
  25. package/src/mcp/receipts.ts +34 -0
  26. package/src/mcp/server.ts +45 -39
  27. package/src/organization-recovery-notifications.ts +103 -0
  28. package/src/routes/canonical-human-identities.ts +29 -14
  29. package/src/routes/codex.ts +5 -1
  30. package/src/routes/environments.ts +23 -0
  31. package/src/routes/interaction-resources.ts +3 -0
  32. package/src/routes/managed-auth-session-sets.ts +994 -0
  33. package/src/routes/managed-onboarding.ts +2 -0
  34. package/src/routes/organization-memberships.ts +2 -0
  35. package/src/routes/organization-recovery.ts +325 -0
  36. package/src/routes/sessions.ts +24 -39
  37. package/src/routes/supergrok.ts +5 -1
  38. package/src/routes/workspaces.ts +23 -1
  39. package/dist/chunk-IBV7Z6F4.js.map +0 -1
@@ -9,6 +9,15 @@ export type McpMutationReceiptInput = Omit<McpMutationReceiptType, "receiptVersi
9
9
  * accidentally adding an unbounded entity or a copy of request fields.
10
10
  */
11
11
  export declare function mcpMutationReceipt(input: McpMutationReceiptInput): McpMutationReceiptType;
12
+ export declare function sessionControlMutationReceipt(input: {
13
+ operation: "session_pause" | "session_resume";
14
+ sessionId: string;
15
+ state: string;
16
+ receiptId: string;
17
+ timestamp: string;
18
+ outcome: "changed" | "unchanged" | "replayed";
19
+ interruptionCount: number;
20
+ }): McpMutationReceiptType;
12
21
  export type SessionCreateReceiptResult = {
13
22
  session: {
14
23
  id: string;
@@ -0,0 +1,41 @@
1
+ import type { Database } from "@opengeni/db";
2
+ import { prepareOrganizationRecoveryNotifications, settleOrganizationRecoveryNotification, type OrganizationRecoveryNotificationClaim, type OrganizationRecoveryNotificationSettlement } from "@opengeni/db";
3
+ export type OrganizationRecoveryNotificationDeliveryResult = {
4
+ status: "sent";
5
+ providerMessageId: string | null;
6
+ } | {
7
+ status: "failed";
8
+ errorClass: string;
9
+ } | {
10
+ status: "outcome_unknown";
11
+ };
12
+ export interface OrganizationRecoveryNotificationTransport {
13
+ readonly provider: string;
14
+ send(claim: OrganizationRecoveryNotificationClaim): Promise<OrganizationRecoveryNotificationDeliveryResult>;
15
+ }
16
+ export type OrganizationRecoveryNotificationLifecycle = {
17
+ prepare: typeof prepareOrganizationRecoveryNotifications;
18
+ settle: typeof settleOrganizationRecoveryNotification;
19
+ };
20
+ export declare function dispatchOrganizationRecoveryNotifications(input: {
21
+ db: Database;
22
+ transport: OrganizationRecoveryNotificationTransport;
23
+ claimOwner: string;
24
+ limit?: number;
25
+ leaseSeconds?: number;
26
+ lifecycle?: OrganizationRecoveryNotificationLifecycle;
27
+ }): Promise<OrganizationRecoveryNotificationSettlement[]>;
28
+ export declare class InMemoryOrganizationRecoveryNotificationTransport implements OrganizationRecoveryNotificationTransport {
29
+ readonly provider = "fake";
30
+ readonly attempts: Array<{
31
+ idempotencyKey: string;
32
+ payloadDigest: string;
33
+ recipientCanonicalIdentityId: string;
34
+ providerMessageId: string;
35
+ }>;
36
+ private readonly deliveries;
37
+ private readonly scripted;
38
+ enqueue(...results: OrganizationRecoveryNotificationDeliveryResult[]): void;
39
+ logicalDeliveryCount(): number;
40
+ send(claim: OrganizationRecoveryNotificationClaim): Promise<OrganizationRecoveryNotificationDeliveryResult>;
41
+ }
@@ -0,0 +1,19 @@
1
+ import { type ApiRouteDeps } from "@opengeni/core";
2
+ import type { Hono } from "hono";
3
+ export declare function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDeps): void;
4
+ /**
5
+ * Better Auth 1.6.26 provider lifecycle routes retained while session-set mode
6
+ * owns all selected-session reads, enumeration, revocation, and user mutation.
7
+ * The list is intentionally exact so a dependency upgrade cannot silently add
8
+ * a selected-session capability under the wildcard.
9
+ */
10
+ export declare function requireManagedAuthProviderRouteAllowed(method: string, pathname: string): void;
11
+ /** Remove provider bearer/session material and enforce browser-auth cache/cookie policy. */
12
+ export declare function scrubManagedAuthProviderResponse(response: Response, options?: {
13
+ replacementCookies?: readonly string[] | undefined;
14
+ }): Promise<Response>;
15
+ export declare function parseSupportedDeepLink(path: string): {
16
+ workspaceId: string | null;
17
+ sessionId: string | null;
18
+ permission: "workspace:read" | "sessions:read";
19
+ } | null;
@@ -0,0 +1,19 @@
1
+ import { getManagedAuthRequestActorAdmissionStamp, getManagedAuthRequestActorLeaseStamp, requireCanonicalHumanRequestIdentity } from "@opengeni/core/canonical-human-identities";
2
+ import type { ApiRouteDeps } from "@opengeni/core";
3
+ import { acceptOrganizationRecoveryCustody, approveOrganizationRecoveryOperation, cancelOrganizationRecoveryOperation, configureOrganizationRecoveryPolicy, disableOrganizationRecoveryPolicy, executeOrganizationRecoveryOperation, getOrganizationRecoveryOverview, startOrganizationRecoveryOperation } from "@opengeni/db";
4
+ import type { Hono } from "hono";
5
+ export type OrganizationRecoveryRouteServices = {
6
+ requireCanonicalHumanRequestIdentity: typeof requireCanonicalHumanRequestIdentity;
7
+ getManagedAuthRequestActorAdmissionStamp: typeof getManagedAuthRequestActorAdmissionStamp;
8
+ getManagedAuthRequestActorLeaseStamp: typeof getManagedAuthRequestActorLeaseStamp;
9
+ getOrganizationRecoveryOverview: typeof getOrganizationRecoveryOverview;
10
+ configureOrganizationRecoveryPolicy: typeof configureOrganizationRecoveryPolicy;
11
+ acceptOrganizationRecoveryCustody: typeof acceptOrganizationRecoveryCustody;
12
+ disableOrganizationRecoveryPolicy: typeof disableOrganizationRecoveryPolicy;
13
+ startOrganizationRecoveryOperation: typeof startOrganizationRecoveryOperation;
14
+ approveOrganizationRecoveryOperation: typeof approveOrganizationRecoveryOperation;
15
+ cancelOrganizationRecoveryOperation: typeof cancelOrganizationRecoveryOperation;
16
+ executeOrganizationRecoveryOperation: typeof executeOrganizationRecoveryOperation;
17
+ };
18
+ export declare function organizationRecoveryHttpError(error: unknown): Error;
19
+ export declare function registerOrganizationRecoveryRoutes(app: Hono, deps: ApiRouteDeps, services?: OrganizationRecoveryRouteServices): void;
@@ -20,5 +20,6 @@ export declare function workspaceMembersResponse(members: readonly WorkspaceMemb
20
20
  createdAt: string;
21
21
  }[];
22
22
  };
23
+ export declare function workspaceUpdateRequestsAccountTransfer(value: unknown): boolean;
23
24
  export declare function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void;
24
25
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "2.3.2-canary.2",
3
+ "version": "2.4.2-canary.0",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,23 +43,23 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@llamaindex/liteparse": "^1.5.3",
45
45
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "@opengeni/agent-proto": "^0.5.1-canary.4",
47
- "@opengeni/artifact-tool": "^0.3.7-canary.2",
48
- "@opengeni/capabilities": "^0.3.1-canary.3",
49
- "@opengeni/codemode": "^0.4.15-canary.2",
50
- "@opengeni/codex": "^0.2.19-canary.3",
51
- "@opengeni/config": "^0.20.1-canary.2",
52
- "@opengeni/contracts": "^2.5.0-canary.2",
53
- "@opengeni/core": "^2.4.0-canary.2",
54
- "@opengeni/db": "^3.4.0-canary.2",
55
- "@opengeni/documents": "^0.8.2-canary.2",
56
- "@opengeni/events": "^0.4.0-canary.2",
57
- "@opengeni/github": "^0.5.5-canary.2",
58
- "@opengeni/network": "^0.2.3-canary.3",
59
- "@opengeni/observability": "^0.8.6-canary.2",
60
- "@opengeni/runtime": "^1.4.0-canary.2",
61
- "@opengeni/storage": "^0.2.108-canary.2",
62
- "@opengeni/xai-subscription": "^0.1.2-canary.3",
46
+ "@opengeni/agent-proto": "^0.5.1-canary.5",
47
+ "@opengeni/artifact-tool": "^0.3.8-canary.0",
48
+ "@opengeni/capabilities": "^0.3.1-canary.4",
49
+ "@opengeni/codemode": "^0.4.16-canary.0",
50
+ "@opengeni/codex": "^0.2.19-canary.4",
51
+ "@opengeni/config": "^0.21.0-canary.0",
52
+ "@opengeni/contracts": "^2.6.0-canary.0",
53
+ "@opengeni/core": "^2.5.2-canary.0",
54
+ "@opengeni/db": "^3.5.2-canary.0",
55
+ "@opengeni/documents": "^0.8.5-canary.0",
56
+ "@opengeni/events": "^0.4.3-canary.0",
57
+ "@opengeni/github": "^0.6.0-canary.0",
58
+ "@opengeni/network": "^0.2.3-canary.4",
59
+ "@opengeni/observability": "^0.8.8-canary.0",
60
+ "@opengeni/runtime": "^1.4.1-canary.0",
61
+ "@opengeni/storage": "^0.2.109-canary.0",
62
+ "@opengeni/xai-subscription": "^0.1.2-canary.4",
63
63
  "@temporalio/client": "^1.17.0",
64
64
  "better-auth": "^1.6.14",
65
65
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -36,7 +36,10 @@ import {
36
36
  configureChildLifecycleNotices,
37
37
  configureWorkspaceControlRequestLockTimeoutMs,
38
38
  dbSql,
39
+ getManagedAuthSessionSetSnapshot,
39
40
  getWorkspace,
41
+ reapManagedAuthIsolatedSessions,
42
+ reapExpiredManagedAuthSessionSets,
40
43
  rlsContextForWorkspace,
41
44
  withSessionRlsActorContext,
42
45
  } from "@opengeni/db";
@@ -49,21 +52,32 @@ import { Hono } from "hono";
49
52
  import { bodyLimit } from "hono/body-limit";
50
53
  import { compress } from "hono/compress";
51
54
  import { cors } from "hono/cors";
55
+ import { getCookie } from "hono/cookie";
52
56
  import { HTTPException } from "hono/http-exception";
53
57
  import { ApiHttpError, workspaceControlBusyHttpError } from "./http/api-error";
54
58
  import type { ContentfulStatusCode } from "hono/utils/http-status";
55
59
  import type { ApiRouteDeps, AppDependencies } from "@opengeni/core";
56
60
  import {
57
61
  CodexCompactionV2ProviderLockedError,
62
+ ManagedAuthActorLeaseOutcomeUnknownError,
58
63
  hasPermission,
59
64
  requireAccessGrant,
60
65
  requireLiveAgentAttemptAuthorization,
61
66
  requirePermission,
67
+ releaseManagedAuthRequestActorLease,
68
+ validateManagedAuthRequestActorLease,
62
69
  requireSessionAuthorization,
63
70
  SessionAuthorizationDeniedError,
64
71
  SessionAuthorizationUnavailableError,
65
72
  } from "@opengeni/core";
66
73
  import { createManagedAuth } from "./auth/managed-auth";
74
+ import {
75
+ MANAGED_AUTH_SESSION_SET_COOKIE,
76
+ ManagedAuthActorChangeError,
77
+ managedAuthSha256,
78
+ } from "@opengeni/core/managed-auth-session-sets";
79
+ import { createBetterAuthSessionAdapter } from "./auth/managed-auth-session-adapter";
80
+ import { runManagedAuthDiscardedProviderSession } from "./auth/managed-auth-attempt-context";
67
81
  import { createManagedEmailTransport } from "./auth/managed-email";
68
82
  import { assertManagedEmailTransportMetadata } from "./auth/organization-user-setup";
69
83
  import { createApiSandboxClient, makeResumeBoxById } from "./sandbox/access";
@@ -135,7 +149,13 @@ import { registerEditableArtifactRoutes } from "./routes/editable-artifacts";
135
149
  import { registerVideoGenerationRoutes } from "./routes/video-generation";
136
150
  import { registerCanonicalHumanIdentityRoutes } from "./routes/canonical-human-identities";
137
151
  import { registerOrganizationMembershipRoutes } from "./routes/organization-memberships";
152
+ import { registerOrganizationRecoveryRoutes } from "./routes/organization-recovery";
138
153
  import { registerManagedOnboardingRoutes } from "./routes/managed-onboarding";
154
+ import {
155
+ registerManagedAuthSessionSetRoutes,
156
+ requireManagedAuthProviderRouteAllowed,
157
+ scrubManagedAuthProviderResponse,
158
+ } from "./routes/managed-auth-session-sets";
139
159
  import { registerUserResourceAuthorityRoutes } from "./routes/user-resource-authorities";
140
160
  import { registerConnectionAuthorityRoutes } from "./routes/connection-authorities";
141
161
  import { projectClientModel } from "./model-catalog";
@@ -167,6 +187,8 @@ export { workflowIdForSession } from "@opengeni/core";
167
187
  export { replaySessionEvents, sseSessionStream, sseWorkspaceControlStream } from "./http/sse";
168
188
 
169
189
  export const API_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
190
+ const managedAuthReaperDatabases = new WeakSet<object>();
191
+ const MANAGED_AUTH_REAPER_INTERVAL_MS = 60_000;
170
192
 
171
193
  /** Effective Hono bodyLimit — API JSON ceiling or voice multipart + multipart overhead. */
172
194
  export function apiRequestBodyLimitBytes(settings: {
@@ -202,6 +224,9 @@ export function createAppComposition(deps: AppDependencies): {
202
224
  assertManagedEmailTransportMetadata(managedEmailTransport);
203
225
  const managedAuth =
204
226
  deps.managedAuth ?? createManagedAuth(deps.settings, deps.db, managedEmailTransport);
227
+ const managedAuthSessionAdapter =
228
+ deps.managedAuthSessionAdapter ??
229
+ (managedAuth ? createBetterAuthSessionAdapter(managedAuth, deps.db) : null);
205
230
  const objectStorage =
206
231
  deps.objectStorage === undefined ? createObjectStorage(deps.settings) : deps.objectStorage;
207
232
  let documentServices: DocumentServices | null = deps.documentServices ?? null;
@@ -284,6 +309,24 @@ export function createAppComposition(deps: AppDependencies): {
284
309
  const resumeBoxById = deps.resumeBoxById ?? makeResumeBoxById(sandboxClient);
285
310
  const observability =
286
311
  deps.observability ?? createObservability(deps.settings, { component: "api" });
312
+ if (
313
+ managedAuth &&
314
+ deps.settings.managedAuthSessionSetMode !== "legacy" &&
315
+ !managedAuthReaperDatabases.has(deps.db as object)
316
+ ) {
317
+ managedAuthReaperDatabases.add(deps.db as object);
318
+ const timer = setInterval(() => {
319
+ void Promise.all([
320
+ reapManagedAuthIsolatedSessions(deps.db, 100),
321
+ reapExpiredManagedAuthSessionSets(deps.db, 100),
322
+ ]).catch((error) => {
323
+ observability.error("Managed isolated-auth orphan reap failed", {
324
+ errorClass: error instanceof Error ? error.name : "UnknownError",
325
+ });
326
+ });
327
+ }, MANAGED_AUTH_REAPER_INTERVAL_MS);
328
+ (timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
329
+ }
287
330
  const transcription =
288
331
  deps.transcription === undefined
289
332
  ? createTranscriptionService({
@@ -304,6 +347,7 @@ export function createAppComposition(deps: AppDependencies): {
304
347
  githubStateSecret:
305
348
  deps.githubStateSecret ?? deps.settings.githubAppManifestStateSecret ?? crypto.randomUUID(),
306
349
  managedAuth,
350
+ managedAuthSessionAdapter,
307
351
  managedEmailTransport,
308
352
  objectStorage,
309
353
  documentIndexer,
@@ -332,13 +376,17 @@ export function createAppComposition(deps: AppDependencies): {
332
376
  "Range",
333
377
  "X-OpenGeni-Access-Key",
334
378
  "X-OpenGeni-Api-Contract",
379
+ "X-OpenGeni-Actor-Epoch",
335
380
  "X-OpenGeni-Correlation-Id",
381
+ "X-OpenGeni-Session-Csrf",
336
382
  "X-OpenGeni-Subject",
337
383
  ],
338
384
  exposeHeaders: [
339
385
  "Accept-Ranges",
340
386
  "Content-Range",
341
387
  "X-OpenGeni-Api-Contract",
388
+ "X-OpenGeni-Actor-Epoch",
389
+ "X-OpenGeni-Actor-State",
342
390
  "X-OpenGeni-Correlation-Id",
343
391
  ],
344
392
  };
@@ -495,11 +543,93 @@ export function createAppComposition(deps: AppDependencies): {
495
543
  await next();
496
544
  });
497
545
 
546
+ app.use("/v1/*", async (c, next) => {
547
+ try {
548
+ await next();
549
+ try {
550
+ await validateManagedAuthRequestActorLease(c.req.raw);
551
+ } catch (error) {
552
+ if (error instanceof ManagedAuthActorChangeError) {
553
+ c.header("x-opengeni-actor-state", "changed");
554
+ throw new HTTPException(409, { message: error.code, cause: error });
555
+ }
556
+ if (error instanceof ManagedAuthActorLeaseOutcomeUnknownError) {
557
+ throw new ApiHttpError(503, {
558
+ code: "upstream_unavailable",
559
+ message: error.code,
560
+ retryable: true,
561
+ outcomeUnknown: true,
562
+ details: { managedAuthCode: error.code },
563
+ });
564
+ }
565
+ throw error;
566
+ }
567
+ } finally {
568
+ await releaseManagedAuthRequestActorLease(c.req.raw).catch((error) => {
569
+ observability.error("Managed actor mutation lease release failed", {
570
+ errorClass: error instanceof Error ? error.name : "UnknownError",
571
+ });
572
+ });
573
+ }
574
+ });
575
+
498
576
  // These product-owned auth routes must be registered before Better Auth's
499
577
  // wildcard handler or the provider returns its own 404 first.
500
578
  registerManagedOnboardingRoutes(app, routeDeps);
579
+ registerManagedAuthSessionSetRoutes(app, routeDeps);
501
580
  if (managedAuth) {
502
- app.on(["GET", "POST"], "/v1/auth/*", (c) => managedAuth.handler(c.req.raw));
581
+ app.on(["GET", "POST"], "/v1/auth/*", async (c) => {
582
+ if (deps.settings.managedAuthSessionSetMode === "legacy") {
583
+ return await managedAuth.handler(c.req.raw);
584
+ }
585
+ requireManagedAuthProviderRouteAllowed(c.req.method, new URL(c.req.url).pathname);
586
+ // Provider authentication/recovery is isolated from whichever actor the
587
+ // browser currently renders. Selected-session capabilities are all
588
+ // product-owned above this wildcard and generation/epoch fenced.
589
+ const headers = new Headers(c.req.raw.headers);
590
+ headers.delete("cookie");
591
+ headers.delete("authorization");
592
+ headers.delete("x-forwarded-user");
593
+ const providerRequest = new Request(c.req.raw, { headers });
594
+ const authority = getCookie(c, MANAGED_AUTH_SESSION_SET_COOKIE);
595
+ const discardProviderSession =
596
+ deps.settings.managedAuthSessionSetMode === "broker" || authority !== undefined;
597
+ const providerResponse = discardProviderSession
598
+ ? await runManagedAuthDiscardedProviderSession(
599
+ async () => await managedAuth.handler(providerRequest),
600
+ )
601
+ : await managedAuth.handler(providerRequest);
602
+ let replacementCookies: readonly string[] | undefined;
603
+ if (deps.settings.managedAuthSessionSetMode === "broker") {
604
+ replacementCookies = await managedAuthSessionAdapter!.createLegacySelectedSessionCookies(
605
+ null,
606
+ c.req.header("cookie") ?? null,
607
+ );
608
+ } else {
609
+ if (authority !== undefined) {
610
+ const snapshot = await getManagedAuthSessionSetSnapshot(deps.db, {
611
+ authorityHash: managedAuthSha256(authority),
612
+ mode: "dual",
613
+ includeInternal: true,
614
+ readOnly: true,
615
+ });
616
+ if (snapshot) {
617
+ replacementCookies =
618
+ await managedAuthSessionAdapter!.createLegacySelectedSessionCookies(
619
+ snapshot.selected,
620
+ c.req.header("cookie") ?? null,
621
+ );
622
+ } else {
623
+ replacementCookies =
624
+ await managedAuthSessionAdapter!.createLegacySelectedSessionCookies(
625
+ null,
626
+ c.req.header("cookie") ?? null,
627
+ );
628
+ }
629
+ }
630
+ }
631
+ return await scrubManagedAuthProviderResponse(providerResponse, { replacementCookies });
632
+ });
503
633
  }
504
634
 
505
635
  app.get("/healthz", (c) =>
@@ -585,6 +715,7 @@ export function createAppComposition(deps: AppDependencies): {
585
715
  : {}),
586
716
  },
587
717
  productAccessMode: deps.settings.productAccessMode,
718
+ managedAuthSessionSetMode: deps.settings.managedAuthSessionSetMode,
588
719
  auth: clientAuthConfig(deps.settings),
589
720
  analytics: clientAnalyticsConfig(deps.settings),
590
721
  // Channel-A structured services (P4.4) ride exec/readFile/createEditor,
@@ -765,6 +896,7 @@ export function createAppComposition(deps: AppDependencies): {
765
896
  registerVideoGenerationRoutes(app, routeDeps);
766
897
  registerCanonicalHumanIdentityRoutes(app, routeDeps);
767
898
  registerOrganizationMembershipRoutes(app, routeDeps);
899
+ registerOrganizationRecoveryRoutes(app, routeDeps);
768
900
  registerUserResourceAuthorityRoutes(app, routeDeps);
769
901
  registerConnectionAuthorityRoutes(app, routeDeps);
770
902
  registerSlackInteractionRoutes(app, routeDeps);
@@ -0,0 +1,24 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ type ManagedAuthAttemptContext =
4
+ | { kind: "isolated_transaction"; transactionId: string }
5
+ | { kind: "discard_provider_session" };
6
+
7
+ const managedAuthAttemptStorage = new AsyncLocalStorage<ManagedAuthAttemptContext>();
8
+
9
+ export function runManagedAuthAttempt<T>(transactionId: string, action: () => T): T {
10
+ return managedAuthAttemptStorage.run({ kind: "isolated_transaction", transactionId }, action);
11
+ }
12
+
13
+ export function runManagedAuthDiscardedProviderSession<T>(action: () => T): T {
14
+ return managedAuthAttemptStorage.run({ kind: "discard_provider_session" }, action);
15
+ }
16
+
17
+ export function currentManagedAuthAttemptId(): string | null {
18
+ const context = managedAuthAttemptStorage.getStore();
19
+ return context?.kind === "isolated_transaction" ? context.transactionId : null;
20
+ }
21
+
22
+ export function shouldDiscardCurrentManagedAuthProviderSession(): boolean {
23
+ return managedAuthAttemptStorage.getStore()?.kind === "discard_provider_session";
24
+ }
@@ -0,0 +1,205 @@
1
+ import type {
2
+ ManagedAuthResolvedSession,
3
+ ManagedAuthSessionAdapter,
4
+ } from "@opengeni/core/managed-auth-session-sets";
5
+ import { sql } from "drizzle-orm";
6
+ import type { Database } from "@opengeni/db";
7
+ import type { ManagedAuth } from "@opengeni/core";
8
+ import { createHmac, timingSafeEqual } from "node:crypto";
9
+ import { runManagedAuthAttempt } from "./managed-auth-attempt-context";
10
+
11
+ export function createBetterAuthSessionAdapter(
12
+ auth: ManagedAuth,
13
+ db: Database,
14
+ ): ManagedAuthSessionAdapter {
15
+ return {
16
+ async authenticate(input) {
17
+ if (input.provider !== "email_password") {
18
+ throw new Error("unsupported managed authentication provider");
19
+ }
20
+ const result = await runManagedAuthAttempt(
21
+ input.transactionId,
22
+ async () =>
23
+ await auth.api.signInEmail({
24
+ body: {
25
+ email: input.credentials.email,
26
+ password: input.credentials.password,
27
+ rememberMe: true,
28
+ },
29
+ headers: input.headers,
30
+ returnHeaders: true,
31
+ }),
32
+ );
33
+ const token = result.response?.token;
34
+ if (typeof token !== "string") {
35
+ throw new Error("managed authentication did not create an isolated session");
36
+ }
37
+ const resolved = await (await auth.$context).internalAdapter.findSession(token);
38
+ if (!resolved?.session?.id) {
39
+ throw new Error("managed authentication session could not be resolved");
40
+ }
41
+ return { authSessionId: resolved.session.id };
42
+ },
43
+
44
+ async resolveSelectedSession(input): Promise<ManagedAuthResolvedSession | null> {
45
+ const resolved = await (await auth.$context).internalAdapter.findSession(input.token);
46
+ return liveResolvedSession(resolved);
47
+ },
48
+
49
+ async resolveAmbientSession(headers): Promise<ManagedAuthResolvedSession | null> {
50
+ const context = await auth.$context;
51
+ const signed = cookieValue(headers.get("cookie"), context.authCookies.sessionToken.name);
52
+ const token = signed ? verifiedSignedCookieValue(signed, context.secret) : null;
53
+ if (!token) return null;
54
+ const resolved = await context.internalAdapter.findSession(token);
55
+ return liveResolvedSession(resolved);
56
+ },
57
+
58
+ async refreshSelectedSession(input): Promise<ManagedAuthResolvedSession | null> {
59
+ const context = await auth.$context;
60
+ const cookie = context.authCookies.sessionToken;
61
+ const headers = new Headers({
62
+ cookie: `${cookie.name}=${signedCookieValue(input.token, context.secret)}`,
63
+ });
64
+ const resolved = await auth.api.getSession({ headers, returnHeaders: true });
65
+ if (!resolved.response) return null;
66
+ // The provider may renew its durable expiry and emit token/cache cookies;
67
+ // this server-side selected-slot resolution intentionally discards them.
68
+ return resolved.response as ManagedAuthResolvedSession;
69
+ },
70
+
71
+ async revokeSession(input) {
72
+ await db.execute(sql`delete from auth_sessions where id = ${input.authSessionId}`);
73
+ },
74
+
75
+ async createLegacySelectedSessionCookies(input, currentCookieHeader) {
76
+ const context = await auth.$context;
77
+ const cookie = context.authCookies.sessionToken;
78
+ const value = input ? signedCookieValue(input.token, context.secret) : "";
79
+ const headers = [
80
+ serializeCookieHeader(cookie.name, value, {
81
+ ...cookie.attributes,
82
+ ...(input ? {} : { maxAge: 0, expires: new Date(0) }),
83
+ }),
84
+ ];
85
+ for (const cacheCookie of [
86
+ context.authCookies.sessionData,
87
+ context.authCookies.accountData,
88
+ context.authCookies.dontRememberToken,
89
+ ]) {
90
+ for (const name of cacheCookieNames(cacheCookie.name, currentCookieHeader)) {
91
+ headers.push(
92
+ serializeCookieHeader(name, "", {
93
+ ...cacheCookie.attributes,
94
+ maxAge: 0,
95
+ expires: new Date(0),
96
+ }),
97
+ );
98
+ }
99
+ }
100
+ return headers;
101
+ },
102
+ };
103
+ }
104
+
105
+ function liveResolvedSession(value: unknown): ManagedAuthResolvedSession | null {
106
+ if (!value || typeof value !== "object") return null;
107
+ const session = (value as { session?: { expiresAt?: unknown } }).session;
108
+ const expiresAt = session?.expiresAt;
109
+ const expiryMillis =
110
+ expiresAt instanceof Date
111
+ ? expiresAt.getTime()
112
+ : typeof expiresAt === "string" || typeof expiresAt === "number"
113
+ ? new Date(expiresAt).getTime()
114
+ : Number.NaN;
115
+ if (!Number.isFinite(expiryMillis) || expiryMillis <= Date.now()) return null;
116
+ return value as ManagedAuthResolvedSession;
117
+ }
118
+
119
+ function cookieValue(header: string | null, name: string): string | null {
120
+ if (!header) return null;
121
+ for (const part of header.split(";")) {
122
+ const separator = part.indexOf("=");
123
+ if (separator < 0 || part.slice(0, separator).trim() !== name) continue;
124
+ return part.slice(separator + 1).trim() || null;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function verifiedSignedCookieValue(value: string, secret: string): string | null {
130
+ let decoded: string;
131
+ try {
132
+ decoded = decodeURIComponent(value);
133
+ } catch {
134
+ return null;
135
+ }
136
+ const separator = decoded.lastIndexOf(".");
137
+ if (separator <= 0) return null;
138
+ const token = decoded.slice(0, separator);
139
+ const signature = decoded.slice(separator + 1);
140
+ const expected = createHmac("sha256", secret).update(token, "utf8").digest("base64");
141
+ const actualBytes = Buffer.from(signature, "utf8");
142
+ const expectedBytes = Buffer.from(expected, "utf8");
143
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
144
+ ? token
145
+ : null;
146
+ }
147
+
148
+ function signedCookieValue(value: string, secret: string): string {
149
+ return encodeURIComponent(
150
+ `${value}.${createHmac("sha256", secret).update(value, "utf8").digest("base64")}`,
151
+ );
152
+ }
153
+
154
+ function serializeCookieHeader(
155
+ name: string,
156
+ value: string,
157
+ attributes: {
158
+ domain?: string;
159
+ expires?: Date;
160
+ httpOnly?: boolean;
161
+ maxAge?: number;
162
+ path?: string;
163
+ partitioned?: boolean;
164
+ prefix?: "secure" | "host";
165
+ sameSite?: string;
166
+ secure?: boolean;
167
+ },
168
+ ): string {
169
+ let cookieName = name;
170
+ if (attributes.prefix === "secure" && !cookieName.startsWith("__Secure-")) {
171
+ cookieName = `__Secure-${cookieName}`;
172
+ } else if (attributes.prefix === "host" && !cookieName.startsWith("__Host-")) {
173
+ cookieName = `__Host-${cookieName}`;
174
+ }
175
+ if (cookieName.startsWith("__Secure-")) attributes.secure = true;
176
+ if (cookieName.startsWith("__Host-")) {
177
+ attributes.secure = true;
178
+ attributes.path = "/";
179
+ delete attributes.domain;
180
+ }
181
+ let header = `${cookieName}=${value}`;
182
+ if (attributes.maxAge !== undefined)
183
+ header += `; Max-Age=${Math.max(0, Math.floor(attributes.maxAge))}`;
184
+ if (attributes.domain) header += `; Domain=${attributes.domain}`;
185
+ if (attributes.path) header += `; Path=${attributes.path}`;
186
+ if (attributes.expires) header += `; Expires=${attributes.expires.toUTCString()}`;
187
+ if (attributes.httpOnly) header += "; HttpOnly";
188
+ if (attributes.secure) header += "; Secure";
189
+ if (attributes.sameSite) {
190
+ header += `; SameSite=${attributes.sameSite[0]?.toUpperCase()}${attributes.sameSite.slice(1)}`;
191
+ }
192
+ if (attributes.partitioned) header += "; Partitioned";
193
+ return header;
194
+ }
195
+
196
+ function cacheCookieNames(baseName: string, cookieHeader?: string | null): string[] {
197
+ const names = new Set([baseName]);
198
+ for (const part of cookieHeader?.split(";") ?? []) {
199
+ const separator = part.indexOf("=");
200
+ if (separator < 0) continue;
201
+ const name = part.slice(0, separator).trim();
202
+ if (name === baseName || name.startsWith(`${baseName}.`)) names.add(name);
203
+ }
204
+ return [...names].sort();
205
+ }