@opengeni/api-router 0.15.1 → 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 +1 -0
- package/dist/app.js +3 -1
- package/dist/{chunk-UVL2KH56.js → chunk-ICRZC3JH.js} +10233 -8946
- package/dist/chunk-ICRZC3JH.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-bot.d.ts +9 -1
- package/dist/integrations/slack-interactions.d.ts +18 -1
- package/dist/integrations/social-api.d.ts +91 -0
- package/dist/integrations/social-oauth.d.ts +84 -0
- package/package.json +10 -10
- package/src/app.ts +56 -0
- package/src/http/auth.ts +5 -0
- package/src/integrations/oauth-client.ts +27 -10
- package/src/integrations/slack-bot.ts +20 -3
- package/src/integrations/slack-interactions.ts +204 -27
- package/src/integrations/social-api.ts +504 -0
- package/src/integrations/social-oauth.ts +745 -0
- package/src/mcp/server.ts +174 -0
- package/src/routes/social.ts +67 -2
- package/dist/chunk-UVL2KH56.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -44,7 +44,8 @@ type SlackBotContext = {
|
|
|
44
44
|
};
|
|
45
45
|
export declare class SlackBotProviderError extends Error {
|
|
46
46
|
readonly code: string;
|
|
47
|
-
|
|
47
|
+
readonly retryAfterMs: number | null;
|
|
48
|
+
constructor(code: string, retryAfterMs?: number | null);
|
|
48
49
|
}
|
|
49
50
|
export type SlackBotCredentialVerificationFailureReason = "scope_mismatch" | "identity_mismatch";
|
|
50
51
|
export declare class SlackBotCredentialVerificationError extends HTTPException {
|
|
@@ -86,6 +87,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
86
87
|
name: string;
|
|
87
88
|
isPrivate: boolean;
|
|
88
89
|
isMember: boolean;
|
|
90
|
+
isDirectMessage: boolean;
|
|
89
91
|
isArchived: boolean;
|
|
90
92
|
topic: string;
|
|
91
93
|
purpose: string;
|
|
@@ -100,6 +102,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
100
102
|
name: string;
|
|
101
103
|
isPrivate: boolean;
|
|
102
104
|
isMember: boolean;
|
|
105
|
+
isDirectMessage: boolean;
|
|
103
106
|
isArchived: boolean;
|
|
104
107
|
topic: string;
|
|
105
108
|
purpose: string;
|
|
@@ -115,6 +118,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
115
118
|
name: string;
|
|
116
119
|
isPrivate: boolean;
|
|
117
120
|
isMember: boolean;
|
|
121
|
+
isDirectMessage: boolean;
|
|
118
122
|
isArchived: boolean;
|
|
119
123
|
topic: string;
|
|
120
124
|
purpose: string;
|
|
@@ -153,6 +157,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
153
157
|
name: string;
|
|
154
158
|
isPrivate: boolean;
|
|
155
159
|
isMember: boolean;
|
|
160
|
+
isDirectMessage: boolean;
|
|
156
161
|
isArchived: boolean;
|
|
157
162
|
topic: string;
|
|
158
163
|
purpose: string;
|
|
@@ -207,6 +212,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
207
212
|
name: string;
|
|
208
213
|
isPrivate: boolean;
|
|
209
214
|
isMember: boolean;
|
|
215
|
+
isDirectMessage: boolean;
|
|
210
216
|
isArchived: boolean;
|
|
211
217
|
topic: string;
|
|
212
218
|
purpose: string;
|
|
@@ -237,6 +243,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
237
243
|
name: string;
|
|
238
244
|
isPrivate: boolean;
|
|
239
245
|
isMember: boolean;
|
|
246
|
+
isDirectMessage: boolean;
|
|
240
247
|
isArchived: boolean;
|
|
241
248
|
topic: string;
|
|
242
249
|
purpose: string;
|
|
@@ -267,6 +274,7 @@ export declare class OpenGeniSlackBotClient {
|
|
|
267
274
|
name: string;
|
|
268
275
|
isPrivate: boolean;
|
|
269
276
|
isMember: boolean;
|
|
277
|
+
isDirectMessage: boolean;
|
|
270
278
|
isArchived: boolean;
|
|
271
279
|
topic: string;
|
|
272
280
|
purpose: string;
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { type SlackInstallationRoute, type SlackInteractionTriggerKind } from "@opengeni/db";
|
|
1
|
+
import { type SlackInstallationRoute, type SlackInteractionInboxEntry, type SlackInteractionTriggerKind } from "@opengeni/db";
|
|
2
2
|
import { type ApiRouteDeps } from "@opengeni/core";
|
|
3
3
|
import type { Hono } from "hono";
|
|
4
4
|
export declare const SLACK_INTERACTION_MAX_BODY_BYTES: number;
|
|
5
5
|
export declare const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
|
|
6
6
|
export declare const SLACK_DELIVERY_EVENT_TYPES: readonly ["agent.message.completed", "session.humanInput.requested", "turn.completed", "turn.failed", "turn.cancelled", "session.status.changed"];
|
|
7
7
|
export declare const SLACK_TASK_INSTRUCTIONS: string;
|
|
8
|
+
/**
|
|
9
|
+
* Slack-originated tasks may retrieve the workspace bot's bounded read surface
|
|
10
|
+
* on demand. Connector tools are explicit-only, so freeze that narrow context
|
|
11
|
+
* selection at session creation while keeping Slack mutations out of the model
|
|
12
|
+
* surface; interaction delivery remains owned by the durable delivery pump.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SLACK_TASK_FIRST_PARTY_MCP_TOOLS: ("artifacts_create" | "artifacts_get_source" | "artifacts_list" | "artifacts_publish" | "artifacts_rollback" | "environment_list" | "environment_set_variable" | "github_connect_link" | "github_repositories_list" | "github_token" | "goal_complete" | "goal_pause" | "goal_set" | "goal_update" | "memory_correct" | "memory_save" | "memory_search" | "preference_registry_get" | "preference_registry_summary" | "rig_get" | "rig_list" | "rig_promote" | "rig_propose_change" | "rig_verify" | "run_on" | "sandbox_attach" | "sandbox_provision" | "sandbox_swap" | "sandboxes_list" | "scheduled_task_runs_list" | "scheduled_tasks_create" | "scheduled_tasks_delete" | "scheduled_tasks_get" | "scheduled_tasks_list" | "scheduled_tasks_pause" | "scheduled_tasks_resume" | "scheduled_tasks_trigger" | "scheduled_tasks_update" | "session_create" | "session_events" | "session_get" | "session_pause" | "session_resume" | "session_send_message" | "session_steer" | "sessions_list" | "set_other_session_title" | "set_session_title" | "slack_bot_channel_history" | "slack_bot_delete_message" | "slack_bot_file_content" | "slack_bot_file_info" | "slack_bot_list_channels" | "slack_bot_list_files" | "slack_bot_list_users" | "slack_bot_post_message" | "slack_bot_thread_replies" | "social_connections_list" | "social_daily_analysis_context" | "social_mentions_live" | "social_post_reply" | "social_posts_recent" | "social_posts_sync" | "social_search_live" | "social_thread_fetch" | "variable_set_list" | "variable_set_set_variable")[];
|
|
8
15
|
export type NormalizedSlackInteraction = {
|
|
9
16
|
providerEventId: string;
|
|
10
17
|
providerMessageId: string;
|
|
@@ -28,3 +35,13 @@ export declare function startSlackInteractionPump(deps: ApiRouteDeps, options?:
|
|
|
28
35
|
intervalMs?: number;
|
|
29
36
|
maxPerTick?: number;
|
|
30
37
|
}): () => void;
|
|
38
|
+
type SlackUserLinkToken = {
|
|
39
|
+
workspaceId: string;
|
|
40
|
+
connectionId: string;
|
|
41
|
+
slackTeamId: string;
|
|
42
|
+
slackUserId: string;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
};
|
|
45
|
+
export declare function createSlackUserLinkToken(signingSecret: string, entry: Pick<SlackInteractionInboxEntry, "workspaceId" | "connectionId" | "slackTeamId" | "slackUserId">, nowMs?: number): string;
|
|
46
|
+
export declare function verifySlackUserLinkToken(signingSecret: string, token: string, nowMs?: number): SlackUserLinkToken | null;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { SocialConnection } from "@opengeni/contracts";
|
|
3
|
+
import type { Observability } from "@opengeni/observability";
|
|
4
|
+
import type { Database } from "@opengeni/db";
|
|
5
|
+
import { type SocialProviderFetch } from "./social-oauth";
|
|
6
|
+
export type SocialLivePost = {
|
|
7
|
+
id: string;
|
|
8
|
+
provider: "x" | "reddit";
|
|
9
|
+
url: string | null;
|
|
10
|
+
author: string | null;
|
|
11
|
+
text: string;
|
|
12
|
+
createdAt: string | null;
|
|
13
|
+
metrics: Record<string, number>;
|
|
14
|
+
context: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
type SocialApiDeps = {
|
|
17
|
+
db: Database;
|
|
18
|
+
settings: Settings;
|
|
19
|
+
observability?: Observability | undefined;
|
|
20
|
+
providerFetch?: SocialProviderFetch | undefined;
|
|
21
|
+
};
|
|
22
|
+
type ConnectionRef = {
|
|
23
|
+
workspaceId: string;
|
|
24
|
+
connectionId: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function socialSearchLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
|
|
27
|
+
query: string;
|
|
28
|
+
limit?: number | undefined;
|
|
29
|
+
subreddit?: string | undefined;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
connection: SocialConnection;
|
|
32
|
+
posts: SocialLivePost[];
|
|
33
|
+
}>;
|
|
34
|
+
export declare function socialMentionsLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
|
|
35
|
+
limit?: number | undefined;
|
|
36
|
+
sinceId?: string | undefined;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
connection: SocialConnection;
|
|
39
|
+
posts: SocialLivePost[];
|
|
40
|
+
}>;
|
|
41
|
+
export declare function socialThreadLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
|
|
42
|
+
id: string;
|
|
43
|
+
limit?: number | undefined;
|
|
44
|
+
}): Promise<{
|
|
45
|
+
connection: SocialConnection;
|
|
46
|
+
posts: SocialLivePost[];
|
|
47
|
+
}>;
|
|
48
|
+
export declare function socialOwnPostsLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
|
|
49
|
+
limit?: number | undefined;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
connection: SocialConnection;
|
|
52
|
+
posts: SocialLivePost[];
|
|
53
|
+
}>;
|
|
54
|
+
export declare function socialPostReply(deps: SocialApiDeps, ref: ConnectionRef, input: {
|
|
55
|
+
inReplyToId: string;
|
|
56
|
+
text: string;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
connection: SocialConnection;
|
|
59
|
+
postedId: string | null;
|
|
60
|
+
url: string | null;
|
|
61
|
+
}>;
|
|
62
|
+
/**
|
|
63
|
+
* Reddit write endpoints address targets by fullname (t3_xxx post, t1_xxx
|
|
64
|
+
* comment). Bare ids are ambiguous, so require the caller to be explicit.
|
|
65
|
+
*/
|
|
66
|
+
export declare function redditThingId(id: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* Agent-controlled values that land in a provider URL PATH must be shape-
|
|
69
|
+
* validated: encodeURIComponent leaves `.` intact, so `..` would collapse a
|
|
70
|
+
* path segment and select a different endpoint on the same host.
|
|
71
|
+
*/
|
|
72
|
+
export declare function redditSubredditName(value: string): string;
|
|
73
|
+
export declare function redditArticleId(value: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* Joins a provider-supplied path against the provider origin and refuses
|
|
76
|
+
* results that escape it (e.g. a hostile `@evil.com/x` or `//evil.com`
|
|
77
|
+
* permalink), so mapper output URLs can be trusted downstream.
|
|
78
|
+
*/
|
|
79
|
+
export declare function redditUrl(path: string): string | null;
|
|
80
|
+
export declare function mapXTweets(payload: Record<string, unknown>, fallbackAuthor?: string): SocialLivePost[];
|
|
81
|
+
export declare function mapRedditListing(payload: Record<string, unknown>): SocialLivePost[];
|
|
82
|
+
/**
|
|
83
|
+
* /comments/{article} returns [post listing, comment listing]; flatten the
|
|
84
|
+
* submission first, then its comment tree in order.
|
|
85
|
+
*/
|
|
86
|
+
export declare function mapRedditThread(payload: Record<string, unknown>): SocialLivePost[];
|
|
87
|
+
export declare function redditCommentFromApiJson(payload: Record<string, unknown>): {
|
|
88
|
+
id: string | null;
|
|
89
|
+
url: string | null;
|
|
90
|
+
};
|
|
91
|
+
export {};
|
|
@@ -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.15.
|
|
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.15.
|
|
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";
|
|
@@ -233,6 +234,20 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
233
234
|
}),
|
|
234
235
|
);
|
|
235
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
|
+
|
|
236
251
|
app.use("*", async (c, next) => {
|
|
237
252
|
const url = new URL(c.req.url);
|
|
238
253
|
const route = routeLabel(url.pathname);
|
|
@@ -392,6 +407,7 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
392
407
|
},
|
|
393
408
|
productAccessMode: deps.settings.productAccessMode,
|
|
394
409
|
auth: clientAuthConfig(deps.settings),
|
|
410
|
+
analytics: clientAnalyticsConfig(deps.settings),
|
|
395
411
|
// Channel-A structured services (P4.4) ride exec/readFile/createEditor,
|
|
396
412
|
// available on every real backend; `none` has no box so they are all off.
|
|
397
413
|
// Per-session availability is still negotiated on /stream-capabilities.
|
|
@@ -542,6 +558,17 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
542
558
|
return { app, routeDeps };
|
|
543
559
|
}
|
|
544
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(", ");
|
|
570
|
+
}
|
|
571
|
+
|
|
545
572
|
async function requireMcpAccessGrant(
|
|
546
573
|
c: Parameters<typeof requireAccessGrant>[0],
|
|
547
574
|
deps: ApiRouteDeps,
|
|
@@ -585,6 +612,31 @@ function clientAuthConfig(settings: AppDependencies["settings"]) {
|
|
|
585
612
|
return { mode: "none" as const };
|
|
586
613
|
}
|
|
587
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
|
+
|
|
588
640
|
function structuredServicesHint(backend: string): {
|
|
589
641
|
fileSystem: boolean;
|
|
590
642
|
git: boolean;
|
|
@@ -1083,6 +1135,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
|
|
|
1083
1135
|
pattern: /^\/v1\/integrations\/slack\/callback$/,
|
|
1084
1136
|
label: "/v1/integrations/slack/callback",
|
|
1085
1137
|
},
|
|
1138
|
+
{
|
|
1139
|
+
pattern: /^\/v1\/social\/oauth\/callback$/,
|
|
1140
|
+
label: "/v1/social/oauth/callback",
|
|
1141
|
+
},
|
|
1086
1142
|
{
|
|
1087
1143
|
pattern: /^\/v1\/enrollments\/device\/start$/,
|
|
1088
1144
|
label: "/v1/enrollments/device/start",
|
package/src/http/auth.ts
CHANGED
|
@@ -57,6 +57,11 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
57
57
|
) {
|
|
58
58
|
return true;
|
|
59
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
|
+
}
|
|
60
65
|
// Catalog logos are rendered via bare <img> tags, which carry no credentials;
|
|
61
66
|
// the images are public vendor logos, digest-keyed by content, and the route
|
|
62
67
|
// itself enforces the catalog-assets/ prefix lock and extension whitelist.
|
|
@@ -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}`;
|
|
@@ -153,7 +153,10 @@ type SlackBotContext = {
|
|
|
153
153
|
};
|
|
154
154
|
|
|
155
155
|
export class SlackBotProviderError extends Error {
|
|
156
|
-
constructor(
|
|
156
|
+
constructor(
|
|
157
|
+
readonly code: string,
|
|
158
|
+
readonly retryAfterMs: number | null = null,
|
|
159
|
+
) {
|
|
157
160
|
super(`Slack bot request failed: ${safeSlackCode(code)}`);
|
|
158
161
|
this.name = "SlackBotProviderError";
|
|
159
162
|
}
|
|
@@ -735,7 +738,10 @@ export class OpenGeniSlackBotClient {
|
|
|
735
738
|
channel: channelId,
|
|
736
739
|
});
|
|
737
740
|
const projected = projectChannel(payload.channel);
|
|
738
|
-
|
|
741
|
+
// Slack omits `is_member` for a bot's one-to-one App Home conversation.
|
|
742
|
+
// A successful conversations.info response with `is_im` is itself proof
|
|
743
|
+
// that this installation can address that direct conversation.
|
|
744
|
+
if (!projected || (projected.isMember !== true && projected.isDirectMessage !== true)) {
|
|
739
745
|
throw new SlackBotProviderError("not_in_channel");
|
|
740
746
|
}
|
|
741
747
|
return projected;
|
|
@@ -1186,8 +1192,9 @@ async function slackApiFetchWithHeaders(
|
|
|
1186
1192
|
throw new SlackBotProviderError("transport_error");
|
|
1187
1193
|
}
|
|
1188
1194
|
if (!response.ok) {
|
|
1195
|
+
const retryAfterMs = slackRetryAfterMs(response);
|
|
1189
1196
|
await response.body?.cancel().catch(() => undefined);
|
|
1190
|
-
throw new SlackBotProviderError(`http_${response.status}
|
|
1197
|
+
throw new SlackBotProviderError(`http_${response.status}`, retryAfterMs);
|
|
1191
1198
|
}
|
|
1192
1199
|
let payload: SlackPayload;
|
|
1193
1200
|
try {
|
|
@@ -1205,6 +1212,15 @@ async function slackApiFetchWithHeaders(
|
|
|
1205
1212
|
return { response, payload };
|
|
1206
1213
|
}
|
|
1207
1214
|
|
|
1215
|
+
function slackRetryAfterMs(response: Response): number | null {
|
|
1216
|
+
if (response.status !== 429) return null;
|
|
1217
|
+
const raw = response.headers.get("retry-after");
|
|
1218
|
+
if (!raw || !/^\d+$/.test(raw)) return null;
|
|
1219
|
+
const seconds = Number(raw);
|
|
1220
|
+
if (!Number.isSafeInteger(seconds) || seconds < 1) return null;
|
|
1221
|
+
return Math.min(seconds, 3_600) * 1_000;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1208
1224
|
function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void {
|
|
1209
1225
|
const policy = evaluateOpenGeniSlackBotScopes(grantedScopes);
|
|
1210
1226
|
if (!policy.accepted) {
|
|
@@ -1245,6 +1261,7 @@ function projectChannel(value: unknown) {
|
|
|
1245
1261
|
name: boundedSlackString(channel.name, 256),
|
|
1246
1262
|
isPrivate: channel.is_private === true,
|
|
1247
1263
|
isMember: channel.is_member === true,
|
|
1264
|
+
isDirectMessage: channel.is_im === true,
|
|
1248
1265
|
isArchived: channel.is_archived === true,
|
|
1249
1266
|
topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
|
|
1250
1267
|
purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),
|