@opengeni/api-router 0.30.3 → 2.1.0-canary.0
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/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-WCXHI3FF.js → chunk-QKDFBBUE.js} +26385 -15573
- package/dist/chunk-QKDFBBUE.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +326 -82
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +18 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +149 -9
- package/dist/integrations/slack-interactions.d.ts +4 -3
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +4 -1
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +153 -23
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +301 -92
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +337 -57
- package/src/integrations/slack-interactions.ts +1265 -208
- package/src/integrations/slack-routing.ts +324 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +16 -1
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +329 -79
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +144 -34
- package/src/routes/documents.ts +221 -3
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +72 -11
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +775 -38
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-WCXHI3FF.js.map +0 -1
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
6
6
|
OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
|
|
7
7
|
evaluateOpenGeniSlackBotScopes,
|
|
8
|
+
hasOpenGeniSlackBotSearchScopes,
|
|
8
9
|
type AccessGrant,
|
|
9
10
|
type ConnectionMetadata,
|
|
11
|
+
type OpenGeniSlackBotDisplayName,
|
|
10
12
|
type OpenGeniSlackBotConnectionMetadata,
|
|
11
13
|
} from "@opengeni/contracts";
|
|
12
14
|
import {
|
|
@@ -64,6 +66,12 @@ const MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS = 1_500;
|
|
|
64
66
|
const MAX_REACTION_CONTEXT_CHECKPOINT_FILES = 16;
|
|
65
67
|
const SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION = 1;
|
|
66
68
|
const MAX_USER_PAGE = 200;
|
|
69
|
+
/** Slack caps `assistant.search.context` at 20 results per request. */
|
|
70
|
+
const MAX_SEARCH_PAGE = 20;
|
|
71
|
+
/** Bounded projection cap for one search result's content excerpt. */
|
|
72
|
+
const MAX_SEARCH_CONTENT_CHARS = 2_000;
|
|
73
|
+
/** Matches the MCP input schema's cap; Slack signals its own via query_too_long. */
|
|
74
|
+
const MAX_SEARCH_QUERY_CHARS = 500;
|
|
67
75
|
const MAX_FILE_PAGE = 200;
|
|
68
76
|
const MAX_FILE_CURSOR_LENGTH = 1_024;
|
|
69
77
|
const SLACK_FILE_CURSOR_VERSION = "files-v1";
|
|
@@ -106,6 +114,11 @@ export type DownloadedSlackReactionImage = Readonly<{
|
|
|
106
114
|
bytes: Uint8Array;
|
|
107
115
|
}>;
|
|
108
116
|
|
|
117
|
+
export type ExchangedOpenGeniSlackAuthorization = Readonly<{
|
|
118
|
+
accessToken: string;
|
|
119
|
+
appId: string;
|
|
120
|
+
}>;
|
|
121
|
+
|
|
109
122
|
export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
110
123
|
input: {
|
|
111
124
|
code: string;
|
|
@@ -114,7 +127,7 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
|
114
127
|
redirectUri: string;
|
|
115
128
|
},
|
|
116
129
|
fetchImpl: FetchLike = fetch,
|
|
117
|
-
): Promise<
|
|
130
|
+
): Promise<ExchangedOpenGeniSlackAuthorization> {
|
|
118
131
|
const body = new URLSearchParams({
|
|
119
132
|
code: input.code,
|
|
120
133
|
client_id: input.clientId,
|
|
@@ -158,7 +171,13 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
|
|
|
158
171
|
message: "Slack installation did not return a bot token",
|
|
159
172
|
});
|
|
160
173
|
}
|
|
161
|
-
|
|
174
|
+
const appId = slackString(record.app_id);
|
|
175
|
+
if (!appId) {
|
|
176
|
+
throw new HTTPException(502, {
|
|
177
|
+
message: "Slack installation did not return an app identity",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return { accessToken, appId };
|
|
162
181
|
}
|
|
163
182
|
|
|
164
183
|
export type SlackBotReceipt = {
|
|
@@ -190,14 +209,51 @@ export type SlackMessageBlock =
|
|
|
190
209
|
}
|
|
191
210
|
| { type: "divider" };
|
|
192
211
|
|
|
212
|
+
export type SlackHomeBlock =
|
|
213
|
+
| {
|
|
214
|
+
type: "header";
|
|
215
|
+
block_id?: string;
|
|
216
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
217
|
+
}
|
|
218
|
+
| {
|
|
219
|
+
type: "section";
|
|
220
|
+
block_id?: string;
|
|
221
|
+
text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean };
|
|
222
|
+
accessory?: {
|
|
223
|
+
type: "button";
|
|
224
|
+
action_id: string;
|
|
225
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
226
|
+
url: string;
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
| {
|
|
230
|
+
type: "context";
|
|
231
|
+
block_id?: string;
|
|
232
|
+
elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>;
|
|
233
|
+
}
|
|
234
|
+
| {
|
|
235
|
+
type: "actions";
|
|
236
|
+
block_id: string;
|
|
237
|
+
elements: Array<{
|
|
238
|
+
type: "button";
|
|
239
|
+
action_id: string;
|
|
240
|
+
text: { type: "plain_text"; text: string; emoji?: boolean };
|
|
241
|
+
url: string;
|
|
242
|
+
style?: "primary" | "danger";
|
|
243
|
+
}>;
|
|
244
|
+
}
|
|
245
|
+
| { type: "divider" };
|
|
246
|
+
|
|
193
247
|
type SlackBotOperation =
|
|
194
248
|
| "channels.list"
|
|
249
|
+
| "search.context"
|
|
195
250
|
| "channel_history.read"
|
|
196
251
|
| "thread_replies.read"
|
|
197
252
|
| "users.list"
|
|
198
253
|
| "files.list"
|
|
199
254
|
| "file.info"
|
|
200
255
|
| "file.content.read"
|
|
256
|
+
| "home.publish"
|
|
201
257
|
| "message.post"
|
|
202
258
|
| "message.update"
|
|
203
259
|
| "message.delete";
|
|
@@ -210,6 +266,9 @@ type SlackBotContext = {
|
|
|
210
266
|
scheduledTaskId?: string | null;
|
|
211
267
|
};
|
|
212
268
|
|
|
269
|
+
type SlackCallAuthority = Readonly<{ operation: SlackBotOperation }>;
|
|
270
|
+
type SlackProviderAuthorization = () => Promise<boolean | void>;
|
|
271
|
+
|
|
213
272
|
export type SlackReactionContextCheckpointBinding = {
|
|
214
273
|
inboxId: string;
|
|
215
274
|
accountId: string;
|
|
@@ -259,6 +318,35 @@ export class SlackBotProviderError extends Error {
|
|
|
259
318
|
}
|
|
260
319
|
}
|
|
261
320
|
|
|
321
|
+
export async function authorizeSlackSharedImageRead(
|
|
322
|
+
channel: {
|
|
323
|
+
isArchived: boolean;
|
|
324
|
+
isShared: boolean;
|
|
325
|
+
isExternallyShared: boolean;
|
|
326
|
+
isOrgShared: boolean;
|
|
327
|
+
isPendingExternallyShared: boolean;
|
|
328
|
+
isMpim: boolean;
|
|
329
|
+
},
|
|
330
|
+
authorizeSharedRead: (() => Promise<void>) | undefined,
|
|
331
|
+
): Promise<void> {
|
|
332
|
+
if (channel.isArchived) {
|
|
333
|
+
throw new SlackBotProviderError("is_archived");
|
|
334
|
+
}
|
|
335
|
+
if (
|
|
336
|
+
!channel.isShared &&
|
|
337
|
+
!channel.isExternallyShared &&
|
|
338
|
+
!channel.isOrgShared &&
|
|
339
|
+
!channel.isPendingExternallyShared &&
|
|
340
|
+
!channel.isMpim
|
|
341
|
+
) {
|
|
342
|
+
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
343
|
+
}
|
|
344
|
+
if (!authorizeSharedRead) {
|
|
345
|
+
throw new SlackBotProviderError("slack_connect_unsupported");
|
|
346
|
+
}
|
|
347
|
+
await authorizeSharedRead();
|
|
348
|
+
}
|
|
349
|
+
|
|
262
350
|
const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
|
|
263
351
|
"account_inactive",
|
|
264
352
|
"invalid_auth",
|
|
@@ -285,12 +373,16 @@ export class SlackBotCredentialVerificationError extends HTTPException {
|
|
|
285
373
|
|
|
286
374
|
/**
|
|
287
375
|
* Validates a write-only xoxb credential before it can enter encrypted storage.
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
376
|
+
* The OAuth exchange's app_id is the immutable app authority. Slack bot profile
|
|
377
|
+
* names are mutable presentation data and may lag manifest/App Home changes, so
|
|
378
|
+
* they must not decide whether a credential belongs to this installation.
|
|
291
379
|
*/
|
|
292
380
|
export async function verifyOpenGeniSlackBotCredential(
|
|
293
381
|
token: string,
|
|
382
|
+
expected: Readonly<{
|
|
383
|
+
appId: string;
|
|
384
|
+
displayName: OpenGeniSlackBotDisplayName;
|
|
385
|
+
}>,
|
|
294
386
|
fetchImpl: FetchLike = fetch,
|
|
295
387
|
now: Date = new Date(),
|
|
296
388
|
): Promise<VerifiedOpenGeniSlackBot> {
|
|
@@ -314,11 +406,11 @@ export async function verifyOpenGeniSlackBotCredential(
|
|
|
314
406
|
);
|
|
315
407
|
}
|
|
316
408
|
const profile = slackRecord(user.profile);
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
409
|
+
const installedAppId = slackString(profile?.api_app_id);
|
|
410
|
+
if (installedAppId !== expected.appId) {
|
|
319
411
|
throw new SlackBotCredentialVerificationError(
|
|
320
412
|
"identity_mismatch",
|
|
321
|
-
|
|
413
|
+
"Slack credential does not belong to the authorized Slack app",
|
|
322
414
|
);
|
|
323
415
|
}
|
|
324
416
|
|
|
@@ -331,7 +423,7 @@ export async function verifyOpenGeniSlackBotCredential(
|
|
|
331
423
|
slackTeamName,
|
|
332
424
|
botUserId,
|
|
333
425
|
botId,
|
|
334
|
-
botDisplayName:
|
|
426
|
+
botDisplayName: expected.displayName,
|
|
335
427
|
verifiedAt: now.toISOString(),
|
|
336
428
|
},
|
|
337
429
|
};
|
|
@@ -427,6 +519,7 @@ export class OpenGeniSlackBotClient {
|
|
|
427
519
|
private readonly metadata: OpenGeniSlackBotConnectionMetadata,
|
|
428
520
|
private readonly context: SlackBotContext,
|
|
429
521
|
private readonly fetchImpl: FetchLike = fetch,
|
|
522
|
+
private readonly authorizeProviderRequest?: SlackProviderAuthorization,
|
|
430
523
|
) {
|
|
431
524
|
this.resolveCredential = buildConnectionTokenResolver(db, settings);
|
|
432
525
|
}
|
|
@@ -453,6 +546,27 @@ export class OpenGeniSlackBotClient {
|
|
|
453
546
|
return await this.requireMemberChannel(headers, channelId);
|
|
454
547
|
}
|
|
455
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Replace one Slack user's private App Home view. `views.publish` is a
|
|
551
|
+
* naturally convergent replace operation: Slack event retries may safely
|
|
552
|
+
* repeat the exact bounded view without creating duplicate provider state.
|
|
553
|
+
*/
|
|
554
|
+
async publishHomeView(input: { userId: string; blocks: SlackHomeBlock[]; hash?: string | null }) {
|
|
555
|
+
const userId = requiredSlackString(input.userId, "user_id");
|
|
556
|
+
const blocks = validateSlackHomeBlocks(input.blocks);
|
|
557
|
+
const hash = input.hash ? requiredSlackString(input.hash, "hash") : null;
|
|
558
|
+
return await this.withAudit("home.publish", async (headers) => {
|
|
559
|
+
const payload = await this.call(headers, "views.publish", {
|
|
560
|
+
user_id: userId,
|
|
561
|
+
view: JSON.stringify({ type: "home", blocks }),
|
|
562
|
+
...(hash ? { hash } : {}),
|
|
563
|
+
});
|
|
564
|
+
return {
|
|
565
|
+
viewId: requiredSlackString(slackRecord(payload.view)?.id, "view.id"),
|
|
566
|
+
};
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
456
570
|
async slackTaskPolicyFacts(channelId: string, userId: string) {
|
|
457
571
|
return await this.withAudit("channel_history.read", async (headers) => {
|
|
458
572
|
const conversation = await this.requireMemberChannel(headers, channelId);
|
|
@@ -649,11 +763,18 @@ export class OpenGeniSlackBotClient {
|
|
|
649
763
|
async prepareReactionImageDownloads(input: {
|
|
650
764
|
channelId: string;
|
|
651
765
|
files: readonly { id: string; name: string; title: string }[];
|
|
766
|
+
authorizeSharedRead?: () => Promise<void>;
|
|
652
767
|
}): Promise<PreparedSlackReactionImage[]> {
|
|
653
768
|
const result = await this.withAudit("file.content.read", async (headers) => {
|
|
654
|
-
|
|
769
|
+
if (input.authorizeSharedRead) {
|
|
770
|
+
const channel = await this.requireMemberChannel(headers, input.channelId);
|
|
771
|
+
await authorizeSlackSharedImageRead(channel, input.authorizeSharedRead);
|
|
772
|
+
} else {
|
|
773
|
+
await this.requireActiveNonSharedMemberChannel(headers, input.channelId);
|
|
774
|
+
}
|
|
655
775
|
const prepared: PreparedSlackReactionImage[] = [];
|
|
656
776
|
for (const candidate of input.files) {
|
|
777
|
+
await input.authorizeSharedRead?.();
|
|
657
778
|
const payload = await this.call(headers, "files.info", { file: candidate.id });
|
|
658
779
|
const fileRecord = slackRecord(payload.file);
|
|
659
780
|
const file = projectFile(fileRecord);
|
|
@@ -688,6 +809,7 @@ export class OpenGeniSlackBotClient {
|
|
|
688
809
|
/** Download and fully validate one previously authorized Slack image. */
|
|
689
810
|
async downloadReactionImage(
|
|
690
811
|
input: PreparedSlackReactionImage,
|
|
812
|
+
authorizeSharedRead?: () => Promise<void>,
|
|
691
813
|
): Promise<DownloadedSlackReactionImage> {
|
|
692
814
|
return await this.withAudit("file.content.read", async () => {
|
|
693
815
|
if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) {
|
|
@@ -700,7 +822,11 @@ export class OpenGeniSlackBotClient {
|
|
|
700
822
|
) {
|
|
701
823
|
throw new SlackBotProviderError("invalid_file_size");
|
|
702
824
|
}
|
|
703
|
-
const response = await this.fetchPrivateFile(
|
|
825
|
+
const response = await this.fetchPrivateFile(
|
|
826
|
+
input.downloadUrl,
|
|
827
|
+
"file.content.read",
|
|
828
|
+
authorizeSharedRead,
|
|
829
|
+
);
|
|
704
830
|
const responseContentType = normalizedContentType(response.headers.get("content-type"));
|
|
705
831
|
if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) {
|
|
706
832
|
await response.body?.cancel().catch(() => undefined);
|
|
@@ -748,6 +874,69 @@ export class OpenGeniSlackBotClient {
|
|
|
748
874
|
});
|
|
749
875
|
}
|
|
750
876
|
|
|
877
|
+
/**
|
|
878
|
+
* Workspace-wide public search through Slack's Real-time Search API
|
|
879
|
+
* (`assistant.search.context`) under the bot identity.
|
|
880
|
+
*
|
|
881
|
+
* The bot never carries private-search authority: `channel_types` is pinned
|
|
882
|
+
* to `public_channel` server-side regardless of caller input, so private
|
|
883
|
+
* channels, DMs, and MPIMs remain reachable only through a member's personal
|
|
884
|
+
* hosted-MCP grant. An install predating the search scopes fails closed with
|
|
885
|
+
* a reinstall hint instead of leaking Slack's `missing_scope` error.
|
|
886
|
+
*/
|
|
887
|
+
async searchContext(input: {
|
|
888
|
+
query: string;
|
|
889
|
+
contentTypes?: readonly ("messages" | "files" | "channels")[];
|
|
890
|
+
includeBots?: boolean;
|
|
891
|
+
/** UNIX seconds bounds on message timestamps. */
|
|
892
|
+
before?: number;
|
|
893
|
+
after?: number;
|
|
894
|
+
sort?: "score" | "timestamp";
|
|
895
|
+
sortDir?: "asc" | "desc";
|
|
896
|
+
cursor?: string;
|
|
897
|
+
limit?: number;
|
|
898
|
+
}) {
|
|
899
|
+
const query = typeof input.query === "string" ? input.query.trim() : "";
|
|
900
|
+
if (!query || query.length > MAX_SEARCH_QUERY_CHARS) {
|
|
901
|
+
throw new SlackBotProviderError("invalid_query");
|
|
902
|
+
}
|
|
903
|
+
const contentTypes = input.contentTypes?.length
|
|
904
|
+
? [...new Set(input.contentTypes)]
|
|
905
|
+
: ["messages"];
|
|
906
|
+
return await this.withAudit("search.context", async (headers) => {
|
|
907
|
+
// Inside the audit boundary so a fail-closed denial on a legacy install
|
|
908
|
+
// leaves the same `failed` evidence as a provider rejection.
|
|
909
|
+
if (!hasOpenGeniSlackBotSearchScopes(this.connection.grantedScopes)) {
|
|
910
|
+
throw new SlackBotProviderError("slack_bot_search_scopes_missing");
|
|
911
|
+
}
|
|
912
|
+
const payload = await this.call(headers, "assistant.search.context", {
|
|
913
|
+
query,
|
|
914
|
+
channel_types: "public_channel",
|
|
915
|
+
content_types: contentTypes.join(","),
|
|
916
|
+
...(input.includeBots ? { include_bots: "true" } : {}),
|
|
917
|
+
...(input.before !== undefined ? { before: String(Math.trunc(input.before)) } : {}),
|
|
918
|
+
...(input.after !== undefined ? { after: String(Math.trunc(input.after)) } : {}),
|
|
919
|
+
...(input.sort ? { sort: input.sort } : {}),
|
|
920
|
+
...(input.sortDir ? { sort_dir: input.sortDir } : {}),
|
|
921
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
922
|
+
limit: String(boundedInt(input.limit, MAX_SEARCH_PAGE, MAX_SEARCH_PAGE)),
|
|
923
|
+
});
|
|
924
|
+
const results = slackRecord(payload.results);
|
|
925
|
+
return {
|
|
926
|
+
messages: slackArray(results?.messages)
|
|
927
|
+
.map(projectSearchMessage)
|
|
928
|
+
.filter((message): message is NonNullable<typeof message> => message !== null),
|
|
929
|
+
files: slackArray(results?.files)
|
|
930
|
+
.map(projectSearchFile)
|
|
931
|
+
.filter((file): file is NonNullable<typeof file> => file !== null),
|
|
932
|
+
channels: slackArray(results?.channels)
|
|
933
|
+
.map(projectSearchChannel)
|
|
934
|
+
.filter((channel): channel is NonNullable<typeof channel> => channel !== null),
|
|
935
|
+
nextCursor: responseCursor(payload),
|
|
936
|
+
};
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
751
940
|
async listFiles(input: { channelId: string; limit?: number; cursor?: string }) {
|
|
752
941
|
return await this.withAudit("files.list", async (headers) => {
|
|
753
942
|
const requestedPage = this.fileListPage(input);
|
|
@@ -1211,10 +1400,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1211
1400
|
}
|
|
1212
1401
|
|
|
1213
1402
|
private async slackMessageExists(channelId: string, timestamp: string): Promise<boolean> {
|
|
1214
|
-
const headers = await this.
|
|
1215
|
-
"message.delete",
|
|
1216
|
-
`${SLACK_API_BASE}chat.getPermalink`,
|
|
1217
|
-
);
|
|
1403
|
+
const headers = await this.headersFor("message.delete");
|
|
1218
1404
|
try {
|
|
1219
1405
|
await this.call(headers, "chat.getPermalink", {
|
|
1220
1406
|
channel: channelId,
|
|
@@ -1236,7 +1422,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1236
1422
|
text: string;
|
|
1237
1423
|
}): Promise<{ timestamp: string }> {
|
|
1238
1424
|
const method = input.threadTimestamp ? "conversations.replies" : "conversations.history";
|
|
1239
|
-
const headers = await this.
|
|
1425
|
+
const headers = await this.headersFor("message.post");
|
|
1240
1426
|
const seenCursors = new Set<string>();
|
|
1241
1427
|
let cursor: string | null = null;
|
|
1242
1428
|
let matchedTimestamp: string | null = null;
|
|
@@ -1284,7 +1470,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1284
1470
|
return { timestamp: matchedTimestamp };
|
|
1285
1471
|
}
|
|
1286
1472
|
|
|
1287
|
-
private async requireMemberChannel(headers:
|
|
1473
|
+
private async requireMemberChannel(headers: SlackCallAuthority, channelId: string) {
|
|
1288
1474
|
const payload = await this.call(headers, "conversations.info", {
|
|
1289
1475
|
channel: channelId,
|
|
1290
1476
|
});
|
|
@@ -1299,7 +1485,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1299
1485
|
}
|
|
1300
1486
|
|
|
1301
1487
|
private async requireActiveNonSharedMemberChannel(
|
|
1302
|
-
headers:
|
|
1488
|
+
headers: SlackCallAuthority,
|
|
1303
1489
|
channelId: string,
|
|
1304
1490
|
) {
|
|
1305
1491
|
const projected = await this.requireMemberChannel(headers, channelId);
|
|
@@ -1313,7 +1499,7 @@ export class OpenGeniSlackBotClient {
|
|
|
1313
1499
|
}
|
|
1314
1500
|
|
|
1315
1501
|
private async requireFileForChannel(
|
|
1316
|
-
headers:
|
|
1502
|
+
headers: SlackCallAuthority,
|
|
1317
1503
|
operation: "file.info" | "file.content.read",
|
|
1318
1504
|
input: { channelId: string; fileId: string; parentFileId?: string },
|
|
1319
1505
|
) {
|
|
@@ -1390,11 +1576,15 @@ export class OpenGeniSlackBotClient {
|
|
|
1390
1576
|
}
|
|
1391
1577
|
|
|
1392
1578
|
private async call(
|
|
1393
|
-
|
|
1579
|
+
authority: SlackCallAuthority,
|
|
1394
1580
|
method: string,
|
|
1395
1581
|
params: Record<string, string>,
|
|
1396
1582
|
): Promise<SlackPayload> {
|
|
1397
1583
|
try {
|
|
1584
|
+
const headers = await this.headersForDestination(
|
|
1585
|
+
authority.operation,
|
|
1586
|
+
`${SLACK_API_BASE}${method}`,
|
|
1587
|
+
);
|
|
1398
1588
|
return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
|
|
1399
1589
|
} catch (error) {
|
|
1400
1590
|
if (slackCredentialRejected(error)) {
|
|
@@ -1410,11 +1600,10 @@ export class OpenGeniSlackBotClient {
|
|
|
1410
1600
|
|
|
1411
1601
|
private async withAudit<T extends Record<string, unknown>>(
|
|
1412
1602
|
operation: SlackBotOperation,
|
|
1413
|
-
run: (
|
|
1603
|
+
run: (authority: SlackCallAuthority) => Promise<T>,
|
|
1414
1604
|
): Promise<T & { receipt: SlackBotReceipt }> {
|
|
1415
1605
|
try {
|
|
1416
|
-
const
|
|
1417
|
-
const result = await run(headers);
|
|
1606
|
+
const result = await run(await this.headersFor(operation));
|
|
1418
1607
|
await this.recordAudit(operation, "succeeded");
|
|
1419
1608
|
return { ...result, receipt: this.receipt(operation) };
|
|
1420
1609
|
} catch (error) {
|
|
@@ -1423,11 +1612,8 @@ export class OpenGeniSlackBotClient {
|
|
|
1423
1612
|
}
|
|
1424
1613
|
}
|
|
1425
1614
|
|
|
1426
|
-
private async headersFor(operation: SlackBotOperation): Promise<
|
|
1427
|
-
return
|
|
1428
|
-
operation,
|
|
1429
|
-
`${SLACK_API_BASE}${slackMethodForOperation(operation)}`,
|
|
1430
|
-
);
|
|
1615
|
+
private async headersFor(operation: SlackBotOperation): Promise<SlackCallAuthority> {
|
|
1616
|
+
return Object.freeze({ operation });
|
|
1431
1617
|
}
|
|
1432
1618
|
|
|
1433
1619
|
private async headersForDestination(
|
|
@@ -1450,14 +1636,40 @@ export class OpenGeniSlackBotClient {
|
|
|
1450
1636
|
if (result.status !== "ok" || result.connectionId !== this.connection.id) {
|
|
1451
1637
|
throw new Error("OpenGeni Slack bot connection needs to be reinstalled");
|
|
1452
1638
|
}
|
|
1639
|
+
const current = await requireOpenGeniSlackBotConnection(
|
|
1640
|
+
this.db,
|
|
1641
|
+
this.context.workspaceId,
|
|
1642
|
+
this.connection.id,
|
|
1643
|
+
);
|
|
1644
|
+
const currentMetadata = openGeniSlackBotMetadata(current.metadata);
|
|
1645
|
+
if (
|
|
1646
|
+
current.accountId !== this.context.accountId ||
|
|
1647
|
+
current.version !== this.connection.version ||
|
|
1648
|
+
result.connectionVersion !== this.connection.version ||
|
|
1649
|
+
!currentMetadata ||
|
|
1650
|
+
currentMetadata.slackTeamId !== this.metadata.slackTeamId ||
|
|
1651
|
+
currentMetadata.botId !== this.metadata.botId ||
|
|
1652
|
+
currentMetadata.botUserId !== this.metadata.botUserId
|
|
1653
|
+
) {
|
|
1654
|
+
throw new Error("OpenGeni Slack bot connection authority changed");
|
|
1655
|
+
}
|
|
1656
|
+
// The adapter callback is a fallible preflight. The canonical credential
|
|
1657
|
+
// callback must remain the final await before the physical fetch.
|
|
1658
|
+
if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) {
|
|
1659
|
+
throw new Error("OpenGeni Slack bot provider request is no longer authorized");
|
|
1660
|
+
}
|
|
1661
|
+
if (result.authorizeProviderRequest && !(await result.authorizeProviderRequest())) {
|
|
1662
|
+
throw new Error("OpenGeni Slack bot provider request is no longer authorized");
|
|
1663
|
+
}
|
|
1453
1664
|
return result.headers;
|
|
1454
1665
|
}
|
|
1455
1666
|
|
|
1456
1667
|
private async fetchPrivateFile(
|
|
1457
1668
|
url: URL,
|
|
1458
1669
|
operation: "file.info" | "file.content.read",
|
|
1670
|
+
authorizeBeforeFetch?: () => Promise<void>,
|
|
1459
1671
|
): Promise<Response> {
|
|
1460
|
-
const response = await this.fetchPrivateFileOnce(url, operation);
|
|
1672
|
+
const response = await this.fetchPrivateFileOnce(url, operation, authorizeBeforeFetch);
|
|
1461
1673
|
if (response.status < 300 || response.status >= 400) {
|
|
1462
1674
|
return response;
|
|
1463
1675
|
}
|
|
@@ -1478,18 +1690,20 @@ export class OpenGeniSlackBotClient {
|
|
|
1478
1690
|
} catch {
|
|
1479
1691
|
throw new SlackBotProviderError("invalid_file_redirect");
|
|
1480
1692
|
}
|
|
1481
|
-
return await this.fetchPrivateFileOnce(redirected, operation);
|
|
1693
|
+
return await this.fetchPrivateFileOnce(redirected, operation, authorizeBeforeFetch);
|
|
1482
1694
|
}
|
|
1483
1695
|
|
|
1484
1696
|
private async fetchPrivateFileOnce(
|
|
1485
1697
|
url: URL,
|
|
1486
1698
|
operation: "file.info" | "file.content.read",
|
|
1699
|
+
authorizeBeforeFetch?: () => Promise<void>,
|
|
1487
1700
|
): Promise<Response> {
|
|
1488
1701
|
try {
|
|
1489
1702
|
assertPrivateSlackFileUrl(url);
|
|
1490
1703
|
} catch {
|
|
1491
1704
|
throw new SlackBotProviderError("invalid_file_url");
|
|
1492
1705
|
}
|
|
1706
|
+
await authorizeBeforeFetch?.();
|
|
1493
1707
|
const headers = await this.headersForDestination(operation, url.toString());
|
|
1494
1708
|
let response: Response;
|
|
1495
1709
|
try {
|
|
@@ -1701,7 +1915,12 @@ export class OpenGeniSlackBotClient {
|
|
|
1701
1915
|
}
|
|
1702
1916
|
|
|
1703
1917
|
export function createOpenGeniSlackBotClient(
|
|
1704
|
-
deps: {
|
|
1918
|
+
deps: {
|
|
1919
|
+
db: Database;
|
|
1920
|
+
settings: Settings;
|
|
1921
|
+
slackFetch?: typeof fetch;
|
|
1922
|
+
authorizeProviderRequest?: SlackProviderAuthorization;
|
|
1923
|
+
},
|
|
1705
1924
|
resolved: Awaited<ReturnType<typeof resolveSlackBotConnectionForTool>>,
|
|
1706
1925
|
): OpenGeniSlackBotClient {
|
|
1707
1926
|
return new OpenGeniSlackBotClient(
|
|
@@ -1711,11 +1930,17 @@ export function createOpenGeniSlackBotClient(
|
|
|
1711
1930
|
resolved.metadata,
|
|
1712
1931
|
resolved.context,
|
|
1713
1932
|
deps.slackFetch,
|
|
1933
|
+
deps.authorizeProviderRequest,
|
|
1714
1934
|
);
|
|
1715
1935
|
}
|
|
1716
1936
|
|
|
1717
1937
|
export async function createOpenGeniSlackBotInteractionClient(
|
|
1718
|
-
deps: {
|
|
1938
|
+
deps: {
|
|
1939
|
+
db: Database;
|
|
1940
|
+
settings: Settings;
|
|
1941
|
+
slackFetch?: typeof fetch;
|
|
1942
|
+
authorizeProviderRequest?: SlackProviderAuthorization;
|
|
1943
|
+
},
|
|
1719
1944
|
input: {
|
|
1720
1945
|
accountId: string;
|
|
1721
1946
|
workspaceId: string;
|
|
@@ -1747,6 +1972,7 @@ export async function createOpenGeniSlackBotInteractionClient(
|
|
|
1747
1972
|
scheduledTaskId: null,
|
|
1748
1973
|
},
|
|
1749
1974
|
deps.slackFetch,
|
|
1975
|
+
deps.authorizeProviderRequest,
|
|
1750
1976
|
);
|
|
1751
1977
|
}
|
|
1752
1978
|
|
|
@@ -1770,7 +1996,7 @@ async function slackApiFetchWithHeaders(
|
|
|
1770
1996
|
credentialHeaders: Record<string, string>,
|
|
1771
1997
|
params: Record<string, string>,
|
|
1772
1998
|
): Promise<{ response: Response; payload: SlackPayload }> {
|
|
1773
|
-
if (!/^[a-z]
|
|
1999
|
+
if (!/^[a-z]+(?:\.[a-z]+){1,2}$/i.test(method)) {
|
|
1774
2000
|
throw new Error("invalid Slack API method");
|
|
1775
2001
|
}
|
|
1776
2002
|
const url = new URL(method, SLACK_API_BASE);
|
|
@@ -2576,6 +2802,74 @@ function embeddedSlackFileIds(content: string): Set<string> {
|
|
|
2576
2802
|
);
|
|
2577
2803
|
}
|
|
2578
2804
|
|
|
2805
|
+
/**
|
|
2806
|
+
* Bounded projections of `assistant.search.context` results. Content excerpts
|
|
2807
|
+
* are capped, Slack's rich `blocks` and context messages are deliberately
|
|
2808
|
+
* dropped, and permalinks are kept only when they are https URLs.
|
|
2809
|
+
*/
|
|
2810
|
+
function projectSearchMessage(value: unknown) {
|
|
2811
|
+
const message = slackRecord(value);
|
|
2812
|
+
const channelId = slackString(message?.channel_id);
|
|
2813
|
+
const ts = slackString(message?.message_ts);
|
|
2814
|
+
if (!message || !channelId || !ts) return null;
|
|
2815
|
+
return {
|
|
2816
|
+
channelId,
|
|
2817
|
+
channelName: boundedSlackString(message.channel_name, 256),
|
|
2818
|
+
ts,
|
|
2819
|
+
authorUserId: boundedSlackString(message.author_user_id, 64),
|
|
2820
|
+
authorName: boundedSlackString(message.author_name, 256),
|
|
2821
|
+
isAuthorBot: message.is_author_bot === true,
|
|
2822
|
+
content: boundedSlackString(message.content, MAX_SEARCH_CONTENT_CHARS),
|
|
2823
|
+
permalink: safeSlackPermalink(message.permalink),
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function projectSearchFile(value: unknown) {
|
|
2828
|
+
const file = slackRecord(value);
|
|
2829
|
+
const id = slackString(file?.file_id);
|
|
2830
|
+
if (!file || !id) return null;
|
|
2831
|
+
return {
|
|
2832
|
+
id,
|
|
2833
|
+
title: boundedSlackString(file.title, 512),
|
|
2834
|
+
filetype: boundedSlackString(file.file_type, 128),
|
|
2835
|
+
authorUserId: boundedSlackString(file.author_user_id, 64),
|
|
2836
|
+
authorName: boundedSlackString(file.author_name, 256),
|
|
2837
|
+
dateCreated: slackUnixSeconds(file.date_created),
|
|
2838
|
+
dateUpdated: slackUnixSeconds(file.date_updated),
|
|
2839
|
+
content: boundedSlackString(file.content, MAX_SEARCH_CONTENT_CHARS),
|
|
2840
|
+
permalink: safeSlackPermalink(file.permalink),
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function projectSearchChannel(value: unknown) {
|
|
2845
|
+
const channel = slackRecord(value);
|
|
2846
|
+
const name = slackString(channel?.name);
|
|
2847
|
+
if (!channel || !name) return null;
|
|
2848
|
+
return {
|
|
2849
|
+
name: boundedSlackString(name, 256),
|
|
2850
|
+
topic: boundedSlackString(channel.topic, 512),
|
|
2851
|
+
purpose: boundedSlackString(channel.purpose, 512),
|
|
2852
|
+
creatorUserId: boundedSlackString(channel.creator_user_id, 64),
|
|
2853
|
+
dateCreated: slackUnixSeconds(channel.date_created),
|
|
2854
|
+
permalink: safeSlackPermalink(channel.permalink),
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
function slackUnixSeconds(value: unknown): number | null {
|
|
2859
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
function safeSlackPermalink(value: unknown): string | null {
|
|
2863
|
+
// An over-long URL is dropped, never truncated into a still-parseable one.
|
|
2864
|
+
const raw = typeof value === "string" && value.length <= 1_024 ? value : "";
|
|
2865
|
+
if (!raw) return null;
|
|
2866
|
+
try {
|
|
2867
|
+
return new URL(raw).protocol === "https:" ? raw : null;
|
|
2868
|
+
} catch {
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2579
2873
|
function projectUser(value: unknown) {
|
|
2580
2874
|
const user = slackRecord(value);
|
|
2581
2875
|
const id = slackString(user?.id);
|
|
@@ -2701,30 +2995,6 @@ export function nextSlackFilesListPage(
|
|
|
2701
2995
|
return nextPage;
|
|
2702
2996
|
}
|
|
2703
2997
|
|
|
2704
|
-
function slackMethodForOperation(operation: SlackBotOperation): string {
|
|
2705
|
-
switch (operation) {
|
|
2706
|
-
case "channels.list":
|
|
2707
|
-
return "conversations.list";
|
|
2708
|
-
case "channel_history.read":
|
|
2709
|
-
return "conversations.history";
|
|
2710
|
-
case "thread_replies.read":
|
|
2711
|
-
return "conversations.replies";
|
|
2712
|
-
case "users.list":
|
|
2713
|
-
return "users.list";
|
|
2714
|
-
case "files.list":
|
|
2715
|
-
return "files.list";
|
|
2716
|
-
case "file.info":
|
|
2717
|
-
case "file.content.read":
|
|
2718
|
-
return "files.info";
|
|
2719
|
-
case "message.post":
|
|
2720
|
-
return "chat.postMessage";
|
|
2721
|
-
case "message.update":
|
|
2722
|
-
return "chat.update";
|
|
2723
|
-
case "message.delete":
|
|
2724
|
-
return "chat.delete";
|
|
2725
|
-
}
|
|
2726
|
-
}
|
|
2727
|
-
|
|
2728
2998
|
function validateSlackMessageBlocks(
|
|
2729
2999
|
blocks: SlackMessageBlock[] | undefined,
|
|
2730
3000
|
): SlackMessageBlock[] | undefined {
|
|
@@ -2738,6 +3008,16 @@ function validateSlackMessageBlocks(
|
|
|
2738
3008
|
return blocks;
|
|
2739
3009
|
}
|
|
2740
3010
|
|
|
3011
|
+
function validateSlackHomeBlocks(blocks: SlackHomeBlock[]): SlackHomeBlock[] {
|
|
3012
|
+
if (blocks.length < 1 || blocks.length > 100) {
|
|
3013
|
+
throw new RangeError("Slack App Home blocks exceed the supported count");
|
|
3014
|
+
}
|
|
3015
|
+
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 48 * 1024) {
|
|
3016
|
+
throw new RangeError("Slack App Home blocks exceed the supported byte size");
|
|
3017
|
+
}
|
|
3018
|
+
return blocks;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2741
3021
|
function boundedInt(value: number | undefined, max: number, fallback: number): number {
|
|
2742
3022
|
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
2743
3023
|
? Math.min(value, max)
|