@opengeni/api-router 0.14.4 → 0.15.4
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 +6 -1
- package/dist/app.js +5 -1
- package/dist/auth/managed-auth.d.ts +2 -2
- package/dist/{chunk-36PW33ND.js → chunk-ICRZC3JH.js} +11438 -8894
- package/dist/chunk-ICRZC3JH.js.map +1 -0
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/integrations/slack-bot.d.ts +30 -1
- package/dist/integrations/slack-interactions.d.ts +47 -0
- package/dist/integrations/social-api.d.ts +91 -0
- package/dist/integrations/social-oauth.d.ts +84 -0
- package/dist/routes/workspace-artifacts.d.ts +3 -0
- package/package.json +10 -10
- package/src/app.ts +115 -28
- package/src/http/auth.ts +9 -1
- package/src/index.ts +17 -4
- package/src/integrations/oauth-client.ts +27 -10
- package/src/integrations/slack-bot.ts +100 -14
- package/src/integrations/slack-interactions.ts +1033 -0
- package/src/integrations/social-api.ts +504 -0
- package/src/integrations/social-oauth.ts +745 -0
- package/src/mcp/server.ts +383 -0
- package/src/routes/sessions.ts +23 -10
- package/src/routes/social.ts +67 -2
- package/src/routes/workspace-artifacts.ts +274 -0
- package/dist/chunk-36PW33ND.js.map +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type Settings } from "@opengeni/config";
|
|
2
|
+
import { OAuthStartResponse, type SocialConnection, type SocialOAuthProviderId, type SocialOAuthStartRequest } from "@opengeni/contracts";
|
|
3
|
+
import type { Observability } from "@opengeni/observability";
|
|
4
|
+
import { type Database } from "@opengeni/db";
|
|
5
|
+
export declare const SOCIAL_USER_AGENT = "opengeni:social-connector:v0.1.0 (self-hosted)";
|
|
6
|
+
export declare const SOCIAL_TIMEOUT_MS = 10000;
|
|
7
|
+
/** Token-endpoint failure that carries enough to tell invalid_grant from a blip. */
|
|
8
|
+
export declare class SocialTokenRequestError extends Error {
|
|
9
|
+
readonly status: number | null;
|
|
10
|
+
readonly oauthError: string | null;
|
|
11
|
+
constructor(message: string, status: number | null, oauthError: string | null);
|
|
12
|
+
/** True only for definitive authorization-server rejections of the grant. */
|
|
13
|
+
get definitive(): boolean;
|
|
14
|
+
}
|
|
15
|
+
type SocialProviderDefinition = {
|
|
16
|
+
id: SocialOAuthProviderId;
|
|
17
|
+
authorizationEndpoint: string;
|
|
18
|
+
tokenEndpoint: string;
|
|
19
|
+
defaultScopes: string[];
|
|
20
|
+
pkce: boolean;
|
|
21
|
+
extraAuthorizeParams: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
export declare const SOCIAL_OAUTH_PROVIDERS: Record<SocialOAuthProviderId, SocialProviderDefinition>;
|
|
24
|
+
export type SocialCredentialBundle = {
|
|
25
|
+
provider: SocialOAuthProviderId;
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
tokenType: string;
|
|
29
|
+
expiresAt?: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Provider-transport seam (Slack-connector pattern): production always goes
|
|
34
|
+
* through pinnedFetch; tests inject an in-process provider to exercise the
|
|
35
|
+
* full callback/refresh/tool loop functionally.
|
|
36
|
+
*/
|
|
37
|
+
export type SocialProviderFetch = (url: string, init: RequestInit, label: string) => Promise<Response>;
|
|
38
|
+
type SocialOAuthDeps = {
|
|
39
|
+
db: Database;
|
|
40
|
+
settings: Settings;
|
|
41
|
+
observability?: Observability | undefined;
|
|
42
|
+
providerFetch?: SocialProviderFetch | undefined;
|
|
43
|
+
};
|
|
44
|
+
export type SocialOAuthStartContext = {
|
|
45
|
+
accountId: string;
|
|
46
|
+
workspaceId: string;
|
|
47
|
+
subjectId: string;
|
|
48
|
+
requestUrl: string;
|
|
49
|
+
payload: SocialOAuthStartRequest;
|
|
50
|
+
};
|
|
51
|
+
export declare function socialOAuthClientFor(settings: Settings, provider: SocialOAuthProviderId): {
|
|
52
|
+
clientId: string;
|
|
53
|
+
clientSecret?: string | undefined;
|
|
54
|
+
};
|
|
55
|
+
export declare function socialOAuthRedirectUri(settings: Settings, requestUrl: string): string;
|
|
56
|
+
export declare function startSocialOAuth(deps: SocialOAuthDeps, context: SocialOAuthStartContext): Promise<OAuthStartResponse>;
|
|
57
|
+
export declare function completeSocialOAuthCallback(deps: SocialOAuthDeps, input: {
|
|
58
|
+
code?: string | undefined;
|
|
59
|
+
state?: string | undefined;
|
|
60
|
+
error?: string | undefined;
|
|
61
|
+
requestUrl: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
redirectTo: string;
|
|
64
|
+
}>;
|
|
65
|
+
/**
|
|
66
|
+
* Resolves a usable access token for a social connection, refreshing (and
|
|
67
|
+
* persisting the rotated bundle) when the stored token is near expiry. Marks
|
|
68
|
+
* the connection needs_reauth and throws when refresh is impossible so agents
|
|
69
|
+
* surface an actionable error instead of opaque 401s.
|
|
70
|
+
*/
|
|
71
|
+
export declare function freshSocialAccessToken(deps: SocialOAuthDeps, ref: {
|
|
72
|
+
workspaceId: string;
|
|
73
|
+
connectionId: string;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
connection: SocialConnection;
|
|
76
|
+
bundle: SocialCredentialBundle;
|
|
77
|
+
}>;
|
|
78
|
+
export declare function markNeedsReauth(deps: SocialOAuthDeps, ref: {
|
|
79
|
+
workspaceId: string;
|
|
80
|
+
connectionId: string;
|
|
81
|
+
}): Promise<void>;
|
|
82
|
+
export declare function parseSocialCredentialBundle(raw: string): SocialCredentialBundle;
|
|
83
|
+
export declare function socialTokenNeedsRefresh(bundle: SocialCredentialBundle, now: Date): boolean;
|
|
84
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.4",
|
|
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": {
|
|
@@ -44,17 +44,17 @@
|
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
45
|
"@opengeni/agent-proto": "^0.3.0",
|
|
46
46
|
"@opengeni/codex": "^0.2.9",
|
|
47
|
-
"@opengeni/config": "^0.
|
|
48
|
-
"@opengeni/contracts": "^0.
|
|
49
|
-
"@opengeni/core": "^0.
|
|
50
|
-
"@opengeni/db": "^0.
|
|
51
|
-
"@opengeni/documents": "^0.2.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.4.
|
|
47
|
+
"@opengeni/config": "^0.9.1",
|
|
48
|
+
"@opengeni/contracts": "^0.28.1",
|
|
49
|
+
"@opengeni/core": "^0.16.1",
|
|
50
|
+
"@opengeni/db": "^0.18.1",
|
|
51
|
+
"@opengeni/documents": "^0.2.63",
|
|
52
|
+
"@opengeni/events": "^0.3.54",
|
|
53
|
+
"@opengeni/github": "^0.4.13",
|
|
54
54
|
"@opengeni/network": "^0.1.1",
|
|
55
55
|
"@opengeni/observability": "^0.3.0",
|
|
56
|
-
"@opengeni/runtime": "^0.
|
|
57
|
-
"@opengeni/storage": "^0.2.
|
|
56
|
+
"@opengeni/runtime": "^0.15.3",
|
|
57
|
+
"@opengeni/storage": "^0.2.50",
|
|
58
58
|
"@temporalio/client": "^1.17.0",
|
|
59
59
|
"better-auth": "^1.6.14",
|
|
60
60
|
"hono": "^4.12.18",
|
package/src/app.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { createObjectStorage } from "@opengeni/storage";
|
|
|
27
27
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
28
28
|
import { Hono } from "hono";
|
|
29
29
|
import { bodyLimit } from "hono/body-limit";
|
|
30
|
+
import { compress } from "hono/compress";
|
|
30
31
|
import { cors } from "hono/cors";
|
|
31
32
|
import { HTTPException } from "hono/http-exception";
|
|
32
33
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
@@ -68,11 +69,13 @@ import { registerSocialRoutes } from "./routes/social";
|
|
|
68
69
|
import { registerWorkspaceRoutes } from "./routes/workspaces";
|
|
69
70
|
import { registerWorkspaceInstructionPolicyRoutes } from "./routes/workspace-instruction-policies";
|
|
70
71
|
import { registerWorkspaceStateRoutes } from "./routes/workspace-state";
|
|
72
|
+
import { registerWorkspaceArtifactRoutes } from "./routes/workspace-artifacts";
|
|
71
73
|
import { registerPreferenceRegistryRoutes } from "./routes/preference-registry";
|
|
72
74
|
import { registerInsightsRoutes } from "./routes/insights";
|
|
73
75
|
import { registerTranscriptionRoutes } from "./routes/transcriptions";
|
|
74
76
|
import { projectClientModel } from "./model-catalog";
|
|
75
77
|
import { createTranscriptionService } from "./transcription/service";
|
|
78
|
+
import { registerSlackInteractionRoutes } from "./integrations/slack-interactions";
|
|
76
79
|
|
|
77
80
|
export type {
|
|
78
81
|
ApiRouteDeps,
|
|
@@ -104,6 +107,13 @@ export function apiRequestBodyLimitBytes(settings: { voiceInputMaxSizeBytes: num
|
|
|
104
107
|
const API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
|
|
105
108
|
|
|
106
109
|
export function createApp(deps: AppDependencies): Hono {
|
|
110
|
+
return createAppComposition(deps).app;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createAppComposition(deps: AppDependencies): {
|
|
114
|
+
app: Hono;
|
|
115
|
+
routeDeps: ApiRouteDeps;
|
|
116
|
+
} {
|
|
107
117
|
const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
|
|
108
118
|
const objectStorage =
|
|
109
119
|
deps.objectStorage === undefined ? createObjectStorage(deps.settings) : deps.objectStorage;
|
|
@@ -186,28 +196,34 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
186
196
|
await next();
|
|
187
197
|
});
|
|
188
198
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
199
|
+
const corsHeaders = {
|
|
200
|
+
allowHeaders: [
|
|
201
|
+
"Accept",
|
|
202
|
+
"Authorization",
|
|
203
|
+
"Content-Type",
|
|
204
|
+
"X-OpenGeni-Access-Key",
|
|
205
|
+
"X-OpenGeni-Api-Contract",
|
|
206
|
+
"X-OpenGeni-Correlation-Id",
|
|
207
|
+
"X-OpenGeni-Subject",
|
|
208
|
+
],
|
|
209
|
+
exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
|
|
210
|
+
};
|
|
211
|
+
const publicApiCors = cors({ ...corsHeaders, credentials: false, origin: "*" });
|
|
212
|
+
const credentialedCors = cors({
|
|
213
|
+
...corsHeaders,
|
|
214
|
+
credentials: true,
|
|
215
|
+
origin: (origin) =>
|
|
216
|
+
allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin) ? origin : null,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
app.use("*", (c, next) => {
|
|
220
|
+
const origin = c.req.header("origin");
|
|
221
|
+
const middleware =
|
|
222
|
+
origin && allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin)
|
|
223
|
+
? credentialedCors
|
|
224
|
+
: publicApiCors;
|
|
225
|
+
return middleware(c, next);
|
|
226
|
+
});
|
|
211
227
|
|
|
212
228
|
app.use(
|
|
213
229
|
"*",
|
|
@@ -218,6 +234,20 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
218
234
|
}),
|
|
219
235
|
);
|
|
220
236
|
|
|
237
|
+
// Large catalog, capture, and session-list responses are on the browser's
|
|
238
|
+
// critical path. Compress JSON at the API boundary while leaving SSE and
|
|
239
|
+
// other streaming transports byte-for-byte unchanged.
|
|
240
|
+
const compressJson = compress({
|
|
241
|
+
encoding: "gzip",
|
|
242
|
+
contentTypeFilter: /^application\/json(?:;|$)/i,
|
|
243
|
+
});
|
|
244
|
+
app.use("/v1/*", async (c, next) => {
|
|
245
|
+
await compressJson(c, next);
|
|
246
|
+
if (/^application\/json(?:;|$)/i.test(c.res.headers.get("content-type") ?? "")) {
|
|
247
|
+
c.res.headers.set("vary", appendVary(c.res.headers.get("vary"), "Accept-Encoding"));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
221
251
|
app.use("*", async (c, next) => {
|
|
222
252
|
const url = new URL(c.req.url);
|
|
223
253
|
const route = routeLabel(url.pathname);
|
|
@@ -377,6 +407,7 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
377
407
|
},
|
|
378
408
|
productAccessMode: deps.settings.productAccessMode,
|
|
379
409
|
auth: clientAuthConfig(deps.settings),
|
|
410
|
+
analytics: clientAnalyticsConfig(deps.settings),
|
|
380
411
|
// Channel-A structured services (P4.4) ride exec/readFile/createEditor,
|
|
381
412
|
// available on every real backend; `none` has no box so they are all off.
|
|
382
413
|
// Per-session availability is still negotiated on /stream-capabilities.
|
|
@@ -392,7 +423,9 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
392
423
|
boundedRequest = await boundedMcpRequest(c.req.raw);
|
|
393
424
|
} catch (error) {
|
|
394
425
|
if (error instanceof McpPayloadTooLargeError) {
|
|
395
|
-
throw new HTTPException(413, {
|
|
426
|
+
throw new HTTPException(413, {
|
|
427
|
+
message: "MCP request body exceeds the safety limit",
|
|
428
|
+
});
|
|
396
429
|
}
|
|
397
430
|
throw error;
|
|
398
431
|
}
|
|
@@ -414,7 +447,9 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
414
447
|
throw new HTTPException(404, { message: "session not found" });
|
|
415
448
|
}
|
|
416
449
|
if (error instanceof SessionAuthorizationUnavailableError) {
|
|
417
|
-
throw new HTTPException(503, {
|
|
450
|
+
throw new HTTPException(503, {
|
|
451
|
+
message: "session authorization is unavailable",
|
|
452
|
+
});
|
|
418
453
|
}
|
|
419
454
|
throw error;
|
|
420
455
|
}
|
|
@@ -422,10 +457,15 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
422
457
|
let toolspace: Awaited<ReturnType<typeof prepareToolspaceMcpSurface>> = null;
|
|
423
458
|
if (toolspaceGrant) {
|
|
424
459
|
try {
|
|
425
|
-
toolspace = await prepareToolspaceMcpSurface({
|
|
460
|
+
toolspace = await prepareToolspaceMcpSurface({
|
|
461
|
+
deps: routeDeps,
|
|
462
|
+
grant,
|
|
463
|
+
});
|
|
426
464
|
} catch (error) {
|
|
427
465
|
if (error instanceof McpPayloadTooLargeError) {
|
|
428
|
-
throw new HTTPException(413, {
|
|
466
|
+
throw new HTTPException(413, {
|
|
467
|
+
message: "MCP tool list exceeds the safety limit",
|
|
468
|
+
});
|
|
429
469
|
}
|
|
430
470
|
throw error;
|
|
431
471
|
}
|
|
@@ -458,6 +498,7 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
458
498
|
registerInsightsRoutes(app, routeDeps);
|
|
459
499
|
registerWorkspaceInstructionPolicyRoutes(app, routeDeps);
|
|
460
500
|
registerWorkspaceStateRoutes(app, routeDeps);
|
|
501
|
+
registerWorkspaceArtifactRoutes(app, routeDeps);
|
|
461
502
|
registerPreferenceRegistryRoutes(app, routeDeps);
|
|
462
503
|
registerSocialRoutes(app, routeDeps);
|
|
463
504
|
registerConnectionRoutes(app, routeDeps);
|
|
@@ -472,6 +513,7 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
472
513
|
registerScheduledTaskRoutes(app, routeDeps);
|
|
473
514
|
registerCodexRoutes(app, routeDeps);
|
|
474
515
|
registerTranscriptionRoutes(app, routeDeps);
|
|
516
|
+
registerSlackInteractionRoutes(app, routeDeps);
|
|
475
517
|
|
|
476
518
|
app.notFound((c) => {
|
|
477
519
|
if (!new URL(c.req.url).pathname.startsWith("/v1/")) return c.text("Not Found", 404);
|
|
@@ -513,7 +555,18 @@ export function createApp(deps: AppDependencies): Hono {
|
|
|
513
555
|
return c.json(envelope, status as ContentfulStatusCode);
|
|
514
556
|
});
|
|
515
557
|
|
|
516
|
-
return app;
|
|
558
|
+
return { app, routeDeps };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function appendVary(current: string | null, value: string): string {
|
|
562
|
+
const values = (current ?? "")
|
|
563
|
+
.split(",")
|
|
564
|
+
.map((entry) => entry.trim())
|
|
565
|
+
.filter(Boolean);
|
|
566
|
+
if (!values.some((entry) => entry.toLowerCase() === value.toLowerCase())) {
|
|
567
|
+
values.push(value);
|
|
568
|
+
}
|
|
569
|
+
return values.join(", ");
|
|
517
570
|
}
|
|
518
571
|
|
|
519
572
|
async function requireMcpAccessGrant(
|
|
@@ -559,6 +612,31 @@ function clientAuthConfig(settings: AppDependencies["settings"]) {
|
|
|
559
612
|
return { mode: "none" as const };
|
|
560
613
|
}
|
|
561
614
|
|
|
615
|
+
function clientAnalyticsConfig(settings: AppDependencies["settings"]) {
|
|
616
|
+
if (!settings.analyticsEnabled) {
|
|
617
|
+
return { consentRequired: true, providers: {} };
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
consentRequired: settings.analyticsConsentRequired,
|
|
621
|
+
providers: {
|
|
622
|
+
...(settings.analyticsReoClientId
|
|
623
|
+
? { reo: { clientId: settings.analyticsReoClientId } }
|
|
624
|
+
: {}),
|
|
625
|
+
...(settings.analyticsPosthogProjectKey && settings.analyticsPosthogHost
|
|
626
|
+
? {
|
|
627
|
+
posthog: {
|
|
628
|
+
projectKey: settings.analyticsPosthogProjectKey,
|
|
629
|
+
host: settings.analyticsPosthogHost,
|
|
630
|
+
},
|
|
631
|
+
}
|
|
632
|
+
: {}),
|
|
633
|
+
...(settings.analyticsGa4MeasurementId
|
|
634
|
+
? { ga4: { measurementId: settings.analyticsGa4MeasurementId } }
|
|
635
|
+
: {}),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
562
640
|
function structuredServicesHint(backend: string): {
|
|
563
641
|
fileSystem: boolean;
|
|
564
642
|
git: boolean;
|
|
@@ -694,7 +772,9 @@ async function runReadinessChecks<const Checks extends Readonly<Record<string, R
|
|
|
694
772
|
}
|
|
695
773
|
}),
|
|
696
774
|
);
|
|
697
|
-
const result = Object.fromEntries(entries) as {
|
|
775
|
+
const result = Object.fromEntries(entries) as {
|
|
776
|
+
[Name in keyof Checks]: ReadinessCheckResult;
|
|
777
|
+
};
|
|
698
778
|
return {
|
|
699
779
|
ok: Object.values(result).every((check) => check.ok),
|
|
700
780
|
checks: result,
|
|
@@ -1055,6 +1135,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
|
|
|
1055
1135
|
pattern: /^\/v1\/integrations\/slack\/callback$/,
|
|
1056
1136
|
label: "/v1/integrations/slack/callback",
|
|
1057
1137
|
},
|
|
1138
|
+
{
|
|
1139
|
+
pattern: /^\/v1\/social\/oauth\/callback$/,
|
|
1140
|
+
label: "/v1/social/oauth/callback",
|
|
1141
|
+
},
|
|
1058
1142
|
{
|
|
1059
1143
|
pattern: /^\/v1\/enrollments\/device\/start$/,
|
|
1060
1144
|
label: "/v1/enrollments/device/start",
|
|
@@ -1122,6 +1206,9 @@ export function isApiContractProtectedMutation(method: string, pathname: string)
|
|
|
1122
1206
|
pathname.startsWith("/v1/auth/") ||
|
|
1123
1207
|
pathname.startsWith("/v1/webhooks/") ||
|
|
1124
1208
|
pathname.startsWith("/v1/integrations/oauth/") ||
|
|
1209
|
+
pathname === "/v1/integrations/slack/events" ||
|
|
1210
|
+
pathname === "/v1/integrations/slack/commands" ||
|
|
1211
|
+
pathname === "/v1/integrations/slack/interactions" ||
|
|
1125
1212
|
pathname.startsWith("/v1/github/") ||
|
|
1126
1213
|
pathname === "/v1/enrollments/device/start" ||
|
|
1127
1214
|
pathname === "/v1/enrollments/device/poll" ||
|
package/src/http/auth.ts
CHANGED
|
@@ -50,10 +50,18 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
50
50
|
if (
|
|
51
51
|
path === "/v1/integrations/oauth/callback" ||
|
|
52
52
|
path === "/v1/integrations/oauth/client-metadata.json" ||
|
|
53
|
-
path === "/v1/integrations/slack/callback"
|
|
53
|
+
path === "/v1/integrations/slack/callback" ||
|
|
54
|
+
path === "/v1/integrations/slack/events" ||
|
|
55
|
+
path === "/v1/integrations/slack/commands" ||
|
|
56
|
+
path === "/v1/integrations/slack/interactions"
|
|
54
57
|
) {
|
|
55
58
|
return true;
|
|
56
59
|
}
|
|
60
|
+
// Social OAuth (X / Reddit) browser redirect: exact path only, protected by
|
|
61
|
+
// signed single-use state plus a callback-time grant recheck.
|
|
62
|
+
if (path === "/v1/social/oauth/callback") {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
57
65
|
// Catalog logos are rendered via bare <img> tags, which carry no credentials;
|
|
58
66
|
// the images are public vendor logos, digest-keyed by content, and the route
|
|
59
67
|
// itself enforces the catalog-assets/ prefix lock and extension whitelist.
|
package/src/index.ts
CHANGED
|
@@ -30,10 +30,11 @@ import {
|
|
|
30
30
|
WorkflowExecutionAlreadyStartedError,
|
|
31
31
|
} from "@temporalio/client";
|
|
32
32
|
import type { ScheduleOptions, ScheduleSpec, ScheduleUpdateOptions } from "@temporalio/client";
|
|
33
|
-
import {
|
|
33
|
+
import { createAppComposition, type DocumentIndexClient, type SessionWorkflowClient } from "./app";
|
|
34
34
|
import { observabilityEventLogger } from "./observability";
|
|
35
35
|
import { startAuthCalloutResponder } from "./sandbox/auth-callout";
|
|
36
36
|
import { startHelloIngestion, startMetricsIngestion } from "./sandbox/metrics-ingestion";
|
|
37
|
+
import { startSlackInteractionPump } from "./integrations/slack-interactions";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* A REJECT_DUPLICATE start collides on the deterministic workflowId when the
|
|
@@ -310,7 +311,7 @@ export async function startApi() {
|
|
|
310
311
|
await dbClient.close();
|
|
311
312
|
throw new Error("OpenGeni API startup dependencies were not initialized");
|
|
312
313
|
}
|
|
313
|
-
const app =
|
|
314
|
+
const { app, routeDeps } = createAppComposition({
|
|
314
315
|
settings,
|
|
315
316
|
db: dbClient.db,
|
|
316
317
|
bus,
|
|
@@ -327,6 +328,9 @@ export async function startApi() {
|
|
|
327
328
|
idleTimeout: 255,
|
|
328
329
|
fetch: app.fetch,
|
|
329
330
|
});
|
|
331
|
+
const stopSlackInteractionPump = settings.slackSigningSecret
|
|
332
|
+
? startSlackInteractionPump(routeDeps)
|
|
333
|
+
: undefined;
|
|
330
334
|
// M10 — start the metrics-ingestion consumer (agent heartbeats → DB last-sample
|
|
331
335
|
// + downsampled series), gated on the selfhosted flag. A no-op when disabled.
|
|
332
336
|
let stopMetricsIngestion: (() => void) | undefined;
|
|
@@ -342,8 +346,16 @@ export async function startApi() {
|
|
|
342
346
|
// user), separate from the privileged control-plane bus.
|
|
343
347
|
let authCalloutResponder: ResponderConnection | undefined;
|
|
344
348
|
if (settings.sandboxSelfhostedEnabled) {
|
|
345
|
-
stopMetricsIngestion = startMetricsIngestion({
|
|
346
|
-
|
|
349
|
+
stopMetricsIngestion = startMetricsIngestion({
|
|
350
|
+
db: dbClient.db,
|
|
351
|
+
bus,
|
|
352
|
+
observability,
|
|
353
|
+
});
|
|
354
|
+
stopHelloIngestion = startHelloIngestion({
|
|
355
|
+
db: dbClient.db,
|
|
356
|
+
bus,
|
|
357
|
+
observability,
|
|
358
|
+
});
|
|
347
359
|
observability.info("OpenGeni machine-metrics + hello ingestion consumers started", {});
|
|
348
360
|
|
|
349
361
|
const callout = resolveNatsCalloutConfig(settings);
|
|
@@ -375,6 +387,7 @@ export async function startApi() {
|
|
|
375
387
|
server,
|
|
376
388
|
close: async () => {
|
|
377
389
|
server.stop(true);
|
|
390
|
+
stopSlackInteractionPump?.();
|
|
378
391
|
stopMetricsIngestion?.();
|
|
379
392
|
stopHelloIngestion?.();
|
|
380
393
|
await Promise.allSettled([
|
|
@@ -5,6 +5,7 @@ import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/conf
|
|
|
5
5
|
import {
|
|
6
6
|
OPENGENI_PERSONAL_SLACK_MCP_URL,
|
|
7
7
|
OAuthStartResponse,
|
|
8
|
+
selectCanonicalPersonalSlackConnection,
|
|
8
9
|
type OAuthStartRequest,
|
|
9
10
|
} from "@opengeni/contracts";
|
|
10
11
|
import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
|
|
@@ -161,6 +162,8 @@ export async function startMcpOAuth(
|
|
|
161
162
|
workspaceId: context.workspaceId,
|
|
162
163
|
subjectId: context.subjectId,
|
|
163
164
|
providerDomain,
|
|
165
|
+
mcpUrl,
|
|
166
|
+
personalSlack,
|
|
164
167
|
connectionId: context.payload.connectionId,
|
|
165
168
|
});
|
|
166
169
|
if (context.payload.connectionId && !existing) {
|
|
@@ -847,6 +850,8 @@ async function existingOAuthConnectionForStart(
|
|
|
847
850
|
workspaceId: string;
|
|
848
851
|
subjectId: string;
|
|
849
852
|
providerDomain: string;
|
|
853
|
+
mcpUrl: string;
|
|
854
|
+
personalSlack: boolean;
|
|
850
855
|
connectionId?: string | undefined;
|
|
851
856
|
},
|
|
852
857
|
) {
|
|
@@ -859,20 +864,23 @@ async function existingOAuthConnectionForStart(
|
|
|
859
864
|
);
|
|
860
865
|
return connection?.subjectId === input.subjectId &&
|
|
861
866
|
connection.kind === "oauth2" &&
|
|
862
|
-
connection.providerDomain === input.providerDomain
|
|
867
|
+
connection.providerDomain === input.providerDomain &&
|
|
868
|
+
(!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl)
|
|
863
869
|
? connection
|
|
864
870
|
: null;
|
|
865
871
|
}
|
|
866
872
|
const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
connection.providerDomain === input.providerDomain,
|
|
874
|
-
) ?? null
|
|
873
|
+
const matching = visible.filter(
|
|
874
|
+
(connection) =>
|
|
875
|
+
connection.subjectId === input.subjectId &&
|
|
876
|
+
connection.kind === "oauth2" &&
|
|
877
|
+
connection.providerDomain === input.providerDomain &&
|
|
878
|
+
(!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl),
|
|
875
879
|
);
|
|
880
|
+
if (input.personalSlack) {
|
|
881
|
+
return selectCanonicalPersonalSlackConnection(matching);
|
|
882
|
+
}
|
|
883
|
+
return matching.find((connection) => connection.status === "active") ?? null;
|
|
876
884
|
}
|
|
877
885
|
|
|
878
886
|
function buildAuthorizationUrl(input: {
|
|
@@ -1300,6 +1308,13 @@ function callbackReturnPath(
|
|
|
1300
1308
|
for (const [key, value] of Object.entries(params)) {
|
|
1301
1309
|
url.searchParams.set(key, value);
|
|
1302
1310
|
}
|
|
1311
|
+
// Defense in depth: a `//host` pathname becomes a protocol-relative absolute
|
|
1312
|
+
// Location — an open redirect from the unauthenticated callback.
|
|
1313
|
+
if (url.pathname.startsWith("//")) {
|
|
1314
|
+
const fallback = new URL("/integrations", "https://opengeni.local");
|
|
1315
|
+
fallback.search = url.search;
|
|
1316
|
+
return `${fallback.pathname}${fallback.search}`;
|
|
1317
|
+
}
|
|
1303
1318
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
1304
1319
|
}
|
|
1305
1320
|
|
|
@@ -1357,7 +1372,9 @@ function safeReturnPath(value: string): string {
|
|
|
1357
1372
|
throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
|
|
1358
1373
|
}
|
|
1359
1374
|
const parsed = new URL(value, "https://opengeni.local");
|
|
1360
|
-
|
|
1375
|
+
// `..` segments can normalize back into a `//host` prefix, which browsers
|
|
1376
|
+
// resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
|
|
1377
|
+
if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
|
|
1361
1378
|
throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
|
|
1362
1379
|
}
|
|
1363
1380
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|