@opengeni/api-router 4.1.0 → 4.1.2-canary.36199476632001
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +7 -1
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth.d.ts +37 -1
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/{chunk-24C5LPNE.js → chunk-QDIBJSM4.js} +3277 -1509
- package/dist/{chunk-24C5LPNE.js.map → chunk-QDIBJSM4.js.map} +1 -1
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-source.d.ts +41 -3
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.js +90 -31
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +30 -0
- package/dist/integrations/slack-bot.d.ts +48 -2
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/documents.d.ts +2 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +12 -0
- package/dist/mcp/server.d.ts +35 -0
- package/dist/model-catalog.d.ts +9 -3
- package/dist/observability.d.ts +3 -1
- package/dist/routes/billing.d.ts +27 -0
- package/dist/routes/browser-sessions.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +42 -0
- package/dist/routes/computer-sessions.d.ts +2 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/insights.d.ts +26 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox-file-artifacts.d.ts +17 -1
- package/package.json +19 -19
- package/src/app.ts +124 -12
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth.ts +137 -17
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/http/auth.ts +6 -0
- package/src/http/cors.ts +37 -5
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-source.ts +154 -13
- package/src/http/sse.ts +36 -16
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +22 -8
- package/src/integrations/atlassian.ts +7 -3
- package/src/integrations/fiken.ts +9 -3
- package/src/integrations/github-app-connect.ts +12 -2
- package/src/integrations/google-drive.ts +7 -3
- package/src/integrations/oauth-client.ts +99 -12
- package/src/integrations/personal-github.ts +4 -2
- package/src/integrations/provider-oauth.ts +10 -3
- package/src/integrations/slack-bot.ts +43 -1
- package/src/integrations/social-oauth.ts +7 -2
- package/src/mcp/documents.ts +9 -2
- package/src/mcp/files.ts +5 -1
- package/src/mcp/first-party-tool-permissions.ts +6 -0
- package/src/mcp/knowledge-entries.ts +15 -2
- package/src/mcp/server.ts +191 -17
- package/src/mcp-oauth.ts +4 -6
- package/src/model-catalog.ts +30 -3
- package/src/observability.ts +15 -1
- package/src/routes/billing.ts +70 -9
- package/src/routes/browser-sessions.ts +169 -0
- package/src/routes/client-errors.ts +182 -0
- package/src/routes/company-brain.ts +4 -0
- package/src/routes/computer-sessions.ts +37 -9
- package/src/routes/connections.ts +7 -1
- package/src/routes/documents.ts +6 -1
- package/src/routes/editable-artifacts.ts +2 -1
- package/src/routes/enrollments.ts +2 -13
- package/src/routes/files.ts +17 -3
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +122 -49
- package/src/routes/insights.ts +70 -6
- package/src/routes/knowledge.ts +24 -1
- package/src/routes/managed-auth-session-sets.ts +26 -6
- package/src/routes/managed-onboarding.ts +23 -19
- package/src/routes/organization-memberships.ts +3 -0
- package/src/routes/scheduled-tasks.ts +9 -2
- package/src/routes/sessions.ts +89 -0
- package/src/routes/workspace-artifacts.ts +9 -0
- package/src/routes/workspace-state.ts +4 -0
- package/src/routes/workspaces.ts +38 -15
- package/src/sandbox/auth-callout.ts +62 -11
- package/src/sandbox/channel-a.ts +8 -0
- package/src/sandbox/viewer.ts +16 -3
- package/src/sandbox-file-artifacts.ts +37 -26
- package/src/site-uploads.ts +8 -1
- package/src/workspace-tool-gateway.ts +1 -0
package/src/routes/github.ts
CHANGED
|
@@ -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
|
|
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
|
-
"&": "&",
|
|
1106
|
-
"<": "<",
|
|
1107
|
-
">": ">",
|
|
1108
|
-
'"': """,
|
|
1109
|
-
"'": "'",
|
|
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
|
+
}
|
package/src/routes/insights.ts
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
-
|
|
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));
|
package/src/routes/knowledge.ts
CHANGED
|
@@ -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({
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
}
|