@opengeni/api-router 2.2.0-canary.0 → 2.3.2-canary.1

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.d.ts +5 -2
  3. package/dist/auth/managed-email.d.ts +29 -0
  4. package/dist/auth/organization-user-setup.d.ts +57 -0
  5. package/dist/{chunk-3TP54PPX.js → chunk-IBV7Z6F4.js} +3545 -2009
  6. package/dist/chunk-IBV7Z6F4.js.map +1 -0
  7. package/dist/index.js +1 -1
  8. package/dist/integrations/slack-app-home.d.ts +1 -1
  9. package/dist/mcp/server.d.ts +1 -1
  10. package/dist/mcp/session-view.d.ts +1 -0
  11. package/dist/routes/automations.d.ts +13 -0
  12. package/dist/routes/insights.d.ts +2 -1
  13. package/dist/routes/managed-onboarding.d.ts +29 -0
  14. package/dist/routes/pr-review-github.d.ts +3 -0
  15. package/package.json +18 -18
  16. package/src/app.ts +56 -5
  17. package/src/auth/managed-auth.ts +29 -34
  18. package/src/auth/managed-email.ts +174 -0
  19. package/src/auth/organization-user-setup.ts +217 -0
  20. package/src/http/auth.ts +15 -0
  21. package/src/http/sse.ts +62 -13
  22. package/src/integrations/slack-app-home.ts +2 -2
  23. package/src/mcp/company-brain-governed-writes.ts +4 -4
  24. package/src/mcp/company-profile-agent-admin.ts +11 -18
  25. package/src/mcp/remember.ts +4 -4
  26. package/src/mcp/server.ts +50 -4
  27. package/src/mcp/session-view.ts +8 -2
  28. package/src/routes/automations.ts +3 -3
  29. package/src/routes/documents.ts +2 -0
  30. package/src/routes/insights.ts +61 -19
  31. package/src/routes/managed-onboarding.ts +317 -0
  32. package/src/routes/organization-memberships.ts +212 -155
  33. package/src/routes/pr-review-github.ts +844 -0
  34. package/src/routes/pr-review.ts +20 -0
  35. package/src/routes/rigs.ts +37 -4
  36. package/src/routes/sessions.ts +101 -0
  37. package/src/sandbox/channel-a.ts +34 -7
  38. package/src/sandbox/viewer.ts +47 -11
  39. package/dist/chunk-3TP54PPX.js.map +0 -1
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  createOpenGeniSlackBotInteractionClient,
7
7
  startSlackInteractionPump,
8
8
  startTemporalScheduleCleanupPump
9
- } from "./chunk-3TP54PPX.js";
9
+ } from "./chunk-IBV7Z6F4.js";
10
10
 
11
11
  // src/index.ts
