@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
@@ -2,6 +2,7 @@ import {
2
2
  GitHubActionPoliciesResponse,
3
3
  GitHubActionPolicyActorState,
4
4
  GitHubAppManifestCreate,
5
+ OrganizationIntegrationDeniedError,
5
6
  UpdateGitHubActionPolicyRequest,
6
7
  type AccessGrant,
7
8
  type GitHubInstallationBindingCandidate,
@@ -32,6 +33,7 @@ import {
32
33
  GitHubPublicRepositoryVerificationError,
33
34
  githubAppMissingSettings,
34
35
  githubOAuthAuthorizeUrl,
36
+ inspectSignedState,
35
37
  organizationAppManifestUrl,
36
38
  personalAppManifestUrl,
37
39
  readSignedState,
@@ -40,9 +42,10 @@ import {
40
42
  type GitHubSignedStatePayload,
41
43
  verifySignedState,
42
44
  } from "@opengeni/github";
43
- import type { Context, Hono } from "hono";
45
+ import type { Context, Hono, MiddlewareHandler } from "hono";
44
46
  import { deleteCookie, setCookie } from "hono/cookie";
45
47
  import { HTTPException } from "hono/http-exception";
48
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
46
49
  import {
47
50
  githubAppActionPolicyActor,
48
51
  hasPermission,
@@ -72,6 +75,7 @@ import {
72
75
  assertPersonalConnectionOwnerPrincipal,
73
76
  requireLegacyOAuthActor,
74
77
  } from "../connection-ownership";
78
+ import { workspaceIntegrationsPathForUntrusted } from "../integrations/oauth-client";
75
79
  import { listPersonalGitHubConnections } from "../integrations/personal-github";
76
80
  import {
77
81
  integrationCommitGrant,
@@ -81,6 +85,14 @@ import {
81
85
  isConsistentGitHubBindingCandidates,
82
86
  isConsistentGitHubBindingProof,
83
87
  } from "../integrations/github-installation-proof";
88
+ import {
89
+ githubConnectFailureHtml,
90
+ githubInstallationChooserHtml,
91
+ githubSetupPendingHtml,
92
+ githubSetupSuccessHtml,
93
+ githubSuccessHtml,
94
+ type GitHubConnectFailure,
95
+ } from "./github-browser-pages";
84
96
  import {
85
97
  completeGitHubAppConnect,
86
98
  isGitHubAppConnectState,
@@ -90,8 +102,61 @@ const githubStateCookie = "opengeni_github_state";
90
102
  const githubBindingStateMaxAgeSeconds = 10 * 60;
91
103
  const legacyInstallationChooserDisabledMessage =
92
104
  "The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
105
+ /**
106
+ * GitHub routes a browser navigates to (not the JSON API). A page-load link
107
+ * opened after it expired, GitHub's Cancel button, or a non-owner all land on
108
+ * one of these, so their failures render a readable page instead of JSON.
109
+ */
110
+ const GITHUB_BROWSER_ROUTES = [
111
+ "/v1/workspaces/:workspaceId/github/connect",
112
+ "/v1/workspaces/:workspaceId/github/installations/select",
113
+ "/v1/workspaces/:workspaceId/github/installations/:installationId/configure",
114
+ "/v1/github/app-manifest/callback",
115
+ "/v1/github/setup",
116
+ "/v1/github/install/callback",
117
+ "/v1/github/oauth/callback",
118
+ ] as const;
119
+
120
+ /** An HTTP failure that already knows which browser page explains it. */
121
+ class GitHubBrowserFailure extends HTTPException {
122
+ constructor(
123
+ status: 400 | 403,
124
+ message: string,
125
+ readonly failure: GitHubConnectFailure,
126
+ ) {
127
+ super(status, { message });
128
+ this.name = "GitHubBrowserFailure";
129
+ }
130
+ }
131
+
93
132
  export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
94
133
  const { db, settings, githubStateSecret } = deps;
134
+ // Registered before the routes so it wraps them. The status code is kept, so
135
+ // automation still sees the same outcome; only the body becomes a page. Every
136
+ // failure is rendered, not only HTTP ones: an organization policy denial or an
137
+ // unexpected fault must not reach the browser as the JSON error envelope.
138
+ const browserFailurePage: MiddlewareHandler = async (c, next) => {
139
+ let failure: unknown;
140
+ let handledStatus: number | undefined;
141
+ try {
142
+ await next();
143
+ if (!c.error) return;
144
+ failure = c.error;
145
+ // The app error handler has already answered this failure.
146
+ handledStatus = c.res.status;
147
+ } catch (error) {
148
+ failure = error;
149
+ }
150
+ c.res = c.html(
151
+ githubConnectFailureHtml(
152
+ githubBrowserFailureKind(failure),
153
+ githubFailureReturnUrl(deps, c),
154
+ githubBrowserFailureDetail(failure),
155
+ ),
156
+ githubBrowserFailureStatus(failure, handledStatus) as ContentfulStatusCode,
157
+ );
158
+ };
159
+ for (const path of GITHUB_BROWSER_ROUTES) app.use(path, browserFailurePage);
95
160
 
96
161
  app.get("/v1/workspaces/:workspaceId/github/app", async (c) => {
97
162
  const workspaceId = c.req.param("workspaceId");
@@ -580,6 +645,9 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
580
645
  });
581
646
  const code = c.req.query("code");
582
647
  const state = c.req.query("state");
648
+ if (c.req.query("error") === "access_denied") {
649
+ throw new GitHubBrowserFailure(400, "GitHub authorization was cancelled", "cancelled");
650
+ }
583
651
  if (!code) {
584
652
  throw new HTTPException(400, { message: "missing GitHub OAuth code" });
585
653
  }
@@ -981,7 +1049,7 @@ function githubAuthorityHttpError(error: unknown): HTTPException {
981
1049
  }
982
1050
  if (error instanceof GitHubInstallationAuthorityError) {
983
1051
  if (error.reason === "authority_denied") {
984
- return new HTTPException(403, { message: error.message });
1052
+ return new GitHubBrowserFailure(403, error.message, "not_owner");
985
1053
  }
986
1054
  if (error.reason === "installation_missing") {
987
1055
  return new HTTPException(404, { message: error.message });
@@ -1051,39 +1119,6 @@ function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
1051
1119
  );
1052
1120
  }
1053
1121
 
1054
- function githubSuccessHtml(envLines: string[]): string {
1055
- const envText = envLines.join("\n");
1056
- const escaped = escapeHtml(envText);
1057
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Created</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(760px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.env-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 8px}.env-header h2{margin:0;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.08em;color:#a1a1aa}pre{white-space:pre-wrap;word-break:break-word;max-height:380px;overflow:auto;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:16px;font-size:13px;line-height:1.5}button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.7}</style></head><body><main><h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script></main></body></html>`;
1058
- }
1059
-
1060
- function githubSetupSuccessHtml(account: string, returnUrl: string): string {
1061
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
1062
- }
1063
-
1064
- function githubInstallationChooserHtml(
1065
- candidates: GitHubInstallationBindingCandidate[],
1066
- state: string,
1067
- workspaceId: string,
1068
- baseUrl: string,
1069
- ): string {
1070
- const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
1071
- const options = candidates
1072
- .map(({ installation, authorityKind }, index) => {
1073
- const account = escapeHtml(
1074
- installation.accountLogin ?? `installation ${installation.installationId}`,
1075
- );
1076
- const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
1077
- return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required${candidates.length === 1 && index === 0 ? " checked" : ""}><span><strong>${account}</strong><small>${label}</small></span></label>`;
1078
- })
1079
- .join("");
1080
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>These accounts already have the OpenGeni GitHub App installed and GitHub proved you are the personal owner or an active organization owner.</p><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
1081
- }
1082
-
1083
- function githubSetupPendingHtml(): string {
1084
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
1085
- }
1086
-
1087
1122
  function parsePositiveInteger(value: string | undefined | null): number | null {
1088
1123
  if (!value || !/^\d+$/.test(value)) {
1089
1124
  return null;
@@ -1097,20 +1132,6 @@ function isFreshGitHubBindingState(payload: GitHubSignedStatePayload): boolean {
1097
1132
  return age >= 0 && age < githubBindingStateMaxAgeSeconds;
1098
1133
  }
1099
1134
 
1100
- function escapeHtml(value: string): string {
1101
- return value.replace(
1102
- /[&<>"']/g,
1103
- (char) =>
1104
- ({
1105
- "&": "&amp;",
1106
- "<": "&lt;",
1107
- ">": "&gt;",
1108
- '"': "&quot;",
1109
- "'": "&#39;",
1110
- })[char] ?? char,
1111
- );
1112
- }
1113
-
1114
1135
  function openGeniReturnUrl(
1115
1136
  settings: ApiRouteDeps["settings"],
1116
1137
  c: Context,
@@ -1132,3 +1153,55 @@ function openGeniReturnUrl(
1132
1153
  function openGeniBaseUrl(settings: ApiRouteDeps["settings"], c: Context): string {
1133
1154
  return githubBrowserBaseUrl(settings, new URL(c.req.url).origin);
1134
1155
  }
1156
+
1157
+ /** The status the app error handler gives this failure; the page keeps it. */
1158
+ function githubBrowserFailureStatus(error: unknown, handledStatus: number | undefined): number {
1159
+ if (error instanceof HTTPException) return error.status;
1160
+ if (error instanceof OrganizationIntegrationDeniedError) return 403;
1161
+ // An unexpected fault keeps whatever the error handler answered (500 by default).
1162
+ return handledStatus ?? 500;
1163
+ }
1164
+
1165
+ function githubBrowserFailureKind(error: unknown): GitHubConnectFailure {
1166
+ if (error instanceof OrganizationIntegrationDeniedError) return "policy_denied";
1167
+ if (!(error instanceof HTTPException)) return "failed";
1168
+ if (error instanceof GitHubBrowserFailure) return error.failure;
1169
+ if (error.status === 401) return "signed_out";
1170
+ if (error.status === 403) return "forbidden";
1171
+ // Every signed-state rejection names its state; they all mean "start again".
1172
+ if (error.status === 400 && /\bstate\b/iu.test(error.message)) return "expired";
1173
+ return "failed";
1174
+ }
1175
+
1176
+ /**
1177
+ * Human-authored HTTP messages only; configuration errors carry a JSON
1178
+ * envelope. Anything else (an unexpected fault) shows no detail.
1179
+ */
1180
+ function githubBrowserFailureDetail(error: unknown): string | null {
1181
+ if (!(error instanceof HTTPException)) return null;
1182
+ if (error instanceof GitHubBrowserFailure && error.failure === "cancelled") return null;
1183
+ try {
1184
+ const parsed = JSON.parse(error.message) as { message?: unknown };
1185
+ return typeof parsed?.message === "string" ? parsed.message : null;
1186
+ } catch {
1187
+ return error.message || null;
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * The failure page links back to the workspace integrations page when the
1193
+ * request names a workspace (its path, or a correctly signed state even if it
1194
+ * aged out); otherwise to the OpenGeni home. This is only a link target, so an
1195
+ * expired state is acceptable evidence of where the user came from.
1196
+ */
1197
+ function githubFailureReturnUrl(deps: ApiRouteDeps, c: Context): string {
1198
+ const baseUrl = (
1199
+ deps.settings.webBaseUrl ??
1200
+ (openGeniBaseUrl(deps.settings, c) || new URL(c.req.url).origin)
1201
+ ).replace(/\/+$/u, "");
1202
+ const rawState = c.req.query("state");
1203
+ const candidate =
1204
+ c.req.param("workspaceId") ??
1205
+ (rawState ? inspectSignedState(rawState, deps.githubStateSecret)?.workspaceId : undefined);
1206
+ return `${baseUrl}${workspaceIntegrationsPathForUntrusted(candidate) ?? "/"}`;
1207
+ }
@@ -9,6 +9,7 @@ import {
9
9
  type ApiRouteDeps,
10
10
  type WorkspaceInsightsFilterField,
11
11
  } from "@opengeni/core";
12
+ import { currentSessionRlsActorIdentityKey } from "@opengeni/db";
12
13
  import { workspaceInsightsMetricObserver } from "@opengeni/observability";
13
14
  import type { Hono } from "hono";
14
15
  import { HTTPException } from "hono/http-exception";
@@ -27,9 +28,62 @@ export function normalizeWorkspaceInsightsQueryFilter(
27
28
  }
28
29
  }
29
30
 
31
+ /**
32
+ * Share one in-flight computation between identical concurrent callers.
33
+ *
34
+ * A reload, a second tab, or a retried fetch joins the rollup already running
35
+ * instead of starting another multi-second aggregate on the database.
36
+ * Authorization and filter validation still run per request before a caller
37
+ * may join; the map only ever holds running work. The caller supplies the
38
+ * complete sharing key, which must identify everything the result depends on.
39
+ */
40
+ export function createInFlightCoalescer<T>(): {
41
+ run: (key: string, work: () => Promise<T>) => Promise<T>;
42
+ readonly size: number;
43
+ } {
44
+ const inFlight = new Map<string, Promise<T>>();
45
+ return {
46
+ run(key, work) {
47
+ const pending = inFlight.get(key);
48
+ if (pending) return pending;
49
+ const started: Promise<T> = work().finally(() => {
50
+ if (inFlight.get(key) === started) inFlight.delete(key);
51
+ });
52
+ inFlight.set(key, started);
53
+ return started;
54
+ },
55
+ get size() {
56
+ return inFlight.size;
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Insights rows are filtered by the database RLS actor (private sessions are
63
+ * visible only to their owner), so two administrators of one workspace can
64
+ * legitimately receive different responses. The actor identity is part of
65
+ * the key; requests share work only when every visibility input is equal.
66
+ */
67
+ export function workspaceInsightsCoalesceKey(input: {
68
+ workspaceId: string;
69
+ range: string;
70
+ provider: string | null;
71
+ model: string | null;
72
+ rlsActor: string | null;
73
+ }): string {
74
+ return JSON.stringify([
75
+ input.workspaceId,
76
+ input.range,
77
+ input.provider,
78
+ input.model,
79
+ input.rlsActor,
80
+ ]);
81
+ }
82
+
30
83
  export function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void {
31
84
  const observeRequest = workspaceInsightsMetricObserver(deps.observability);
32
85
  const observePhase = workspaceInsightsPhaseMetricObserver(deps.observability);
86
+ const coalesce = createInFlightCoalescer<Awaited<ReturnType<typeof getWorkspaceInsights>>>();
33
87
  app.get("/v1/workspaces/:workspaceId/insights", async (c) => {
34
88
  const startedAtMs = performance.now();
35
89
  const rangeRaw = c.req.query("range") ?? "week";
@@ -54,16 +108,26 @@ export function registerInsightsRoutes(app: Hono, deps: ApiRouteDeps): void {
54
108
  provider = normalizeWorkspaceInsightsQueryFilter(providerRaw, "provider");
55
109
  model = normalizeWorkspaceInsightsQueryFilter(modelRaw, "model");
56
110
 
57
- const response = await getWorkspaceInsights(
58
- deps.db,
59
- deps.settings,
60
- {
111
+ const response = await coalesce.run(
112
+ workspaceInsightsCoalesceKey({
61
113
  workspaceId,
62
114
  range: rangeParsed.data,
63
115
  provider,
64
116
  model,
65
- },
66
- observePhase,
117
+ rlsActor: currentSessionRlsActorIdentityKey(),
118
+ }),
119
+ () =>
120
+ getWorkspaceInsights(
121
+ deps.db,
122
+ deps.settings,
123
+ {
124
+ workspaceId,
125
+ range: rangeParsed.data,
126
+ provider,
127
+ model,
128
+ },
129
+ observePhase,
130
+ ),
67
131
  );
68
132
  c.header("cache-control", "private, no-store");
69
133
  const result = c.json(WorkspaceInsightsResponse.parse(response));
@@ -18,6 +18,8 @@ import {
18
18
  prepareKnowledgeFile,
19
19
  prepareKnowledgeSave,
20
20
  searchKnowledgeEntries,
21
+ KnowledgeVectorFundingError,
22
+ KnowledgeVectorQueryRejectedError,
21
23
  hasPermission,
22
24
  knowledgeContextForAccess,
23
25
  requireAccessGrantAuthorization,
@@ -47,6 +49,8 @@ import type { Context, Hono } from "hono";
47
49
  import { HTTPException } from "hono/http-exception";
48
50
  import { z } from "zod";
49
51
  import { withAccessGrantSessionRlsContext } from "../access-grant-rls";
52
+ import { ApiHttpError } from "../http/api-error";
53
+ import { userContentSignedGetUrlOptions } from "../http/user-content";
50
54
 
51
55
  const ReadSettings = z
52
56
  .object({
@@ -63,6 +67,18 @@ const WriteSettings = ReadSettings.extend({
63
67
  const Id = z.uuid();
64
68
  function knowledgeHttpError(error: unknown): never {
65
69
  if (error instanceof HTTPException) throw error;
70
+ if (error instanceof KnowledgeVectorFundingError)
71
+ throw new ApiHttpError(402, {
72
+ code: "payment_required",
73
+ message: error.message,
74
+ details: { code: error.code, keywordAvailable: true },
75
+ });
76
+ if (error instanceof KnowledgeVectorQueryRejectedError)
77
+ throw new ApiHttpError(error.code === "quota" ? 429 : 422, {
78
+ code: error.code === "quota" ? "limit_exceeded" : "validation_failed",
79
+ message: error.message,
80
+ details: { code: error.code, keywordAvailable: true },
81
+ });
66
82
  if (error instanceof z.ZodError)
67
83
  throw new HTTPException(422, {
68
84
  message: error.issues[0]?.message ?? "Invalid Knowledge request",
@@ -166,7 +182,10 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
166
182
  ))
167
183
  )
168
184
  throw new HTTPException(410, { message: "Original file bytes are unavailable" });
169
- const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
185
+ const signed = await deps.objectStorage.createGetUrl({
186
+ key: file.objectKey,
187
+ ...userContentSignedGetUrlOptions(file.contentType, file.filename),
188
+ });
170
189
  await recordAuditEvent(deps.db, {
171
190
  accountId: context.accountId,
172
191
  workspaceId: context.workspaceId,
@@ -216,6 +235,8 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
216
235
  context,
217
236
  KnowledgeSavePreparationRequest.parse(await c.req.json()),
218
237
  () => deps.getDocumentServices().embedder,
238
+ undefined,
239
+ deps.settings,
219
240
  ),
220
241
  );
221
242
  }),
@@ -228,6 +249,7 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
228
249
  context,
229
250
  KnowledgeEntryListRequest.parse(await c.req.json()),
230
251
  () => deps.getDocumentServices().embedder,
252
+ deps.settings,
231
253
  ),
232
254
  ),
233
255
  ),
@@ -270,6 +292,7 @@ export function registerKnowledgeRoutes(app: Hono, deps: ApiRouteDeps) {
270
292
  ...(query.limit ? { limit: Number(query.limit) } : {}),
271
293
  }),
272
294
  () => deps.getDocumentServices().embedder,
295
+ deps.settings,
273
296
  ),
274
297
  );
275
298
  }),
@@ -64,7 +64,10 @@ import { sql } from "drizzle-orm";
64
64
  import type { Context, Hono } from "hono";
65
65
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
66
66
  import { HTTPException } from "hono/http-exception";
67
+ import { SIGNUP_ATTRIBUTION_AUTH_FIELD } from "../auth/signup-funnel-metrics";
67
68
  import { ApiHttpError } from "../http/api-error";
69
+ import { trustedRequestSourceRateLimitKey } from "../http/request-source";
70
+ import { ManagedAuthEmailThrottleError } from "../auth/managed-auth-rate-limits";
68
71
  import { z } from "zod";
69
72
 
70
73
  const ManagedAuthSocialStartReceipt = z
@@ -313,7 +316,7 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
313
316
  }),
314
317
  );
315
318
  } catch (error) {
316
- throwHttp(error);
319
+ throwHttp(error, context);
317
320
  }
318
321
  });
319
322
 
@@ -392,6 +395,9 @@ export function registerManagedAuthSessionSetRoutes(app: Hono, deps: ApiRouteDep
392
395
  expectedGeneration: body.expectedGeneration,
393
396
  expectedActorEpoch: actorEpoch,
394
397
  },
398
+ // Content-free acquisition metrics read this from the OAuth
399
+ // state if the callback creates a new account.
400
+ ...(body.attribution ? { [SIGNUP_ATTRIBUTION_AUTH_FIELD]: body.attribution } : {}),
395
401
  },
396
402
  },
397
403
  headers: isolatedManagedAuthHeaders(context.req.raw),
@@ -1009,11 +1015,9 @@ function digest(deps: ApiRouteDeps, value: unknown): string {
1009
1015
  }
1010
1016
 
1011
1017
  function loginTransactionClientScope(context: Context, deps: ApiRouteDeps): string {
1012
- const forwarded = context.req.header("x-forwarded-for")?.split(",")[0]?.trim();
1013
- const address = forwarded || context.req.header("x-real-ip")?.trim() || "unknown";
1014
1018
  return digest(deps, {
1015
1019
  purpose: "managed-auth-login-transaction-rate-limit",
1016
- client: address.slice(0, 128),
1020
+ client: trustedRequestSourceRateLimitKey(context, deps.settings),
1017
1021
  });
1018
1022
  }
1019
1023
 
@@ -1107,7 +1111,7 @@ function appendCookies(context: Context, cookies: readonly string[]): void {
1107
1111
  for (const cookie of cookies) context.header("set-cookie", cookie, { append: true });
1108
1112
  }
1109
1113
 
1110
- function throwHttp(error: unknown): never {
1114
+ function throwHttp(error: unknown, context?: Context): never {
1111
1115
  if (error instanceof HTTPException) throw error;
1112
1116
  if (error instanceof ManagedAuthSessionSetGenerationConflictError) {
1113
1117
  throw managedAuthApiError(409, "generation_conflict", { cause: error, retryable: true });
@@ -1127,6 +1131,16 @@ function throwHttp(error: unknown): never {
1127
1131
  if (error instanceof ManagedAuthActorMutationInFlightError) {
1128
1132
  throw managedAuthApiError(409, "actor_mutation_in_flight", { cause: error, retryable: true });
1129
1133
  }
1134
+ if (error instanceof ManagedAuthEmailThrottleError) {
1135
+ // A per-email window lasts 15 minutes to an hour; tell the client when,
1136
+ // unlike the short login-transaction backoff.
1137
+ context?.header("Retry-After", String(error.retryAfterSeconds));
1138
+ throw managedAuthApiError(429, "login_transaction_rate_limited", {
1139
+ cause: error,
1140
+ retryable: true,
1141
+ retryAfterSeconds: error.retryAfterSeconds,
1142
+ });
1143
+ }
1130
1144
  if (error instanceof ManagedAuthLoginTransactionRateLimitError) {
1131
1145
  throw managedAuthApiError(429, "login_transaction_rate_limited", {
1132
1146
  cause: error,
@@ -1153,6 +1167,7 @@ function managedAuthApiError(
1153
1167
  cause?: unknown;
1154
1168
  retryable?: boolean;
1155
1169
  outcomeUnknown?: boolean;
1170
+ retryAfterSeconds?: number;
1156
1171
  } = {},
1157
1172
  ): ApiHttpError {
1158
1173
  ManagedAuthSessionSetErrorCode.parse(code);
@@ -1177,7 +1192,12 @@ function managedAuthApiError(
1177
1192
  message: code,
1178
1193
  retryable: options.retryable ?? false,
1179
1194
  ...(options.outcomeUnknown === undefined ? {} : { outcomeUnknown: options.outcomeUnknown }),
1180
- details: { managedAuthCode: code },
1195
+ details: {
1196
+ managedAuthCode: code,
1197
+ ...(options.retryAfterSeconds === undefined
1198
+ ? {}
1199
+ : { retryAfterSeconds: options.retryAfterSeconds }),
1200
+ },
1181
1201
  });
1182
1202
  if (options.cause !== undefined) (error as Error & { cause?: unknown }).cause = options.cause;
1183
1203
  return error;
@@ -24,7 +24,9 @@ import {
24
24
  organizationUserSetupTokenDigest,
25
25
  selfServiceOrganizationSetupRequestFingerprint,
26
26
  } from "../auth/organization-user-setup";
27
+ import { recordOrganizationSetupOutcome } from "../auth/signup-funnel-metrics";
27
28
  import { hashManagedAuthPassword } from "../auth/managed-auth";
29
+ import { trustedRequestSourceRateLimitKey } from "../http/request-source";
28
30
 
29
31
  export type ManagedOnboardingRouteOptions = {
30
32
  accountSetupLimiter?: { take(key: string): boolean };
@@ -59,6 +61,7 @@ export function registerManagedOnboardingRoutes(
59
61
  await context.req.json().catch(() => null),
60
62
  );
61
63
  if (!parsed.success) {
64
+ recordOrganizationSetupOutcome(deps.observability, "failed");
62
65
  throw new HTTPException(422, {
63
66
  message: "invalid organization setup request",
64
67
  });
@@ -69,18 +72,24 @@ export function registerManagedOnboardingRoutes(
69
72
  authUserId: session.user.id,
70
73
  organizationName,
71
74
  });
72
- return context.json(
73
- CompleteSelfServiceOrganizationSetupResponse.parse(
74
- await completeSelfServiceOrganizationSetup(deps.db, {
75
- authUserId: session.user.id,
76
- actorSubjectId: `user:${session.user.id}`,
77
- organizationName,
78
- operationId: parsed.data.operationId,
79
- requestFingerprint,
80
- }),
81
- ),
75
+ const completed = CompleteSelfServiceOrganizationSetupResponse.parse(
76
+ await completeSelfServiceOrganizationSetup(deps.db, {
77
+ authUserId: session.user.id,
78
+ actorSubjectId: `user:${session.user.id}`,
79
+ organizationName,
80
+ operationId: parsed.data.operationId,
81
+ requestFingerprint,
82
+ trialCreditsEnabled:
83
+ deps.settings.productAccessMode === "managed" &&
84
+ deps.settings.verifiedSignupTrialCreditsEnabled,
85
+ }),
82
86
  );
87
+ // An idempotent replay of the same operation returns the same committed
88
+ // setup and is counted again; clients retry only on an ambiguous response.
89
+ recordOrganizationSetupOutcome(deps.observability, "created");
90
+ return context.json(completed);
83
91
  } catch (error) {
92
+ recordOrganizationSetupOutcome(deps.observability, "failed");
84
93
  const sqlState = nestedPostgresSqlState(error);
85
94
  if (sqlState === "22023") {
86
95
  throw new HTTPException(422, {
@@ -105,7 +114,7 @@ export function registerManagedOnboardingRoutes(
105
114
  if (deps.settings.productAccessMode !== "managed" || !deps.managedAuth) {
106
115
  throw new HTTPException(404, { message: "account setup is unavailable" });
107
116
  }
108
- enforceAccountSetupRateLimit(context, accountSetupLimiter);
117
+ enforceAccountSetupRateLimit(context, deps, accountSetupLimiter);
109
118
  const parsed = PreviewOrganizationUserSetupRequest.safeParse(
110
119
  await context.req.json().catch(() => null),
111
120
  );
@@ -126,7 +135,7 @@ export function registerManagedOnboardingRoutes(
126
135
  if (deps.settings.productAccessMode !== "managed" || !deps.managedAuth) {
127
136
  throw new HTTPException(404, { message: "account setup is unavailable" });
128
137
  }
129
- enforceAccountSetupRateLimit(context, accountSetupLimiter);
138
+ enforceAccountSetupRateLimit(context, deps, accountSetupLimiter);
130
139
  const parsed = CompleteOrganizationUserSetupRequest.safeParse(
131
140
  await context.req.json().catch(() => null),
132
141
  );
@@ -189,11 +198,12 @@ export function registerManagedOnboardingRoutes(
189
198
 
190
199
  function enforceAccountSetupRateLimit(
191
200
  context: Context,
201
+ deps: ApiRouteDeps,
192
202
  limiter: { take(key: string): boolean },
193
203
  ): void {
194
204
  let allowed = false;
195
205
  try {
196
- allowed = limiter.take(accountSetupClientKey(context));
206
+ allowed = limiter.take(trustedRequestSourceRateLimitKey(context, deps.settings));
197
207
  } catch {
198
208
  // A public credential-setting endpoint must fail closed if its abuse gate
199
209
  // cannot make a decision.
@@ -203,12 +213,6 @@ function enforceAccountSetupRateLimit(
203
213
  }
204
214
  }
205
215
 
206
- function accountSetupClientKey(context: Context): string {
207
- const forwarded = context.req.header("x-forwarded-for")?.split(",")[0]?.trim();
208
- const address = forwarded || context.req.header("x-real-ip")?.trim() || "unknown";
209
- return address.slice(0, 128);
210
- }
211
-
212
216
  /**
213
217
  * Bounded application-tier protection for the public setup bearer endpoint.
214
218
  * The global bucket prevents spoofed/high-cardinality client keys from
@@ -237,6 +237,9 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
237
237
  subjectId,
238
238
  subjectLabel: session.user.email || session.user.name,
239
239
  ...payload,
240
+ trialCreditsEnabled:
241
+ deps.settings.productAccessMode === "managed" &&
242
+ deps.settings.verifiedSignupTrialCreditsEnabled,
240
243
  }),
241
244
  ),
242
245
  201,
@@ -12,7 +12,11 @@ import {
12
12
  requireAccessGrantAuthorization,
13
13
  resolveWorkspaceCatalogSettings,
14
14
  } from "@opengeni/core";
15
- import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
15
+ import {
16
+ recordWorkspaceUsage,
17
+ requireLimit,
18
+ resolveScheduledTaskPreflightModel,
19
+ } from "@opengeni/core";
16
20
  import type { ApiRouteDeps } from "@opengeni/core";
17
21
  import {
18
22
  captureScheduledTaskRestoreState,
@@ -249,7 +253,10 @@ export function registerScheduledTaskRoutes(app: Hono, deps: ApiRouteDeps): void
249
253
  workspaceId,
250
254
  action: "agent_run:create",
251
255
  quantity: 1,
252
- model: task.agentConfig.model ?? catalogSettings.openaiModel,
256
+ // A model-less task is checked against the model its occurrence
257
+ // will run (a connected subscription, the credits default, or the
258
+ // deployment default), not always the deployment default.
259
+ model: await resolveScheduledTaskPreflightModel(db, catalogSettings, task),
253
260
  },
254
261
  );
255
262
  }