@sentry/junior 0.266.0 → 0.268.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/{actor-tlxzqaRe.js → actor-CP9K2Idu.js} +9 -1
- package/dist/{adapter-eRFA_naf.js → adapter-04AcD2SO.js} +5 -7
- package/dist/{admin-tILpP51p.js → admin-DIU1QyG3.js} +1 -1
- package/dist/{agent-invocations-CxKldAeB.js → agent-invocations-C1RNOCs8.js} +2 -0
- package/dist/{agent-runner-BtYK7bht.js → agent-runner-CAYfFmU7.js} +2636 -601
- package/dist/api/schema.d.ts +2 -5454
- package/dist/api/schema.js +6 -5
- package/dist/api.d.ts +6 -1
- package/dist/api.js +255 -1208
- package/dist/{app-Csbi9dW7.d.ts → app-z6Uuyq3j.d.ts} +23 -4
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1869 -378
- package/dist/{automation-BKpY9LPa.js → automation-CmeFBSEh.js} +42 -14
- package/dist/{briefs-DgfWMU7e.js → briefs-DQbV2jNr.js} +4 -4
- package/dist/{catalog-runtime-DAJjAbSx.js → catalog-runtime-BD9GSpc6.js} +1 -1
- package/dist/cli/chat.js +15 -15
- package/dist/cli/check.js +3 -3
- package/dist/cli/init.js +0 -1
- package/dist/cli/main.js +3 -3
- package/dist/cli/plugins.js +4 -4
- package/dist/cli/snapshot-create.js +4 -4
- package/dist/cli/upgrade.d.ts +1 -1
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-B0YZfxNy.js → client-C1yDYjoS.js} +2 -2
- package/dist/{config-C0UwYqDK.js → config-DrTeF80T.js} +32 -10
- package/dist/{connections-Bwu3VORi.js → connections-D46f8vvi.js} +12 -12
- package/dist/{conversation-DF4rUcjb.js → conversation-DSo310Ps.js} +8 -1
- package/dist/{conversation-privacy-CY7JVjxn.js → conversation-privacy-CxlOCiUo.js} +1 -1
- package/dist/{credential-sync-VGAMUTGp.js → credential-sync-sm9fegLY.js} +29 -34
- package/dist/{credentialed-enF5Bf3y.js → credentialed-lukuD-cc.js} +2933 -1026
- package/dist/db-BU4LQD8s.d.ts +6 -0
- package/dist/{executor-TnWbvL09.js → executor-4vhvsMZJ.js} +83 -6
- package/dist/instrumentation.js +6 -2
- package/dist/{assistant-reply-UyzAfZQo.js → interruption-marker-CkncDT8d.js} +8 -2
- package/dist/{job-queue-DBpHmKnd.js → job-queue-In9QKz-I.js} +128 -3
- package/dist/{web-input-B-bMjD1V.js → list-BkgnXEEB.js} +1611 -817
- package/dist/{logging-Ce0mXZ8J.js → logging-DLSpkSSr.js} +11 -3
- package/dist/{message-VZ3DFpGb.js → message-BObyg59s.js} +4 -4
- package/dist/nitro.d.ts +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-callback-server-CpqWLjVY.js → oauth-callback-server-B-WbSdoH.js} +2 -2
- package/dist/{prompt-BcCGlmg2.js → prompt-BW9jFsqZ.js} +1 -1
- package/dist/{registry-1BEIPdbT.js → registry-BgecKva0.js} +2 -2
- package/dist/{web-message-id-CJSUiksE.js → reporting-window-DVCD_10C.js} +1 -10
- package/dist/{resolve-BSHHkEGE.js → resolve-Cpa4Bu1O.js} +3 -3
- package/dist/{result-CwOpzqdh.js → result-D3TSmZZ8.js} +9 -9
- package/dist/{runner-CEaX04kz.js → runner-DS5OKhEp.js} +11 -11
- package/dist/{sandbox-egress-signals-BwtGHwqa.js → sandbox-egress-signals-CnDjY5rx.js} +1 -1
- package/dist/{schema-RsaBHxRH.js → schema-0050HFnB.js} +82 -5
- package/dist/schema-ZngqcOWM.d.ts +5813 -0
- package/dist/{service-account-6wC76eBZ.js → service-account-CKV2WTY7.js} +3 -3
- package/dist/{service-account-Dg4lIbHm.js → service-account-DGjnDw9y.js} +3 -3
- package/dist/{session-stop-tZOw9AJD.js → session-stop-Dam1EI7B.js} +527 -134
- package/dist/{skills-DzyGN7wd.js → skills-CfAnfImD.js} +2 -2
- package/dist/{spawn-6rokq7l_.js → spawn-cmq9p4Ly.js} +2 -2
- package/dist/{store-DcKkzrdt.js → store-C5PN02QO.js} +4 -4
- package/dist/{store-CG0VJyye.d.ts → store-xMuuXRE0.d.ts} +23 -9
- package/dist/store-zPEoJPBB.js +295 -0
- package/dist/{task-queue-Bg4RS2Gy.js → task-queue-_mf28dSu.js} +10 -5
- package/dist/{task-runner-8N5LIkSg.js → task-runner-DMI2prDO.js} +25 -14
- package/dist/{tasks-DDT-J3Rf.js → tasks-SB9z-VTU.js} +1551 -54
- package/dist/{thread-state-cprEaxgl.js → thread-state-BbbPh0Sn.js} +8 -18
- package/dist/{turn-failure-response-C8sMdsdX.js → turn-failure-response-BKKmnMB5.js} +11 -16
- package/dist/{turn-lifecycle-DaBVoz7h.js → turn-lifecycle-oP0kVeGz.js} +1 -1
- package/dist/{validation-C2lllinq.js → validation--JsAGLwc.js} +1 -1
- package/dist/{version-oaLfxxnf.js → version-jHwJp4hC.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-BpE6Z3qf.js → viewer-CpXTGlep.js} +329 -230
- package/dist/web-message-id-DlHNANtd.js +132 -0
- package/dist/{work-VX1SHWha.js → work-3N-GHY9m.js} +10 -10
- package/dist/{worker-hook-y_Y.js → worker-CCdbcItd.js} +30 -19
- package/migrations/0064_any_resource_event_triggers.sql +2 -0
- package/migrations/0065_user_customization.sql +1 -0
- package/migrations/0066_review_items.sql +53 -0
- package/migrations/0067_review_item_dedupe_tenant.sql +2 -0
- package/migrations/0068_review_item_slack_cards.sql +10 -0
- package/migrations/meta/0064_snapshot.json +3505 -0
- package/migrations/meta/0065_snapshot.json +3511 -0
- package/migrations/meta/0066_snapshot.json +4099 -0
- package/migrations/meta/0067_snapshot.json +3921 -0
- package/migrations/meta/0068_snapshot.json +4176 -0
- package/migrations/meta/_journal.json +36 -1
- package/package.json +19 -4
- package/dist/db-BnRqQ7G5.d.ts +0 -5
- package/dist/images-BKQqSi5W.js +0 -64
- package/dist/store-FALy-JQb.js +0 -1424
- package/dist/turn-session-routing-a9CHX-w6.js +0 -76
package/dist/app.js
CHANGED
|
@@ -1,37 +1,38 @@
|
|
|
1
|
-
import { C as automationVersionOf,
|
|
2
|
-
import { _ as snapshotRuntimeConfig, d as restoreRuntimeConfig, f as setBotModelConfig, g as setSlackReactionConfig, h as setRuntimeLimits, i as getChatConfig, m as setProfiles, n as botConfig, o as getSlackBotToken, p as setCrossActorMidRunMode, u as resetRuntimeConfig } from "./config-
|
|
3
|
-
import {
|
|
1
|
+
import { $t as CONVERSATION_WORK_STALE_ENQUEUE_MS, C as automationVersionOf, I as eventGuidance, _n as isInvalidConversationRecordError, _r as setConfigDefaults, _t as settleConversationTitleWork, ar as getThreadStopDecision, b as admitEventAutomationRun, c as withScheduledAutomationLock, dr as setExperimentalFeatures, dt as appendConversationMessages, gr as getConfigDefaults, hn as hasRunnableConversationWork, i as readScheduledAutomation, l as blockEventAutomation, lr as getExperimentalFeatures, mt as projectConversationMessages, n as listScheduledAutomationsForTeam, nr as SubscribedReplyReason, o as saveScheduledAutomationInLock, p as findMatchingEventAutomations, s as scheduledRunId, u as collectEventAutomationMatchKeys, ur as isExperimentalFeatureEnabled, x as eventAutomationRunLimitReason } from "./tasks-SB9z-VTU.js";
|
|
2
|
+
import { _ as snapshotRuntimeConfig, d as restoreRuntimeConfig, f as setBotModelConfig, g as setSlackReactionConfig, h as setRuntimeLimits, i as getChatConfig, m as setProfiles, n as botConfig, o as getSlackBotToken, p as setCrossActorMidRunMode, u as resetRuntimeConfig } from "./config-DrTeF80T.js";
|
|
3
|
+
import { A as JUNIOR_PLUGIN_TASK_CALLBACK_ROUTE, E as withSpan, N as JUNIOR_WORKSPACE_SNAPSHOT_JOB_CALLBACK_ROUTE, T as withLogContext, b as setSpanAttributes, h as logWarn, m as logInfo, p as logException, r as createRequestContext, w as withContext, z as normalizeIdentityEmail } from "./logging-DLSpkSSr.js";
|
|
4
4
|
import { n as captureMessage } from "./sentry-MDkYAVPV.js";
|
|
5
|
-
import { t as JUNIOR_VERSION } from "./version-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { a as juniorAgentInvocations, d as juniorDestinations, i as juniorAgentBindings, s as juniorConversations } from "./agent-invocations-
|
|
9
|
-
import {
|
|
10
|
-
import { n as pluginCatalogRuntime } from "./catalog-runtime-
|
|
11
|
-
import { C as requestAttachmentDeletion,
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
5
|
+
import { t as JUNIOR_VERSION } from "./version-jHwJp4hC.js";
|
|
6
|
+
import { f as parseSlackTeamId, g as slackMessageTsSchema, h as slackMessageTsFromMs, m as parseSlackMessageTs, n as createSlackActor, p as parseSlackUserId, s as parseActorUserId, u as parseSlackChannelId } from "./actor-CP9K2Idu.js";
|
|
7
|
+
import { O as recordGoogleSignIn, _ as withConversationEventLock, b as withConversationMutationLock, d as getConversationEventStore, h as getSqlExecutor, i as readActorIdentity, l as closeDb, m as getDb, o as resolveViewerUser, p as getConversationStore } from "./viewer-CpXTGlep.js";
|
|
8
|
+
import { a as juniorAgentInvocations, d as juniorDestinations, i as juniorAgentBindings, s as juniorConversations } from "./agent-invocations-C1RNOCs8.js";
|
|
9
|
+
import { d as juniorAutomations, g as juniorConversationBriefs, l as juniorAutomationRuns, y as juniorConversationEvents } from "./executor-4vhvsMZJ.js";
|
|
10
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-BD9GSpc6.js";
|
|
11
|
+
import { C as getOpenSlackUserInput, Ct as requestAttachmentDeletion, Dt as recordSubagentEnded, E as parseSlackThreadId, Et as stopConversationSession, G as parseSlackReviewItemClick, H as syncReviewItemSlackCards, J as buildSlackUserInputModal, K as parseSlackReviewItemFormTarget, N as logScheduledAutomationRunSkipped, Nt as processWorkspaceSnapshotJob, P as scheduledAutomationRunAttributes, Pt as ingestEvent, Q as slackUserInputAnswerNote, R as effectiveTaskOutcomes, S as closeSlackUserInput, T as releaseSlackUserInput, V as readSlackReviewItemCardConversationId, W as buildSlackReviewItemModal, X as parseSlackUserInputSubmission, Y as parseSlackUserInputFormTarget, Z as slackUserInputAction, _ as unpublishedArtifactHeaders, _t as recordWebConversationActivity, dt as sendReviewItemMessages, et as runWithSlackInstallation, g as readPublicArtifact, h as publicArtifactHeaders, mt as conversationTurnIdForMessage, n as executeAgentRun, pt as appendAndEnqueueWebMessage, t as createAgentRunner, tt as verifySlackSignature, ut as renderReviewItemMessage, v as lookupSlackUserProfile, vt as webActorFromEmail, w as persistSlackUserInputReply, x as claimSlackUserInput, xt as collectAttachmentGarbage } from "./agent-runner-CAYfFmU7.js";
|
|
12
|
+
import { $ as openSlackModal, I as isVercelSandboxEgressRequest, In as createPluginLogger, Jt as bindScheduledAutomationCredentialSubject, Kt as takeDueTimerWatches, L as proxyVercelSandboxEgressRequest, Ln as readConversationAccessFromSql, Nn as createPluginState, Qt as verifySlackDirectCredentialSubject, Tt as normalizeSandboxEgressTracePropagationDomains, Ut as findMatchingWatches, V as resolveBaseUrl, Xt as verifyEventAutomationCredentialSubject, Yt as bindSlackDirectCredentialSubject, Zt as verifyScheduledAutomationCredentialSubject, _n as getDashboardTaskLink, _t as consumeSandboxEgressPermissionDeniedSignal, at as slackEntitySchema, c as getPluginRoutes, cr as createPluginCache, dn as getWorkspaceTeamId, er as resolveRootVisibility, et as postSlackEphemeralMessage, fn as runWithWorkspaceTeamId, gt as consumeSandboxEgressAuthRequiredSignal, h as validatePlugins, ht as clearSandboxEgressSignals, in as getSlackClient, lr as createCache, m as setPlugins, mn as getDashboardConversationLink, n as createUserTokenStore, on as isDmChannel, ot as slackExternalRefIdSchema, p as getPlugins, q as slackMention, qt as bindEventAutomationCredentialSubject, rr as listConversationAnnotations, s as getPluginApiRoutes, v as setBriefsConfig, vn as setDashboardConversationLinkOptions, yt as parseSandboxEgressCredentialToken, zt as collectEventMatchKeys } from "./credentialed-lukuD-cc.js";
|
|
13
|
+
import { a as MUTATION_LOCK_TTL_MS, c as withLock, i as getStateAdapter, s as fenceLock, t as disconnectStateAdapter } from "./adapter-04AcD2SO.js";
|
|
14
14
|
import { n as getNextRunAtMs } from "./cadence-R7Em4RjE.js";
|
|
15
|
-
import {
|
|
16
|
-
import { b as listPendingAgentInvocationMailboxAppends, x as listPendingAgentInvocationResults } from "./store-
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
15
|
+
import { D as conversationSurfaceSchema, I as annotationCard, T as conversationReportStatusSchema, h as conversationFeedQuerySchema } from "./conversation-DSo310Ps.js";
|
|
16
|
+
import { b as listPendingAgentInvocationMailboxAppends, x as listPendingAgentInvocationResults } from "./store-C5PN02QO.js";
|
|
17
|
+
import { E as renderSlackObjectCard, S as renderTaskInput, d as getEventCatalog, f as SLACK_EVENT_NAMESPACE, g as slackChannelMessageRunsAutomation, h as slackChannelMessageReplyThreadTs, m as slackChannelMessageEvent } from "./session-stop-Dam1EI7B.js";
|
|
18
|
+
import { a as isEmptyUserInputAnswer, i as formatUserInputAnswer, o as normalizeUserInputAnswer, r as buttonUserInputField } from "./web-message-id-DlHNANtd.js";
|
|
19
|
+
import { c as setSandboxResourceConfig, o as getSandboxResourceConfig } from "./resolve-Cpa4Bu1O.js";
|
|
20
|
+
import { a as getVercelConversationWorkQueue, d as getQueueProvider, g as isConversationQueueMessageRejectedError, h as conversationQueueMessageSchema, i as verifyWorkspaceSnapshotJobMessage, o as resolveConversationWorkQueueTopic, s as verifyConversationQueueMessage, t as WORKSPACE_SNAPSHOT_JOB_QUEUE_TOPIC } from "./job-queue-In9QKz-I.js";
|
|
21
|
+
import { S as removeActiveConversation, _ as hasRunnableConversationWork$1, c as clearExpiredConversationLease, g as getConversationWorkState, h as getConversation, m as ensureConversationWake, r as appendAndEnqueueInboundMessage, v as listActiveConversationIds } from "./store-zPEoJPBB.js";
|
|
22
|
+
import { D as scheduledRunSchema } from "./automation-CmeFBSEh.js";
|
|
23
|
+
import { n as validatePluginRegistrations, t as validatePluginEgressCredentialHooks } from "./validation--JsAGLwc.js";
|
|
23
24
|
import { a as pluginRuntimeRegistrationsFromPluginSet, n as pluginCatalogConfigFromEnv, r as pluginCatalogConfigFromPluginSet, t as defineJuniorPlugins } from "./plugins-DOm9Y6qI.js";
|
|
24
|
-
import { A as
|
|
25
|
-
import { C as existingConversationId, N as recordAutomationExecution, c as getDispatchConversationId, d as getDispatchRecord, h as listPendingDispatchMailboxAppends, i as buildDispatchId, m as isTerminalDispatchStatus, o as confirmDispatchMailboxAppend, p as getPluginDispatchProjection, s as createOrGetDispatch, t as processPluginTask, x as notifyAutomationBlocked, y as markDispatchFailed } from "./task-runner-
|
|
26
|
-
import { o as getAuthPausedConversationTurnId, t as enqueueAgentInvocationResult } from "./result-
|
|
27
|
-
import { r as enqueueAgentInvocation } from "./work-
|
|
28
|
-
import {
|
|
29
|
-
import { o as unlinkProvider } from "./connections-
|
|
30
|
-
import { a as GET$4, i as publishAppHomeView, r as GET$5, t as receiveLocalOAuthCredential } from "./credential-sync-
|
|
31
|
-
import { a as
|
|
32
|
-
import { t as processConversationWork } from "./worker-
|
|
33
|
-
import { t as bindMessageAgent } from "./message-
|
|
34
|
-
import { t as bindSpawnAgent } from "./spawn-
|
|
25
|
+
import { A as assertNoLegacyAllowedDomains, B as normalizeIncomingSlackThreadId, E as readViewerAutomationSummary, F as recordSkippedConversationMessage, G as parseContent, H as stopConversationTurn, I as buildSlackInboundMessage, J as canActOnReviewItem, K as ReviewItemActionError, L as clearSlackPendingReactions, M as isAllowedSignInEmail, N as readEnvList, P as resolveAllowedDomains, Q as getAttachmentStorage, R as isSlackMessageStopped, U as THREAD_OPTOUT_ACK, V as withNormalizedThreadId, W as startProcessingReaction, X as readReviewItemForUser, Z as reviewItemChange, _ as createProductionConversationWorkOptions, b as createSlackWebApiStatusSender, et as enqueueAgentDispatch, j as isAllowedDomain, q as actOnReviewItem, t as readConversationFeed, tt as GET$3, v as createProductionSlackWebhookServices, y as getProductionSlackAdapter, z as stopSlackThread } from "./list-BkgnXEEB.js";
|
|
26
|
+
import { C as existingConversationId, N as recordAutomationExecution, c as getDispatchConversationId, d as getDispatchRecord, h as listPendingDispatchMailboxAppends, i as buildDispatchId, m as isTerminalDispatchStatus, o as confirmDispatchMailboxAppend, p as getPluginDispatchProjection, s as createOrGetDispatch, t as processPluginTask, x as notifyAutomationBlocked, y as markDispatchFailed } from "./task-runner-DMI2prDO.js";
|
|
27
|
+
import { o as getAuthPausedConversationTurnId, t as enqueueAgentInvocationResult } from "./result-D3TSmZZ8.js";
|
|
28
|
+
import { r as enqueueAgentInvocation } from "./work-3N-GHY9m.js";
|
|
29
|
+
import { o as coerceThreadConversationState } from "./thread-state-BbbPh0Sn.js";
|
|
30
|
+
import { o as unlinkProvider } from "./connections-D46f8vvi.js";
|
|
31
|
+
import { a as GET$4, i as publishAppHomeView, r as GET$5, t as receiveLocalOAuthCredential } from "./credential-sync-sm9fegLY.js";
|
|
32
|
+
import { a as pluginTaskQueueConsumer, c as queueCallback, n as createVercelPluginTaskCallback, o as registerVercelPluginTaskDevConsumer } from "./task-queue-_mf28dSu.js";
|
|
33
|
+
import { t as processConversationWork } from "./worker-CCdbcItd.js";
|
|
34
|
+
import { t as bindMessageAgent } from "./message-BObyg59s.js";
|
|
35
|
+
import { t as bindSpawnAgent } from "./spawn-cmq9p4Ly.js";
|
|
35
36
|
import { createRequire } from "node:module";
|
|
36
37
|
import { Hono } from "hono";
|
|
37
38
|
import { z } from "zod";
|
|
@@ -39,10 +40,15 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypt
|
|
|
39
40
|
import { and, asc, eq, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
|
|
40
41
|
import { EVENT_SUMMARY_MAX_LENGTH, EVENT_TEXT_MAX_LENGTH, dispatchOptionsSchema, eventSchema, eventTriggerFiltersText, isSlackDestination, userSchema } from "@sentry/junior-plugin-api";
|
|
41
42
|
import { pathToFileURL } from "node:url";
|
|
42
|
-
import
|
|
43
|
+
import net from "node:net";
|
|
43
44
|
import { decodeJwt } from "jose";
|
|
44
|
-
import
|
|
45
|
+
import https from "node:https";
|
|
46
|
+
import { Message, ThreadImpl } from "chat";
|
|
45
47
|
import * as acp from "@agentclientprotocol/sdk";
|
|
48
|
+
import dns from "node:dns";
|
|
49
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
50
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
51
|
+
import { bodyLimit } from "hono/body-limit";
|
|
46
52
|
import { createAuthMiddleware } from "better-auth/api";
|
|
47
53
|
import { betterAuth } from "better-auth/minimal";
|
|
48
54
|
// src/chat/events/timers.ts
|
|
@@ -146,7 +152,7 @@ async function recoverConversationWork(args) {
|
|
|
146
152
|
}
|
|
147
153
|
return;
|
|
148
154
|
}
|
|
149
|
-
if (work.execution.lease || !hasRunnableConversationWork(work)) return;
|
|
155
|
+
if (work.execution.lease || !hasRunnableConversationWork$1(work)) return;
|
|
150
156
|
if ((await ensureConversationWake({
|
|
151
157
|
conversationId,
|
|
152
158
|
idempotencyKey: heartbeatIdempotencyKey("pending", conversationId, args.nowMs),
|
|
@@ -1548,14 +1554,6 @@ async function presentSlackAnnotationDetails(event, teamId) {
|
|
|
1548
1554
|
}
|
|
1549
1555
|
// src/chat/ingress/slack-payload.ts
|
|
1550
1556
|
const eventTimestampSchema = slackMessageTsSchema.refine((value) => Number.isFinite((/* @__PURE__ */ new Date(Number(value) * 1e3)).getTime()));
|
|
1551
|
-
const slackSlashCommandSchema = z.object({
|
|
1552
|
-
channel_id: z.string().trim().pipe(slackChannelIdSchema),
|
|
1553
|
-
team_id: z.string().trim().pipe(slackTeamIdSchema),
|
|
1554
|
-
user_id: z.string().trim().pipe(slackUserIdSchema),
|
|
1555
|
-
text: z.string(),
|
|
1556
|
-
enterprise_id: z.string().optional(),
|
|
1557
|
-
is_enterprise_install: z.enum(["true", "false"]).optional()
|
|
1558
|
-
});
|
|
1559
1557
|
const slackAssistantThreadSchema = z.object({
|
|
1560
1558
|
channel_id: z.string().min(1),
|
|
1561
1559
|
thread_ts: eventTimestampSchema,
|
|
@@ -1630,163 +1628,547 @@ const slackEventEnvelopeSchema = z.object({
|
|
|
1630
1628
|
type: z.string(),
|
|
1631
1629
|
challenge: z.string().optional()
|
|
1632
1630
|
});
|
|
1631
|
+
const slackViewStateValueSchema = z.object({
|
|
1632
|
+
selected_option: z.object({ value: z.string().optional() }).nullish(),
|
|
1633
|
+
selected_options: z.array(z.object({ value: z.string().optional() })).nullish(),
|
|
1634
|
+
value: z.string().nullish()
|
|
1635
|
+
});
|
|
1633
1636
|
const slackInteractivePayloadSchema = z.object({
|
|
1634
1637
|
actions: z.array(z.object({
|
|
1635
1638
|
action_id: z.string().optional(),
|
|
1639
|
+
action_ts: z.string().optional(),
|
|
1636
1640
|
selected_option: z.object({ value: z.string().optional() }).optional(),
|
|
1637
1641
|
value: z.string().optional()
|
|
1638
1642
|
})).optional(),
|
|
1643
|
+
channel: z.object({ id: z.string().optional() }).nullish(),
|
|
1644
|
+
message: z.object({
|
|
1645
|
+
thread_ts: z.string().optional(),
|
|
1646
|
+
ts: z.string().optional()
|
|
1647
|
+
}).nullish(),
|
|
1639
1648
|
team: z.object({ id: z.string().optional() }).nullish(),
|
|
1649
|
+
trigger_id: z.string().optional(),
|
|
1640
1650
|
type: z.string(),
|
|
1641
1651
|
user: z.object({
|
|
1642
1652
|
id: z.string().optional(),
|
|
1643
1653
|
team_id: z.string().optional()
|
|
1644
|
-
}).optional()
|
|
1654
|
+
}).optional(),
|
|
1655
|
+
view: z.object({
|
|
1656
|
+
callback_id: z.string().optional(),
|
|
1657
|
+
id: z.string().optional(),
|
|
1658
|
+
private_metadata: z.string().optional(),
|
|
1659
|
+
state: z.object({ values: z.record(z.string(), z.record(z.string(), slackViewStateValueSchema)) }).optional()
|
|
1660
|
+
}).nullish()
|
|
1661
|
+
});
|
|
1662
|
+
// src/chat/ingress/workspace-membership.ts
|
|
1663
|
+
const MEMBER_TTL_MS = 3e5;
|
|
1664
|
+
const NON_MEMBER_TTL_MS = 3e4;
|
|
1665
|
+
const authorTeamSchema = z.object({
|
|
1666
|
+
user_team: z.string().optional(),
|
|
1667
|
+
source_team: z.string().optional(),
|
|
1668
|
+
user: z.unknown().optional()
|
|
1645
1669
|
});
|
|
1646
|
-
|
|
1647
|
-
async function
|
|
1648
|
-
|
|
1670
|
+
/** Accept only verified workspace authors, reusing recent membership lookups. */
|
|
1671
|
+
async function isSlackWorkspaceMember(raw, state) {
|
|
1672
|
+
const workspaceTeamId = getWorkspaceTeamId();
|
|
1673
|
+
if (!workspaceTeamId) return false;
|
|
1674
|
+
const parsed = authorTeamSchema.safeParse(raw);
|
|
1675
|
+
if (!parsed.success) return false;
|
|
1676
|
+
const event = parsed.data;
|
|
1677
|
+
const userId = parseSlackUserId(event.user);
|
|
1678
|
+
if (!userId) return false;
|
|
1679
|
+
const cache = createCache(state, "core");
|
|
1680
|
+
const key = `slack:membership:${workspaceTeamId}:${userId}`;
|
|
1681
|
+
const authorTeam = parseSlackTeamId(event.user_team);
|
|
1682
|
+
if (authorTeam === workspaceTeamId) return true;
|
|
1683
|
+
if (event.user_team !== void 0 && !authorTeam && !/^E[A-Z0-9]+$/.test(event.user_team)) return false;
|
|
1684
|
+
if (authorTeam !== void 0) return await cache.getOrLoad(key, async () => ({
|
|
1685
|
+
value: false,
|
|
1686
|
+
ttlMs: NON_MEMBER_TTL_MS
|
|
1687
|
+
}), { refresh: true });
|
|
1688
|
+
return await cache.getOrLoad(key, async () => {
|
|
1689
|
+
const user = await lookupSlackUserProfile(userId);
|
|
1690
|
+
const authorTeam = parseSlackTeamId(user.team_id);
|
|
1691
|
+
if (user.id !== userId || !authorTeam) return { value: false };
|
|
1692
|
+
const member = authorTeam === workspaceTeamId && !user.is_deleted;
|
|
1693
|
+
return {
|
|
1694
|
+
value: member,
|
|
1695
|
+
ttlMs: member ? MEMBER_TTL_MS : NON_MEMBER_TTL_MS
|
|
1696
|
+
};
|
|
1697
|
+
});
|
|
1649
1698
|
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1699
|
+
// src/chat/ingress/slack-interaction-input.ts
|
|
1700
|
+
/**
|
|
1701
|
+
* Build the Slack input that a click or form makes in a thread, as a
|
|
1702
|
+
* mention from the person who acted. The input has no Slack message of its
|
|
1703
|
+
* own, so `messageId` is not a timestamp. Every Slack timestamp reader, such
|
|
1704
|
+
* as reactions, the actor `slack_ts`, and attachment lookup, then resolves
|
|
1705
|
+
* `raw.ts`: the message that the person clicked. `inputTs` sets the time of
|
|
1706
|
+
* the input.
|
|
1707
|
+
*/
|
|
1708
|
+
function buildSlackInteractionInput(args) {
|
|
1709
|
+
const event = {
|
|
1710
|
+
type: "message",
|
|
1711
|
+
channel: args.channelId,
|
|
1712
|
+
team: args.payload.team?.id,
|
|
1713
|
+
text: args.text,
|
|
1714
|
+
thread_ts: args.threadTs,
|
|
1715
|
+
ts: args.inputTs,
|
|
1716
|
+
user: args.userId,
|
|
1717
|
+
user_team: args.payload.user?.team_id
|
|
1718
|
+
};
|
|
1719
|
+
const parsed = args.adapter.parseMessage(event);
|
|
1720
|
+
return new Message({
|
|
1721
|
+
attachments: parsed.attachments,
|
|
1722
|
+
author: parsed.author,
|
|
1723
|
+
formatted: parsed.formatted,
|
|
1724
|
+
id: args.messageId,
|
|
1725
|
+
isMention: true,
|
|
1726
|
+
links: parsed.links,
|
|
1727
|
+
metadata: parsed.metadata,
|
|
1728
|
+
raw: {
|
|
1729
|
+
...event,
|
|
1730
|
+
ts: args.messageTs
|
|
1731
|
+
},
|
|
1732
|
+
text: parsed.text,
|
|
1733
|
+
threadId: parsed.threadId
|
|
1734
|
+
});
|
|
1652
1735
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1736
|
+
// src/chat/ingress/slack-review-item.ts
|
|
1737
|
+
async function tellPerson(target, userId, text) {
|
|
1738
|
+
await postSlackEphemeralMessage({
|
|
1739
|
+
channelId: target.channelId,
|
|
1740
|
+
text,
|
|
1741
|
+
threadTs: target.threadTs,
|
|
1742
|
+
userId
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Resolve the Junior User of the person who acted. An action starts a Turn
|
|
1747
|
+
* as that person, so it needs the same workspace and allowed domain checks
|
|
1748
|
+
* as a Slack mention.
|
|
1749
|
+
*/
|
|
1750
|
+
async function resolveActingUser(services, payload, target) {
|
|
1751
|
+
const slackUserId = parseActorUserId(payload.user?.id);
|
|
1752
|
+
const teamId = payload.team?.id ?? payload.user?.team_id;
|
|
1753
|
+
if (!slackUserId || !teamId) throw new Error("Slack review item payload is missing a user or team");
|
|
1754
|
+
if (!await isSlackWorkspaceMember({
|
|
1755
|
+
user: slackUserId,
|
|
1756
|
+
user_team: payload.user?.team_id
|
|
1757
|
+
}, services.state) || !await services.isAuthorAllowed(slackUserId)) return;
|
|
1758
|
+
const user = (await readActorIdentity(createSlackActor(teamId, slackUserId, void 0)))?.user;
|
|
1759
|
+
if (!user) {
|
|
1760
|
+
await tellPerson(target, slackUserId, "Junior does not know your account yet. Sign in to the Junior dashboard, then try again.");
|
|
1660
1761
|
return;
|
|
1661
1762
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1763
|
+
return {
|
|
1764
|
+
slackUserId,
|
|
1765
|
+
user
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
/** Show the current state of an item, such as after a click on a stale card. */
|
|
1769
|
+
async function refreshCards(services, user, itemId) {
|
|
1770
|
+
const item = await readReviewItemForUser(user, itemId).catch(() => void 0);
|
|
1771
|
+
if (!item) return;
|
|
1772
|
+
await syncReviewItemSlackCards({
|
|
1773
|
+
getSlackAdapter: () => services.adapter,
|
|
1774
|
+
itemId: item.id
|
|
1670
1775
|
});
|
|
1671
|
-
|
|
1672
|
-
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Do one person action from a Slack card. The thread of the card gets the
|
|
1779
|
+
* action as Slack input from the person, like an answer to
|
|
1780
|
+
* `requestUserInput`. The other linked Conversations get messages from
|
|
1781
|
+
* `actOnReviewItem`. An expected failure, such as an item that is already
|
|
1782
|
+
* closed, goes to the person as an ephemeral message.
|
|
1783
|
+
*/
|
|
1784
|
+
async function act(args) {
|
|
1785
|
+
const { services, target } = args;
|
|
1786
|
+
const actor = await resolveActingUser(services, args.payload, target);
|
|
1787
|
+
if (!actor) return;
|
|
1788
|
+
const conversationId = await readSlackReviewItemCardConversationId({
|
|
1789
|
+
...target,
|
|
1790
|
+
itemId: args.itemId
|
|
1791
|
+
}) ?? `slack:${target.channelId}:${target.threadTs}`;
|
|
1792
|
+
let item;
|
|
1793
|
+
try {
|
|
1794
|
+
const current = await readReviewItemForUser(actor.user, args.itemId);
|
|
1795
|
+
item = await actOnReviewItem({
|
|
1796
|
+
action: args.action,
|
|
1797
|
+
exceptConversationId: conversationId,
|
|
1798
|
+
firstCloseOnly: true,
|
|
1799
|
+
getSlackAdapter: () => services.adapter,
|
|
1800
|
+
id: current.id,
|
|
1801
|
+
queue: services.queue,
|
|
1802
|
+
user: actor.user,
|
|
1803
|
+
...args.answer ? { answer: args.answer(current) } : void 0
|
|
1804
|
+
});
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
if (!(error instanceof ReviewItemActionError)) throw error;
|
|
1807
|
+
await tellPerson(target, actor.slackUserId, error.message);
|
|
1808
|
+
await refreshCards(services, actor.user, args.itemId);
|
|
1673
1809
|
return;
|
|
1674
1810
|
}
|
|
1675
|
-
if (
|
|
1676
|
-
|
|
1811
|
+
if (!item.conversationIds.includes(conversationId)) return;
|
|
1812
|
+
const change = reviewItemChange(args.action, actor.user);
|
|
1813
|
+
try {
|
|
1814
|
+
await services.persistMessage(buildSlackInteractionInput({
|
|
1815
|
+
adapter: services.adapter,
|
|
1816
|
+
channelId: target.channelId,
|
|
1817
|
+
inputTs: args.inputTs,
|
|
1818
|
+
messageId: args.messageId,
|
|
1819
|
+
messageTs: target.messageTs,
|
|
1820
|
+
payload: args.payload,
|
|
1821
|
+
text: renderReviewItemMessage(item, change),
|
|
1822
|
+
threadTs: target.threadTs,
|
|
1823
|
+
userId: actor.slackUserId
|
|
1824
|
+
}));
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
logException(error, "review_item.slack_input.persist.failed", { "app.review_item.id": item.id });
|
|
1827
|
+
await sendReviewItemMessages({
|
|
1828
|
+
change,
|
|
1829
|
+
item,
|
|
1830
|
+
nowMs: Date.now(),
|
|
1831
|
+
queue: services.queue
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function cardTarget(payload) {
|
|
1836
|
+
const channelId = parseSlackChannelId(payload.channel?.id);
|
|
1837
|
+
const messageTs = parseSlackMessageTs(payload.message?.ts);
|
|
1838
|
+
if (!channelId || !messageTs) throw new Error("Slack review item payload is incomplete");
|
|
1839
|
+
return {
|
|
1840
|
+
channelId,
|
|
1841
|
+
messageTs,
|
|
1842
|
+
threadTs: parseSlackMessageTs(payload.message?.thread_ts) ?? messageTs
|
|
1843
|
+
};
|
|
1677
1844
|
}
|
|
1678
|
-
async function
|
|
1679
|
-
|
|
1680
|
-
|
|
1845
|
+
async function openAnswerForm(args) {
|
|
1846
|
+
const { target } = args;
|
|
1847
|
+
const triggerId = args.payload.trigger_id;
|
|
1848
|
+
if (!triggerId) throw new Error("Slack review item form payload is missing a trigger id");
|
|
1849
|
+
const actor = await resolveActingUser(args.services, args.payload, target);
|
|
1850
|
+
if (!actor) return;
|
|
1851
|
+
let item;
|
|
1852
|
+
try {
|
|
1853
|
+
item = await readReviewItemForUser(actor.user, args.itemId);
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
if (!(error instanceof ReviewItemActionError)) throw error;
|
|
1856
|
+
await tellPerson(target, actor.slackUserId, error.message);
|
|
1681
1857
|
return;
|
|
1682
1858
|
}
|
|
1683
|
-
if (!(
|
|
1684
|
-
await
|
|
1859
|
+
if (!canActOnReviewItem(item, actor.user.id)) {
|
|
1860
|
+
await tellPerson(target, actor.slackUserId, item.status === "open" ? "Only an assignee can act on this item." : "This item is already closed.");
|
|
1861
|
+
await refreshCards(args.services, actor.user, item.id);
|
|
1685
1862
|
return;
|
|
1686
1863
|
}
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1864
|
+
const view = buildSlackReviewItemModal(item, {
|
|
1865
|
+
...target,
|
|
1866
|
+
itemId: item.id
|
|
1867
|
+
});
|
|
1868
|
+
if (!view) return;
|
|
1869
|
+
await openSlackModal({
|
|
1870
|
+
triggerId,
|
|
1871
|
+
view
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
/** Handle one click on a Review item card. */
|
|
1875
|
+
async function handleSlackReviewItemClick(args) {
|
|
1876
|
+
const { click, payload, services } = args;
|
|
1877
|
+
const target = cardTarget(payload);
|
|
1878
|
+
if (click.kind === "answer") {
|
|
1879
|
+
await openAnswerForm({
|
|
1880
|
+
itemId: click.itemId,
|
|
1881
|
+
payload,
|
|
1882
|
+
services,
|
|
1883
|
+
target
|
|
1884
|
+
});
|
|
1697
1885
|
return;
|
|
1698
1886
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1887
|
+
const actionTs = parseSlackMessageTs(args.action.action_ts);
|
|
1888
|
+
if (!actionTs) throw new Error("Slack review item click payload is incomplete");
|
|
1889
|
+
const common = {
|
|
1890
|
+
inputTs: actionTs,
|
|
1891
|
+
itemId: click.itemId,
|
|
1892
|
+
messageId: `slack-review-item:${actionTs}`,
|
|
1893
|
+
payload,
|
|
1894
|
+
services,
|
|
1895
|
+
target
|
|
1896
|
+
};
|
|
1897
|
+
if (click.kind === "done") {
|
|
1898
|
+
await act({
|
|
1899
|
+
...common,
|
|
1900
|
+
action: "complete"
|
|
1901
|
+
});
|
|
1701
1902
|
return;
|
|
1702
1903
|
}
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
function parseJson$1(body) {
|
|
1709
|
-
try {
|
|
1710
|
-
return JSON.parse(body);
|
|
1711
|
-
} catch {
|
|
1904
|
+
if (click.kind === "dismiss") {
|
|
1905
|
+
await act({
|
|
1906
|
+
...common,
|
|
1907
|
+
action: "dismiss"
|
|
1908
|
+
});
|
|
1712
1909
|
return;
|
|
1713
1910
|
}
|
|
1911
|
+
await act({
|
|
1912
|
+
...common,
|
|
1913
|
+
action: "submit",
|
|
1914
|
+
answer: (item) => {
|
|
1915
|
+
const field = item.request && buttonUserInputField(item.request);
|
|
1916
|
+
if (!field?.options.some((option) => option.label === click.label)) throw new ReviewItemActionError("conflict", "This card changed. Use the updated card.");
|
|
1917
|
+
return {
|
|
1918
|
+
requestId: item.id,
|
|
1919
|
+
values: { [field.id]: [click.label] }
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
/** Submit the answer of a Review item form. */
|
|
1925
|
+
async function handleSlackReviewItemSubmission(args) {
|
|
1926
|
+
const view = args.payload.view;
|
|
1927
|
+
const formTarget = parseSlackReviewItemFormTarget(view?.private_metadata);
|
|
1928
|
+
const channelId = parseSlackChannelId(formTarget?.channelId);
|
|
1929
|
+
const messageTs = parseSlackMessageTs(formTarget?.messageTs);
|
|
1930
|
+
const threadTs = parseSlackMessageTs(formTarget?.threadTs);
|
|
1931
|
+
if (!view?.id || !formTarget || !channelId || !messageTs || !threadTs) throw new Error("Slack review item form submission is incomplete");
|
|
1932
|
+
await act({
|
|
1933
|
+
action: "submit",
|
|
1934
|
+
answer: (item) => item.request ? parseSlackUserInputSubmission({
|
|
1935
|
+
request: item.request,
|
|
1936
|
+
requestId: item.id,
|
|
1937
|
+
values: view.state?.values ?? {}
|
|
1938
|
+
}) : {
|
|
1939
|
+
requestId: item.id,
|
|
1940
|
+
values: {}
|
|
1941
|
+
},
|
|
1942
|
+
inputTs: slackMessageTsFromMs(Date.now()),
|
|
1943
|
+
itemId: formTarget.itemId,
|
|
1944
|
+
messageId: `slack-review-item:${view.id}`,
|
|
1945
|
+
payload: args.payload,
|
|
1946
|
+
services: args.services,
|
|
1947
|
+
target: {
|
|
1948
|
+
channelId,
|
|
1949
|
+
messageTs,
|
|
1950
|
+
threadTs
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1714
1953
|
}
|
|
1954
|
+
// src/chat/ingress/slack-interactive.ts
|
|
1715
1955
|
function requireSlackPayloadUserId(value, source) {
|
|
1716
1956
|
const userId = parseActorUserId(value);
|
|
1717
1957
|
if (!userId) throw new Error(`${source} is missing a Slack user id`);
|
|
1718
1958
|
return userId;
|
|
1719
1959
|
}
|
|
1720
|
-
async function
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1960
|
+
async function handleAppHomeDisconnect(args) {
|
|
1961
|
+
await withSpan("chat.app_home_disconnect", "chat.app_home_disconnect", { userId: args.userId }, async () => {
|
|
1962
|
+
try {
|
|
1963
|
+
await unlinkProvider(args.userId, args.provider, args.userTokenStore, getWorkspaceTeamId());
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
logException(error, "app_home.disconnect_unlink.failed", { "app.credential.provider": args.provider });
|
|
1966
|
+
}
|
|
1967
|
+
try {
|
|
1968
|
+
await publishAppHomeView(getSlackClient(), args.userId, args.userTokenStore);
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
logException(error, "app_home.disconnect_publish.failed", { "app.credential.provider": args.provider });
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* Save the first answer to the open request of a Slack thread as Slack input
|
|
1976
|
+
* from the person who answered. The input text is the formatted answer, so
|
|
1977
|
+
* the model reads one format on every surface. Later answers to the same
|
|
1978
|
+
* request do nothing. A stale request, such as one that a newer request
|
|
1979
|
+
* replaced, also does nothing.
|
|
1980
|
+
*/
|
|
1981
|
+
async function answerSlackUserInput(args) {
|
|
1982
|
+
const userId = requireSlackPayloadUserId(args.payload.user?.id, "Slack user input payload");
|
|
1983
|
+
const { channelId, messageTs } = args;
|
|
1984
|
+
const open = await getOpenSlackUserInput(args.state, {
|
|
1985
|
+
channelId,
|
|
1986
|
+
threadTs: args.threadTs
|
|
1987
|
+
});
|
|
1988
|
+
if (!open || open.messageTs !== messageTs) return;
|
|
1989
|
+
const answer = args.answer(open);
|
|
1990
|
+
if (isEmptyUserInputAnswer(answer)) return;
|
|
1991
|
+
const userTeam = args.payload.user?.team_id;
|
|
1992
|
+
if (!await isSlackWorkspaceMember({
|
|
1993
|
+
user: userId,
|
|
1994
|
+
user_team: userTeam
|
|
1995
|
+
}, args.state)) return;
|
|
1996
|
+
if (!await args.isAuthorAllowed(userId)) return;
|
|
1997
|
+
if (!await claimSlackUserInput(args.state, {
|
|
1998
|
+
channelId,
|
|
1999
|
+
claimedBy: userId,
|
|
2000
|
+
messageTs
|
|
2001
|
+
})) return;
|
|
2002
|
+
const message = buildSlackInteractionInput({
|
|
1724
2003
|
adapter: args.adapter,
|
|
1725
|
-
|
|
2004
|
+
channelId,
|
|
2005
|
+
inputTs: args.inputTs,
|
|
2006
|
+
messageId: args.messageId,
|
|
2007
|
+
messageTs,
|
|
2008
|
+
payload: args.payload,
|
|
2009
|
+
text: formatUserInputAnswer(open.request, answer),
|
|
2010
|
+
threadTs: args.threadTs,
|
|
2011
|
+
userId
|
|
1726
2012
|
});
|
|
1727
|
-
|
|
1728
|
-
await
|
|
1729
|
-
|
|
2013
|
+
try {
|
|
2014
|
+
await args.persistMessage(message);
|
|
2015
|
+
} catch (error) {
|
|
2016
|
+
await releaseSlackUserInput(args.state, {
|
|
1730
2017
|
channelId,
|
|
1731
|
-
|
|
1732
|
-
text: args.form.text,
|
|
1733
|
-
userId
|
|
2018
|
+
messageTs
|
|
1734
2019
|
});
|
|
2020
|
+
throw error;
|
|
2021
|
+
}
|
|
2022
|
+
await closeSlackUserInput({
|
|
2023
|
+
note: slackUserInputAnswerNote({
|
|
2024
|
+
answer,
|
|
2025
|
+
request: open.request,
|
|
2026
|
+
who: slackMention(userId)
|
|
2027
|
+
}),
|
|
2028
|
+
open
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
/** Read the request message and thread of a block action. */
|
|
2032
|
+
function blockActionTarget(payload) {
|
|
2033
|
+
const channelId = parseSlackChannelId(payload.channel?.id);
|
|
2034
|
+
const messageTs = parseSlackMessageTs(payload.message?.ts);
|
|
2035
|
+
if (!channelId || !messageTs) throw new Error("Slack user input payload is incomplete");
|
|
2036
|
+
return {
|
|
2037
|
+
channelId,
|
|
2038
|
+
messageTs,
|
|
2039
|
+
threadTs: parseSlackMessageTs(payload.message?.thread_ts) ?? messageTs
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
/** Answer a one-click request with the clicked choice. */
|
|
2043
|
+
async function handleUserInputChoice(args) {
|
|
2044
|
+
const target = blockActionTarget(args.payload);
|
|
2045
|
+
const actionTs = parseSlackMessageTs(args.action.action_ts);
|
|
2046
|
+
const choice = args.action.value?.trim();
|
|
2047
|
+
if (!actionTs || !choice) throw new Error("Slack user input click payload is incomplete");
|
|
2048
|
+
await answerSlackUserInput({
|
|
2049
|
+
...target,
|
|
2050
|
+
adapter: args.adapter,
|
|
2051
|
+
answer: (open) => {
|
|
2052
|
+
const field = buttonUserInputField(open.request);
|
|
2053
|
+
return normalizeUserInputAnswer(open.request, {
|
|
2054
|
+
requestId: open.requestId,
|
|
2055
|
+
values: field ? { [field.id]: [choice] } : {}
|
|
2056
|
+
});
|
|
2057
|
+
},
|
|
2058
|
+
inputTs: actionTs,
|
|
2059
|
+
messageId: `slack-user-input-answer:${actionTs}`,
|
|
2060
|
+
payload: args.payload,
|
|
2061
|
+
isAuthorAllowed: args.isAuthorAllowed,
|
|
2062
|
+
persistMessage: args.persistMessage,
|
|
2063
|
+
state: args.state
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
/** Open the answer form of the open request for the person who clicked. */
|
|
2067
|
+
async function handleUserInputOpenForm(args) {
|
|
2068
|
+
const target = blockActionTarget(args.payload);
|
|
2069
|
+
const triggerId = args.payload.trigger_id;
|
|
2070
|
+
if (!triggerId) throw new Error("Slack user input form payload is missing a trigger id");
|
|
2071
|
+
const open = await getOpenSlackUserInput(args.state, target);
|
|
2072
|
+
if (!open || open.messageTs !== target.messageTs) return;
|
|
2073
|
+
await openSlackModal({
|
|
2074
|
+
triggerId,
|
|
2075
|
+
view: buildSlackUserInputModal(open.request, target)
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
/** Answer a request with the values of its submitted form. */
|
|
2079
|
+
async function handleUserInputSubmission(args) {
|
|
2080
|
+
const view = args.payload.view;
|
|
2081
|
+
const formTarget = parseSlackUserInputFormTarget(view?.private_metadata);
|
|
2082
|
+
const channelId = parseSlackChannelId(formTarget?.channelId);
|
|
2083
|
+
const messageTs = parseSlackMessageTs(formTarget?.messageTs);
|
|
2084
|
+
const threadTs = parseSlackMessageTs(formTarget?.threadTs);
|
|
2085
|
+
if (!view?.id || !channelId || !messageTs || !threadTs) throw new Error("Slack user input form submission is incomplete");
|
|
2086
|
+
const inputTs = slackMessageTsFromMs(Date.now());
|
|
2087
|
+
await answerSlackUserInput({
|
|
2088
|
+
adapter: args.adapter,
|
|
2089
|
+
answer: (open) => parseSlackUserInputSubmission({
|
|
2090
|
+
request: open.request,
|
|
2091
|
+
requestId: open.requestId,
|
|
2092
|
+
values: view.state?.values ?? {}
|
|
2093
|
+
}),
|
|
2094
|
+
channelId,
|
|
2095
|
+
inputTs,
|
|
2096
|
+
messageId: `slack-user-input-answer:${view.id}`,
|
|
2097
|
+
messageTs,
|
|
2098
|
+
payload: args.payload,
|
|
2099
|
+
isAuthorAllowed: args.isAuthorAllowed,
|
|
2100
|
+
persistMessage: args.persistMessage,
|
|
2101
|
+
state: args.state,
|
|
2102
|
+
threadTs
|
|
1735
2103
|
});
|
|
1736
2104
|
}
|
|
1737
|
-
|
|
2105
|
+
/**
|
|
2106
|
+
* Handle Slack interactions from App Home, user input requests, and Review
|
|
2107
|
+
* item cards: block actions and submitted forms.
|
|
2108
|
+
*/
|
|
2109
|
+
async function handleSlackInteractivePayload(args) {
|
|
2110
|
+
const spanAttributes = { userId: args.payload.user?.id?.trim() || void 0 };
|
|
2111
|
+
if (args.payload.type === "view_submission" && args.payload.view?.callback_id === "junior_review_item") {
|
|
2112
|
+
await withSpan("chat.slack_review_item_submit", "chat.slack_review_item_submit", spanAttributes, () => handleSlackReviewItemSubmission({
|
|
2113
|
+
payload: args.payload,
|
|
2114
|
+
services: args
|
|
2115
|
+
}));
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
if (args.payload.type === "view_submission" && args.payload.view?.callback_id === "junior_user_input") {
|
|
2119
|
+
await withSpan("chat.slack_user_input_submit", "chat.slack_user_input_submit", spanAttributes, () => handleUserInputSubmission(args));
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
1738
2122
|
if (args.payload.type !== "block_actions") return;
|
|
1739
|
-
const action
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
2123
|
+
for (const action of args.payload.actions ?? []) {
|
|
2124
|
+
const click = parseSlackReviewItemClick(action);
|
|
2125
|
+
if (click) {
|
|
2126
|
+
await withSpan("chat.slack_review_item_click", "chat.slack_review_item_click", spanAttributes, () => handleSlackReviewItemClick({
|
|
2127
|
+
action,
|
|
2128
|
+
click,
|
|
2129
|
+
payload: args.payload,
|
|
2130
|
+
services: args
|
|
2131
|
+
}));
|
|
2132
|
+
return;
|
|
1748
2133
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2134
|
+
const kind = slackUserInputAction(action.action_id);
|
|
2135
|
+
if (kind === "choice") {
|
|
2136
|
+
await withSpan("chat.slack_user_input_click", "chat.slack_user_input_click", spanAttributes, () => handleUserInputChoice({
|
|
2137
|
+
...args,
|
|
2138
|
+
action
|
|
2139
|
+
}));
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (kind === "open_form") {
|
|
2143
|
+
await withSpan("chat.slack_user_input_open_form", "chat.slack_user_input_open_form", spanAttributes, () => handleUserInputOpenForm(args));
|
|
2144
|
+
return;
|
|
1753
2145
|
}
|
|
2146
|
+
}
|
|
2147
|
+
const disconnect = args.payload.actions?.find((candidate) => candidate.action_id === "app_home_disconnect");
|
|
2148
|
+
const provider = disconnect?.selected_option?.value ?? disconnect?.value;
|
|
2149
|
+
if (!provider) return;
|
|
2150
|
+
await handleAppHomeDisconnect({
|
|
2151
|
+
provider,
|
|
2152
|
+
userId: requireSlackPayloadUserId(args.payload.user?.id, "Slack app home disconnect payload"),
|
|
2153
|
+
userTokenStore: args.userTokenStore
|
|
1754
2154
|
});
|
|
1755
2155
|
}
|
|
2156
|
+
// src/chat/ingress/slack-form.ts
|
|
2157
|
+
function parseJson$1(body) {
|
|
2158
|
+
try {
|
|
2159
|
+
return JSON.parse(body);
|
|
2160
|
+
} catch {
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
1756
2164
|
function installationFromInteractive(payload) {
|
|
1757
2165
|
return { teamId: payload.team?.id ?? payload.user?.team_id };
|
|
1758
2166
|
}
|
|
1759
|
-
/** Handle a Slack
|
|
2167
|
+
/** Handle a Slack interactive payload form. */
|
|
1760
2168
|
async function handleSlackForm(args) {
|
|
1761
2169
|
const params = new URLSearchParams(args.body);
|
|
1762
2170
|
const { adapter, state } = args;
|
|
1763
2171
|
await state.connect();
|
|
1764
|
-
if (params.has("command") && !params.has("payload")) {
|
|
1765
|
-
const result = slackSlashCommandSchema.safeParse(Object.fromEntries(params));
|
|
1766
|
-
if (!result.success) return new Response("Invalid slash command payload", { status: 400 });
|
|
1767
|
-
const form = result.data;
|
|
1768
|
-
const installation = {
|
|
1769
|
-
teamId: form.team_id,
|
|
1770
|
-
enterpriseId: form.enterprise_id,
|
|
1771
|
-
isEnterpriseInstall: form.is_enterprise_install === "true"
|
|
1772
|
-
};
|
|
1773
|
-
args.waitUntil(withLogContext({
|
|
1774
|
-
platform: "slack",
|
|
1775
|
-
userId: form.user_id
|
|
1776
|
-
}, () => runWithWorkspaceTeamId(installation.teamId, () => runWithSlackInstallation({
|
|
1777
|
-
adapter,
|
|
1778
|
-
installation,
|
|
1779
|
-
state,
|
|
1780
|
-
task: () => handleSlashCommandForm({
|
|
1781
|
-
adapter,
|
|
1782
|
-
form,
|
|
1783
|
-
state
|
|
1784
|
-
})
|
|
1785
|
-
})).catch((error) => {
|
|
1786
|
-
logException(error, "slash_command.failed");
|
|
1787
|
-
})));
|
|
1788
|
-
return new Response("", { status: 200 });
|
|
1789
|
-
}
|
|
1790
2172
|
const rawPayload = params.get("payload");
|
|
1791
2173
|
if (!rawPayload) return new Response("Missing payload", { status: 400 });
|
|
1792
2174
|
const result = slackInteractivePayloadSchema.safeParse(parseJson$1(rawPayload));
|
|
@@ -1800,8 +2182,13 @@ async function handleSlackForm(args) {
|
|
|
1800
2182
|
adapter,
|
|
1801
2183
|
installation,
|
|
1802
2184
|
state,
|
|
1803
|
-
task: () =>
|
|
2185
|
+
task: () => handleSlackInteractivePayload({
|
|
2186
|
+
adapter,
|
|
1804
2187
|
payload,
|
|
2188
|
+
isAuthorAllowed: (userId) => args.isAuthorAllowed(userId, installation),
|
|
2189
|
+
persistMessage: (message) => args.persistMessage(message, installation),
|
|
2190
|
+
queue: args.queue,
|
|
2191
|
+
state,
|
|
1805
2192
|
userTokenStore: args.getUserTokenStore()
|
|
1806
2193
|
})
|
|
1807
2194
|
})).catch((error) => {
|
|
@@ -1898,8 +2285,8 @@ function eventAutomationDispatchKey(taskId, namespace, eventKey) {
|
|
|
1898
2285
|
return `event-automation:${createHash("sha256").update(`${taskId}\0${namespace}\0${eventKey}`).digest("hex").slice(0, 32)}`;
|
|
1899
2286
|
}
|
|
1900
2287
|
/** Render plain agent input for one matching event automation. */
|
|
1901
|
-
function eventInput(task, event, outcomes) {
|
|
1902
|
-
const guidance = eventGuidance(
|
|
2288
|
+
function eventInput(task, event, outcomes, catalog) {
|
|
2289
|
+
const guidance = eventGuidance(catalog, event.namespace, event.eventType);
|
|
1903
2290
|
return renderTaskInput({
|
|
1904
2291
|
about: task.trigger.label,
|
|
1905
2292
|
creator: slackMention(task.createdBy.slackUserId),
|
|
@@ -1913,12 +2300,20 @@ function eventInput(task, event, outcomes) {
|
|
|
1913
2300
|
externalTextMaxLength: EVENT_TEXT_MAX_LENGTH
|
|
1914
2301
|
});
|
|
1915
2302
|
}
|
|
1916
|
-
/**
|
|
2303
|
+
/**
|
|
2304
|
+
* Match a normalized event and dispatch every matching automation.
|
|
2305
|
+
*
|
|
2306
|
+
* Plugins publish one copy of an occurrence for each scope, such as a pull
|
|
2307
|
+
* request and its repository. All copies share one event key. An automation
|
|
2308
|
+
* with no identifier matches every copy, and the dispatch key and the run
|
|
2309
|
+
* limit both use the event key, so it runs and counts once.
|
|
2310
|
+
*/
|
|
1917
2311
|
async function ingestEventAutomations(input, options) {
|
|
1918
2312
|
const event = eventSchema.parse(input);
|
|
1919
2313
|
const db = getDb();
|
|
1920
2314
|
const nowMs = options.nowMs ?? Date.now();
|
|
1921
|
-
const
|
|
2315
|
+
const catalog = getEventCatalog();
|
|
2316
|
+
const tasks = (await findMatchingEventAutomations(db, event, options.teamId, catalog)).filter((task) => slackChannelMessageRunsAutomation(event, task));
|
|
1922
2317
|
let dispatched = 0;
|
|
1923
2318
|
const errors = [];
|
|
1924
2319
|
for (const task of tasks) try {
|
|
@@ -1945,7 +2340,7 @@ async function ingestEventAutomations(input, options) {
|
|
|
1945
2340
|
"app.event.event_type": event.eventType,
|
|
1946
2341
|
"app.event_automation.max_runs": botConfig.maxEventAutomationRuns,
|
|
1947
2342
|
"app.event_automation.run_window_ms": botConfig.eventAutomationRunWindowMs,
|
|
1948
|
-
"app.event_automation.resource_type": task.trigger.resourceType,
|
|
2343
|
+
"app.event_automation.resource_type": task.trigger.resourceType ?? "any",
|
|
1949
2344
|
"app.task.id": task.id
|
|
1950
2345
|
}
|
|
1951
2346
|
});
|
|
@@ -1978,7 +2373,7 @@ async function ingestEventAutomations(input, options) {
|
|
|
1978
2373
|
...credentialSubject ? { credentialSubject } : void 0,
|
|
1979
2374
|
destination,
|
|
1980
2375
|
destinationVisibility: task.destinationVisibility,
|
|
1981
|
-
input: eventInput(task, event, outcomes),
|
|
2376
|
+
input: eventInput(task, event, outcomes, catalog),
|
|
1982
2377
|
metadata: { eventAutomationId: task.id },
|
|
1983
2378
|
replyAttribution: automationReplyAttribution(task.title),
|
|
1984
2379
|
outcomes
|
|
@@ -1996,85 +2391,6 @@ async function ingestEventAutomations(input, options) {
|
|
|
1996
2391
|
if (errors.length > 0) throw new AggregateError(errors, "Failed to dispatch one or more event automations");
|
|
1997
2392
|
return { dispatched };
|
|
1998
2393
|
}
|
|
1999
|
-
// src/chat/ingress/workspace-membership.ts
|
|
2000
|
-
const MEMBER_TTL_MS = 3e5;
|
|
2001
|
-
const NON_MEMBER_TTL_MS = 3e4;
|
|
2002
|
-
const authorTeamSchema = z.object({
|
|
2003
|
-
user_team: z.string().optional(),
|
|
2004
|
-
source_team: z.string().optional(),
|
|
2005
|
-
user: z.unknown().optional()
|
|
2006
|
-
});
|
|
2007
|
-
/** Accept only verified workspace authors, reusing recent membership lookups. */
|
|
2008
|
-
async function isSlackWorkspaceMember(raw, state) {
|
|
2009
|
-
const workspaceTeamId = getWorkspaceTeamId();
|
|
2010
|
-
if (!workspaceTeamId) return false;
|
|
2011
|
-
const parsed = authorTeamSchema.safeParse(raw);
|
|
2012
|
-
if (!parsed.success) return false;
|
|
2013
|
-
const event = parsed.data;
|
|
2014
|
-
const userId = parseSlackUserId(event.user);
|
|
2015
|
-
if (!userId) return false;
|
|
2016
|
-
const cache = createCache(state, "core");
|
|
2017
|
-
const key = `slack:membership:${workspaceTeamId}:${userId}`;
|
|
2018
|
-
const authorTeam = parseSlackTeamId(event.user_team);
|
|
2019
|
-
if (authorTeam === workspaceTeamId) return true;
|
|
2020
|
-
if (event.user_team !== void 0 && !authorTeam && !/^E[A-Z0-9]+$/.test(event.user_team)) return false;
|
|
2021
|
-
if (authorTeam !== void 0) return await cache.getOrLoad(key, async () => ({
|
|
2022
|
-
value: false,
|
|
2023
|
-
ttlMs: NON_MEMBER_TTL_MS
|
|
2024
|
-
}), { refresh: true });
|
|
2025
|
-
return await cache.getOrLoad(key, async () => {
|
|
2026
|
-
const user = await lookupSlackUserProfile(userId);
|
|
2027
|
-
const authorTeam = parseSlackTeamId(user.team_id);
|
|
2028
|
-
if (user.id !== userId || !authorTeam) return { value: false };
|
|
2029
|
-
const member = authorTeam === workspaceTeamId && !user.is_deleted;
|
|
2030
|
-
return {
|
|
2031
|
-
value: member,
|
|
2032
|
-
ttlMs: member ? MEMBER_TTL_MS : NON_MEMBER_TTL_MS
|
|
2033
|
-
};
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
// src/chat/sign-in/allowlist.ts
|
|
2037
|
-
/**
|
|
2038
|
-
* Domains that may use Junior. Slack authors must have a Slack profile email
|
|
2039
|
-
* on one of them, and dashboard Google sign-ins must come from a Google
|
|
2040
|
-
* Workspace account whose `hd` claim is one of them. Slack and the dashboard
|
|
2041
|
-
* share one list.
|
|
2042
|
-
*/
|
|
2043
|
-
/** Read a list env var: a JSON string array or comma-separated values. */
|
|
2044
|
-
function readEnvList(name) {
|
|
2045
|
-
const value = process.env[name]?.trim();
|
|
2046
|
-
if (!value) return void 0;
|
|
2047
|
-
if (!value.startsWith("[")) return value.split(",");
|
|
2048
|
-
let parsed;
|
|
2049
|
-
try {
|
|
2050
|
-
parsed = JSON.parse(value);
|
|
2051
|
-
} catch (error) {
|
|
2052
|
-
throw new Error(`${name} must be a JSON string array`, { cause: error });
|
|
2053
|
-
}
|
|
2054
|
-
const list = z.array(z.string()).safeParse(parsed);
|
|
2055
|
-
if (!list.success) throw new Error(`${name} must be a JSON string array`);
|
|
2056
|
-
return list.data;
|
|
2057
|
-
}
|
|
2058
|
-
/**
|
|
2059
|
-
* Resolve the allowed domains: the `createApp` option when set, else
|
|
2060
|
-
* `JUNIOR_ALLOWED_DOMAINS`. An empty list allows every domain.
|
|
2061
|
-
*/
|
|
2062
|
-
function resolveAllowedDomains(allowedDomains) {
|
|
2063
|
-
return [...new Set((allowedDomains ?? readEnvList("JUNIOR_ALLOWED_DOMAINS") ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean))];
|
|
2064
|
-
}
|
|
2065
|
-
/**
|
|
2066
|
-
* Fail startup on the removed Google-specific names. Ignoring them would
|
|
2067
|
-
* leave the list empty, which lets every Slack workspace member in.
|
|
2068
|
-
*/
|
|
2069
|
-
function assertNoLegacyAllowedDomains(dashboardOptionKeys) {
|
|
2070
|
-
if (dashboardOptionKeys.includes("allowedGoogleDomains")) throw new Error("dashboard.allowedGoogleDomains was removed; set createApp({ allowedDomains }) instead");
|
|
2071
|
-
if (process.env.JUNIOR_DASHBOARD_GOOGLE_DOMAINS?.trim()) throw new Error("JUNIOR_DASHBOARD_GOOGLE_DOMAINS was removed; set JUNIOR_ALLOWED_DOMAINS instead");
|
|
2072
|
-
}
|
|
2073
|
-
/** Return whether a verified domain may use Junior. An empty list allows all. */
|
|
2074
|
-
function isAllowedDomain(domain, allowedDomains) {
|
|
2075
|
-
if (allowedDomains.length === 0) return true;
|
|
2076
|
-
return Boolean(domain && allowedDomains.includes(domain.toLowerCase()));
|
|
2077
|
-
}
|
|
2078
2394
|
// src/chat/ingress/slack-allowed-domain.ts
|
|
2079
2395
|
const ALLOWED_TTL_MS = 3e5;
|
|
2080
2396
|
const NOT_ALLOWED_TTL_MS = 3e4;
|
|
@@ -2126,6 +2442,19 @@ function slackMessageAttributes(body) {
|
|
|
2126
2442
|
"app.slack.is_ext_shared_channel": typeof body.is_ext_shared_channel === "boolean" ? body.is_ext_shared_channel : body.is_ext_shared_channel === void 0 ? "missing" : "invalid"
|
|
2127
2443
|
};
|
|
2128
2444
|
}
|
|
2445
|
+
// src/chat/ingress/slack-event-persistence-error.ts
|
|
2446
|
+
/** A Slack input that Junior could not fully save and wake a worker for. */
|
|
2447
|
+
var SlackEventPersistenceError = class extends Error {
|
|
2448
|
+
cause;
|
|
2449
|
+
/** True when the mailbox kept the input and only the queue send failed. */
|
|
2450
|
+
saved;
|
|
2451
|
+
constructor(cause, saved) {
|
|
2452
|
+
super("Slack event durable persistence failed");
|
|
2453
|
+
this.name = "SlackEventPersistenceError";
|
|
2454
|
+
this.cause = cause;
|
|
2455
|
+
this.saved = saved;
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2129
2458
|
// src/chat/slack/automation-details.ts
|
|
2130
2459
|
function textField(key, label, value) {
|
|
2131
2460
|
return {
|
|
@@ -2265,14 +2594,6 @@ const IGNORED_MESSAGE_SUBTYPES = /* @__PURE__ */ new Set([
|
|
|
2265
2594
|
"ekm_access_denied",
|
|
2266
2595
|
"tombstone"
|
|
2267
2596
|
]);
|
|
2268
|
-
var SlackEventPersistenceError = class extends Error {
|
|
2269
|
-
cause;
|
|
2270
|
-
constructor(cause) {
|
|
2271
|
-
super("Slack event durable persistence failed");
|
|
2272
|
-
this.name = "SlackEventPersistenceError";
|
|
2273
|
-
this.cause = cause;
|
|
2274
|
-
}
|
|
2275
|
-
};
|
|
2276
2597
|
function enqueue(waitUntil, task) {
|
|
2277
2598
|
waitUntil(task);
|
|
2278
2599
|
}
|
|
@@ -2373,11 +2694,12 @@ async function persistSlackMessage(args) {
|
|
|
2373
2694
|
queue: args.queue,
|
|
2374
2695
|
state: args.state
|
|
2375
2696
|
}).catch(async (error) => {
|
|
2376
|
-
|
|
2697
|
+
const kept = (await getConversationWorkState({
|
|
2377
2698
|
conversationId,
|
|
2378
2699
|
state: args.state
|
|
2379
|
-
}))?.execution.inboundMessageIds.includes(inbound.inboundMessageId)
|
|
2380
|
-
|
|
2700
|
+
}))?.execution.inboundMessageIds.includes(inbound.inboundMessageId) === true;
|
|
2701
|
+
if (!kept) await receipt?.stop();
|
|
2702
|
+
throw new SlackEventPersistenceError(error, kept);
|
|
2381
2703
|
});
|
|
2382
2704
|
}
|
|
2383
2705
|
/**
|
|
@@ -2430,7 +2752,7 @@ async function handleSlackThreadStop(args) {
|
|
|
2430
2752
|
});
|
|
2431
2753
|
await appendConversationMessages(getConversationEventStore(), {
|
|
2432
2754
|
conversation,
|
|
2433
|
-
conversationId
|
|
2755
|
+
conversationId
|
|
2434
2756
|
});
|
|
2435
2757
|
await thread.post(THREAD_OPTOUT_ACK);
|
|
2436
2758
|
}
|
|
@@ -2533,6 +2855,11 @@ async function routeParsedMessage(args) {
|
|
|
2533
2855
|
teamId: args.installation.teamId,
|
|
2534
2856
|
user: args.event.user
|
|
2535
2857
|
})) return;
|
|
2858
|
+
const conversationId = await resolveSlackConversationId({
|
|
2859
|
+
canonicalThreadId,
|
|
2860
|
+
conversationStore: args.conversationStore,
|
|
2861
|
+
installation: args.installation
|
|
2862
|
+
});
|
|
2536
2863
|
if (isSubscribed && !isExperimentalFeatureEnabled("passive-routing")) {
|
|
2537
2864
|
const content = parseContent(message);
|
|
2538
2865
|
const conversation = coerceThreadConversationState(void 0);
|
|
@@ -2544,16 +2871,11 @@ async function routeParsedMessage(args) {
|
|
|
2544
2871
|
});
|
|
2545
2872
|
await appendConversationMessages(getConversationEventStore(), {
|
|
2546
2873
|
conversation,
|
|
2547
|
-
conversationId
|
|
2874
|
+
conversationId
|
|
2548
2875
|
});
|
|
2549
2876
|
return;
|
|
2550
2877
|
}
|
|
2551
|
-
const
|
|
2552
|
-
canonicalThreadId,
|
|
2553
|
-
conversationStore: args.conversationStore,
|
|
2554
|
-
installation: args.installation
|
|
2555
|
-
});
|
|
2556
|
-
await persistSlackMessage({
|
|
2878
|
+
const persist = () => persistSlackMessage({
|
|
2557
2879
|
adapter: args.adapter,
|
|
2558
2880
|
installation: args.installation,
|
|
2559
2881
|
message,
|
|
@@ -2564,6 +2886,22 @@ async function routeParsedMessage(args) {
|
|
|
2564
2886
|
route,
|
|
2565
2887
|
state: args.state
|
|
2566
2888
|
});
|
|
2889
|
+
if (route !== "mention") {
|
|
2890
|
+
await persist();
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
await persistSlackUserInputReply({
|
|
2894
|
+
channelId: args.event.channel,
|
|
2895
|
+
claimedBy: message.author.userId,
|
|
2896
|
+
isSaved: (error) => error instanceof SlackEventPersistenceError && error.saved,
|
|
2897
|
+
note: (request) => slackUserInputAnswerNote({
|
|
2898
|
+
request,
|
|
2899
|
+
who: slackMention(message.author.userId)
|
|
2900
|
+
}),
|
|
2901
|
+
persist,
|
|
2902
|
+
state: args.state,
|
|
2903
|
+
threadTs: args.event.thread_ts
|
|
2904
|
+
});
|
|
2567
2905
|
}
|
|
2568
2906
|
async function handleSlackEvent(args) {
|
|
2569
2907
|
const event = args.body.event;
|
|
@@ -2622,7 +2960,7 @@ async function handleSlackEvent(args) {
|
|
|
2622
2960
|
if (!await isSlackWorkspaceMember(event, state)) return;
|
|
2623
2961
|
const channelId = event.channel;
|
|
2624
2962
|
const threadTs = event.thread_ts;
|
|
2625
|
-
if (!(await withLock
|
|
2963
|
+
if (!(await withLock(state, `slack:ingress:slack:${channelId}:${threadTs}`, () => handleSlackSessionStop({
|
|
2626
2964
|
adapter,
|
|
2627
2965
|
channelId,
|
|
2628
2966
|
conversationStore: args.services.conversationStore,
|
|
@@ -2641,7 +2979,7 @@ async function handleSlackEvent(args) {
|
|
|
2641
2979
|
setSpanAttributes({ "app.slack.membership": member ? "verified" : "unverified" });
|
|
2642
2980
|
if (!member) return;
|
|
2643
2981
|
const message = adapter.parseMessage(event);
|
|
2644
|
-
if (!(await withLock
|
|
2982
|
+
if (!(await withLock(state, `slack:ingress:${normalizeMessageThreadId(message).threadId}`, () => routeParsedMessage({
|
|
2645
2983
|
adapter,
|
|
2646
2984
|
event,
|
|
2647
2985
|
installation,
|
|
@@ -2660,22 +2998,69 @@ async function handleSlackEvent(args) {
|
|
|
2660
2998
|
}
|
|
2661
2999
|
}));
|
|
2662
3000
|
}
|
|
2663
|
-
/**
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
3001
|
+
/**
|
|
3002
|
+
* Persist one Slack input as a mention under the thread's ingress lock.
|
|
3003
|
+
* Slack does not retry interactions. Saved input still runs through heartbeat
|
|
3004
|
+
* recovery, so a failed queue send after the append does not reject it.
|
|
3005
|
+
*/
|
|
3006
|
+
async function persistSlackMentionWithLock(args) {
|
|
3007
|
+
const { message, threadId } = normalizeMessageThreadId(args.message);
|
|
3008
|
+
try {
|
|
3009
|
+
if (!(await withLock(args.state, `slack:ingress:${threadId}`, () => persistSlackMessage({
|
|
3010
|
+
adapter: args.adapter,
|
|
3011
|
+
installation: args.installation,
|
|
3012
|
+
message,
|
|
3013
|
+
conversationStore: args.services.conversationStore,
|
|
3014
|
+
queue: args.services.queue,
|
|
3015
|
+
receivedAtMs: Date.now(),
|
|
3016
|
+
route: "mention",
|
|
3017
|
+
state: args.state
|
|
3018
|
+
}), {
|
|
3019
|
+
keepAlive: true,
|
|
3020
|
+
waitMs: 1e4
|
|
3021
|
+
})).acquired) throw new Error("Could not acquire Slack ingress lock");
|
|
3022
|
+
} catch (error) {
|
|
3023
|
+
if (error instanceof SlackEventPersistenceError && error.saved) {
|
|
3024
|
+
logException(error.cause, "slack.event.wake.failed");
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
throw error;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
/** Handle Slack webhooks by enqueueing durable conversation work. */
|
|
3031
|
+
async function handleSlackWebhook(args) {
|
|
3032
|
+
const adapter = args.services.getSlackAdapter();
|
|
3033
|
+
const body = await args.request.text();
|
|
2667
3034
|
if (!verifySlackSignature({
|
|
2668
3035
|
adapter,
|
|
2669
3036
|
body,
|
|
2670
3037
|
request: args.request
|
|
2671
3038
|
})) return new Response("Invalid signature", { status: 401 });
|
|
2672
|
-
if ((args.request.headers.get("content-type") || "").includes("application/x-www-form-urlencoded"))
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
3039
|
+
if ((args.request.headers.get("content-type") || "").includes("application/x-www-form-urlencoded")) {
|
|
3040
|
+
const state = args.services.state ?? getStateAdapter();
|
|
3041
|
+
return await handleSlackForm({
|
|
3042
|
+
adapter,
|
|
3043
|
+
body,
|
|
3044
|
+
getUserTokenStore: () => getUserTokenStore(args.services),
|
|
3045
|
+
isAuthorAllowed: (userId, installation) => isSlackAuthorDomainAllowed({
|
|
3046
|
+
allowedBotUserIds: args.services.allowedBotUserIds ?? [],
|
|
3047
|
+
allowedDomains: args.services.allowedDomains ?? [],
|
|
3048
|
+
state,
|
|
3049
|
+
teamId: installation.teamId,
|
|
3050
|
+
user: userId
|
|
3051
|
+
}),
|
|
3052
|
+
persistMessage: (message, installation) => persistSlackMentionWithLock({
|
|
3053
|
+
adapter,
|
|
3054
|
+
installation,
|
|
3055
|
+
message,
|
|
3056
|
+
services: args.services,
|
|
3057
|
+
state
|
|
3058
|
+
}),
|
|
3059
|
+
queue: args.services.queue,
|
|
3060
|
+
state,
|
|
3061
|
+
waitUntil: args.waitUntil
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
2679
3064
|
const result = slackEventEnvelopeSchema.safeParse(parseJson(body));
|
|
2680
3065
|
if (!result.success) return new Response("Invalid JSON", { status: 400 });
|
|
2681
3066
|
const parsed = result.data;
|
|
@@ -2855,6 +3240,10 @@ function conversationWorkCallback(options) {
|
|
|
2855
3240
|
function createVercelConversationWorkCallback(options) {
|
|
2856
3241
|
return conversationWorkCallback(options).create();
|
|
2857
3242
|
}
|
|
3243
|
+
/** Return the queue consumer for conversation work, for a pulling queue worker. */
|
|
3244
|
+
function conversationWorkQueueConsumer(options) {
|
|
3245
|
+
return conversationWorkCallback(options).consumer;
|
|
3246
|
+
}
|
|
2858
3247
|
/** Register the Vercel Queue local-dev consumer for Nitro's central route dispatcher. */
|
|
2859
3248
|
function registerVercelConversationWorkDevConsumer(options) {
|
|
2860
3249
|
return conversationWorkCallback(options).registerDev();
|
|
@@ -2885,6 +3274,10 @@ function workspaceSnapshotJobCallback() {
|
|
|
2885
3274
|
function createVercelWorkspaceSnapshotJobCallback() {
|
|
2886
3275
|
return workspaceSnapshotJobCallback().create();
|
|
2887
3276
|
}
|
|
3277
|
+
/** Return the queue consumer for snapshot builds, for a pulling queue worker. */
|
|
3278
|
+
function workspaceSnapshotJobQueueConsumer() {
|
|
3279
|
+
return workspaceSnapshotJobCallback().consumer;
|
|
3280
|
+
}
|
|
2888
3281
|
/** Receive snapshot build messages during local development. */
|
|
2889
3282
|
function registerVercelWorkspaceSnapshotJobDevConsumer() {
|
|
2890
3283
|
return workspaceSnapshotJobCallback().registerDev();
|
|
@@ -2946,7 +3339,7 @@ function createEventAppPublisher(args) {
|
|
|
2946
3339
|
namespace: event.namespace,
|
|
2947
3340
|
identifier: event.identifier,
|
|
2948
3341
|
state: work.state
|
|
2949
|
-
}), eventAutomationTeamId ? findMatchingEventAutomations(getDb(), event, eventAutomationTeamId) : Promise.resolve([])]);
|
|
3342
|
+
}), eventAutomationTeamId ? findMatchingEventAutomations(getDb(), event, eventAutomationTeamId, getEventCatalog()) : Promise.resolve([])]);
|
|
2950
3343
|
return subscriptions.length > 0 || tasks.length > 0;
|
|
2951
3344
|
},
|
|
2952
3345
|
async neededMatchKeys(input) {
|
|
@@ -2958,6 +3351,8 @@ function createEventAppPublisher(args) {
|
|
|
2958
3351
|
namespace: input.namespace,
|
|
2959
3352
|
state: work.state
|
|
2960
3353
|
}), eventAutomationTeamId ? collectEventAutomationMatchKeys(getDb(), {
|
|
3354
|
+
catalog: getEventCatalog(),
|
|
3355
|
+
...input.data ? { data: input.data } : void 0,
|
|
2961
3356
|
eventTypes: input.eventTypes,
|
|
2962
3357
|
identifiers: input.identifiers,
|
|
2963
3358
|
namespace: input.namespace,
|
|
@@ -2996,7 +3391,7 @@ async function turnTerminalIsReady(args) {
|
|
|
2996
3391
|
state: args.state
|
|
2997
3392
|
});
|
|
2998
3393
|
if (conversation?.execution.pendingMessages.some((message) => message.inboundMessageId === args.messageId)) return false;
|
|
2999
|
-
if (!conversation || !hasRunnableConversationWork
|
|
3394
|
+
if (!conversation || !hasRunnableConversationWork(conversation)) return true;
|
|
3000
3395
|
return (await args.eventStore.query(args.conversationId, {
|
|
3001
3396
|
afterSeq: args.terminalSeq,
|
|
3002
3397
|
limit: 1,
|
|
@@ -3116,34 +3511,32 @@ function createAcpConversations(options) {
|
|
|
3116
3511
|
}
|
|
3117
3512
|
};
|
|
3118
3513
|
}
|
|
3119
|
-
// src/api/
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
const
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
} finally {
|
|
3140
|
-
if (heartbeat) clearInterval(heartbeat);
|
|
3141
|
-
await state.releaseLock(lock);
|
|
3514
|
+
// src/api/public-origin.ts
|
|
3515
|
+
/** Loopback hostnames, in the bracketed form that `URL.hostname` uses. */
|
|
3516
|
+
const LOOPBACK_HOSTNAMES = [
|
|
3517
|
+
"localhost",
|
|
3518
|
+
"127.0.0.1",
|
|
3519
|
+
"[::1]"
|
|
3520
|
+
];
|
|
3521
|
+
/**
|
|
3522
|
+
* Resolve the public origin of this Junior app for browser sign-in and
|
|
3523
|
+
* protocol metadata. A configured origin always wins. The request origin is
|
|
3524
|
+
* used only on loopback, because a deployed request can carry any host.
|
|
3525
|
+
*/
|
|
3526
|
+
function resolvePublicOrigin(request, configured) {
|
|
3527
|
+
const value = configured?.trim();
|
|
3528
|
+
let origin;
|
|
3529
|
+
if (value) origin = /^https?:\/\//.test(value) ? value : `https://${value}`;
|
|
3530
|
+
else {
|
|
3531
|
+
const requestURL = new URL(request.url);
|
|
3532
|
+
if (!LOOPBACK_HOSTNAMES.includes(requestURL.hostname)) return void 0;
|
|
3533
|
+
origin = requestURL.origin;
|
|
3142
3534
|
}
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3535
|
+
const url = new URL(origin);
|
|
3536
|
+
url.pathname = "/";
|
|
3537
|
+
url.search = "";
|
|
3538
|
+
url.hash = "";
|
|
3539
|
+
return url;
|
|
3147
3540
|
}
|
|
3148
3541
|
// src/api/acp/schema.ts
|
|
3149
3542
|
/** Validate the ACP wire messages that Junior supports. */
|
|
@@ -3859,11 +4252,11 @@ const ACP_AUTH_METHOD = {
|
|
|
3859
4252
|
name: "Sign in to Junior",
|
|
3860
4253
|
description: "Sign in with the Google account allowed by this Junior app."
|
|
3861
4254
|
};
|
|
3862
|
-
function authorizationKey(transactionId) {
|
|
4255
|
+
function authorizationKey$1(transactionId) {
|
|
3863
4256
|
return `junior:acp:v1:authorization:${transactionId}`;
|
|
3864
4257
|
}
|
|
3865
4258
|
function authorizationLockKey(transactionId) {
|
|
3866
|
-
return `${authorizationKey(transactionId)}:lock`;
|
|
4259
|
+
return `${authorizationKey$1(transactionId)}:lock`;
|
|
3867
4260
|
}
|
|
3868
4261
|
function authorizationPointerKey(connectionId) {
|
|
3869
4262
|
return `junior:acp:v1:connection:${connectionId}:authorization`;
|
|
@@ -3923,27 +4316,12 @@ function hasAcpConnectionCredential(request, expectedHash) {
|
|
|
3923
4316
|
}
|
|
3924
4317
|
/** Resolve a trusted browser origin, with request fallback only on loopback. */
|
|
3925
4318
|
function authorizationBaseURL(request, configured) {
|
|
3926
|
-
const
|
|
3927
|
-
|
|
3928
|
-
if (value) origin = /^https?:\/\//.test(value) ? value : `https://${value}`;
|
|
3929
|
-
else {
|
|
3930
|
-
const requestURL = new URL(request.url);
|
|
3931
|
-
const hostname = requestURL.hostname.replace(/^\[|\]$/g, "");
|
|
3932
|
-
if (![
|
|
3933
|
-
"localhost",
|
|
3934
|
-
"127.0.0.1",
|
|
3935
|
-
"::1"
|
|
3936
|
-
].includes(hostname)) throw acp.RequestError.internalError(void 0, "Junior ACP sign-in requires a configured public base URL");
|
|
3937
|
-
origin = requestURL.origin;
|
|
3938
|
-
}
|
|
3939
|
-
const url = new URL(origin);
|
|
3940
|
-
url.pathname = "/";
|
|
3941
|
-
url.search = "";
|
|
3942
|
-
url.hash = "";
|
|
4319
|
+
const url = resolvePublicOrigin(request, configured);
|
|
4320
|
+
if (!url) throw acp.RequestError.internalError(void 0, "Junior ACP sign-in requires a configured public base URL");
|
|
3943
4321
|
return url;
|
|
3944
4322
|
}
|
|
3945
4323
|
async function readAuthorization(state, transactionId) {
|
|
3946
|
-
const value = await state.get(authorizationKey(transactionId));
|
|
4324
|
+
const value = await state.get(authorizationKey$1(transactionId));
|
|
3947
4325
|
return value === null || value === void 0 ? void 0 : acpAuthorizationSchema.parse(value);
|
|
3948
4326
|
}
|
|
3949
4327
|
async function readAuthorizationPointer(state, connectionId) {
|
|
@@ -3964,7 +4342,7 @@ async function deleteAuthorizationPointer(state, connectionId, transactionId) {
|
|
|
3964
4342
|
/** Remove one completed transaction and its matching connection pointer. */
|
|
3965
4343
|
async function deleteAuthorization(state, authorization, transactionId) {
|
|
3966
4344
|
await deleteAuthorizationPointer(state, authorization.connectionId, transactionId);
|
|
3967
|
-
await state.delete(authorizationKey(transactionId));
|
|
4345
|
+
await state.delete(authorizationKey$1(transactionId));
|
|
3968
4346
|
}
|
|
3969
4347
|
function completionReceipt(authorization, response) {
|
|
3970
4348
|
return {
|
|
@@ -4018,7 +4396,7 @@ async function beginAcpAuthorization(args) {
|
|
|
4018
4396
|
userCodeHash: userCodeHash(userCode)
|
|
4019
4397
|
});
|
|
4020
4398
|
await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS);
|
|
4021
|
-
await args.state.set(authorizationKey(transactionId), authorization, ACP_STATE_TTL_MS);
|
|
4399
|
+
await args.state.set(authorizationKey$1(transactionId), authorization, ACP_STATE_TTL_MS);
|
|
4022
4400
|
await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS);
|
|
4023
4401
|
await args.state.set(authorizationPointerKey(args.connectionId), {
|
|
4024
4402
|
expiresAtMs: authorization.expiresAtMs,
|
|
@@ -4608,12 +4986,1093 @@ function createAcpHttpHandler(options) {
|
|
|
4608
4986
|
}
|
|
4609
4987
|
};
|
|
4610
4988
|
}
|
|
4611
|
-
// src/api/
|
|
4612
|
-
|
|
4613
|
-
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4989
|
+
// src/api/html.ts
|
|
4990
|
+
/** Escape text before it enters server-rendered HTML. */
|
|
4614
4991
|
function escapeHtml(value) {
|
|
4615
4992
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
4616
4993
|
}
|
|
4994
|
+
/** Return true when a browser request comes from its own URL origin. */
|
|
4995
|
+
function requestHasSameOrigin(request) {
|
|
4996
|
+
const origin = request.headers.get("origin");
|
|
4997
|
+
return origin !== null && URL.parse(origin)?.origin === new URL(request.url).origin;
|
|
4998
|
+
}
|
|
4999
|
+
/**
|
|
5000
|
+
* Render one sign-in page that browsers cannot frame or cache. `bodyHtml`
|
|
5001
|
+
* follows the heading, and the caller escapes every value in it.
|
|
5002
|
+
*/
|
|
5003
|
+
function renderHtmlPage(page) {
|
|
5004
|
+
return new Response(`<!DOCTYPE html>
|
|
5005
|
+
<html lang="en">
|
|
5006
|
+
<head>
|
|
5007
|
+
<meta charset="utf-8" />
|
|
5008
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5009
|
+
<title>${escapeHtml(page.title)}</title>
|
|
5010
|
+
</head>
|
|
5011
|
+
<body style="margin:0;background:#000;color:#fff;font-family:ui-sans-serif,system-ui,sans-serif">
|
|
5012
|
+
<main style="min-height:100vh;display:grid;place-items:center;padding:2rem">
|
|
5013
|
+
<section style="max-width:32rem;border-left:4px solid #a78bfa;padding-left:1rem">
|
|
5014
|
+
<h1 style="margin:0 0 .75rem;font-size:2rem">${escapeHtml(page.title)}</h1>
|
|
5015
|
+
${page.bodyHtml}
|
|
5016
|
+
</section>
|
|
5017
|
+
</main>
|
|
5018
|
+
</body>
|
|
5019
|
+
</html>`, {
|
|
5020
|
+
status: page.status,
|
|
5021
|
+
headers: {
|
|
5022
|
+
"cache-control": "no-store",
|
|
5023
|
+
"content-type": "text/html; charset=utf-8",
|
|
5024
|
+
"x-frame-options": "DENY",
|
|
5025
|
+
...page.headers
|
|
5026
|
+
}
|
|
5027
|
+
});
|
|
5028
|
+
}
|
|
5029
|
+
// src/net/public-address.ts
|
|
5030
|
+
/**
|
|
5031
|
+
* Own the public-address rule for fetches of URLs that any person can submit.
|
|
5032
|
+
*
|
|
5033
|
+
* Such a fetch must not reach loopback, private, link-local, cloud metadata,
|
|
5034
|
+
* or other special addresses. The lookup checks every resolved address at
|
|
5035
|
+
* connect time, so a second DNS answer cannot move the request to a blocked
|
|
5036
|
+
* address.
|
|
5037
|
+
*/
|
|
5038
|
+
/** Special-use ranges from RFC 6890 and the IANA special-purpose registries. */
|
|
5039
|
+
const BLOCKED_SUBNETS = [
|
|
5040
|
+
[
|
|
5041
|
+
"0.0.0.0",
|
|
5042
|
+
8,
|
|
5043
|
+
"ipv4"
|
|
5044
|
+
],
|
|
5045
|
+
[
|
|
5046
|
+
"10.0.0.0",
|
|
5047
|
+
8,
|
|
5048
|
+
"ipv4"
|
|
5049
|
+
],
|
|
5050
|
+
[
|
|
5051
|
+
"100.64.0.0",
|
|
5052
|
+
10,
|
|
5053
|
+
"ipv4"
|
|
5054
|
+
],
|
|
5055
|
+
[
|
|
5056
|
+
"127.0.0.0",
|
|
5057
|
+
8,
|
|
5058
|
+
"ipv4"
|
|
5059
|
+
],
|
|
5060
|
+
[
|
|
5061
|
+
"169.254.0.0",
|
|
5062
|
+
16,
|
|
5063
|
+
"ipv4"
|
|
5064
|
+
],
|
|
5065
|
+
[
|
|
5066
|
+
"172.16.0.0",
|
|
5067
|
+
12,
|
|
5068
|
+
"ipv4"
|
|
5069
|
+
],
|
|
5070
|
+
[
|
|
5071
|
+
"192.0.0.0",
|
|
5072
|
+
24,
|
|
5073
|
+
"ipv4"
|
|
5074
|
+
],
|
|
5075
|
+
[
|
|
5076
|
+
"192.0.2.0",
|
|
5077
|
+
24,
|
|
5078
|
+
"ipv4"
|
|
5079
|
+
],
|
|
5080
|
+
[
|
|
5081
|
+
"192.168.0.0",
|
|
5082
|
+
16,
|
|
5083
|
+
"ipv4"
|
|
5084
|
+
],
|
|
5085
|
+
[
|
|
5086
|
+
"198.18.0.0",
|
|
5087
|
+
15,
|
|
5088
|
+
"ipv4"
|
|
5089
|
+
],
|
|
5090
|
+
[
|
|
5091
|
+
"198.51.100.0",
|
|
5092
|
+
24,
|
|
5093
|
+
"ipv4"
|
|
5094
|
+
],
|
|
5095
|
+
[
|
|
5096
|
+
"203.0.113.0",
|
|
5097
|
+
24,
|
|
5098
|
+
"ipv4"
|
|
5099
|
+
],
|
|
5100
|
+
[
|
|
5101
|
+
"224.0.0.0",
|
|
5102
|
+
4,
|
|
5103
|
+
"ipv4"
|
|
5104
|
+
],
|
|
5105
|
+
[
|
|
5106
|
+
"240.0.0.0",
|
|
5107
|
+
4,
|
|
5108
|
+
"ipv4"
|
|
5109
|
+
],
|
|
5110
|
+
[
|
|
5111
|
+
"::",
|
|
5112
|
+
96,
|
|
5113
|
+
"ipv6"
|
|
5114
|
+
],
|
|
5115
|
+
[
|
|
5116
|
+
"64:ff9b::",
|
|
5117
|
+
96,
|
|
5118
|
+
"ipv6"
|
|
5119
|
+
],
|
|
5120
|
+
[
|
|
5121
|
+
"64:ff9b:1::",
|
|
5122
|
+
48,
|
|
5123
|
+
"ipv6"
|
|
5124
|
+
],
|
|
5125
|
+
[
|
|
5126
|
+
"100::",
|
|
5127
|
+
64,
|
|
5128
|
+
"ipv6"
|
|
5129
|
+
],
|
|
5130
|
+
[
|
|
5131
|
+
"2001::",
|
|
5132
|
+
32,
|
|
5133
|
+
"ipv6"
|
|
5134
|
+
],
|
|
5135
|
+
[
|
|
5136
|
+
"2001:db8::",
|
|
5137
|
+
32,
|
|
5138
|
+
"ipv6"
|
|
5139
|
+
],
|
|
5140
|
+
[
|
|
5141
|
+
"2002::",
|
|
5142
|
+
16,
|
|
5143
|
+
"ipv6"
|
|
5144
|
+
],
|
|
5145
|
+
[
|
|
5146
|
+
"fc00::",
|
|
5147
|
+
7,
|
|
5148
|
+
"ipv6"
|
|
5149
|
+
],
|
|
5150
|
+
[
|
|
5151
|
+
"fe80::",
|
|
5152
|
+
10,
|
|
5153
|
+
"ipv6"
|
|
5154
|
+
],
|
|
5155
|
+
[
|
|
5156
|
+
"fec0::",
|
|
5157
|
+
10,
|
|
5158
|
+
"ipv6"
|
|
5159
|
+
],
|
|
5160
|
+
[
|
|
5161
|
+
"ff00::",
|
|
5162
|
+
8,
|
|
5163
|
+
"ipv6"
|
|
5164
|
+
]
|
|
5165
|
+
];
|
|
5166
|
+
function blockedAddressList() {
|
|
5167
|
+
const list = new net.BlockList();
|
|
5168
|
+
for (const [network, prefix, family] of BLOCKED_SUBNETS) list.addSubnet(network, prefix, family);
|
|
5169
|
+
return list;
|
|
5170
|
+
}
|
|
5171
|
+
const blockedAddresses = blockedAddressList();
|
|
5172
|
+
/** Return true only for an IP address outside every blocked range. */
|
|
5173
|
+
function isPublicAddress(address) {
|
|
5174
|
+
const family = net.isIP(address);
|
|
5175
|
+
if (family === 0) return false;
|
|
5176
|
+
return !blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6");
|
|
5177
|
+
}
|
|
5178
|
+
function blockedAddressError(hostname) {
|
|
5179
|
+
return Object.assign(/* @__PURE__ */ new Error(`${hostname} does not resolve to a public address`), { code: "EADDRNOTPUBLIC" });
|
|
5180
|
+
}
|
|
5181
|
+
/**
|
|
5182
|
+
* Resolve a hostname for an outbound socket. Fail when any answer is not a
|
|
5183
|
+
* public address.
|
|
5184
|
+
*/
|
|
5185
|
+
const publicAddressLookup = (hostname, options, callback) => {
|
|
5186
|
+
dns.lookup(hostname, {
|
|
5187
|
+
all: true,
|
|
5188
|
+
verbatim: true
|
|
5189
|
+
}, (error, addresses) => {
|
|
5190
|
+
if (error) {
|
|
5191
|
+
callback(error, "", 0);
|
|
5192
|
+
return;
|
|
5193
|
+
}
|
|
5194
|
+
const allowed = addresses.filter((entry) => !options.family || entry.family === options.family || `IPv${entry.family}` === options.family);
|
|
5195
|
+
const first = allowed[0];
|
|
5196
|
+
if (!first || !addresses.every((entry) => isPublicAddress(entry.address))) {
|
|
5197
|
+
callback(blockedAddressError(hostname), "", 0);
|
|
5198
|
+
return;
|
|
5199
|
+
}
|
|
5200
|
+
if (options.all) {
|
|
5201
|
+
callback(null, allowed);
|
|
5202
|
+
return;
|
|
5203
|
+
}
|
|
5204
|
+
callback(null, first.address, first.family);
|
|
5205
|
+
});
|
|
5206
|
+
};
|
|
5207
|
+
// src/api/mcp/oauth/state.ts
|
|
5208
|
+
/** Return the SHA-256 hex digest of a secret or unbounded value. */
|
|
5209
|
+
function sha256Hex(value) {
|
|
5210
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
5211
|
+
}
|
|
5212
|
+
/** Build one versioned MCP state key. */
|
|
5213
|
+
function mcpStateKey(kind, id) {
|
|
5214
|
+
return `junior:mcp:v1:${kind}:${id}`;
|
|
5215
|
+
}
|
|
5216
|
+
/** Read and parse one MCP state record. A missing record reads as undefined. */
|
|
5217
|
+
async function readRecord(state, key, schema) {
|
|
5218
|
+
const value = await state.get(key);
|
|
5219
|
+
return value === null ? void 0 : schema.parse(value);
|
|
5220
|
+
}
|
|
5221
|
+
// src/api/mcp/oauth/client-metadata.ts
|
|
5222
|
+
/**
|
|
5223
|
+
* Own MCP OAuth clients. Junior accepts only Client ID Metadata Documents
|
|
5224
|
+
* (CIMD): the `client_id` is an HTTPS URL, and the document at that URL
|
|
5225
|
+
* names the client and its redirect URIs. Junior has no client registration.
|
|
5226
|
+
*/
|
|
5227
|
+
const CLIENT_ID_MAX_LENGTH = 2e3;
|
|
5228
|
+
const CLIENT_METADATA_MAX_BYTES = 5120;
|
|
5229
|
+
const CLIENT_METADATA_TIMEOUT_MS = 5e3;
|
|
5230
|
+
const CLIENT_METADATA_CACHE_TTL_MS = 3e5;
|
|
5231
|
+
const INVALID_CLIENT_CACHE_TTL_MS = 6e4;
|
|
5232
|
+
const clientMetadataDocumentSchema = z.object({
|
|
5233
|
+
client_id: z.string(),
|
|
5234
|
+
client_name: z.string().trim().min(1).max(200).optional(),
|
|
5235
|
+
redirect_uris: z.array(z.string().max(CLIENT_ID_MAX_LENGTH)).min(1).max(20),
|
|
5236
|
+
token_endpoint_auth_method: z.literal("none").optional()
|
|
5237
|
+
});
|
|
5238
|
+
const mcpClientSchema = z.object({
|
|
5239
|
+
clientId: z.string().min(1),
|
|
5240
|
+
name: z.string().min(1).optional(),
|
|
5241
|
+
redirectUris: z.array(z.string()).min(1)
|
|
5242
|
+
}).strict();
|
|
5243
|
+
const invalidClientSchema = z.object({ invalid: z.literal(true) }).strict();
|
|
5244
|
+
const cachedClientSchema = z.union([mcpClientSchema, invalidClientSchema]);
|
|
5245
|
+
/**
|
|
5246
|
+
* Parse a CIMD client ID. It must be a canonical `https:` URL with a path,
|
|
5247
|
+
* no fragment, no credentials, and no loopback or private IP host.
|
|
5248
|
+
*/
|
|
5249
|
+
function parseClientId(value) {
|
|
5250
|
+
if (value.length > CLIENT_ID_MAX_LENGTH || value.includes("#")) return;
|
|
5251
|
+
const url = URL.parse(value);
|
|
5252
|
+
if (!url) return void 0;
|
|
5253
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
5254
|
+
if (url.href !== value || url.protocol !== "https:" || url.pathname === "/" || url.username || url.password || hostname === "localhost" || hostname.endsWith(".localhost") || net.isIP(hostname) !== 0 && !isPublicAddress(hostname)) return;
|
|
5255
|
+
return url;
|
|
5256
|
+
}
|
|
5257
|
+
/**
|
|
5258
|
+
* Fetch one client metadata document. Redirects, non-200 responses, slow or
|
|
5259
|
+
* large responses, blocked addresses, and invalid JSON all give `undefined`,
|
|
5260
|
+
* because each one only means that the client is invalid.
|
|
5261
|
+
*/
|
|
5262
|
+
function fetchClientMetadataDocument(url) {
|
|
5263
|
+
return new Promise((resolve) => {
|
|
5264
|
+
const request = https.request(url, {
|
|
5265
|
+
headers: { accept: "application/json" },
|
|
5266
|
+
agent: false,
|
|
5267
|
+
lookup: publicAddressLookup,
|
|
5268
|
+
method: "GET",
|
|
5269
|
+
signal: AbortSignal.timeout(CLIENT_METADATA_TIMEOUT_MS)
|
|
5270
|
+
}, (response) => {
|
|
5271
|
+
if (response.statusCode !== 200) {
|
|
5272
|
+
request.destroy();
|
|
5273
|
+
resolve(void 0);
|
|
5274
|
+
return;
|
|
5275
|
+
}
|
|
5276
|
+
const chunks = [];
|
|
5277
|
+
let size = 0;
|
|
5278
|
+
response.on("data", (chunk) => {
|
|
5279
|
+
size += chunk.length;
|
|
5280
|
+
if (size > CLIENT_METADATA_MAX_BYTES) {
|
|
5281
|
+
request.destroy();
|
|
5282
|
+
resolve(void 0);
|
|
5283
|
+
return;
|
|
5284
|
+
}
|
|
5285
|
+
chunks.push(chunk);
|
|
5286
|
+
});
|
|
5287
|
+
response.on("end", () => {
|
|
5288
|
+
try {
|
|
5289
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
5290
|
+
} catch {
|
|
5291
|
+
resolve(void 0);
|
|
5292
|
+
}
|
|
5293
|
+
});
|
|
5294
|
+
response.on("error", () => resolve(void 0));
|
|
5295
|
+
});
|
|
5296
|
+
request.on("error", () => resolve(void 0));
|
|
5297
|
+
request.end();
|
|
5298
|
+
});
|
|
5299
|
+
}
|
|
5300
|
+
/**
|
|
5301
|
+
* Resolve a client from its CIMD URL. Return `undefined` when the client ID or
|
|
5302
|
+
* its document is invalid. Valid documents are cached for 5 minutes, and
|
|
5303
|
+
* failed fetches for 1 minute.
|
|
5304
|
+
*/
|
|
5305
|
+
async function resolveMcpClient(state, clientId) {
|
|
5306
|
+
const url = parseClientId(clientId);
|
|
5307
|
+
if (!url) return void 0;
|
|
5308
|
+
const key = mcpStateKey("client", sha256Hex(clientId));
|
|
5309
|
+
const cached = await readRecord(state, key, cachedClientSchema);
|
|
5310
|
+
if (cached) return "invalid" in cached ? void 0 : cached;
|
|
5311
|
+
const parsed = clientMetadataDocumentSchema.safeParse(await fetchClientMetadataDocument(url));
|
|
5312
|
+
if (!parsed.success || parsed.data.client_id !== clientId) {
|
|
5313
|
+
await state.set(key, { invalid: true }, INVALID_CLIENT_CACHE_TTL_MS);
|
|
5314
|
+
return;
|
|
5315
|
+
}
|
|
5316
|
+
const client = {
|
|
5317
|
+
clientId,
|
|
5318
|
+
name: parsed.data.client_name,
|
|
5319
|
+
redirectUris: parsed.data.redirect_uris
|
|
5320
|
+
};
|
|
5321
|
+
await state.set(key, client, CLIENT_METADATA_CACHE_TTL_MS);
|
|
5322
|
+
return client;
|
|
5323
|
+
}
|
|
5324
|
+
function isLoopbackHttp(url) {
|
|
5325
|
+
return url.protocol === "http:" && LOOPBACK_HOSTNAMES.includes(url.hostname);
|
|
5326
|
+
}
|
|
5327
|
+
/**
|
|
5328
|
+
* Return true when a requested redirect URI is allowed for a client. The URI
|
|
5329
|
+
* must be `https:` or loopback `http:`, and must equal a registered URI. A
|
|
5330
|
+
* loopback URI may use any port (RFC 8252 section 7.3), because native
|
|
5331
|
+
* clients such as Claude Code listen on a random port.
|
|
5332
|
+
*/
|
|
5333
|
+
function redirectUriMatches(registered, requested) {
|
|
5334
|
+
const url = URL.parse(requested);
|
|
5335
|
+
if (!url || requested.includes("#") || url.protocol !== "https:" && !isLoopbackHttp(url)) return false;
|
|
5336
|
+
return registered.some((entry) => {
|
|
5337
|
+
if (entry === requested) return true;
|
|
5338
|
+
if (!isLoopbackHttp(url)) return false;
|
|
5339
|
+
const candidate = URL.parse(entry);
|
|
5340
|
+
return candidate !== null && isLoopbackHttp(candidate) && candidate.hostname === url.hostname && candidate.pathname === url.pathname && candidate.search === url.search;
|
|
5341
|
+
});
|
|
5342
|
+
}
|
|
5343
|
+
// src/api/mcp/oauth/metadata.ts
|
|
5344
|
+
/**
|
|
5345
|
+
* Own the MCP OAuth paths, scope, and discovery documents.
|
|
5346
|
+
*
|
|
5347
|
+
* Every URL comes from the trusted public origin of the app. The documents
|
|
5348
|
+
* advertise Client ID Metadata Documents and no client registration endpoint.
|
|
5349
|
+
*/
|
|
5350
|
+
const MCP_PATH = "/api/mcp";
|
|
5351
|
+
const MCP_SCOPE = "conversations:read";
|
|
5352
|
+
const MCP_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/api/mcp";
|
|
5353
|
+
const MCP_AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
5354
|
+
const MCP_AUTHORIZE_PATH = "/api/mcp/oauth/authorize";
|
|
5355
|
+
const MCP_TOKEN_PATH = "/api/mcp/oauth/token";
|
|
5356
|
+
/** Dashboard-authenticated consent page for one pending authorization. */
|
|
5357
|
+
const MCP_CONSENT_PATH = "/_junior/mcp/authorize/:authorizationId";
|
|
5358
|
+
/** Resolve the MCP URLs for this request from the trusted public origin. */
|
|
5359
|
+
function resolveMcpUrls(request, baseURL) {
|
|
5360
|
+
const origin = resolvePublicOrigin(request, baseURL);
|
|
5361
|
+
if (!origin) throw new Error("Junior MCP requires a configured public base URL");
|
|
5362
|
+
const url = (path) => new URL(path, origin).toString();
|
|
5363
|
+
return {
|
|
5364
|
+
authorize: url(MCP_AUTHORIZE_PATH),
|
|
5365
|
+
issuer: origin.origin,
|
|
5366
|
+
resource: url(MCP_PATH),
|
|
5367
|
+
resourceMetadata: url(MCP_RESOURCE_METADATA_PATH),
|
|
5368
|
+
token: url(MCP_TOKEN_PATH),
|
|
5369
|
+
consent: (authorizationId) => url(MCP_CONSENT_PATH.replace(":authorizationId", encodeURIComponent(authorizationId)))
|
|
5370
|
+
};
|
|
5371
|
+
}
|
|
5372
|
+
/** RFC 9728 protected resource metadata for `/api/mcp`. */
|
|
5373
|
+
function protectedResourceMetadata(urls) {
|
|
5374
|
+
return {
|
|
5375
|
+
resource: urls.resource,
|
|
5376
|
+
authorization_servers: [urls.issuer],
|
|
5377
|
+
bearer_methods_supported: ["header"],
|
|
5378
|
+
scopes_supported: [MCP_SCOPE]
|
|
5379
|
+
};
|
|
5380
|
+
}
|
|
5381
|
+
/** RFC 8414 authorization server metadata. CIMD is the only client model. */
|
|
5382
|
+
function authorizationServerMetadata(urls) {
|
|
5383
|
+
return {
|
|
5384
|
+
issuer: urls.issuer,
|
|
5385
|
+
authorization_endpoint: urls.authorize,
|
|
5386
|
+
token_endpoint: urls.token,
|
|
5387
|
+
scopes_supported: [MCP_SCOPE],
|
|
5388
|
+
response_types_supported: ["code"],
|
|
5389
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
5390
|
+
code_challenge_methods_supported: ["S256"],
|
|
5391
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
5392
|
+
authorization_response_iss_parameter_supported: true,
|
|
5393
|
+
client_id_metadata_document_supported: true
|
|
5394
|
+
};
|
|
5395
|
+
}
|
|
5396
|
+
// src/api/mcp/oauth/page.ts
|
|
5397
|
+
/** Render one MCP sign-in page. It sends no referrer and loads no resources. */
|
|
5398
|
+
function renderMcpPage(page) {
|
|
5399
|
+
const formAction = ["'self'", ...page.formOrigin ? [page.formOrigin] : []];
|
|
5400
|
+
const message = ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">${escapeHtml(page.message)}</p>`;
|
|
5401
|
+
return renderHtmlPage({
|
|
5402
|
+
bodyHtml: page.bodyHtml ? `${message}\n${page.bodyHtml}` : message,
|
|
5403
|
+
headers: {
|
|
5404
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; form-action ${formAction.join(" ")}`,
|
|
5405
|
+
"referrer-policy": "no-referrer"
|
|
5406
|
+
},
|
|
5407
|
+
status: page.status,
|
|
5408
|
+
title: page.title
|
|
5409
|
+
});
|
|
5410
|
+
}
|
|
5411
|
+
// src/api/mcp/oauth/authorization.ts
|
|
5412
|
+
/**
|
|
5413
|
+
* Own the OAuth authorization endpoint and its pending requests.
|
|
5414
|
+
*
|
|
5415
|
+
* The endpoint checks the client and redirect URI first. When either one is
|
|
5416
|
+
* invalid, it shows an error page and never redirects, so an attacker cannot
|
|
5417
|
+
* use Junior as an open redirect. Other errors go back to the client as OAuth
|
|
5418
|
+
* error redirects. A valid request waits for consent on a dashboard page.
|
|
5419
|
+
*/
|
|
5420
|
+
const AUTHORIZATION_TTL_MS = 6e5;
|
|
5421
|
+
const STATE_MAX_LENGTH = 1e3;
|
|
5422
|
+
const AUTHORIZATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
5423
|
+
const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
5424
|
+
const mcpAuthorizationSchema = z.object({
|
|
5425
|
+
clientId: z.string().min(1),
|
|
5426
|
+
clientName: z.string().min(1).optional(),
|
|
5427
|
+
codeChallenge: z.string().regex(CODE_CHALLENGE_PATTERN),
|
|
5428
|
+
/** Fixed end of the request. Opening the consent page does not move it. */
|
|
5429
|
+
expiresAtMs: z.number().int().positive(),
|
|
5430
|
+
redirectUri: z.string().min(1),
|
|
5431
|
+
resource: z.string().min(1),
|
|
5432
|
+
scope: z.literal(MCP_SCOPE),
|
|
5433
|
+
state: z.string().max(STATE_MAX_LENGTH).optional()
|
|
5434
|
+
}).strict();
|
|
5435
|
+
/** The user who owns one consent page, and the hash of its form token. */
|
|
5436
|
+
const mcpConsentSchema = z.object({
|
|
5437
|
+
csrfHash: z.string().length(64),
|
|
5438
|
+
userId: z.string().min(1)
|
|
5439
|
+
}).strict();
|
|
5440
|
+
function authorizationKey(authorizationId) {
|
|
5441
|
+
return mcpStateKey("authorization", authorizationId);
|
|
5442
|
+
}
|
|
5443
|
+
function consentKey(authorizationId) {
|
|
5444
|
+
return `${authorizationKey(authorizationId)}:consent`;
|
|
5445
|
+
}
|
|
5446
|
+
function claimedKey(authorizationId) {
|
|
5447
|
+
return `${authorizationKey(authorizationId)}:claimed`;
|
|
5448
|
+
}
|
|
5449
|
+
/**
|
|
5450
|
+
* Read one pending authorization and its consent binding. Malformed ids,
|
|
5451
|
+
* ended requests, and claimed requests read as missing.
|
|
5452
|
+
*/
|
|
5453
|
+
async function readMcpAuthorization(state, authorizationId) {
|
|
5454
|
+
if (!AUTHORIZATION_ID_PATTERN.test(authorizationId)) return void 0;
|
|
5455
|
+
const authorization = await readRecord(state, authorizationKey(authorizationId), mcpAuthorizationSchema);
|
|
5456
|
+
if (!authorization || authorization.expiresAtMs <= Date.now()) return;
|
|
5457
|
+
if (await state.get(claimedKey(authorizationId)) !== null) return void 0;
|
|
5458
|
+
return {
|
|
5459
|
+
authorization,
|
|
5460
|
+
consent: await readRecord(state, consentKey(authorizationId), mcpConsentSchema)
|
|
5461
|
+
};
|
|
5462
|
+
}
|
|
5463
|
+
/**
|
|
5464
|
+
* Bind a pending authorization to the first user who opens its consent page,
|
|
5465
|
+
* and return a new form token for that user. Return `undefined` when another
|
|
5466
|
+
* user owns it. Only the bound user can approve, so a page that an attacker
|
|
5467
|
+
* opened cannot be submitted for a victim. The binding never extends the
|
|
5468
|
+
* request, and a claimed request stays claimed.
|
|
5469
|
+
*/
|
|
5470
|
+
async function bindMcpConsent(args) {
|
|
5471
|
+
const ttlMs = args.authorization.expiresAtMs - Date.now();
|
|
5472
|
+
if (ttlMs <= 0) return void 0;
|
|
5473
|
+
const csrfToken = randomBytes(32).toString("base64url");
|
|
5474
|
+
const consent = {
|
|
5475
|
+
csrfHash: sha256Hex(csrfToken),
|
|
5476
|
+
userId: args.userId
|
|
5477
|
+
};
|
|
5478
|
+
const key = consentKey(args.authorizationId);
|
|
5479
|
+
if (await args.state.setIfNotExists(key, consent, ttlMs)) return csrfToken;
|
|
5480
|
+
if ((await readRecord(args.state, key, mcpConsentSchema))?.userId !== args.userId) return void 0;
|
|
5481
|
+
await args.state.set(key, consent, ttlMs);
|
|
5482
|
+
return csrfToken;
|
|
5483
|
+
}
|
|
5484
|
+
/**
|
|
5485
|
+
* Claim a pending authorization for one consent decision. Only one caller
|
|
5486
|
+
* can claim it, so one consent cannot issue two codes.
|
|
5487
|
+
*/
|
|
5488
|
+
async function claimMcpAuthorization(state, authorizationId) {
|
|
5489
|
+
const claimed = await state.setIfNotExists(claimedKey(authorizationId), true, AUTHORIZATION_TTL_MS);
|
|
5490
|
+
if (claimed) {
|
|
5491
|
+
await state.delete(authorizationKey(authorizationId));
|
|
5492
|
+
await state.delete(consentKey(authorizationId));
|
|
5493
|
+
}
|
|
5494
|
+
return claimed;
|
|
5495
|
+
}
|
|
5496
|
+
/** Build the client redirect for an OAuth authorization response. */
|
|
5497
|
+
function authorizationRedirect(args) {
|
|
5498
|
+
const url = new URL(args.redirectUri);
|
|
5499
|
+
for (const [name, value] of Object.entries(args.params)) url.searchParams.set(name, value);
|
|
5500
|
+
if (args.state !== void 0) url.searchParams.set("state", args.state);
|
|
5501
|
+
url.searchParams.set("iss", args.issuer);
|
|
5502
|
+
return new Response(null, {
|
|
5503
|
+
status: 303,
|
|
5504
|
+
headers: {
|
|
5505
|
+
"cache-control": "no-store",
|
|
5506
|
+
location: url.toString()
|
|
5507
|
+
}
|
|
5508
|
+
});
|
|
5509
|
+
}
|
|
5510
|
+
function invalidClientPage(message) {
|
|
5511
|
+
return renderMcpPage({
|
|
5512
|
+
message,
|
|
5513
|
+
status: 400,
|
|
5514
|
+
title: "This app cannot connect"
|
|
5515
|
+
});
|
|
5516
|
+
}
|
|
5517
|
+
/** Handle `GET /api/mcp/oauth/authorize` and hand off to the consent page. */
|
|
5518
|
+
async function handleMcpAuthorize(args) {
|
|
5519
|
+
const params = new URL(args.request.url).searchParams;
|
|
5520
|
+
const clientId = params.get("client_id") ?? "";
|
|
5521
|
+
const redirectUri = params.get("redirect_uri") ?? "";
|
|
5522
|
+
const client = await resolveMcpClient(args.state, clientId);
|
|
5523
|
+
if (!client) return invalidClientPage("The client ID is not a valid client metadata document URL.");
|
|
5524
|
+
if (!redirectUriMatches(client.redirectUris, redirectUri)) return invalidClientPage("The redirect URI is not registered by this client.");
|
|
5525
|
+
const oauthState = params.get("state") ?? void 0;
|
|
5526
|
+
const fail = (error, description) => authorizationRedirect({
|
|
5527
|
+
issuer: args.urls.issuer,
|
|
5528
|
+
params: {
|
|
5529
|
+
error,
|
|
5530
|
+
error_description: description
|
|
5531
|
+
},
|
|
5532
|
+
redirectUri,
|
|
5533
|
+
state: oauthState
|
|
5534
|
+
});
|
|
5535
|
+
if (params.get("response_type") !== "code") return fail("unsupported_response_type", "Use response_type=code.");
|
|
5536
|
+
const codeChallenge = params.get("code_challenge") ?? "";
|
|
5537
|
+
if (params.get("code_challenge_method") !== "S256" || !CODE_CHALLENGE_PATTERN.test(codeChallenge)) return fail("invalid_request", "PKCE with S256 is required.");
|
|
5538
|
+
if (oauthState !== void 0 && oauthState.length > STATE_MAX_LENGTH) return fail("invalid_request", "The state parameter is too long.");
|
|
5539
|
+
const scope = params.get("scope")?.trim();
|
|
5540
|
+
if (scope && scope !== "conversations:read") return fail("invalid_scope", `The only scope is ${MCP_SCOPE}.`);
|
|
5541
|
+
if (params.get("resource") !== args.urls.resource) return fail("invalid_target", `The resource must be ${args.urls.resource}.`);
|
|
5542
|
+
const authorizationId = randomBytes(32).toString("base64url");
|
|
5543
|
+
const authorization = {
|
|
5544
|
+
clientId: client.clientId,
|
|
5545
|
+
clientName: client.name,
|
|
5546
|
+
codeChallenge,
|
|
5547
|
+
expiresAtMs: Date.now() + AUTHORIZATION_TTL_MS,
|
|
5548
|
+
redirectUri,
|
|
5549
|
+
resource: args.urls.resource,
|
|
5550
|
+
scope: MCP_SCOPE,
|
|
5551
|
+
state: oauthState
|
|
5552
|
+
};
|
|
5553
|
+
await args.state.set(authorizationKey(authorizationId), authorization, AUTHORIZATION_TTL_MS);
|
|
5554
|
+
return new Response(null, {
|
|
5555
|
+
status: 302,
|
|
5556
|
+
headers: {
|
|
5557
|
+
"cache-control": "no-store",
|
|
5558
|
+
location: args.urls.consent(authorizationId)
|
|
5559
|
+
}
|
|
5560
|
+
});
|
|
5561
|
+
}
|
|
5562
|
+
// src/api/mcp/oauth/grants.ts
|
|
5563
|
+
/**
|
|
5564
|
+
* Own MCP OAuth grants: authorization codes, access tokens, and refresh
|
|
5565
|
+
* tokens. Every secret is random, opaque, and stored only as a SHA-256 hash
|
|
5566
|
+
* in shared state with a TTL. A grant ends `GRANT_MAX_AGE_MS` after consent,
|
|
5567
|
+
* and refresh does not extend it.
|
|
5568
|
+
*/
|
|
5569
|
+
const CODE_TTL_MS = 6e4;
|
|
5570
|
+
const ACCESS_TOKEN_TTL_MS = 36e5;
|
|
5571
|
+
const GRANT_MAX_AGE_MS = 2592e6;
|
|
5572
|
+
const REFRESH_REUSE_GRACE_MS = 6e4;
|
|
5573
|
+
const ACCESS_TOKEN_PREFIX = "jr_mcp_at_";
|
|
5574
|
+
const REFRESH_TOKEN_PREFIX = "jr_mcp_rt_";
|
|
5575
|
+
const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
5576
|
+
const authorizationCodeSchema = z.object({
|
|
5577
|
+
clientId: z.string().min(1),
|
|
5578
|
+
codeChallenge: z.string().min(1),
|
|
5579
|
+
/** Verified email of the user who approved consent. */
|
|
5580
|
+
email: z.string().min(1),
|
|
5581
|
+
/** Grant that this code creates. Reuse of the code revokes it. */
|
|
5582
|
+
grantId: z.string().uuid(),
|
|
5583
|
+
redirectUri: z.string().min(1),
|
|
5584
|
+
resource: z.string().min(1),
|
|
5585
|
+
scope: z.literal(MCP_SCOPE)
|
|
5586
|
+
}).strict();
|
|
5587
|
+
const grantSchema = z.object({
|
|
5588
|
+
clientId: z.string().min(1),
|
|
5589
|
+
email: z.string().min(1),
|
|
5590
|
+
/** Absolute end of the grant. Refresh never moves it. */
|
|
5591
|
+
expiresAtMs: z.number().int().positive(),
|
|
5592
|
+
resource: z.string().min(1),
|
|
5593
|
+
scope: z.literal(MCP_SCOPE)
|
|
5594
|
+
}).strict();
|
|
5595
|
+
const tokenSchema = z.object({ grantId: z.string().uuid() }).strict();
|
|
5596
|
+
function codeKey(code) {
|
|
5597
|
+
return mcpStateKey("code", sha256Hex(code));
|
|
5598
|
+
}
|
|
5599
|
+
function grantKey(grantId) {
|
|
5600
|
+
return mcpStateKey("grant", grantId);
|
|
5601
|
+
}
|
|
5602
|
+
function revokedGrantKey(grantId) {
|
|
5603
|
+
return `${grantKey(grantId)}:revoked`;
|
|
5604
|
+
}
|
|
5605
|
+
function accessTokenKey(token) {
|
|
5606
|
+
return mcpStateKey("access", sha256Hex(token));
|
|
5607
|
+
}
|
|
5608
|
+
function refreshTokenKey(token) {
|
|
5609
|
+
return mcpStateKey("refresh", sha256Hex(token));
|
|
5610
|
+
}
|
|
5611
|
+
function invalidGrant(description) {
|
|
5612
|
+
return {
|
|
5613
|
+
description,
|
|
5614
|
+
error: "invalid_grant",
|
|
5615
|
+
ok: false
|
|
5616
|
+
};
|
|
5617
|
+
}
|
|
5618
|
+
/** Issue a single-use authorization code for one approved consent. */
|
|
5619
|
+
async function issueAuthorizationCode(args) {
|
|
5620
|
+
const code = randomBytes(32).toString("base64url");
|
|
5621
|
+
const record = {
|
|
5622
|
+
clientId: args.clientId,
|
|
5623
|
+
codeChallenge: args.codeChallenge,
|
|
5624
|
+
email: args.email,
|
|
5625
|
+
grantId: randomUUID(),
|
|
5626
|
+
redirectUri: args.redirectUri,
|
|
5627
|
+
resource: args.resource,
|
|
5628
|
+
scope: MCP_SCOPE
|
|
5629
|
+
};
|
|
5630
|
+
await args.state.set(codeKey(code), record, CODE_TTL_MS);
|
|
5631
|
+
return code;
|
|
5632
|
+
}
|
|
5633
|
+
/**
|
|
5634
|
+
* Revoke a grant after a reused code or refresh token. The marker lives for
|
|
5635
|
+
* `GRANT_MAX_AGE_MS`, and no grant or token lives longer, so a racing request
|
|
5636
|
+
* that saves the grant again cannot bring it back.
|
|
5637
|
+
*/
|
|
5638
|
+
async function revokeGrant(args) {
|
|
5639
|
+
await args.state.set(revokedGrantKey(args.grantId), true, GRANT_MAX_AGE_MS);
|
|
5640
|
+
await args.state.delete(grantKey(args.grantId));
|
|
5641
|
+
logWarn("mcp.oauth.grant.revoked", {
|
|
5642
|
+
"app.mcp.client_id": args.clientId,
|
|
5643
|
+
"app.mcp.revoke_reason": args.reason
|
|
5644
|
+
});
|
|
5645
|
+
}
|
|
5646
|
+
/** Read a grant that is not revoked and has not ended. */
|
|
5647
|
+
async function readGrant(state, grantId) {
|
|
5648
|
+
if (await state.get(revokedGrantKey(grantId)) !== null) return void 0;
|
|
5649
|
+
const grant = await readRecord(state, grantKey(grantId), grantSchema);
|
|
5650
|
+
return grant && grant.expiresAtMs > Date.now() ? grant : void 0;
|
|
5651
|
+
}
|
|
5652
|
+
/** Return true when the RFC 7636 S256 verifier matches the challenge. */
|
|
5653
|
+
function verifierMatches(verifier, challenge) {
|
|
5654
|
+
return CODE_VERIFIER_PATTERN.test(verifier) && createHash("sha256").update(verifier).digest("base64url") === challenge;
|
|
5655
|
+
}
|
|
5656
|
+
/**
|
|
5657
|
+
* Save a grant and issue a new access and refresh token pair for it. No
|
|
5658
|
+
* record outlives the grant.
|
|
5659
|
+
*/
|
|
5660
|
+
async function issueTokens(state, grantId, grant) {
|
|
5661
|
+
const grantTtlMs = grant.expiresAtMs - Date.now();
|
|
5662
|
+
if (grantTtlMs <= 0) return invalidGrant("The grant has expired.");
|
|
5663
|
+
const accessTtlMs = Math.min(ACCESS_TOKEN_TTL_MS, grantTtlMs);
|
|
5664
|
+
const accessToken = `${ACCESS_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
5665
|
+
const refreshToken = `${REFRESH_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
5666
|
+
await state.set(grantKey(grantId), grant, grantTtlMs);
|
|
5667
|
+
await state.set(accessTokenKey(accessToken), { grantId }, accessTtlMs);
|
|
5668
|
+
await state.set(refreshTokenKey(refreshToken), { grantId }, grantTtlMs);
|
|
5669
|
+
return {
|
|
5670
|
+
ok: true,
|
|
5671
|
+
tokens: {
|
|
5672
|
+
access_token: accessToken,
|
|
5673
|
+
expires_in: Math.floor(accessTtlMs / 1e3),
|
|
5674
|
+
refresh_token: refreshToken,
|
|
5675
|
+
scope: grant.scope,
|
|
5676
|
+
token_type: "Bearer"
|
|
5677
|
+
}
|
|
5678
|
+
};
|
|
5679
|
+
}
|
|
5680
|
+
/**
|
|
5681
|
+
* Exchange one authorization code for tokens. The code stays stored until it
|
|
5682
|
+
* expires. A second valid use of the code revokes the grant that the first
|
|
5683
|
+
* use created (RFC 6749 section 4.1.2).
|
|
5684
|
+
*/
|
|
5685
|
+
async function exchangeAuthorizationCode(args) {
|
|
5686
|
+
const key = codeKey(args.code);
|
|
5687
|
+
const code = await readRecord(args.state, key, authorizationCodeSchema);
|
|
5688
|
+
if (!code || code.clientId !== args.clientId || code.redirectUri !== args.redirectUri || args.resource !== void 0 && args.resource !== code.resource || !verifierMatches(args.codeVerifier, code.codeChallenge)) return invalidGrant("The authorization code is not valid.");
|
|
5689
|
+
if (!await args.state.setIfNotExists(`${key}:used`, true, CODE_TTL_MS)) {
|
|
5690
|
+
await revokeGrant({
|
|
5691
|
+
clientId: code.clientId,
|
|
5692
|
+
grantId: code.grantId,
|
|
5693
|
+
reason: "authorization_code_reused",
|
|
5694
|
+
state: args.state
|
|
5695
|
+
});
|
|
5696
|
+
return invalidGrant("The authorization code was already used.");
|
|
5697
|
+
}
|
|
5698
|
+
return await issueTokens(args.state, code.grantId, {
|
|
5699
|
+
clientId: code.clientId,
|
|
5700
|
+
email: code.email,
|
|
5701
|
+
expiresAtMs: Date.now() + GRANT_MAX_AGE_MS,
|
|
5702
|
+
resource: code.resource,
|
|
5703
|
+
scope: code.scope
|
|
5704
|
+
});
|
|
5705
|
+
}
|
|
5706
|
+
/**
|
|
5707
|
+
* Rotate a refresh token. Each refresh token works once. A second use of a
|
|
5708
|
+
* rotated token within `REFRESH_REUSE_GRACE_MS` fails. A later second use
|
|
5709
|
+
* revokes the whole grant, so a stolen token stops working for both the
|
|
5710
|
+
* thief and the client.
|
|
5711
|
+
*/
|
|
5712
|
+
async function refreshGrant(args) {
|
|
5713
|
+
if (!args.refreshToken.startsWith(REFRESH_TOKEN_PREFIX)) return invalidGrant("The refresh token is not valid.");
|
|
5714
|
+
const key = refreshTokenKey(args.refreshToken);
|
|
5715
|
+
const token = await readRecord(args.state, key, tokenSchema);
|
|
5716
|
+
const grant = token ? await readGrant(args.state, token.grantId) : void 0;
|
|
5717
|
+
if (!token || !grant || grant.clientId !== args.clientId || args.resource !== void 0 && args.resource !== grant.resource) return invalidGrant("The refresh token is not valid.");
|
|
5718
|
+
if (args.scope !== void 0 && args.scope !== grant.scope) return {
|
|
5719
|
+
description: `The only scope is ${MCP_SCOPE}.`,
|
|
5720
|
+
error: "invalid_scope",
|
|
5721
|
+
ok: false
|
|
5722
|
+
};
|
|
5723
|
+
const usedKey = `${key}:used`;
|
|
5724
|
+
if (!await args.state.setIfNotExists(usedKey, Date.now(), grant.expiresAtMs - Date.now())) {
|
|
5725
|
+
const usedAtMs = await readRecord(args.state, usedKey, z.number());
|
|
5726
|
+
if (usedAtMs !== void 0 && Date.now() - usedAtMs < REFRESH_REUSE_GRACE_MS) return invalidGrant("The refresh token was already rotated.");
|
|
5727
|
+
await revokeGrant({
|
|
5728
|
+
clientId: grant.clientId,
|
|
5729
|
+
grantId: token.grantId,
|
|
5730
|
+
reason: "refresh_token_reused",
|
|
5731
|
+
state: args.state
|
|
5732
|
+
});
|
|
5733
|
+
return invalidGrant("The refresh token was already used.");
|
|
5734
|
+
}
|
|
5735
|
+
return await issueTokens(args.state, token.grantId, grant);
|
|
5736
|
+
}
|
|
5737
|
+
/**
|
|
5738
|
+
* Return the user email of one MCP access token for one resource. Personal
|
|
5739
|
+
* tokens and other bearer values never match, because only this module
|
|
5740
|
+
* stores MCP access tokens.
|
|
5741
|
+
*/
|
|
5742
|
+
async function authenticateMcpAccessToken(args) {
|
|
5743
|
+
if (!args.token.startsWith(ACCESS_TOKEN_PREFIX)) return void 0;
|
|
5744
|
+
const token = await readRecord(args.state, accessTokenKey(args.token), tokenSchema);
|
|
5745
|
+
if (!token) return void 0;
|
|
5746
|
+
const grant = await readGrant(args.state, token.grantId);
|
|
5747
|
+
if (!grant || grant.resource !== args.resource) return void 0;
|
|
5748
|
+
return grant.email;
|
|
5749
|
+
}
|
|
5750
|
+
// src/api/mcp/oauth/consent-page.ts
|
|
5751
|
+
/**
|
|
5752
|
+
* Own the dashboard-authenticated MCP consent page.
|
|
5753
|
+
*
|
|
5754
|
+
* The dashboard verifies Google sign-in before it calls this page. A POST must
|
|
5755
|
+
* come from the same origin and carry the form token issued to the same user
|
|
5756
|
+
* on GET. Approve and deny each end the pending authorization.
|
|
5757
|
+
*/
|
|
5758
|
+
function expiredPage() {
|
|
5759
|
+
return renderMcpPage({
|
|
5760
|
+
message: "Return to your MCP client and connect again.",
|
|
5761
|
+
status: 410,
|
|
5762
|
+
title: "This sign-in request expired"
|
|
5763
|
+
});
|
|
5764
|
+
}
|
|
5765
|
+
function blockedPage() {
|
|
5766
|
+
return renderMcpPage({
|
|
5767
|
+
message: "Return to your MCP client and connect again.",
|
|
5768
|
+
status: 403,
|
|
5769
|
+
title: "Request blocked"
|
|
5770
|
+
});
|
|
5771
|
+
}
|
|
5772
|
+
function consentPage(args) {
|
|
5773
|
+
const clientHost = new URL(args.authorization.clientId).host;
|
|
5774
|
+
const clientName = args.authorization.clientName ?? clientHost;
|
|
5775
|
+
const button = (action, label, primary) => `<button type="submit" name="action" value="${action}" style="border:0;border-radius:.375rem;cursor:pointer;font:inherit;font-weight:700;padding:.625rem 1rem;margin-right:.5rem;${primary ? "background:#a78bfa;color:#000" : "background:#333;color:#fff"}">${label}</button>`;
|
|
5776
|
+
return renderMcpPage({
|
|
5777
|
+
bodyHtml: ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">The app is published at <strong style="color:#fff">${escapeHtml(clientHost)}</strong>. It can list your ${escapeHtml(args.agentName)} conversations.</p>
|
|
5778
|
+
<form method="post">
|
|
5779
|
+
<input type="hidden" name="csrf" value="${escapeHtml(args.csrfToken)}">
|
|
5780
|
+
${button("approve", "Allow", true)}${button("deny", "Deny", false)}
|
|
5781
|
+
</form>`,
|
|
5782
|
+
formOrigin: new URL(args.authorization.redirectUri).origin,
|
|
5783
|
+
message: `${clientName} wants to connect to ${args.agentName}. Only continue if you started this connection.`,
|
|
5784
|
+
status: 200,
|
|
5785
|
+
title: `Connect ${clientName}`
|
|
5786
|
+
});
|
|
5787
|
+
}
|
|
5788
|
+
function csrfMatches(value, expectedHash) {
|
|
5789
|
+
const actual = Buffer.from(sha256Hex(value), "hex");
|
|
5790
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
5791
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
5792
|
+
}
|
|
5793
|
+
async function readForm(request) {
|
|
5794
|
+
try {
|
|
5795
|
+
const form = await request.formData();
|
|
5796
|
+
const action = form.get("action");
|
|
5797
|
+
const csrf = form.get("csrf");
|
|
5798
|
+
return {
|
|
5799
|
+
action: typeof action === "string" ? action : void 0,
|
|
5800
|
+
csrf: typeof csrf === "string" ? csrf : void 0
|
|
5801
|
+
};
|
|
5802
|
+
} catch {
|
|
5803
|
+
return {};
|
|
5804
|
+
}
|
|
5805
|
+
}
|
|
5806
|
+
/** Show or complete MCP consent for one dashboard-authenticated user. */
|
|
5807
|
+
async function handleMcpConsentPage(args) {
|
|
5808
|
+
const authorizationId = new URL(args.request.url).pathname.split("/").at(-1) ?? "";
|
|
5809
|
+
const pending = await readMcpAuthorization(args.state, authorizationId);
|
|
5810
|
+
if (!pending) return expiredPage();
|
|
5811
|
+
const { authorization, consent } = pending;
|
|
5812
|
+
if (args.request.method === "GET") {
|
|
5813
|
+
const csrfToken = await bindMcpConsent({
|
|
5814
|
+
authorization,
|
|
5815
|
+
authorizationId,
|
|
5816
|
+
state: args.state,
|
|
5817
|
+
userId: args.user.id
|
|
5818
|
+
});
|
|
5819
|
+
if (!csrfToken) return blockedPage();
|
|
5820
|
+
return consentPage({
|
|
5821
|
+
agentName: args.agentName,
|
|
5822
|
+
authorization,
|
|
5823
|
+
csrfToken
|
|
5824
|
+
});
|
|
5825
|
+
}
|
|
5826
|
+
const form = await readForm(args.request);
|
|
5827
|
+
if (!requestHasSameOrigin(args.request) || consent?.userId !== args.user.id || !form.csrf || !csrfMatches(form.csrf, consent.csrfHash) || form.action !== "approve" && form.action !== "deny") return blockedPage();
|
|
5828
|
+
if (!await claimMcpAuthorization(args.state, authorizationId)) return expiredPage();
|
|
5829
|
+
const { issuer } = resolveMcpUrls(args.request, args.baseURL);
|
|
5830
|
+
if (form.action === "deny") return authorizationRedirect({
|
|
5831
|
+
issuer,
|
|
5832
|
+
params: {
|
|
5833
|
+
error: "access_denied",
|
|
5834
|
+
error_description: "The user denied access."
|
|
5835
|
+
},
|
|
5836
|
+
redirectUri: authorization.redirectUri,
|
|
5837
|
+
state: authorization.state
|
|
5838
|
+
});
|
|
5839
|
+
return authorizationRedirect({
|
|
5840
|
+
issuer,
|
|
5841
|
+
params: { code: await issueAuthorizationCode({
|
|
5842
|
+
clientId: authorization.clientId,
|
|
5843
|
+
codeChallenge: authorization.codeChallenge,
|
|
5844
|
+
email: args.user.email,
|
|
5845
|
+
redirectUri: authorization.redirectUri,
|
|
5846
|
+
resource: authorization.resource,
|
|
5847
|
+
state: args.state
|
|
5848
|
+
}) },
|
|
5849
|
+
redirectUri: authorization.redirectUri,
|
|
5850
|
+
state: authorization.state
|
|
5851
|
+
});
|
|
5852
|
+
}
|
|
5853
|
+
// src/api/mcp/tools/list-conversations.ts
|
|
5854
|
+
/** MCP view of one Conversation. It omits dashboard-only fields. */
|
|
5855
|
+
const mcpConversationSchema = z.object({
|
|
5856
|
+
conversationId: z.string(),
|
|
5857
|
+
title: z.string(),
|
|
5858
|
+
status: conversationReportStatusSchema,
|
|
5859
|
+
surface: conversationSurfaceSchema,
|
|
5860
|
+
channelName: z.string().optional(),
|
|
5861
|
+
locationUrl: z.string().optional(),
|
|
5862
|
+
startedAt: z.string(),
|
|
5863
|
+
lastSeenAt: z.string(),
|
|
5864
|
+
archivedAt: z.string().optional(),
|
|
5865
|
+
url: z.string().optional()
|
|
5866
|
+
}).strict();
|
|
5867
|
+
function mcpConversation(summary) {
|
|
5868
|
+
return {
|
|
5869
|
+
conversationId: summary.conversationId,
|
|
5870
|
+
title: summary.displayTitle,
|
|
5871
|
+
status: summary.status,
|
|
5872
|
+
surface: summary.surface,
|
|
5873
|
+
channelName: summary.channelNameRedacted ? void 0 : summary.channelName,
|
|
5874
|
+
locationUrl: summary.locationUrl,
|
|
5875
|
+
startedAt: summary.startedAt,
|
|
5876
|
+
lastSeenAt: summary.lastSeenAt,
|
|
5877
|
+
archivedAt: summary.archivedAt ?? void 0,
|
|
5878
|
+
url: getDashboardConversationLink(summary.conversationId)
|
|
5879
|
+
};
|
|
5880
|
+
}
|
|
5881
|
+
/** Register `listConversations`, which lists the Conversations of `user`. */
|
|
5882
|
+
function registerListConversationsTool(server, user) {
|
|
5883
|
+
const feedQuery = conversationFeedQuerySchema.shape;
|
|
5884
|
+
server.registerTool("listConversations", {
|
|
5885
|
+
title: "List conversations",
|
|
5886
|
+
description: "List up to 50 of your conversations, newest activity first. Only conversations that you started or take part in are listed.",
|
|
5887
|
+
inputSchema: {
|
|
5888
|
+
q: feedQuery.q.describe("Text to search for in titles and Briefs."),
|
|
5889
|
+
status: feedQuery.status.describe("List conversations that you have not archived (default) or that you archived. A search ignores this filter.")
|
|
5890
|
+
},
|
|
5891
|
+
outputSchema: { conversations: z.array(mcpConversationSchema) },
|
|
5892
|
+
annotations: {
|
|
5893
|
+
readOnlyHint: true,
|
|
5894
|
+
openWorldHint: false
|
|
5895
|
+
}
|
|
5896
|
+
}, async ({ q, status }) => {
|
|
5897
|
+
const result = { conversations: (await readConversationFeed({
|
|
5898
|
+
q,
|
|
5899
|
+
status,
|
|
5900
|
+
viewer: user
|
|
5901
|
+
})).conversations.map(mcpConversation) };
|
|
5902
|
+
return {
|
|
5903
|
+
content: [{
|
|
5904
|
+
type: "text",
|
|
5905
|
+
text: JSON.stringify(result)
|
|
5906
|
+
}],
|
|
5907
|
+
structuredContent: result
|
|
5908
|
+
};
|
|
5909
|
+
});
|
|
5910
|
+
}
|
|
5911
|
+
// src/api/mcp/server.ts
|
|
5912
|
+
/**
|
|
5913
|
+
* Own the MCP server behind `/api/mcp`. Each tool lives in its own file under
|
|
5914
|
+
* `tools/`.
|
|
5915
|
+
*
|
|
5916
|
+
* Each request gets a new server and a stateless transport that answers with
|
|
5917
|
+
* JSON. No MCP session or SSE stream outlives the request, so the route works
|
|
5918
|
+
* on any app instance and is not limited by the live-stream duration.
|
|
5919
|
+
*/
|
|
5920
|
+
/** Build the MCP server for one authenticated user. */
|
|
5921
|
+
function createMcpServer(user) {
|
|
5922
|
+
const server = new McpServer({
|
|
5923
|
+
name: "junior",
|
|
5924
|
+
version: JUNIOR_VERSION
|
|
5925
|
+
});
|
|
5926
|
+
registerListConversationsTool(server, user);
|
|
5927
|
+
return server;
|
|
5928
|
+
}
|
|
5929
|
+
/** Answer one Streamable HTTP POST for an authenticated user. */
|
|
5930
|
+
async function handleMcpServerRequest(request, user) {
|
|
5931
|
+
const server = createMcpServer(user);
|
|
5932
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
5933
|
+
enableJsonResponse: true,
|
|
5934
|
+
sessionIdGenerator: void 0
|
|
5935
|
+
});
|
|
5936
|
+
await server.connect(transport);
|
|
5937
|
+
try {
|
|
5938
|
+
return await transport.handleRequest(request);
|
|
5939
|
+
} finally {
|
|
5940
|
+
await server.close();
|
|
5941
|
+
}
|
|
5942
|
+
}
|
|
5943
|
+
// src/api/mcp/oauth/token.ts
|
|
5944
|
+
/**
|
|
5945
|
+
* Own the OAuth token endpoint. It accepts public clients only, so a request
|
|
5946
|
+
* names its `client_id` and carries no client secret. Every response uses
|
|
5947
|
+
* `Cache-Control: no-store`.
|
|
5948
|
+
*/
|
|
5949
|
+
const TOKEN_HEADERS = {
|
|
5950
|
+
"cache-control": "no-store",
|
|
5951
|
+
pragma: "no-cache"
|
|
5952
|
+
};
|
|
5953
|
+
function tokenError(error, description, status = 400) {
|
|
5954
|
+
return Response.json({
|
|
5955
|
+
error,
|
|
5956
|
+
error_description: description
|
|
5957
|
+
}, {
|
|
5958
|
+
status,
|
|
5959
|
+
headers: TOKEN_HEADERS
|
|
5960
|
+
});
|
|
5961
|
+
}
|
|
5962
|
+
/** Limit token request bodies. A token request is a few short form fields. */
|
|
5963
|
+
const mcpTokenBodyLimit = bodyLimit({
|
|
5964
|
+
maxSize: 8192,
|
|
5965
|
+
onError: () => tokenError("invalid_request", "The request body is too large.", 413)
|
|
5966
|
+
});
|
|
5967
|
+
function tokenResponse(result) {
|
|
5968
|
+
return result.ok ? Response.json(result.tokens, { headers: TOKEN_HEADERS }) : tokenError(result.error, result.description);
|
|
5969
|
+
}
|
|
5970
|
+
/** Handle `POST /api/mcp/oauth/token` for code exchange and refresh. */
|
|
5971
|
+
async function handleMcpToken(args) {
|
|
5972
|
+
if (!(args.request.headers.get("content-type") ?? "").startsWith("application/x-www-form-urlencoded")) return tokenError("invalid_request", "Send a form-encoded body.");
|
|
5973
|
+
const form = new URLSearchParams(await args.request.text());
|
|
5974
|
+
const value = (name) => form.get(name) ?? void 0;
|
|
5975
|
+
const clientId = value("client_id");
|
|
5976
|
+
if (!clientId) return tokenError("invalid_request", "The client_id is required.");
|
|
5977
|
+
switch (value("grant_type")) {
|
|
5978
|
+
case "authorization_code": {
|
|
5979
|
+
const code = value("code");
|
|
5980
|
+
const codeVerifier = value("code_verifier");
|
|
5981
|
+
const redirectUri = value("redirect_uri");
|
|
5982
|
+
if (!code || !codeVerifier || !redirectUri) return tokenError("invalid_request", "The code, code_verifier, and redirect_uri are required.");
|
|
5983
|
+
return tokenResponse(await exchangeAuthorizationCode({
|
|
5984
|
+
clientId,
|
|
5985
|
+
code,
|
|
5986
|
+
codeVerifier,
|
|
5987
|
+
redirectUri,
|
|
5988
|
+
resource: value("resource"),
|
|
5989
|
+
state: args.state
|
|
5990
|
+
}));
|
|
5991
|
+
}
|
|
5992
|
+
case "refresh_token": {
|
|
5993
|
+
const refreshToken = value("refresh_token");
|
|
5994
|
+
if (!refreshToken) return tokenError("invalid_request", "The refresh_token is required.");
|
|
5995
|
+
return tokenResponse(await refreshGrant({
|
|
5996
|
+
clientId,
|
|
5997
|
+
refreshToken,
|
|
5998
|
+
resource: value("resource"),
|
|
5999
|
+
scope: value("scope"),
|
|
6000
|
+
state: args.state
|
|
6001
|
+
}));
|
|
6002
|
+
}
|
|
6003
|
+
default: return tokenError("unsupported_grant_type", "Use authorization_code or refresh_token.");
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
// src/api/mcp/route.ts
|
|
6007
|
+
/**
|
|
6008
|
+
* Own the remote MCP HTTP edge: discovery metadata, OAuth endpoints, and the
|
|
6009
|
+
* Streamable HTTP route at `/api/mcp`.
|
|
6010
|
+
*/
|
|
6011
|
+
/** Host paths that the app forwards to the MCP handler. */
|
|
6012
|
+
const MCP_ROUTE_PATHS = [
|
|
6013
|
+
MCP_PATH,
|
|
6014
|
+
MCP_RESOURCE_METADATA_PATH,
|
|
6015
|
+
MCP_AUTHORIZATION_SERVER_METADATA_PATH,
|
|
6016
|
+
MCP_AUTHORIZE_PATH,
|
|
6017
|
+
MCP_TOKEN_PATH
|
|
6018
|
+
];
|
|
6019
|
+
/**
|
|
6020
|
+
* Return the RFC 6750 challenge that points clients to resource metadata.
|
|
6021
|
+
* A request that sent a token also gets `invalid_token`.
|
|
6022
|
+
*/
|
|
6023
|
+
function unauthorized(urls, sentToken) {
|
|
6024
|
+
return new Response(null, {
|
|
6025
|
+
status: 401,
|
|
6026
|
+
headers: {
|
|
6027
|
+
"cache-control": "no-store",
|
|
6028
|
+
"www-authenticate": `Bearer ${sentToken ? "error=\"invalid_token\", " : ""}resource_metadata="${urls.resourceMetadata}", scope="${MCP_SCOPE}"`
|
|
6029
|
+
}
|
|
6030
|
+
});
|
|
6031
|
+
}
|
|
6032
|
+
function bearerToken(request) {
|
|
6033
|
+
return (request.headers.get("authorization")?.match(/^Bearer\s+(\S+)$/i))?.[1];
|
|
6034
|
+
}
|
|
6035
|
+
/** Create the fetch handler for every path in `MCP_ROUTE_PATHS`. */
|
|
6036
|
+
function createMcpHttpHandler(options) {
|
|
6037
|
+
const app = new Hono();
|
|
6038
|
+
const urls = (request) => resolveMcpUrls(request, options.baseURL);
|
|
6039
|
+
app.get(MCP_RESOURCE_METADATA_PATH, (c) => Response.json(protectedResourceMetadata(urls(c.req.raw))));
|
|
6040
|
+
app.get(MCP_AUTHORIZATION_SERVER_METADATA_PATH, (c) => Response.json(authorizationServerMetadata(urls(c.req.raw))));
|
|
6041
|
+
app.get(MCP_AUTHORIZE_PATH, (c) => handleMcpAuthorize({
|
|
6042
|
+
request: c.req.raw,
|
|
6043
|
+
state: options.state,
|
|
6044
|
+
urls: urls(c.req.raw)
|
|
6045
|
+
}));
|
|
6046
|
+
app.post(MCP_TOKEN_PATH, mcpTokenBodyLimit, (c) => handleMcpToken({
|
|
6047
|
+
request: c.req.raw,
|
|
6048
|
+
state: options.state
|
|
6049
|
+
}));
|
|
6050
|
+
app.on([
|
|
6051
|
+
"GET",
|
|
6052
|
+
"POST",
|
|
6053
|
+
"DELETE"
|
|
6054
|
+
], MCP_PATH, async (c) => {
|
|
6055
|
+
const requestUrls = urls(c.req.raw);
|
|
6056
|
+
const token = bearerToken(c.req.raw);
|
|
6057
|
+
const email = token ? await authenticateMcpAccessToken({
|
|
6058
|
+
resource: requestUrls.resource,
|
|
6059
|
+
state: options.state,
|
|
6060
|
+
token
|
|
6061
|
+
}) : void 0;
|
|
6062
|
+
const user = email && isAllowedSignInEmail(email, options.allowedDomains) ? await resolveViewerUser(email) : void 0;
|
|
6063
|
+
if (!user) return unauthorized(requestUrls, token !== void 0);
|
|
6064
|
+
if (c.req.method !== "POST") return new Response(null, {
|
|
6065
|
+
status: 405,
|
|
6066
|
+
headers: { allow: "POST" }
|
|
6067
|
+
});
|
|
6068
|
+
return await handleMcpServerRequest(c.req.raw, user);
|
|
6069
|
+
});
|
|
6070
|
+
return app.fetch;
|
|
6071
|
+
}
|
|
6072
|
+
// src/api/acp/authorization-page.ts
|
|
6073
|
+
const ACP_AUTHORIZATION_PATH = "/_junior/acp/auth/:transactionId";
|
|
6074
|
+
const ACP_PAGE_HEADERS = { "content-security-policy": "frame-ancestors 'none'; form-action 'self'" };
|
|
6075
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4617
6076
|
function renderResult(agentName, result) {
|
|
4618
6077
|
const content = {
|
|
4619
6078
|
blocked: {
|
|
@@ -4647,66 +6106,24 @@ function renderResult(agentName, result) {
|
|
|
4647
6106
|
message: "Return to your ACP client and enter the code shown there."
|
|
4648
6107
|
}
|
|
4649
6108
|
}[result];
|
|
4650
|
-
return
|
|
4651
|
-
<
|
|
4652
|
-
|
|
4653
|
-
<meta charset="utf-8" />
|
|
4654
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
4655
|
-
<title>${escapeHtml(content.title)}</title>
|
|
4656
|
-
</head>
|
|
4657
|
-
<body style="margin:0;background:#000;color:#fff;font-family:ui-sans-serif,system-ui,sans-serif">
|
|
4658
|
-
<main style="min-height:100vh;display:grid;place-items:center;padding:2rem">
|
|
4659
|
-
<section style="max-width:32rem;border-left:4px solid #a78bfa;padding-left:1rem">
|
|
4660
|
-
<h1 style="margin:0 0 .75rem;font-size:2rem">${escapeHtml(content.title)}</h1>
|
|
4661
|
-
<p style="margin:0;color:#b8b8b8;line-height:1.5">${escapeHtml(content.message)}</p>
|
|
4662
|
-
</section>
|
|
4663
|
-
</main>
|
|
4664
|
-
</body>
|
|
4665
|
-
</html>`, {
|
|
6109
|
+
return renderHtmlPage({
|
|
6110
|
+
bodyHtml: ` <p style="margin:0;color:#b8b8b8;line-height:1.5">${escapeHtml(content.message)}</p>`,
|
|
6111
|
+
headers: ACP_PAGE_HEADERS,
|
|
4666
6112
|
status: content.status,
|
|
4667
|
-
|
|
4668
|
-
"cache-control": "no-store",
|
|
4669
|
-
"content-security-policy": "frame-ancestors 'none'; form-action 'self'",
|
|
4670
|
-
"content-type": "text/html; charset=utf-8",
|
|
4671
|
-
"x-frame-options": "DENY"
|
|
4672
|
-
}
|
|
6113
|
+
title: content.title
|
|
4673
6114
|
});
|
|
4674
6115
|
}
|
|
4675
6116
|
function renderConfirmation(agentName) {
|
|
4676
|
-
return
|
|
4677
|
-
<
|
|
4678
|
-
<head>
|
|
4679
|
-
<meta charset="utf-8" />
|
|
4680
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
4681
|
-
<title>Connect ${escapeHtml(agentName)}</title>
|
|
4682
|
-
</head>
|
|
4683
|
-
<body style="margin:0;background:#000;color:#fff;font-family:ui-sans-serif,system-ui,sans-serif">
|
|
4684
|
-
<main style="min-height:100vh;display:grid;place-items:center;padding:2rem">
|
|
4685
|
-
<section style="max-width:32rem;border-left:4px solid #a78bfa;padding-left:1rem">
|
|
4686
|
-
<h1 style="margin:0 0 .75rem;font-size:2rem">Connect ${escapeHtml(agentName)}</h1>
|
|
4687
|
-
<p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">Only continue if you started sign-in from your ACP client. Enter the verification code shown by the client.</p>
|
|
6117
|
+
return renderHtmlPage({
|
|
6118
|
+
bodyHtml: ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">Only continue if you started sign-in from your ACP client. Enter the verification code shown by the client.</p>
|
|
4688
6119
|
<form method="post">
|
|
4689
6120
|
<label style="display:block;margin:0 0 1rem">Verification code<br><input name="code" required autocomplete="one-time-code" inputmode="text" style="box-sizing:border-box;margin-top:.5rem;padding:.625rem;width:100%"></label>
|
|
4690
6121
|
<button type="submit" style="border:0;border-radius:.375rem;background:#a78bfa;color:#000;cursor:pointer;font:inherit;font-weight:700;padding:.625rem 1rem">Connect</button>
|
|
4691
|
-
</form
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
"cache-control": "no-store",
|
|
4697
|
-
"content-security-policy": "frame-ancestors 'none'; form-action 'self'",
|
|
4698
|
-
"content-type": "text/html; charset=utf-8",
|
|
4699
|
-
"x-frame-options": "DENY"
|
|
4700
|
-
} });
|
|
4701
|
-
}
|
|
4702
|
-
function requestHasSameOrigin(request) {
|
|
4703
|
-
const origin = request.headers.get("origin");
|
|
4704
|
-
if (!origin) return false;
|
|
4705
|
-
try {
|
|
4706
|
-
return new URL(origin).origin === new URL(request.url).origin;
|
|
4707
|
-
} catch {
|
|
4708
|
-
return false;
|
|
4709
|
-
}
|
|
6122
|
+
</form>`,
|
|
6123
|
+
headers: ACP_PAGE_HEADERS,
|
|
6124
|
+
status: 200,
|
|
6125
|
+
title: `Connect ${agentName}`
|
|
6126
|
+
});
|
|
4710
6127
|
}
|
|
4711
6128
|
function transactionIdFromRequest(request) {
|
|
4712
6129
|
const transactionId = new URL(request.url).pathname.split("/").at(-1);
|
|
@@ -4894,6 +6311,16 @@ function createSignInAuth(config) {
|
|
|
4894
6311
|
}
|
|
4895
6312
|
// src/dashboard-routes.ts
|
|
4896
6313
|
const DASHBOARD_PACKAGE_NAME = "@sentry/junior-dashboard";
|
|
6314
|
+
/**
|
|
6315
|
+
* Return true when the dashboard requires Google sign-in. Core passes this
|
|
6316
|
+
* value to the dashboard app, so features that need a signed-in user use the
|
|
6317
|
+
* same rule as the dashboard.
|
|
6318
|
+
*/
|
|
6319
|
+
function dashboardRequiresSignIn(dashboard) {
|
|
6320
|
+
if (!dashboard || dashboard.disabled) return false;
|
|
6321
|
+
if (dashboard.authRequired !== void 0) return dashboard.authRequired;
|
|
6322
|
+
return process.env.JUNIOR_DASHBOARD_AUTH_REQUIRED?.trim() !== "false";
|
|
6323
|
+
}
|
|
4897
6324
|
/** Mount the dashboard app on the dashboard-owned host paths. */
|
|
4898
6325
|
async function createDashboardRouteRegistrations(args) {
|
|
4899
6326
|
if (!args.dashboard || args.dashboard.disabled) return [];
|
|
@@ -4951,6 +6378,7 @@ function dashboardHostRoutePaths(dashboard, authenticatedRoutes = []) {
|
|
|
4951
6378
|
`${conversationsPath}/*`,
|
|
4952
6379
|
pagePath("locations"),
|
|
4953
6380
|
`${pagePath("locations")}/*`,
|
|
6381
|
+
pagePath("review"),
|
|
4954
6382
|
peoplePath,
|
|
4955
6383
|
`${peoplePath}/*`,
|
|
4956
6384
|
pagePath("automations"),
|
|
@@ -4984,6 +6412,8 @@ function dashboardHostRoutePaths(dashboard, authenticatedRoutes = []) {
|
|
|
4984
6412
|
"/api/plugin-reports",
|
|
4985
6413
|
"/api/user-pages",
|
|
4986
6414
|
"/api/user-pages/*",
|
|
6415
|
+
"/api/user-customization",
|
|
6416
|
+
"/api/user-customization/*",
|
|
4987
6417
|
"/api/automations",
|
|
4988
6418
|
"/api/automations/*",
|
|
4989
6419
|
"/api/skills",
|
|
@@ -4998,6 +6428,8 @@ function dashboardHostRoutePaths(dashboard, authenticatedRoutes = []) {
|
|
|
4998
6428
|
"/api/locations/*",
|
|
4999
6429
|
"/api/people",
|
|
5000
6430
|
"/api/people/*",
|
|
6431
|
+
"/api/review-items",
|
|
6432
|
+
"/api/review-items/*",
|
|
5001
6433
|
"/api/personal-tokens",
|
|
5002
6434
|
"/api/personal-tokens/*",
|
|
5003
6435
|
"/api/service-accounts",
|
|
@@ -5051,6 +6483,7 @@ function dashboardRouteRegistrations(args) {
|
|
|
5051
6483
|
...args.dashboard,
|
|
5052
6484
|
agentName: botConfig.userName,
|
|
5053
6485
|
allowedDomains: args.allowedDomains,
|
|
6486
|
+
authRequired: dashboardRequiresSignIn(args.dashboard),
|
|
5054
6487
|
...args.auth ? { auth: args.auth } : void 0,
|
|
5055
6488
|
authenticatedRoutes: args.authenticatedRoutes,
|
|
5056
6489
|
conversationWorkQueue: args.conversationWorkQueue,
|
|
@@ -5065,14 +6498,17 @@ function dashboardRouteRegistrations(args) {
|
|
|
5065
6498
|
path
|
|
5066
6499
|
}));
|
|
5067
6500
|
}
|
|
5068
|
-
/**
|
|
6501
|
+
/**
|
|
6502
|
+
* Reject plugin routes that would shadow dashboard or auth paths, or other
|
|
6503
|
+
* core routes that need the dashboard, such as the MCP routes.
|
|
6504
|
+
*/
|
|
5069
6505
|
function validateDashboardRouteOwnership(args) {
|
|
5070
6506
|
if (!args.dashboard || args.dashboard.disabled) return;
|
|
5071
|
-
for (const route of args.routes) if (dashboardOwnedRoutePath(route.path, args.dashboard, args.authenticatedRoutes)) throw new Error(`Plugin "${route.pluginName}" route "${route.path}" conflicts with core dashboard routes`);
|
|
6507
|
+
for (const route of args.routes) if (dashboardOwnedRoutePath(route.path, args.dashboard, args.authenticatedRoutes) || (args.coreRoutePaths ?? []).some((path) => routePatternOverlaps(path, route.path))) throw new Error(`Plugin "${route.pluginName}" route "${route.path}" conflicts with core dashboard routes`);
|
|
5072
6508
|
}
|
|
5073
6509
|
// src/app.ts
|
|
5074
|
-
/** Resolve the public deployment URL used by ACP browser authorization. */
|
|
5075
|
-
function
|
|
6510
|
+
/** Resolve the public deployment URL used by ACP and MCP browser authorization. */
|
|
6511
|
+
function resolvePublicBaseURL(dashboard) {
|
|
5076
6512
|
const configured = dashboard?.baseURL?.trim() || process.env.JUNIOR_BASE_URL?.trim();
|
|
5077
6513
|
if (configured) return configured;
|
|
5078
6514
|
const vercelURL = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
|
|
@@ -5150,6 +6586,23 @@ function mountRoutes(app, routes) {
|
|
|
5150
6586
|
else if (explicitMethods.length > 0) app.on(explicitMethods, route.path, handler);
|
|
5151
6587
|
}
|
|
5152
6588
|
}
|
|
6589
|
+
async function startQueueWorker(consumers, backgroundWork) {
|
|
6590
|
+
const provider = getQueueProvider();
|
|
6591
|
+
if (!provider.startWorker) throw new Error(`The ${provider.messagingSystem} queue provider has no queue worker`);
|
|
6592
|
+
const worker = await provider.startWorker(consumers);
|
|
6593
|
+
return { close: async () => {
|
|
6594
|
+
try {
|
|
6595
|
+
await worker.close();
|
|
6596
|
+
} finally {
|
|
6597
|
+
while (backgroundWork.size > 0) await Promise.allSettled(backgroundWork);
|
|
6598
|
+
try {
|
|
6599
|
+
await disconnectStateAdapter();
|
|
6600
|
+
} finally {
|
|
6601
|
+
await closeDb();
|
|
6602
|
+
}
|
|
6603
|
+
}
|
|
6604
|
+
} };
|
|
6605
|
+
}
|
|
5153
6606
|
/** Create a Hono app with all Junior routes. */
|
|
5154
6607
|
async function createApp(options) {
|
|
5155
6608
|
const virtualConfig = await resolveVirtualConfig();
|
|
@@ -5235,6 +6688,7 @@ async function createApp(options) {
|
|
|
5235
6688
|
sandbox: { tracePropagation },
|
|
5236
6689
|
visionContext: { attachmentStorage }
|
|
5237
6690
|
};
|
|
6691
|
+
const backgroundWork = /* @__PURE__ */ new Set();
|
|
5238
6692
|
let conversationWorkOptions;
|
|
5239
6693
|
const getConversationWorkOptions = () => {
|
|
5240
6694
|
conversationWorkOptions ??= options?.conversationWork ?? createProductionConversationWorkOptions({
|
|
@@ -5242,7 +6696,11 @@ async function createApp(options) {
|
|
|
5242
6696
|
queue: conversationWorkQueue,
|
|
5243
6697
|
...pluginTaskQueue ? { sendPluginTask: (message) => pluginTaskQueue.send(message) } : void 0,
|
|
5244
6698
|
services: runtimeServiceOverrides,
|
|
5245
|
-
waitUntil: (task) =>
|
|
6699
|
+
waitUntil: (task) => {
|
|
6700
|
+
backgroundWork.add(task);
|
|
6701
|
+
task.finally(() => backgroundWork.delete(task)).catch(() => void 0);
|
|
6702
|
+
waitUntil(task);
|
|
6703
|
+
}
|
|
5246
6704
|
});
|
|
5247
6705
|
return conversationWorkOptions;
|
|
5248
6706
|
};
|
|
@@ -5254,7 +6712,7 @@ async function createApp(options) {
|
|
|
5254
6712
|
const state = work.state ?? getStateAdapter();
|
|
5255
6713
|
acpRuntime = {
|
|
5256
6714
|
handleRequest: createAcpHttpHandler({
|
|
5257
|
-
...dashboard && !dashboard.disabled ? { browserAuth: { baseURL:
|
|
6715
|
+
...dashboard && !dashboard.disabled ? { browserAuth: { baseURL: resolvePublicBaseURL(dashboard) } } : void 0,
|
|
5258
6716
|
conversations: createAcpConversations({
|
|
5259
6717
|
conversationStore: work.conversationStore,
|
|
5260
6718
|
eventStore: getConversationEventStore(),
|
|
@@ -5272,6 +6730,19 @@ async function createApp(options) {
|
|
|
5272
6730
|
};
|
|
5273
6731
|
return acpRuntime;
|
|
5274
6732
|
};
|
|
6733
|
+
const mcpEnabled = dashboardRequiresSignIn(dashboard);
|
|
6734
|
+
const getMcpState = () => options?.conversationWork?.state ?? getStateAdapter();
|
|
6735
|
+
const mcpConsentRoute = {
|
|
6736
|
+
handler: (request, user) => handleMcpConsentPage({
|
|
6737
|
+
agentName: botConfig.userName,
|
|
6738
|
+
baseURL: resolvePublicBaseURL(dashboard),
|
|
6739
|
+
request,
|
|
6740
|
+
state: getMcpState(),
|
|
6741
|
+
user
|
|
6742
|
+
}),
|
|
6743
|
+
method: ["GET", "POST"],
|
|
6744
|
+
path: MCP_CONSENT_PATH
|
|
6745
|
+
};
|
|
5275
6746
|
const authenticatedRoutes = dashboard && !dashboard.disabled ? [{
|
|
5276
6747
|
handler: (request, user) => handleAcpAuthorizationPage({
|
|
5277
6748
|
agentName: botConfig.userName,
|
|
@@ -5281,10 +6752,11 @@ async function createApp(options) {
|
|
|
5281
6752
|
}),
|
|
5282
6753
|
method: ["GET", "POST"],
|
|
5283
6754
|
path: ACP_AUTHORIZATION_PATH
|
|
5284
|
-
}] : [];
|
|
6755
|
+
}, ...mcpEnabled ? [mcpConsentRoute] : []] : [];
|
|
5285
6756
|
try {
|
|
5286
6757
|
validateDashboardRouteOwnership({
|
|
5287
6758
|
authenticatedRoutes,
|
|
6759
|
+
coreRoutePaths: mcpEnabled ? MCP_ROUTE_PATHS : [],
|
|
5288
6760
|
dashboard,
|
|
5289
6761
|
routes: pluginRoutes
|
|
5290
6762
|
});
|
|
@@ -5314,6 +6786,21 @@ async function createApp(options) {
|
|
|
5314
6786
|
"POST",
|
|
5315
6787
|
"DELETE"
|
|
5316
6788
|
], "/api/acp", (c) => getAcpRuntime().handleRequest(c.req.raw));
|
|
6789
|
+
if (mcpEnabled) {
|
|
6790
|
+
let handleMcpRequest;
|
|
6791
|
+
app.on([
|
|
6792
|
+
"GET",
|
|
6793
|
+
"POST",
|
|
6794
|
+
"DELETE"
|
|
6795
|
+
], MCP_ROUTE_PATHS, (c) => {
|
|
6796
|
+
handleMcpRequest ??= createMcpHttpHandler({
|
|
6797
|
+
allowedDomains,
|
|
6798
|
+
baseURL: resolvePublicBaseURL(dashboard),
|
|
6799
|
+
state: getMcpState()
|
|
6800
|
+
});
|
|
6801
|
+
return handleMcpRequest(c.req.raw);
|
|
6802
|
+
});
|
|
6803
|
+
}
|
|
5317
6804
|
mountRoutes(app, pluginRoutes);
|
|
5318
6805
|
mountRoutes(app, await createDashboardRouteRegistrations({
|
|
5319
6806
|
allowedDomains,
|
|
@@ -5380,6 +6867,10 @@ async function createApp(options) {
|
|
|
5380
6867
|
app.post("/api/webhooks/:platform", (c) => {
|
|
5381
6868
|
return new Response(`Unknown platform: ${c.req.param("platform")}`, { status: 404 });
|
|
5382
6869
|
});
|
|
5383
|
-
return app
|
|
6870
|
+
return Object.assign(app, { startQueueWorker: () => startQueueWorker([
|
|
6871
|
+
conversationWorkQueueConsumer(getConversationWorkOptions()),
|
|
6872
|
+
pluginTaskQueueConsumer(),
|
|
6873
|
+
workspaceSnapshotJobQueueConsumer()
|
|
6874
|
+
], backgroundWork) });
|
|
5384
6875
|
}
|
|
5385
6876
|
export { JUNIOR_VERSION, createApp, defineJuniorPlugins };
|