12
12
  import {
@@ -1,4 +1,4 @@
1
- import type { Session } from "@opengeni/contracts";
1
+ import { type Session } from "@opengeni/contracts";
2
2
  import type { SlackHomeBlock } from "./slack-bot.js";
3
3
  export type SlackAppHomeOpenedEvent = {
4
4
  eventId: string;
@@ -95,7 +95,7 @@ export declare function capSessionDiscoveryPage(page: Awaited<ReturnType<typeof
95
95
  truncated: boolean;
96
96
  };
97
97
  latestMessage?: {
98
- type: "agent.message.completed" | "agent.message.delta" | "agent.model.request" | "agent.model.usage" | "agent.reasoning.delta" | "agent.toolCall.created" | "agent.toolCall.output" | "agent.updated" | "artifact.created" | "codex.account.switched" | "codex.capacity.resumed" | "codex.capacity.superseded" | "codex.capacity.waiting" | "codex.credential.selected" | "codex.fleet.decision" | "credential.auth_needed" | "fs.changed" | "git.changed" | "goal.cleared" | "goal.completed" | "goal.continuation" | "goal.held" | "goal.paused" | "goal.progress" | "goal.resumed" | "goal.rewrite.proposed" | "goal.rewrite.rejected" | "goal.set" | "goal.updated" | "machine.link.lost" | "machine.link.restored" | "machine.op.failed" | "machine.op.recovered" | "machine.runner.restarted" | "memory.corrected" | "memory.saved" | "recording.available" | "recording.failed" | "recording.started" | "rig.setup.completed" | "rig.setup.failed" | "rig.setup.skipped" | "rig.setup.started" | "sandbox.box.created" | "sandbox.box.lost" | "sandbox.box.snapshot" | "sandbox.box.terminated" | "sandbox.command.output.delta" | "sandbox.env.drift" | "sandbox.operation.completed" | "sandbox.operation.failed" | "sandbox.operation.started" | "session.command.backgrounded" | "session.command.finished" | "session.context.cleared" | "session.context.compacted" | "session.context.compaction.requested" | "session.context.compaction.skipped" | "session.context.compaction.started" | "session.control.paused" | "session.control.resumed" | "session.control.steer_requested" | "session.created" | "session.event.envelope_omitted" | "session.humanInput.requested" | "session.mcp.approval_policy.updated" | "session.personal_resources.attached" | "session.queue.changed" | "session.queue.history" | "session.queue.prompt.cancelled" | "session.realtime.ended" | "session.realtime.started" | "session.requiresAction" | "session.route.reconciled" | "session.status.changed" | "session.title_set" | "session.tool_policy.updated" | "session.visibility.changed" | "stream.closed" | "stream.opened" | "stream.revoked" | "stream.url.rotated" | "system.update.cancelled" | "system.update.delivered" | "system.update.pending" | "system.update.settled" | "system.update.superseded" | "terminal.pty.exited" | "terminal.pty.output.delta" | "terminal.pty.started" | "tool.auth_needed" | "turn.cancelled" | "turn.capacity_waiting" | "turn.completed" | "turn.event.rejected_late" | "turn.failed" | "turn.queued" | "turn.recovery.requested" | "turn.started" | "turn.startup.phase.completed" | "turn.startup.phase.failed" | "turn.startup.phase.started" | "turn.superseded" | "user.approvalDecision" | "user.humanInputResponse" | "user.message" | "user.pause" | "workspace.inference.paused" | "workspace.inference.resumed" | "workspace.revision.captured" | "workspace.revision.degraded";
98
+ type: "agent.message.completed" | "agent.message.delta" | "agent.model.request" | "agent.model.usage" | "agent.reasoning.delta" | "agent.toolCall.created" | "agent.toolCall.output" | "agent.updated" | "artifact.created" | "codex.account.switched" | "codex.capacity.resumed" | "codex.capacity.superseded" | "codex.capacity.waiting" | "codex.credential.selected" | "codex.fleet.decision" | "credential.auth_needed" | "fs.changed" | "git.changed" | "goal.cleared" | "goal.completed" | "goal.continuation" | "goal.held" | "goal.paused" | "goal.progress" | "goal.resumed" | "goal.rewrite.proposed" | "goal.rewrite.rejected" | "goal.set" | "goal.updated" | "machine.link.lost" | "machine.link.restored" | "machine.op.failed" | "machine.op.recovered" | "machine.runner.restarted" | "memory.corrected" | "memory.saved" | "recording.available" | "recording.failed" | "recording.started" | "rig.setup.completed" | "rig.setup.failed" | "rig.setup.skipped" | "rig.setup.started" | "sandbox.box.created" | "sandbox.box.lost" | "sandbox.box.snapshot" | "sandbox.box.terminated" | "sandbox.command.output.delta" | "sandbox.env.drift" | "sandbox.operation.completed" | "sandbox.operation.failed" | "sandbox.operation.started" | "session.command.backgrounded" | "session.command.finished" | "session.context.cleared" | "session.context.compacted" | "session.context.compaction.requested" | "session.context.compaction.skipped" | "session.context.compaction.started" | "session.control.paused" | "session.control.resumed" | "session.control.steer_requested" | "session.created" | "session.event.envelope_omitted" | "session.humanInput.requested" | "session.mcp.approval_policy.updated" | "session.personal_resources.attached" | "session.queue.changed" | "session.queue.history" | "session.queue.prompt.cancelled" | "session.realtime.ended" | "session.realtime.started" | "session.requiresAction" | "session.route.reconciled" | "session.runtime.configured" | "session.status.changed" | "session.title_set" | "session.tool_policy.updated" | "session.variable_sets.updated" | "session.visibility.changed" | "stream.closed" | "stream.opened" | "stream.revoked" | "stream.url.rotated" | "system.update.cancelled" | "system.update.delivered" | "system.update.pending" | "system.update.settled" | "system.update.superseded" | "terminal.pty.exited" | "terminal.pty.output.delta" | "terminal.pty.started" | "tool.auth_needed" | "turn.cancelled" | "turn.capacity_waiting" | "turn.completed" | "turn.event.rejected_late" | "turn.failed" | "turn.queued" | "turn.recovery.requested" | "turn.started" | "turn.startup.phase.completed" | "turn.startup.phase.failed" | "turn.startup.phase.started" | "turn.superseded" | "user.approvalDecision" | "user.humanInputResponse" | "user.message" | "user.pause" | "workspace.inference.paused" | "workspace.inference.resumed" | "workspace.revision.captured" | "workspace.revision.degraded";
99
99
  preview: string | null;
100
100
  previewTruncated: boolean;
101
101
  } | null;
@@ -88,6 +88,7 @@ export declare function boundSessionDetailMcp(session: Session, effectiveControl
88
88
  sandboxGroupId: string;
89
89
  activeSandboxId: string | null;
90
90
  activeEpoch: number;
91
+ variableSetIds: string[];
91
92
  variableSetId: string | null;
92
93
  environmentId: string | null;
93
94
  rigId: string | null;
@@ -1,4 +1,17 @@
1
+ import { AutomationNormalizedEvent } from "@opengeni/contracts";
2
+ import { type AutomationSourceSecret } from "@opengeni/db";
1
3
  import { type ApiRouteDeps } from "@opengeni/core";
2
4
  import type { Hono } from "hono";
3
5
  export declare function registerAutomationRoutes(app: Hono, deps: ApiRouteDeps): void;
6
+ export declare function acceptAutomationEvent(deps: ApiRouteDeps, source: AutomationSourceSecret, input: {
7
+ deliveryKey: string;
8
+ requestDigest: string;
9
+ normalizedEvent: AutomationNormalizedEvent;
10
+ }): Promise<{
11
+ accepted: boolean;
12
+ duplicate: boolean;
13
+ ignoredReason: string | null;
14
+ eventId: string | null;
15
+ runIds: string[];
16
+ }>;
4
17
  export declare function readAutomationWebhookBody(request: Request, maxBytes: number): Promise<Uint8Array>;
@@ -1,3 +1,4 @@
1
- import { type ApiRouteDeps } from "@opengeni/core";
1
+ import { type ApiRouteDeps, type WorkspaceInsightsFilterField } from "@opengeni/core";
2
2
  import type { Hono } from "hono";
3
+ export declare function normalizeWorkspaceInsightsQueryFilter(value: string | null | undefined, field: WorkspaceInsightsFilterField): string | null;
3
4
  export declare function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void;
@@ -0,0 +1,29 @@
1
+ import { type ApiRouteDeps } from "@opengeni/core";
2
+ import type { Hono } from "hono";
3
+ export type ManagedOnboardingRouteOptions = {
4
+ accountSetupLimiter?: {
5
+ take(key: string): boolean;
6
+ };
7
+ hashPassword?: (password: string) => Promise<string>;
8
+ };
9
+ export declare function registerManagedOnboardingRoutes(app: Hono, deps: ApiRouteDeps, options?: ManagedOnboardingRouteOptions): void;
10
+ /**
11
+ * Bounded application-tier protection for the public setup bearer endpoint.
12
+ * The global bucket prevents spoofed/high-cardinality client keys from
13
+ * multiplying password-hash work, while the per-client bucket limits bursts.
14
+ * A full key map rejects new clients until an idle bucket can be pruned.
15
+ */
16
+ export declare class PublicSetupRateLimiter {
17
+ private readonly options;
18
+ private readonly buckets;
19
+ private global;
20
+ constructor(options?: {
21
+ globalCapacity?: number;
22
+ globalRefillPerSecond?: number;
23
+ clientCapacity?: number;
24
+ clientRefillPerSecond?: number;
25
+ maxClientKeys?: number;
26
+ now?: () => number;
27
+ });
28
+ take(key: string): boolean;
29
+ }
@@ -0,0 +1,3 @@
1
+ import { type ApiRouteDeps } from "@opengeni/core";
2
+ import type { Hono } from "hono";
3
+ export declare function registerPrReviewGitHubRoutes(app: Hono, deps: ApiRouteDeps): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "2.2.0-canary.0",
3
+ "version": "2.3.2-canary.1",
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.1",
47
- "@opengeni/artifact-tool": "^0.3.5-canary.0",
48
- "@opengeni/capabilities": "^0.3.1-canary.0",
49
- "@opengeni/codemode": "^0.4.13-canary.0",
50
- "@opengeni/codex": "^0.2.19-canary.0",
51
- "@opengeni/config": "^0.19.1-canary.0",
52
- "@opengeni/contracts": "^2.3.0-canary.0",
53
- "@opengeni/core": "^2.2.0-canary.0",
54
- "@opengeni/db": "^3.2.0-canary.0",
55
- "@opengeni/documents": "^0.7.0-canary.0",
56
- "@opengeni/events": "^0.3.124-canary.0",
57
- "@opengeni/github": "^0.5.3-canary.0",
58
- "@opengeni/network": "^0.2.3-canary.0",
59
- "@opengeni/observability": "^0.8.4-canary.0",
60
- "@opengeni/runtime": "^1.3.1-canary.0",
61
- "@opengeni/storage": "^0.2.106-canary.0",
62
- "@opengeni/xai-subscription": "^0.1.2-canary.0",
46
+ "@opengeni/agent-proto": "^0.5.1-canary.3",
47
+ "@opengeni/artifact-tool": "^0.3.7-canary.1",
48
+ "@opengeni/capabilities": "^0.3.1-canary.2",
49
+ "@opengeni/codemode": "^0.4.15-canary.1",
50
+ "@opengeni/codex": "^0.2.19-canary.2",
51
+ "@opengeni/config": "^0.20.1-canary.1",
52
+ "@opengeni/contracts": "^2.5.0-canary.1",
53
+ "@opengeni/core": "^2.4.0-canary.1",
54
+ "@opengeni/db": "^3.4.0-canary.1",
55
+ "@opengeni/documents": "^0.8.2-canary.1",
56
+ "@opengeni/events": "^0.4.0-canary.1",
57
+ "@opengeni/github": "^0.5.5-canary.1",
58
+ "@opengeni/network": "^0.2.3-canary.2",
59
+ "@opengeni/observability": "^0.8.6-canary.1",
60
+ "@opengeni/runtime": "^1.4.0-canary.1",
61
+ "@opengeni/storage": "^0.2.108-canary.1",
62
+ "@opengeni/xai-subscription": "^0.1.2-canary.2",
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
@@ -40,6 +40,7 @@ import {
40
40
  rlsContextForWorkspace,
41
41
  withSessionRlsActorContext,
42
42
  } from "@opengeni/db";
43
+ import { requireSessionEventDurableFanoutCapability } from "@opengeni/events";
43
44
  import { createObservability } from "@opengeni/observability";
44
45
  import { createObjectStorage } from "@opengeni/storage";
45
46
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -63,6 +64,8 @@ import {
63
64
  SessionAuthorizationUnavailableError,
64
65
  } from "@opengeni/core";
65
66
  import { createManagedAuth } from "./auth/managed-auth";
67
+ import { createManagedEmailTransport } from "./auth/managed-email";
68
+ import { assertManagedEmailTransportMetadata } from "./auth/organization-user-setup";
66
69
  import { createApiSandboxClient, makeResumeBoxById } from "./sandbox/access";
67
70
  import { requireLimit } from "@opengeni/core";
68
71
  import { buildOpenGeniMcpServer } from "./mcp/server";
@@ -132,6 +135,7 @@ import { registerEditableArtifactRoutes } from "./routes/editable-artifacts";
132
135
  import { registerVideoGenerationRoutes } from "./routes/video-generation";
133
136
  import { registerCanonicalHumanIdentityRoutes } from "./routes/canonical-human-identities";
134
137
  import { registerOrganizationMembershipRoutes } from "./routes/organization-memberships";
138
+ import { registerManagedOnboardingRoutes } from "./routes/managed-onboarding";
135
139
  import { registerUserResourceAuthorityRoutes } from "./routes/user-resource-authorities";
136
140
  import { registerConnectionAuthorityRoutes } from "./routes/connection-authorities";
137
141
  import { projectClientModel } from "./model-catalog";
@@ -193,7 +197,11 @@ export function createAppComposition(deps: AppDependencies): {
193
197
  // Pause) run inside API-originated db commands; install the boot-validated
194
198
  // rollout flag once for this process.
195
199
  configureChildLifecycleNotices({ enabled: deps.settings.childLifecycleNoticesEnabled });
196
- const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
200
+ const managedEmailTransport =
201
+ deps.managedEmailTransport ?? createManagedEmailTransport(deps.settings);
202
+ assertManagedEmailTransportMetadata(managedEmailTransport);
203
+ const managedAuth =
204
+ deps.managedAuth ?? createManagedAuth(deps.settings, deps.db, managedEmailTransport);
197
205
  const objectStorage =
198
206
  deps.objectStorage === undefined ? createObjectStorage(deps.settings) : deps.objectStorage;
199
207
  let documentServices: DocumentServices | null = deps.documentServices ?? null;
@@ -296,6 +304,7 @@ export function createAppComposition(deps: AppDependencies): {
296
304
  githubStateSecret:
297
305
  deps.githubStateSecret ?? deps.settings.githubAppManifestStateSecret ?? crypto.randomUUID(),
298
306
  managedAuth,
307
+ managedEmailTransport,
299
308
  objectStorage,
300
309
  documentIndexer,
301
310
  getDocumentServices,
@@ -486,6 +495,9 @@ export function createAppComposition(deps: AppDependencies): {
486
495
  await next();
487
496
  });
488
497
 
498
+ // These product-owned auth routes must be registered before Better Auth's
499
+ // wildcard handler or the provider returns its own 404 first.
500
+ registerManagedOnboardingRoutes(app, routeDeps);
489
501
  if (managedAuth) {
490
502
  app.on(["GET", "POST"], "/v1/auth/*", (c) => managedAuth.handler(c.req.raw));
491
503
  }
@@ -1114,19 +1126,23 @@ type ReadinessChecks = Record<ReadinessCheckName, ReadinessCheck>;
1114
1126
  type ReadinessCheckResult = { ok: boolean; error?: string };
1115
1127
 
1116
1128
  function readinessChecks(deps: AppDependencies): ReadinessChecks {
1129
+ const configuredNatsCheck = deps.readinessChecks?.nats;
1117
1130
  return {
1118
1131
  db:
1119
1132
  deps.readinessChecks?.db ??
1120
1133
  (async () => {
1121
1134
  await deps.db.execute(dbSql`select 1`);
1122
1135
  }),
1123
- nats:
1124
- deps.readinessChecks?.nats ??
1125
- (() => {
1136
+ nats: async () => {
1137
+ requireSessionEventDurableFanoutCapability(deps.bus);
1138
+ if (configuredNatsCheck) {
1139
+ await configuredNatsCheck();
1140
+ } else {
1126
1141
  if (deps.bus.isConnected && !deps.bus.isConnected()) {
1127
1142
  throw new Error("NATS is not connected");
1128
1143
  }
1129
- }),
1144
+ }
1145
+ },
1130
1146
  temporal:
1131
1147
  deps.readinessChecks?.temporal ??
1132
1148
  deps.workflowClient.check ??
@@ -1637,6 +1653,22 @@ const routeLabelPatterns: Array<{
1637
1653
  pattern: /^\/v1\/workspaces\/[^/]+\/github\/app-manifest$/,
1638
1654
  label: "/v1/workspaces/:workspaceId/github/app-manifest",
1639
1655
  },
1656
+ {
1657
+ pattern: /^\/v1\/workspaces\/[^/]+\/pr-review\/github$/,
1658
+ label: "/v1/workspaces/:workspaceId/pr-review/github",
1659
+ },
1660
+ {
1661
+ pattern: /^\/v1\/workspaces\/[^/]+\/pr-review\/github\/connect$/,
1662
+ label: "/v1/workspaces/:workspaceId/pr-review/github/connect",
1663
+ },
1664
+ {
1665
+ pattern: /^\/v1\/workspaces\/[^/]+\/pr-review\/github\/installations\/select$/,
1666
+ label: "/v1/workspaces/:workspaceId/pr-review/github/installations/select",
1667
+ },
1668
+ {
1669
+ pattern: /^\/v1\/workspaces\/[^/]+\/pr-review\/github\/installations\/[^/]+\/configure$/,
1670
+ label: "/v1/workspaces/:workspaceId/pr-review/github/installations/:installationId/configure",
1671
+ },
1640
1672
  {
1641
1673
  pattern: /^\/v1\/workspaces\/[^/]+\/capabilities$/,
1642
1674
  label: "/v1/workspaces/:workspaceId/capabilities",
@@ -1929,6 +1961,19 @@ const routeLabelPatterns: Array<{
1929
1961
  pattern: /^\/v1\/github\/oauth\/callback$/,
1930
1962
  label: "/v1/github/oauth/callback",
1931
1963
  },
1964
+ { pattern: /^\/v1\/pr-review\/github\/setup$/, label: "/v1/pr-review/github/setup" },
1965
+ {
1966
+ pattern: /^\/v1\/pr-review\/github\/install\/callback$/,
1967
+ label: "/v1/pr-review/github/install/callback",
1968
+ },
1969
+ {
1970
+ pattern: /^\/v1\/pr-review\/github\/oauth\/callback$/,
1971
+ label: "/v1/pr-review/github/oauth/callback",
1972
+ },
1973
+ {
1974
+ pattern: /^\/v1\/webhooks\/pr-review\/github$/,
1975
+ label: "/v1/webhooks/pr-review/github",
1976
+ },
1932
1977
  {
1933
1978
  pattern: /^\/v1\/workspaces\/[^/]+$/,
1934
1979
  label: "/v1/workspaces/:workspaceId",
@@ -1957,6 +2002,12 @@ export function isApiContractProtectedMutation(method: string, pathname: string)
1957
2002
  if (!pathname.startsWith("/v1/")) {
1958
2003
  return false;
1959
2004
  }
2005
+ if (
2006
+ pathname === "/v1/auth/organization-onboarding" ||
2007
+ pathname === "/v1/auth/organization-setup"
2008
+ ) {
2009
+ return true;
2010
+ }
1960
2011
  if (
1961
2012
  pathname.startsWith("/v1/auth/") ||
1962
2013
  pathname.startsWith("/v1/webhooks/") ||
@@ -1,5 +1,9 @@
1
1
  import type { Settings } from "@opengeni/config";
2
- import { type ManagedAuth } from "@opengeni/core";
2
+ import {
3
+ type ManagedAuth,
4
+ type ManagedEmailMessage,
5
+ type ManagedEmailTransport,
6
+ } from "@opengeni/core";
3
7
  import type { Database } from "@opengeni/db";
4
8
  import { ensureManagedAccessForUser } from "@opengeni/db";
5
9
  import {
@@ -9,8 +13,8 @@ import {
9
13
  } from "@opengeni/db/canonical-human-identities";
10
14
  import { betterAuth } from "better-auth";
11
15
  import { createEmailVerificationToken } from "better-auth/api";
16
+ import { hashPassword } from "better-auth/crypto";
12
17
  import { Pool } from "pg";
13
- import { Resend } from "resend";
14
18
 
15
19
  import { decideCanonicalHumanSessionAdmission } from "./canonical-human-session-admission";
16
20
 
@@ -34,7 +38,16 @@ export function managedAuthUserCreateOverride(
34
38
  return { data: { ...user, emailVerified: true } };
35
39
  }
36
40
 
37
- export function createManagedAuth(settings: Settings, db: Database): ManagedAuth | null {
41
+ /** Keep Better Auth password policy and storage format behind this boundary. */
42
+ export async function hashManagedAuthPassword(password: string): Promise<string> {
43
+ return await hashPassword(password);
44
+ }
45
+
46
+ export function createManagedAuth(
47
+ settings: Settings,
48
+ db: Database,
49
+ managedEmailTransport: ManagedEmailTransport,
50
+ ): ManagedAuth | null {
38
51
  if (settings.productAccessMode !== "managed") {
39
52
  return null;
40
53
  }
@@ -147,7 +160,8 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
147
160
  onExistingUserSignUp: async ({ user }) => {
148
161
  if (requireEmailVerification && !user.emailVerified) {
149
162
  const url = await verificationUrl(settings, user.email);
150
- await sendEmail(settings, {
163
+ await sendManagedAuthEmail(managedEmailTransport, {
164
+ kind: "email_verification",
151
165
  to: user.email,
152
166
  subject: "Verify your OpenGeni email",
153
167
  text: `Verify your OpenGeni email: ${url}`,
@@ -156,7 +170,8 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
156
170
  }
157
171
  },
158
172
  sendResetPassword: async ({ user, url }) => {
159
- await sendEmail(settings, {
173
+ await sendManagedAuthEmail(managedEmailTransport, {
174
+ kind: "password_reset",
160
175
  to: user.email,
161
176
  subject: "Reset your OpenGeni password",
162
177
  text: `Reset your OpenGeni password: ${url}`,
@@ -167,7 +182,8 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
167
182
  emailVerification: {
168
183
  sendOnSignUp: requireEmailVerification,
169
184
  sendVerificationEmail: async ({ user, url }) => {
170
- await sendEmail(settings, {
185
+ await sendManagedAuthEmail(managedEmailTransport, {
186
+ kind: "email_verification",
171
187
  to: user.email,
172
188
  subject: "Verify your OpenGeni email",
173
189
  text: `Verify your OpenGeni email: ${url}`,
@@ -180,6 +196,7 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
180
196
  email: user.email,
181
197
  name: user.name,
182
198
  emailVerified: true,
199
+ provisionFallbackOrganization: false,
183
200
  });
184
201
  },
185
202
  },
@@ -266,6 +283,7 @@ export function createManagedAuth(settings: Settings, db: Database): ManagedAuth
266
283
  email: user.email,
267
284
  name: user.name,
268
285
  emailVerified: true,
286
+ provisionFallbackOrganization: false,
269
287
  });
270
288
  },
271
289
  },
@@ -297,35 +315,12 @@ function betterAuthTrustedOrigins(settings: Settings): string[] {
297
315
  return [...origins];
298
316
  }
299
317
 
300
- async function sendEmail(
301
- settings: Settings,
302
- input: {
303
- to: string;
304
- subject: string;
305
- text: string;
306
- html: string;
307
- },
318
+ export async function sendManagedAuthEmail(
319
+ transport: ManagedEmailTransport,
320
+ input: Omit<ManagedEmailMessage, "from">,
308
321
  ): Promise<void> {
309
- if (!settings.resendApiKey) {
310
- if (settings.environment === "local" || settings.environment === "test") {
311
- console.warn(
312
- `[opengeni] Skipping email to ${input.to}: OPENGENI_RESEND_API_KEY is not configured`,
313
- );
314
- return;
315
- }
316
- throw new Error("OPENGENI_RESEND_API_KEY is required to send managed auth email");
317
- }
318
- const resend = new Resend(settings.resendApiKey);
319
- const result = await resend.emails.send({
320
- from: settings.emailFrom,
321
- to: input.to,
322
- subject: input.subject,
323
- text: input.text,
324
- html: input.html,
325
- });
326
- if (result.error) {
327
- throw new Error(result.error.message);
328
- }
322
+ const result = await transport.send({ ...input, from: transport.sender });
323
+ if (result.status !== "sent") throw new Error(`managed email ${result.status}`);
329
324
  }
330
325
 
331
326
  async function verificationUrl(settings: Settings, email: string): Promise<string> {
@@ -0,0 +1,174 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type {
3
+ ManagedEmailDeliveryResult,
4
+ ManagedEmailMessage,
5
+ ManagedEmailTransport,
6
+ } from "@opengeni/core";
7
+ import { createHmac } from "node:crypto";
8
+ import { Resend } from "resend";
9
+
10
+ const CAPTURE_MAX_MESSAGES = 250;
11
+ const CAPTURE_TTL_MS = 15 * 60 * 1000;
12
+
13
+ export type CapturedManagedEmail = ManagedEmailMessage & {
14
+ capturedAt: string;
15
+ };
16
+
17
+ /**
18
+ * Process-local test/development transport. Captures are count/TTL bounded and
19
+ * one-time readable; there is deliberately no route, disk, database, or log
20
+ * projection for message bodies or setup bearers.
21
+ */
22
+ export class InMemoryManagedEmailTransport implements ManagedEmailTransport {
23
+ private readonly messages: CapturedManagedEmail[] = [];
24
+ readonly sender: string;
25
+ readonly idempotency: ManagedEmailTransport["idempotency"];
26
+
27
+ constructor(
28
+ private readonly options: {
29
+ maxMessages?: number;
30
+ ttlMs?: number;
31
+ now?: () => number;
32
+ sender?: string;
33
+ idempotency?: ManagedEmailTransport["idempotency"];
34
+ } = {},
35
+ ) {
36
+ this.sender = options.sender ?? "OpenGeni <auth@mail.opengeni.ai>";
37
+ this.idempotency = options.idempotency ?? {
38
+ scope: "opengeni-in-memory-v1",
39
+ retentionSeconds: 86_400,
40
+ };
41
+ }
42
+
43
+ async send(message: ManagedEmailMessage): Promise<ManagedEmailDeliveryResult> {
44
+ this.prune();
45
+ const maxMessages = this.options.maxMessages ?? CAPTURE_MAX_MESSAGES;
46
+ if (this.messages.length >= maxMessages) this.messages.splice(0, 1);
47
+ this.messages.push({
48
+ ...message,
49
+ capturedAt: new Date(this.now()).toISOString(),
50
+ });
51
+ return { status: "sent", providerMessageId: null };
52
+ }
53
+
54
+ take(predicate: (message: CapturedManagedEmail) => boolean): CapturedManagedEmail | null {
55
+ this.prune();
56
+ const index = this.messages.findIndex(predicate);
57
+ if (index < 0) return null;
58
+ return this.messages.splice(index, 1)[0] ?? null;
59
+ }
60
+
61
+ size(): number {
62
+ this.prune();
63
+ return this.messages.length;
64
+ }
65
+
66
+ private prune(): void {
67
+ const cutoff = this.now() - (this.options.ttlMs ?? CAPTURE_TTL_MS);
68
+ while (this.messages[0] && Date.parse(this.messages[0].capturedAt) <= cutoff) {
69
+ this.messages.shift();
70
+ }
71
+ }
72
+
73
+ private now(): number {
74
+ return this.options.now?.() ?? Date.now();
75
+ }
76
+ }
77
+
78
+ class UnconfiguredManagedEmailTransport implements ManagedEmailTransport {
79
+ readonly idempotency = {
80
+ scope: "opengeni-unconfigured-v1",
81
+ retentionSeconds: 0,
82
+ } as const;
83
+
84
+ constructor(readonly sender: string) {}
85
+
86
+ async send(): Promise<ManagedEmailDeliveryResult> {
87
+ return { status: "failed", errorClass: "provider_not_configured" };
88
+ }
89
+ }
90
+
91
+ class ResendManagedEmailTransport implements ManagedEmailTransport {
92
+ private readonly client: Resend;
93
+ readonly idempotency: ManagedEmailTransport["idempotency"];
94
+
95
+ constructor(
96
+ apiKey: string,
97
+ readonly sender: string,
98
+ scopeSecret: string,
99
+ ) {
100
+ this.client = new Resend(apiKey);
101
+ this.idempotency = {
102
+ // The keyed digest binds this delivery to the Resend account without
103
+ // persisting an API-key derivative that can be tested offline.
104
+ scope: `resend-v1-24h:${createHmac("sha256", scopeSecret).update(apiKey).digest("hex")}`,
105
+ retentionSeconds: 86_400,
106
+ };
107
+ }
108
+
109
+ async send(message: ManagedEmailMessage): Promise<ManagedEmailDeliveryResult> {
110
+ if (message.from !== this.sender) {
111
+ return { status: "failed", errorClass: "sender_changed" };
112
+ }
113
+ try {
114
+ const result = await this.client.emails.send(
115
+ {
116
+ from: message.from,
117
+ to: message.to,
118
+ subject: message.subject,
119
+ text: message.text,
120
+ html: message.html,
121
+ },
122
+ message.idempotencyKey ? { idempotencyKey: message.idempotencyKey } : undefined,
123
+ );
124
+ if (!result.error) {
125
+ return { status: "sent", providerMessageId: result.data?.id ?? null };
126
+ }
127
+ const statusCode = "statusCode" in result.error ? Number(result.error.statusCode) : NaN;
128
+ return clearProviderRefusal(statusCode)
129
+ ? { status: "failed", errorClass: boundedErrorClass(result.error.name, "provider_refused") }
130
+ : {
131
+ status: "outcome_unknown",
132
+ errorClass: boundedErrorClass(result.error.name, "provider_ambiguous"),
133
+ };
134
+ } catch (error) {
135
+ return {
136
+ status: "outcome_unknown",
137
+ errorClass: boundedErrorClass(
138
+ error instanceof Error ? error.name : "transport_error",
139
+ "transport_error",
140
+ ),
141
+ };
142
+ }
143
+ }
144
+ }
145
+
146
+ export function createManagedEmailTransport(settings: Settings): ManagedEmailTransport {
147
+ if (settings.resendApiKey) {
148
+ if (!settings.betterAuthSecret) {
149
+ throw new Error("OPENGENI_BETTER_AUTH_SECRET is required for managed email delivery");
150
+ }
151
+ return new ResendManagedEmailTransport(
152
+ settings.resendApiKey,
153
+ settings.emailFrom,
154
+ settings.betterAuthSecret,
155
+ );
156
+ }
157
+ if (settings.environment === "local" || settings.environment === "test") {
158
+ return new InMemoryManagedEmailTransport({ sender: settings.emailFrom });
159
+ }
160
+ return new UnconfiguredManagedEmailTransport(settings.emailFrom);
161
+ }
162
+
163
+ function clearProviderRefusal(statusCode: number): boolean {
164
+ return statusCode >= 400 && statusCode < 500 && ![408, 409, 425, 429].includes(statusCode);
165
+ }
166
+
167
+ function boundedErrorClass(value: string, fallback: string): string {
168
+ const normalized = value
169
+ .trim()
170
+ .toLowerCase()
171
+ .replaceAll(/[^a-z0-9_-]+/g, "_")
172
+ .slice(0, 64);
173
+ return normalized || fallback;
174
+ }