@sentry/junior 0.265.0 → 0.267.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-DZSokpwj.js} +8 -1
- package/dist/{adapter-cUkPH3Om.js → adapter-BybMEezj.js} +2 -2
- package/dist/{admin-Cf0rDmWV.js → admin-DOs1_EiW.js} +1 -1
- package/dist/{agent-runner-C6MwHJEs.js → agent-runner-DgNATN4D.js} +874 -228
- package/dist/api/schema.d.ts +202 -23
- package/dist/api/schema.js +6 -5
- package/dist/api.d.ts +6 -1
- package/dist/api.js +130 -1205
- package/dist/{app-Csbi9dW7.d.ts → app-DAB3fzyp.d.ts} +21 -3
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1611 -353
- package/dist/{assistant-reply-UPA8vKlC.js → assistant-reply-CT7VcXQe.js} +1 -1
- package/dist/{automation-2RbKzpMT.js → automation-CmeFBSEh.js} +42 -14
- package/dist/{briefs-DRjx8kQ9.js → briefs-CWKjka7v.js} +5 -5
- package/dist/{catalog-runtime-D3qbJlYz.js → catalog-runtime-cMlAI5LY.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-DX34VNtH.js → client-BjgbMawj.js} +2 -2
- package/dist/{config-BayQ9hHH.js → config-CdB6yJj5.js} +32 -10
- package/dist/{connections-mpN5Lsnj.js → connections-RE_65jzK.js} +13 -12
- package/dist/{conversation-B0baEwoA.js → conversation-DSo310Ps.js} +14 -3
- package/dist/{conversation-privacy-frjQCJa9.js → conversation-privacy-BzgJC5k0.js} +1 -1
- package/dist/{credential-sync-CZmyDNdR.js → credential-sync-DhEGEpgq.js} +15 -15
- package/dist/{credentialed-C6pKlB1p.js → credentialed-CIGwoLU2.js} +2913 -1026
- package/dist/{db-BnRqQ7G5.d.ts → db-Bg-zC3r5.d.ts} +1 -1
- package/dist/{executor-CAr7xQr2.js → executor-VYrx_K4w.js} +7 -4
- package/dist/{history-AY8rs-FR.js → history-C84mlAwO.js} +2 -1
- package/dist/{images-B2yA3HWE.js → images-CecjXAAz.js} +3 -3
- package/dist/instrumentation.js +6 -2
- package/dist/{job-queue-Cjo6Yg_b.js → job-queue-C0BZSa7r.js} +128 -3
- package/dist/{web-input-Y6aXvmfa.js → list-1XQP-o6K.js} +1374 -71
- package/dist/{logging-D2QBriUo.js → logging-DuZ1A4ab.js} +11 -3
- package/dist/{message-C9ymnpBS.js → message-NKSbbnA6.js} +4 -4
- package/dist/nitro.d.ts +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-callback-server-BtMf3TPZ.js → oauth-callback-server-qAHLPtml.js} +2 -2
- package/dist/{prompt-CvaDdSX3.js → prompt-BW9jFsqZ.js} +1 -1
- package/dist/{registry-COTeZ2cV.js → registry-FQutO926.js} +2 -2
- package/dist/{resolve-g9PKz9pH.js → resolve-Bk4yeuHy.js} +3 -3
- package/dist/{result-COnC42GR.js → result-B9e9ATOU.js} +9 -9
- package/dist/{runner-BnTNS7x_.js → runner-C3S2ObH-.js} +11 -11
- package/dist/{sandbox-egress-signals-C_IIdLFk.js → sandbox-egress-signals-DqpGzzMB.js} +1 -1
- package/dist/{schema-D5Amlzkp.js → schema-X7Fj4fv1.js} +3 -2
- package/dist/{service-account-CwvToidT.js → service-account-BQasl-qq.js} +3 -3
- package/dist/{service-account-YdYftwV2.js → service-account-Do30HUNv.js} +3 -3
- package/dist/{session-stop-CpCUy1ZJ.js → session-stop-DkOjt46_.js} +45 -128
- package/dist/{skills-Bb2KaJ3m.js → skills-CQHrwfOa.js} +2 -2
- package/dist/{spawn-mbTcrLb3.js → spawn-BHuqN14f.js} +2 -2
- package/dist/{store-CqRnaZSq.js → store-6EetVP-m.js} +3 -3
- package/dist/{store-CG0VJyye.d.ts → store-B8CABbmR.d.ts} +10 -2
- package/dist/store-DvNZIsCr.js +295 -0
- package/dist/{task-queue-8XjDIORF.js → task-queue-B0DLgd2P.js} +10 -5
- package/dist/{task-runner-6mLmG6g_.js → task-runner-B-PD_JOj.js} +26 -14
- package/dist/{tasks-DGbrur6x.js → tasks-0ilmuCux.js} +1440 -46
- package/dist/{thread-state-D45lz-oC.js → thread-state-DRmdJ2QN.js} +5 -5
- package/dist/{turn-failure-response-CaEPLSBn.js → turn-failure-response-G11cs2gE.js} +9 -9
- package/dist/{turn-lifecycle-C35IyRGp.js → turn-lifecycle-OzLIwvi1.js} +2 -2
- package/dist/{turn-session-routing-BKx6rfVd.js → turn-session-routing-Dyut9l7Y.js} +1 -1
- package/dist/user-input-request-BDFBdF8r.js +116 -0
- package/dist/{validation-C0X3CX51.js → validation-CxEmt1id.js} +1 -1
- package/dist/{version-BPHgJwnm.js → version-Bapedn4A.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-B2wXNspU.js → viewer-DrN3oC2X.js} +22 -11
- package/dist/{work-DMbqK0kT.js → work-DUQoNcqP.js} +52 -51
- package/dist/{worker-B-rDJ3XN.js → worker-D1l4Ugk0.js} +30 -19
- package/migrations/0064_any_resource_event_triggers.sql +2 -0
- package/migrations/meta/0064_snapshot.json +3505 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +20 -5
- package/dist/store-DdT74Lvq.js +0 -1424
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 { C as automationVersionOf, P as eventGuidance, Zt as CONVERSATION_WORK_STALE_ENQUEUE_MS, b as admitEventAutomationRun, c as withScheduledAutomationLock, cr as isExperimentalFeatureEnabled, er as SubscribedReplyReason, ft as projectConversationMessages, hn as isInvalidConversationRecordError, hr as setConfigDefaults, ht as settleConversationTitleWork, i as readScheduledAutomation, l as blockEventAutomation, lr as setExperimentalFeatures, lt as appendConversationMessages, mr as getConfigDefaults, n as listScheduledAutomationsForTeam, o as saveScheduledAutomationInLock, p as findMatchingEventAutomations, pn as hasRunnableConversationWork, rr as getThreadStopDecision, s as scheduledRunId, sr as getExperimentalFeatures, u as collectEventAutomationMatchKeys, x as eventAutomationRunLimitReason } from "./tasks-0ilmuCux.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-CdB6yJj5.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-DuZ1A4ab.js";
|
|
4
4
|
import { n as captureMessage } from "./sentry-MDkYAVPV.js";
|
|
5
|
-
import { t as JUNIOR_VERSION } from "./version-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { t as JUNIOR_VERSION } from "./version-Bapedn4A.js";
|
|
6
|
+
import { d as parseSlackTeamId, f as parseSlackUserId, h as slackMessageTsSchema, l as parseSlackChannelId, m as slackMessageTsFromMs, o as parseActorUserId, p as parseSlackMessageTs } from "./actor-DZSokpwj.js";
|
|
7
|
+
import { F as recordGoogleSignIn, a as resolveViewerUser, c as closeDb, f as getConversationStore, g as withConversationEventLock, m as getSqlExecutor, p as getDb, r as readActorIdentity, u as getConversationEventStore, y as withConversationMutationLock } from "./viewer-DrN3oC2X.js";
|
|
8
8
|
import { a as juniorAgentInvocations, d as juniorDestinations, i as juniorAgentBindings, s as juniorConversations } from "./agent-invocations-CxKldAeB.js";
|
|
9
|
-
import { a as juniorAutomationRuns, f as juniorConversationBriefs, h as juniorConversationEvents, s as juniorAutomations } from "./executor-
|
|
10
|
-
import { n as pluginCatalogRuntime } from "./catalog-runtime-
|
|
11
|
-
import { C as requestAttachmentDeletion,
|
|
12
|
-
import { An as
|
|
13
|
-
import {
|
|
9
|
+
import { a as juniorAutomationRuns, f as juniorConversationBriefs, h as juniorConversationEvents, s as juniorAutomations } from "./executor-VYrx_K4w.js";
|
|
10
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-cMlAI5LY.js";
|
|
11
|
+
import { $ as stopConversationSession, C as requestAttachmentDeletion, D as getOpenSlackUserInput, E as closeSlackUserInput, F as slackUserInputAnswerNote, G as scheduledAutomationRunAttributes, L as parseSlackThreadId, M as parseSlackUserInputFormTarget, N as parseSlackUserInputSubmission, O as persistSlackUserInputReply, P as slackUserInputAction, T as claimSlackUserInput, W as logScheduledAutomationRunSkipped, Y as effectiveTaskOutcomes, _ as unpublishedArtifactHeaders, et as recordSubagentEnded, g as readPublicArtifact, h as publicArtifactHeaders, j as buildSlackUserInputModal, k as releaseSlackUserInput, n as executeAgentRun, ot as processWorkspaceSnapshotJob, st as ingestEvent, t as createAgentRunner, v as lookupSlackUserProfile, x as collectAttachmentGarbage } from "./agent-runner-DgNATN4D.js";
|
|
12
|
+
import { An as createPluginState, Ct as normalizeSandboxEgressTracePropagationDomains, Gt as bindEventAutomationCredentialSubject, I as isVercelSandboxEgressRequest, Jt as verifyEventAutomationCredentialSubject, Kt as bindScheduledAutomationCredentialSubject, L as proxyVercelSandboxEgressRequest, Lt as collectEventMatchKeys, Nn as createPluginLogger, Pn as readConversationAccessFromSql, Q as openSlackModal, V as resolveBaseUrl, Vt as findMatchingWatches, Wt as takeDueTimerWatches, Xt as verifySlackDirectCredentialSubject, Yt as verifyScheduledAutomationCredentialSubject, Zn as resolveRootVisibility, _t as parseSandboxEgressCredentialToken, ar as createPluginCache, c as getPluginRoutes, er as listConversationAnnotations, fn as getDashboardConversationLink, h as validatePlugins, hn as setDashboardConversationLinkOptions, ht as consumeSandboxEgressPermissionDeniedSignal, in as isDmChannel, it as slackExternalRefIdSchema, ln as getWorkspaceTeamId, m as setPlugins, mn as getDashboardTaskLink, mt as consumeSandboxEgressAuthRequiredSignal, n as createUserTokenStore, nn as getSlackClient, or as createCache, p as getPlugins, pt as clearSandboxEgressSignals, q as slackMention, qt as bindSlackDirectCredentialSubject, rt as slackEntitySchema, s as getPluginApiRoutes, un as runWithWorkspaceTeamId, v as setBriefsConfig } from "./credentialed-CIGwoLU2.js";
|
|
13
|
+
import { c as withLock$1, i as getStateAdapter, t as disconnectStateAdapter } from "./adapter-BybMEezj.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 { c as setSandboxResourceConfig, o as getSandboxResourceConfig } from "./resolve-
|
|
19
|
-
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-
|
|
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-6EetVP-m.js";
|
|
17
|
+
import { S as renderTaskInput, d as getEventCatalog, f as SLACK_EVENT_NAMESPACE, g as slackChannelMessageRunsAutomation, h as slackChannelMessageReplyThreadTs, m as slackChannelMessageEvent } from "./session-stop-DkOjt46_.js";
|
|
18
|
+
import { c as setSandboxResourceConfig, o as getSandboxResourceConfig } from "./resolve-Bk4yeuHy.js";
|
|
19
|
+
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-C0BZSa7r.js";
|
|
20
|
+
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-DvNZIsCr.js";
|
|
21
|
+
import { a as normalizeUserInputAnswer, i as isEmptyUserInputAnswer, n as buttonUserInputField, r as formatUserInputAnswer } from "./user-input-request-BDFBdF8r.js";
|
|
22
|
+
import { D as scheduledRunSchema } from "./automation-CmeFBSEh.js";
|
|
23
|
+
import { n as validatePluginRegistrations, t as validatePluginEgressCredentialHooks } from "./validation-CxEmt1id.js";
|
|
23
24
|
import { a as pluginRuntimeRegistrationsFromPluginSet, n as pluginCatalogConfigFromEnv, r as pluginCatalogConfigFromPluginSet, t as defineJuniorPlugins } from "./plugins-DOm9Y6qI.js";
|
|
24
|
-
import {
|
|
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 { s as coerceThreadConversationState } from "./thread-state-
|
|
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 { $ as renderSlackObjectCard, B as recordSkippedConversationMessage, C as createProductionSlackWebhookServices, F as assertNoLegacyAllowedDomains, G as normalizeIncomingSlackThreadId, H as clearSlackPendingReactions, I as isAllowedDomain, J as THREAD_OPTOUT_ACK, K as withNormalizedThreadId, L as isAllowedSignInEmail, Q as verifySlackSignature, R as readEnvList, S as createProductionConversationWorkOptions, T as createSlackWebApiStatusSender, U as isSlackMessageStopped, V as buildSlackInboundMessage, W as stopSlackThread, X as parseContent, Y as startProcessingReaction, Z as runWithSlackInstallation, _ as appendAndEnqueueWebMessage, b as recordWebConversationActivity, et as getAttachmentStorage, j as readViewerAutomationSummary, nt as enqueueAgentDispatch, q as stopConversationTurn, rt as GET$3, t as readConversationFeed, v as conversationTurnIdForMessage, w as getProductionSlackAdapter, x as webActorFromEmail, z as resolveAllowedDomains } from "./list-1XQP-o6K.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-B-PD_JOj.js";
|
|
27
|
+
import { o as getAuthPausedConversationTurnId, t as enqueueAgentInvocationResult } from "./result-B9e9ATOU.js";
|
|
28
|
+
import { r as enqueueAgentInvocation } from "./work-DUQoNcqP.js";
|
|
29
|
+
import { s as coerceThreadConversationState } from "./thread-state-DRmdJ2QN.js";
|
|
30
|
+
import { o as unlinkProvider } from "./connections-RE_65jzK.js";
|
|
31
|
+
import { a as GET$4, i as publishAppHomeView, r as GET$5, t as receiveLocalOAuthCredential } from "./credential-sync-DhEGEpgq.js";
|
|
32
|
+
import { a as pluginTaskQueueConsumer, c as queueCallback, n as createVercelPluginTaskCallback, o as registerVercelPluginTaskDevConsumer } from "./task-queue-B0DLgd2P.js";
|
|
33
|
+
import { t as processConversationWork } from "./worker-D1l4Ugk0.js";
|
|
34
|
+
import { t as bindMessageAgent } from "./message-NKSbbnA6.js";
|
|
35
|
+
import { t as bindSpawnAgent } from "./spawn-BHuqN14f.js";
|
|
35
36
|
import { createRequire } from "node:module";
|
|
36
37
|
import { Hono } from "hono";
|
|
37
38
|
import { z } from "zod";
|
|
@@ -40,9 +41,15 @@ import { and, asc, eq, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-
|
|
|
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
43
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
44
|
+
import net from "node:net";
|
|
43
45
|
import { decodeJwt } from "jose";
|
|
44
|
-
import
|
|
46
|
+
import https from "node:https";
|
|
47
|
+
import { Message, ThreadImpl } from "chat";
|
|
45
48
|
import * as acp from "@agentclientprotocol/sdk";
|
|
49
|
+
import dns from "node:dns";
|
|
50
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
51
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
52
|
+
import { bodyLimit } from "hono/body-limit";
|
|
46
53
|
import { createAuthMiddleware } from "better-auth/api";
|
|
47
54
|
import { betterAuth } from "better-auth/minimal";
|
|
48
55
|
// src/chat/events/timers.ts
|
|
@@ -146,7 +153,7 @@ async function recoverConversationWork(args) {
|
|
|
146
153
|
}
|
|
147
154
|
return;
|
|
148
155
|
}
|
|
149
|
-
if (work.execution.lease || !hasRunnableConversationWork(work)) return;
|
|
156
|
+
if (work.execution.lease || !hasRunnableConversationWork$1(work)) return;
|
|
150
157
|
if ((await ensureConversationWake({
|
|
151
158
|
conversationId,
|
|
152
159
|
idempotencyKey: heartbeatIdempotencyKey("pending", conversationId, args.nowMs),
|
|
@@ -1548,14 +1555,6 @@ async function presentSlackAnnotationDetails(event, teamId) {
|
|
|
1548
1555
|
}
|
|
1549
1556
|
// src/chat/ingress/slack-payload.ts
|
|
1550
1557
|
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
1558
|
const slackAssistantThreadSchema = z.object({
|
|
1560
1559
|
channel_id: z.string().min(1),
|
|
1561
1560
|
thread_ts: eventTimestampSchema,
|
|
@@ -1630,79 +1629,273 @@ const slackEventEnvelopeSchema = z.object({
|
|
|
1630
1629
|
type: z.string(),
|
|
1631
1630
|
challenge: z.string().optional()
|
|
1632
1631
|
});
|
|
1632
|
+
const slackViewStateValueSchema = z.object({
|
|
1633
|
+
selected_option: z.object({ value: z.string().optional() }).nullish(),
|
|
1634
|
+
selected_options: z.array(z.object({ value: z.string().optional() })).nullish(),
|
|
1635
|
+
value: z.string().nullish()
|
|
1636
|
+
});
|
|
1633
1637
|
const slackInteractivePayloadSchema = z.object({
|
|
1634
1638
|
actions: z.array(z.object({
|
|
1635
1639
|
action_id: z.string().optional(),
|
|
1640
|
+
action_ts: z.string().optional(),
|
|
1636
1641
|
selected_option: z.object({ value: z.string().optional() }).optional(),
|
|
1637
1642
|
value: z.string().optional()
|
|
1638
1643
|
})).optional(),
|
|
1644
|
+
channel: z.object({ id: z.string().optional() }).nullish(),
|
|
1645
|
+
message: z.object({
|
|
1646
|
+
thread_ts: z.string().optional(),
|
|
1647
|
+
ts: z.string().optional()
|
|
1648
|
+
}).nullish(),
|
|
1639
1649
|
team: z.object({ id: z.string().optional() }).nullish(),
|
|
1650
|
+
trigger_id: z.string().optional(),
|
|
1640
1651
|
type: z.string(),
|
|
1641
1652
|
user: z.object({
|
|
1642
1653
|
id: z.string().optional(),
|
|
1643
1654
|
team_id: z.string().optional()
|
|
1644
|
-
}).optional()
|
|
1655
|
+
}).optional(),
|
|
1656
|
+
view: z.object({
|
|
1657
|
+
callback_id: z.string().optional(),
|
|
1658
|
+
id: z.string().optional(),
|
|
1659
|
+
private_metadata: z.string().optional(),
|
|
1660
|
+
state: z.object({ values: z.record(z.string(), z.record(z.string(), slackViewStateValueSchema)) }).optional()
|
|
1661
|
+
}).nullish()
|
|
1662
|
+
});
|
|
1663
|
+
// src/chat/ingress/workspace-membership.ts
|
|
1664
|
+
const MEMBER_TTL_MS = 3e5;
|
|
1665
|
+
const NON_MEMBER_TTL_MS = 3e4;
|
|
1666
|
+
const authorTeamSchema = z.object({
|
|
1667
|
+
user_team: z.string().optional(),
|
|
1668
|
+
source_team: z.string().optional(),
|
|
1669
|
+
user: z.unknown().optional()
|
|
1645
1670
|
});
|
|
1646
|
-
|
|
1647
|
-
async function
|
|
1648
|
-
|
|
1671
|
+
/** Accept only verified workspace authors, reusing recent membership lookups. */
|
|
1672
|
+
async function isSlackWorkspaceMember(raw, state) {
|
|
1673
|
+
const workspaceTeamId = getWorkspaceTeamId();
|
|
1674
|
+
if (!workspaceTeamId) return false;
|
|
1675
|
+
const parsed = authorTeamSchema.safeParse(raw);
|
|
1676
|
+
if (!parsed.success) return false;
|
|
1677
|
+
const event = parsed.data;
|
|
1678
|
+
const userId = parseSlackUserId(event.user);
|
|
1679
|
+
if (!userId) return false;
|
|
1680
|
+
const cache = createCache(state, "core");
|
|
1681
|
+
const key = `slack:membership:${workspaceTeamId}:${userId}`;
|
|
1682
|
+
const authorTeam = parseSlackTeamId(event.user_team);
|
|
1683
|
+
if (authorTeam === workspaceTeamId) return true;
|
|
1684
|
+
if (event.user_team !== void 0 && !authorTeam && !/^E[A-Z0-9]+$/.test(event.user_team)) return false;
|
|
1685
|
+
if (authorTeam !== void 0) return await cache.getOrLoad(key, async () => ({
|
|
1686
|
+
value: false,
|
|
1687
|
+
ttlMs: NON_MEMBER_TTL_MS
|
|
1688
|
+
}), { refresh: true });
|
|
1689
|
+
return await cache.getOrLoad(key, async () => {
|
|
1690
|
+
const user = await lookupSlackUserProfile(userId);
|
|
1691
|
+
const authorTeam = parseSlackTeamId(user.team_id);
|
|
1692
|
+
if (user.id !== userId || !authorTeam) return { value: false };
|
|
1693
|
+
const member = authorTeam === workspaceTeamId && !user.is_deleted;
|
|
1694
|
+
return {
|
|
1695
|
+
value: member,
|
|
1696
|
+
ttlMs: member ? MEMBER_TTL_MS : NON_MEMBER_TTL_MS
|
|
1697
|
+
};
|
|
1698
|
+
});
|
|
1649
1699
|
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1700
|
+
// src/chat/ingress/slack-interactive.ts
|
|
1701
|
+
function requireSlackPayloadUserId(value, source) {
|
|
1702
|
+
const userId = parseActorUserId(value);
|
|
1703
|
+
if (!userId) throw new Error(`${source} is missing a Slack user id`);
|
|
1704
|
+
return userId;
|
|
1652
1705
|
}
|
|
1653
|
-
async function
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1706
|
+
async function handleAppHomeDisconnect(args) {
|
|
1707
|
+
await withSpan("chat.app_home_disconnect", "chat.app_home_disconnect", { userId: args.userId }, async () => {
|
|
1708
|
+
try {
|
|
1709
|
+
await unlinkProvider(args.userId, args.provider, args.userTokenStore, getWorkspaceTeamId());
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
logException(error, "app_home.disconnect_unlink.failed", { "app.credential.provider": args.provider });
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
await publishAppHomeView(getSlackClient(), args.userId, args.userTokenStore);
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
logException(error, "app_home.disconnect_publish.failed", { "app.credential.provider": args.provider });
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Save the first answer to the open request of a Slack thread as Slack input
|
|
1722
|
+
* from the person who answered. The input text is the formatted answer, so
|
|
1723
|
+
* the model reads one format on every surface. Later answers to the same
|
|
1724
|
+
* request do nothing. A stale request, such as one that a newer request
|
|
1725
|
+
* replaced, also does nothing.
|
|
1726
|
+
*/
|
|
1727
|
+
async function answerSlackUserInput(args) {
|
|
1728
|
+
const userId = requireSlackPayloadUserId(args.payload.user?.id, "Slack user input payload");
|
|
1729
|
+
const { channelId, messageTs } = args;
|
|
1730
|
+
const open = await getOpenSlackUserInput(args.state, {
|
|
1731
|
+
channelId,
|
|
1732
|
+
threadTs: args.threadTs
|
|
1733
|
+
});
|
|
1734
|
+
if (!open || open.messageTs !== messageTs) return;
|
|
1735
|
+
const answer = args.answer(open);
|
|
1736
|
+
if (isEmptyUserInputAnswer(answer)) return;
|
|
1737
|
+
const userTeam = args.payload.user?.team_id;
|
|
1738
|
+
if (!await isSlackWorkspaceMember({
|
|
1739
|
+
user: userId,
|
|
1740
|
+
user_team: userTeam
|
|
1741
|
+
}, args.state)) return;
|
|
1742
|
+
if (!await args.isAuthorAllowed(userId)) return;
|
|
1743
|
+
if (!await claimSlackUserInput(args.state, {
|
|
1744
|
+
channelId,
|
|
1745
|
+
claimedBy: userId,
|
|
1746
|
+
messageTs
|
|
1747
|
+
})) return;
|
|
1748
|
+
const event = {
|
|
1749
|
+
type: "message",
|
|
1750
|
+
channel: channelId,
|
|
1751
|
+
team: args.payload.team?.id,
|
|
1752
|
+
text: formatUserInputAnswer(open.request, answer),
|
|
1753
|
+
thread_ts: args.threadTs,
|
|
1754
|
+
ts: args.inputTs,
|
|
1755
|
+
user: userId,
|
|
1756
|
+
user_team: userTeam
|
|
1757
|
+
};
|
|
1758
|
+
const parsed = args.adapter.parseMessage(event);
|
|
1759
|
+
const message = new Message({
|
|
1760
|
+
attachments: parsed.attachments,
|
|
1761
|
+
author: parsed.author,
|
|
1762
|
+
formatted: parsed.formatted,
|
|
1763
|
+
id: args.messageId,
|
|
1764
|
+
isMention: true,
|
|
1765
|
+
links: parsed.links,
|
|
1766
|
+
metadata: parsed.metadata,
|
|
1767
|
+
raw: {
|
|
1768
|
+
...event,
|
|
1769
|
+
ts: messageTs
|
|
1668
1770
|
},
|
|
1669
|
-
|
|
1771
|
+
text: parsed.text,
|
|
1772
|
+
threadId: parsed.threadId
|
|
1670
1773
|
});
|
|
1671
|
-
|
|
1672
|
-
await
|
|
1673
|
-
|
|
1774
|
+
try {
|
|
1775
|
+
await args.persistMessage(message);
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
await releaseSlackUserInput(args.state, {
|
|
1778
|
+
channelId,
|
|
1779
|
+
messageTs
|
|
1780
|
+
});
|
|
1781
|
+
throw error;
|
|
1674
1782
|
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1783
|
+
await closeSlackUserInput({
|
|
1784
|
+
note: slackUserInputAnswerNote({
|
|
1785
|
+
answer,
|
|
1786
|
+
request: open.request,
|
|
1787
|
+
userId
|
|
1788
|
+
}),
|
|
1789
|
+
open
|
|
1790
|
+
});
|
|
1677
1791
|
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1792
|
+
/** Read the request message and thread of a block action. */
|
|
1793
|
+
function blockActionTarget(payload) {
|
|
1794
|
+
const channelId = parseSlackChannelId(payload.channel?.id);
|
|
1795
|
+
const messageTs = parseSlackMessageTs(payload.message?.ts);
|
|
1796
|
+
if (!channelId || !messageTs) throw new Error("Slack user input payload is incomplete");
|
|
1797
|
+
return {
|
|
1798
|
+
channelId,
|
|
1799
|
+
messageTs,
|
|
1800
|
+
threadTs: parseSlackMessageTs(payload.message?.thread_ts) ?? messageTs
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
/** Answer a one-click request with the clicked choice. */
|
|
1804
|
+
async function handleUserInputChoice(args) {
|
|
1805
|
+
const target = blockActionTarget(args.payload);
|
|
1806
|
+
const actionTs = parseSlackMessageTs(args.action.action_ts);
|
|
1807
|
+
const choice = args.action.value?.trim();
|
|
1808
|
+
if (!actionTs || !choice) throw new Error("Slack user input click payload is incomplete");
|
|
1809
|
+
await answerSlackUserInput({
|
|
1810
|
+
...target,
|
|
1811
|
+
adapter: args.adapter,
|
|
1812
|
+
answer: (open) => {
|
|
1813
|
+
const field = buttonUserInputField(open.request);
|
|
1814
|
+
return normalizeUserInputAnswer(open.request, {
|
|
1815
|
+
requestId: open.requestId,
|
|
1816
|
+
values: field ? { [field.id]: [choice] } : {}
|
|
1817
|
+
});
|
|
1818
|
+
},
|
|
1819
|
+
inputTs: actionTs,
|
|
1820
|
+
messageId: `slack-user-input-answer:${actionTs}`,
|
|
1821
|
+
payload: args.payload,
|
|
1822
|
+
isAuthorAllowed: args.isAuthorAllowed,
|
|
1823
|
+
persistMessage: args.persistMessage,
|
|
1824
|
+
state: args.state
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
/** Open the answer form of the open request for the person who clicked. */
|
|
1828
|
+
async function handleUserInputOpenForm(args) {
|
|
1829
|
+
const target = blockActionTarget(args.payload);
|
|
1830
|
+
const triggerId = args.payload.trigger_id;
|
|
1831
|
+
if (!triggerId) throw new Error("Slack user input form payload is missing a trigger id");
|
|
1832
|
+
const open = await getOpenSlackUserInput(args.state, target);
|
|
1833
|
+
if (!open || open.messageTs !== target.messageTs) return;
|
|
1834
|
+
await openSlackModal({
|
|
1835
|
+
triggerId,
|
|
1836
|
+
view: buildSlackUserInputModal(open.request, target)
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
/** Answer a request with the values of its submitted form. */
|
|
1840
|
+
async function handleUserInputSubmission(args) {
|
|
1841
|
+
const view = args.payload.view;
|
|
1842
|
+
const formTarget = parseSlackUserInputFormTarget(view?.private_metadata);
|
|
1843
|
+
const channelId = parseSlackChannelId(formTarget?.channelId);
|
|
1844
|
+
const messageTs = parseSlackMessageTs(formTarget?.messageTs);
|
|
1845
|
+
const threadTs = parseSlackMessageTs(formTarget?.threadTs);
|
|
1846
|
+
if (!view?.id || !channelId || !messageTs || !threadTs) throw new Error("Slack user input form submission is incomplete");
|
|
1847
|
+
const inputTs = slackMessageTsFromMs(Date.now());
|
|
1848
|
+
await answerSlackUserInput({
|
|
1849
|
+
adapter: args.adapter,
|
|
1850
|
+
answer: (open) => parseSlackUserInputSubmission({
|
|
1851
|
+
request: open.request,
|
|
1852
|
+
requestId: open.requestId,
|
|
1853
|
+
values: view.state?.values ?? {}
|
|
1854
|
+
}),
|
|
1855
|
+
channelId,
|
|
1856
|
+
inputTs,
|
|
1857
|
+
messageId: `slack-user-input-answer:${view.id}`,
|
|
1858
|
+
messageTs,
|
|
1859
|
+
payload: args.payload,
|
|
1860
|
+
isAuthorAllowed: args.isAuthorAllowed,
|
|
1861
|
+
persistMessage: args.persistMessage,
|
|
1862
|
+
state: args.state,
|
|
1863
|
+
threadTs
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Handle Slack interactions from App Home and from user input requests:
|
|
1868
|
+
* block actions and submitted user input forms.
|
|
1869
|
+
*/
|
|
1870
|
+
async function handleSlackInteractivePayload(args) {
|
|
1871
|
+
const spanAttributes = { userId: args.payload.user?.id?.trim() || void 0 };
|
|
1872
|
+
if (args.payload.type === "view_submission" && args.payload.view?.callback_id === "junior_user_input") {
|
|
1873
|
+
await withSpan("chat.slack_user_input_submit", "chat.slack_user_input_submit", spanAttributes, () => handleUserInputSubmission(args));
|
|
1697
1874
|
return;
|
|
1698
1875
|
}
|
|
1699
|
-
if (
|
|
1700
|
-
|
|
1701
|
-
|
|
1876
|
+
if (args.payload.type !== "block_actions") return;
|
|
1877
|
+
for (const action of args.payload.actions ?? []) {
|
|
1878
|
+
const kind = slackUserInputAction(action.action_id);
|
|
1879
|
+
if (kind === "choice") {
|
|
1880
|
+
await withSpan("chat.slack_user_input_click", "chat.slack_user_input_click", spanAttributes, () => handleUserInputChoice({
|
|
1881
|
+
...args,
|
|
1882
|
+
action
|
|
1883
|
+
}));
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
if (kind === "open_form") {
|
|
1887
|
+
await withSpan("chat.slack_user_input_open_form", "chat.slack_user_input_open_form", spanAttributes, () => handleUserInputOpenForm(args));
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1702
1890
|
}
|
|
1703
|
-
const
|
|
1704
|
-
|
|
1705
|
-
|
|
1891
|
+
const disconnect = args.payload.actions?.find((candidate) => candidate.action_id === "app_home_disconnect");
|
|
1892
|
+
const provider = disconnect?.selected_option?.value ?? disconnect?.value;
|
|
1893
|
+
if (!provider) return;
|
|
1894
|
+
await handleAppHomeDisconnect({
|
|
1895
|
+
provider,
|
|
1896
|
+
userId: requireSlackPayloadUserId(args.payload.user?.id, "Slack app home disconnect payload"),
|
|
1897
|
+
userTokenStore: args.userTokenStore
|
|
1898
|
+
});
|
|
1706
1899
|
}
|
|
1707
1900
|
// src/chat/ingress/slack-form.ts
|
|
1708
1901
|
function parseJson$1(body) {
|
|
@@ -1712,81 +1905,14 @@ function parseJson$1(body) {
|
|
|
1712
1905
|
return;
|
|
1713
1906
|
}
|
|
1714
1907
|
}
|
|
1715
|
-
function requireSlackPayloadUserId(value, source) {
|
|
1716
|
-
const userId = parseActorUserId(value);
|
|
1717
|
-
if (!userId) throw new Error(`${source} is missing a Slack user id`);
|
|
1718
|
-
return userId;
|
|
1719
|
-
}
|
|
1720
|
-
async function handleSlashCommandForm(args) {
|
|
1721
|
-
const { channel_id: channelId, team_id: teamId, user_id: userId } = args.form;
|
|
1722
|
-
const channel = new ChannelImpl({
|
|
1723
|
-
id: `slack:${channelId}`,
|
|
1724
|
-
adapter: args.adapter,
|
|
1725
|
-
stateAdapter: args.state
|
|
1726
|
-
});
|
|
1727
|
-
await withSpan("chat.slash_command", "chat.slash_command", { userId }, async () => {
|
|
1728
|
-
await handleSlashCommand({
|
|
1729
|
-
channel,
|
|
1730
|
-
channelId,
|
|
1731
|
-
teamId,
|
|
1732
|
-
text: args.form.text,
|
|
1733
|
-
userId
|
|
1734
|
-
});
|
|
1735
|
-
});
|
|
1736
|
-
}
|
|
1737
|
-
async function handleInteractivePayload(args) {
|
|
1738
|
-
if (args.payload.type !== "block_actions") return;
|
|
1739
|
-
const action = args.payload.actions?.find((candidate) => candidate.action_id === "app_home_disconnect");
|
|
1740
|
-
const provider = action?.selected_option?.value ?? action?.value;
|
|
1741
|
-
if (!provider) return;
|
|
1742
|
-
const userId = requireSlackPayloadUserId(args.payload.user?.id, "Slack app home disconnect payload");
|
|
1743
|
-
await withSpan("chat.app_home_disconnect", "chat.app_home_disconnect", { userId }, async () => {
|
|
1744
|
-
try {
|
|
1745
|
-
await unlinkProvider(userId, provider, args.userTokenStore, getWorkspaceTeamId());
|
|
1746
|
-
} catch (error) {
|
|
1747
|
-
logException(error, "app_home.disconnect_unlink.failed", { "app.credential.provider": provider });
|
|
1748
|
-
}
|
|
1749
|
-
try {
|
|
1750
|
-
await publishAppHomeView(getSlackClient(), userId, args.userTokenStore);
|
|
1751
|
-
} catch (error) {
|
|
1752
|
-
logException(error, "app_home.disconnect_publish.failed", { "app.credential.provider": provider });
|
|
1753
|
-
}
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
1908
|
function installationFromInteractive(payload) {
|
|
1757
1909
|
return { teamId: payload.team?.id ?? payload.user?.team_id };
|
|
1758
1910
|
}
|
|
1759
|
-
/** Handle a Slack
|
|
1911
|
+
/** Handle a Slack interactive payload form. */
|
|
1760
1912
|
async function handleSlackForm(args) {
|
|
1761
1913
|
const params = new URLSearchParams(args.body);
|
|
1762
1914
|
const { adapter, state } = args;
|
|
1763
1915
|
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
1916
|
const rawPayload = params.get("payload");
|
|
1791
1917
|
if (!rawPayload) return new Response("Missing payload", { status: 400 });
|
|
1792
1918
|
const result = slackInteractivePayloadSchema.safeParse(parseJson$1(rawPayload));
|
|
@@ -1800,8 +1926,12 @@ async function handleSlackForm(args) {
|
|
|
1800
1926
|
adapter,
|
|
1801
1927
|
installation,
|
|
1802
1928
|
state,
|
|
1803
|
-
task: () =>
|
|
1929
|
+
task: () => handleSlackInteractivePayload({
|
|
1930
|
+
adapter,
|
|
1804
1931
|
payload,
|
|
1932
|
+
isAuthorAllowed: (userId) => args.isAuthorAllowed(userId, installation),
|
|
1933
|
+
persistMessage: (message) => args.persistMessage(message, installation),
|
|
1934
|
+
state,
|
|
1805
1935
|
userTokenStore: args.getUserTokenStore()
|
|
1806
1936
|
})
|
|
1807
1937
|
})).catch((error) => {
|
|
@@ -1898,8 +2028,8 @@ function eventAutomationDispatchKey(taskId, namespace, eventKey) {
|
|
|
1898
2028
|
return `event-automation:${createHash("sha256").update(`${taskId}\0${namespace}\0${eventKey}`).digest("hex").slice(0, 32)}`;
|
|
1899
2029
|
}
|
|
1900
2030
|
/** Render plain agent input for one matching event automation. */
|
|
1901
|
-
function eventInput(task, event, outcomes) {
|
|
1902
|
-
const guidance = eventGuidance(
|
|
2031
|
+
function eventInput(task, event, outcomes, catalog) {
|
|
2032
|
+
const guidance = eventGuidance(catalog, event.namespace, event.eventType);
|
|
1903
2033
|
return renderTaskInput({
|
|
1904
2034
|
about: task.trigger.label,
|
|
1905
2035
|
creator: slackMention(task.createdBy.slackUserId),
|
|
@@ -1913,12 +2043,20 @@ function eventInput(task, event, outcomes) {
|
|
|
1913
2043
|
externalTextMaxLength: EVENT_TEXT_MAX_LENGTH
|
|
1914
2044
|
});
|
|
1915
2045
|
}
|
|
1916
|
-
/**
|
|
2046
|
+
/**
|
|
2047
|
+
* Match a normalized event and dispatch every matching automation.
|
|
2048
|
+
*
|
|
2049
|
+
* Plugins publish one copy of an occurrence for each scope, such as a pull
|
|
2050
|
+
* request and its repository. All copies share one event key. An automation
|
|
2051
|
+
* with no identifier matches every copy, and the dispatch key and the run
|
|
2052
|
+
* limit both use the event key, so it runs and counts once.
|
|
2053
|
+
*/
|
|
1917
2054
|
async function ingestEventAutomations(input, options) {
|
|
1918
2055
|
const event = eventSchema.parse(input);
|
|
1919
2056
|
const db = getDb();
|
|
1920
2057
|
const nowMs = options.nowMs ?? Date.now();
|
|
1921
|
-
const
|
|
2058
|
+
const catalog = getEventCatalog();
|
|
2059
|
+
const tasks = (await findMatchingEventAutomations(db, event, options.teamId, catalog)).filter((task) => slackChannelMessageRunsAutomation(event, task));
|
|
1922
2060
|
let dispatched = 0;
|
|
1923
2061
|
const errors = [];
|
|
1924
2062
|
for (const task of tasks) try {
|
|
@@ -1945,7 +2083,7 @@ async function ingestEventAutomations(input, options) {
|
|
|
1945
2083
|
"app.event.event_type": event.eventType,
|
|
1946
2084
|
"app.event_automation.max_runs": botConfig.maxEventAutomationRuns,
|
|
1947
2085
|
"app.event_automation.run_window_ms": botConfig.eventAutomationRunWindowMs,
|
|
1948
|
-
"app.event_automation.resource_type": task.trigger.resourceType,
|
|
2086
|
+
"app.event_automation.resource_type": task.trigger.resourceType ?? "any",
|
|
1949
2087
|
"app.task.id": task.id
|
|
1950
2088
|
}
|
|
1951
2089
|
});
|
|
@@ -1978,7 +2116,7 @@ async function ingestEventAutomations(input, options) {
|
|
|
1978
2116
|
...credentialSubject ? { credentialSubject } : void 0,
|
|
1979
2117
|
destination,
|
|
1980
2118
|
destinationVisibility: task.destinationVisibility,
|
|
1981
|
-
input: eventInput(task, event, outcomes),
|
|
2119
|
+
input: eventInput(task, event, outcomes, catalog),
|
|
1982
2120
|
metadata: { eventAutomationId: task.id },
|
|
1983
2121
|
replyAttribution: automationReplyAttribution(task.title),
|
|
1984
2122
|
outcomes
|
|
@@ -1996,85 +2134,6 @@ async function ingestEventAutomations(input, options) {
|
|
|
1996
2134
|
if (errors.length > 0) throw new AggregateError(errors, "Failed to dispatch one or more event automations");
|
|
1997
2135
|
return { dispatched };
|
|
1998
2136
|
}
|
|
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
2137
|
// src/chat/ingress/slack-allowed-domain.ts
|
|
2079
2138
|
const ALLOWED_TTL_MS = 3e5;
|
|
2080
2139
|
const NOT_ALLOWED_TTL_MS = 3e4;
|
|
@@ -2126,6 +2185,19 @@ function slackMessageAttributes(body) {
|
|
|
2126
2185
|
"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
2186
|
};
|
|
2128
2187
|
}
|
|
2188
|
+
// src/chat/ingress/slack-event-persistence-error.ts
|
|
2189
|
+
/** A Slack input that Junior could not fully save and wake a worker for. */
|
|
2190
|
+
var SlackEventPersistenceError = class extends Error {
|
|
2191
|
+
cause;
|
|
2192
|
+
/** True when the mailbox kept the input and only the queue send failed. */
|
|
2193
|
+
saved;
|
|
2194
|
+
constructor(cause, saved) {
|
|
2195
|
+
super("Slack event durable persistence failed");
|
|
2196
|
+
this.name = "SlackEventPersistenceError";
|
|
2197
|
+
this.cause = cause;
|
|
2198
|
+
this.saved = saved;
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2129
2201
|
// src/chat/slack/automation-details.ts
|
|
2130
2202
|
function textField(key, label, value) {
|
|
2131
2203
|
return {
|
|
@@ -2265,14 +2337,6 @@ const IGNORED_MESSAGE_SUBTYPES = /* @__PURE__ */ new Set([
|
|
|
2265
2337
|
"ekm_access_denied",
|
|
2266
2338
|
"tombstone"
|
|
2267
2339
|
]);
|
|
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
2340
|
function enqueue(waitUntil, task) {
|
|
2277
2341
|
waitUntil(task);
|
|
2278
2342
|
}
|
|
@@ -2373,11 +2437,12 @@ async function persistSlackMessage(args) {
|
|
|
2373
2437
|
queue: args.queue,
|
|
2374
2438
|
state: args.state
|
|
2375
2439
|
}).catch(async (error) => {
|
|
2376
|
-
|
|
2440
|
+
const kept = (await getConversationWorkState({
|
|
2377
2441
|
conversationId,
|
|
2378
2442
|
state: args.state
|
|
2379
|
-
}))?.execution.inboundMessageIds.includes(inbound.inboundMessageId)
|
|
2380
|
-
|
|
2443
|
+
}))?.execution.inboundMessageIds.includes(inbound.inboundMessageId) === true;
|
|
2444
|
+
if (!kept) await receipt?.stop();
|
|
2445
|
+
throw new SlackEventPersistenceError(error, kept);
|
|
2381
2446
|
});
|
|
2382
2447
|
}
|
|
2383
2448
|
/**
|
|
@@ -2553,7 +2618,7 @@ async function routeParsedMessage(args) {
|
|
|
2553
2618
|
conversationStore: args.conversationStore,
|
|
2554
2619
|
installation: args.installation
|
|
2555
2620
|
});
|
|
2556
|
-
|
|
2621
|
+
const persist = () => persistSlackMessage({
|
|
2557
2622
|
adapter: args.adapter,
|
|
2558
2623
|
installation: args.installation,
|
|
2559
2624
|
message,
|
|
@@ -2564,6 +2629,22 @@ async function routeParsedMessage(args) {
|
|
|
2564
2629
|
route,
|
|
2565
2630
|
state: args.state
|
|
2566
2631
|
});
|
|
2632
|
+
if (route !== "mention") {
|
|
2633
|
+
await persist();
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
await persistSlackUserInputReply({
|
|
2637
|
+
channelId: args.event.channel,
|
|
2638
|
+
claimedBy: message.author.userId,
|
|
2639
|
+
isSaved: (error) => error instanceof SlackEventPersistenceError && error.saved,
|
|
2640
|
+
note: (request) => slackUserInputAnswerNote({
|
|
2641
|
+
request,
|
|
2642
|
+
userId: message.author.userId
|
|
2643
|
+
}),
|
|
2644
|
+
persist,
|
|
2645
|
+
state: args.state,
|
|
2646
|
+
threadTs: args.event.thread_ts
|
|
2647
|
+
});
|
|
2567
2648
|
}
|
|
2568
2649
|
async function handleSlackEvent(args) {
|
|
2569
2650
|
const event = args.body.event;
|
|
@@ -2660,6 +2741,35 @@ async function handleSlackEvent(args) {
|
|
|
2660
2741
|
}
|
|
2661
2742
|
}));
|
|
2662
2743
|
}
|
|
2744
|
+
/**
|
|
2745
|
+
* Persist one Slack input as a mention under the thread's ingress lock.
|
|
2746
|
+
* Slack does not retry interactions. Saved input still runs through heartbeat
|
|
2747
|
+
* recovery, so a failed queue send after the append does not reject it.
|
|
2748
|
+
*/
|
|
2749
|
+
async function persistSlackMentionWithLock(args) {
|
|
2750
|
+
const { message, threadId } = normalizeMessageThreadId(args.message);
|
|
2751
|
+
try {
|
|
2752
|
+
if (!(await withLock$1(args.state, `slack:ingress:${threadId}`, () => persistSlackMessage({
|
|
2753
|
+
adapter: args.adapter,
|
|
2754
|
+
installation: args.installation,
|
|
2755
|
+
message,
|
|
2756
|
+
conversationStore: args.services.conversationStore,
|
|
2757
|
+
queue: args.services.queue,
|
|
2758
|
+
receivedAtMs: Date.now(),
|
|
2759
|
+
route: "mention",
|
|
2760
|
+
state: args.state
|
|
2761
|
+
}), {
|
|
2762
|
+
keepAlive: true,
|
|
2763
|
+
waitMs: 1e4
|
|
2764
|
+
})).acquired) throw new Error("Could not acquire Slack ingress lock");
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
if (error instanceof SlackEventPersistenceError && error.saved) {
|
|
2767
|
+
logException(error.cause, "slack.event.wake.failed");
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
throw error;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2663
2773
|
/** Handle Slack webhooks by enqueueing durable conversation work. */
|
|
2664
2774
|
async function handleSlackWebhook(args) {
|
|
2665
2775
|
const adapter = args.services.getSlackAdapter();
|
|
@@ -2669,13 +2779,30 @@ async function handleSlackWebhook(args) {
|
|
|
2669
2779
|
body,
|
|
2670
2780
|
request: args.request
|
|
2671
2781
|
})) 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
|
-
|
|
2782
|
+
if ((args.request.headers.get("content-type") || "").includes("application/x-www-form-urlencoded")) {
|
|
2783
|
+
const state = args.services.state ?? getStateAdapter();
|
|
2784
|
+
return await handleSlackForm({
|
|
2785
|
+
adapter,
|
|
2786
|
+
body,
|
|
2787
|
+
getUserTokenStore: () => getUserTokenStore(args.services),
|
|
2788
|
+
isAuthorAllowed: (userId, installation) => isSlackAuthorDomainAllowed({
|
|
2789
|
+
allowedBotUserIds: args.services.allowedBotUserIds ?? [],
|
|
2790
|
+
allowedDomains: args.services.allowedDomains ?? [],
|
|
2791
|
+
state,
|
|
2792
|
+
teamId: installation.teamId,
|
|
2793
|
+
user: userId
|
|
2794
|
+
}),
|
|
2795
|
+
persistMessage: (message, installation) => persistSlackMentionWithLock({
|
|
2796
|
+
adapter,
|
|
2797
|
+
installation,
|
|
2798
|
+
message,
|
|
2799
|
+
services: args.services,
|
|
2800
|
+
state
|
|
2801
|
+
}),
|
|
2802
|
+
state,
|
|
2803
|
+
waitUntil: args.waitUntil
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2679
2806
|
const result = slackEventEnvelopeSchema.safeParse(parseJson(body));
|
|
2680
2807
|
if (!result.success) return new Response("Invalid JSON", { status: 400 });
|
|
2681
2808
|
const parsed = result.data;
|
|
@@ -2855,6 +2982,10 @@ function conversationWorkCallback(options) {
|
|
|
2855
2982
|
function createVercelConversationWorkCallback(options) {
|
|
2856
2983
|
return conversationWorkCallback(options).create();
|
|
2857
2984
|
}
|
|
2985
|
+
/** Return the queue consumer for conversation work, for a pulling queue worker. */
|
|
2986
|
+
function conversationWorkQueueConsumer(options) {
|
|
2987
|
+
return conversationWorkCallback(options).consumer;
|
|
2988
|
+
}
|
|
2858
2989
|
/** Register the Vercel Queue local-dev consumer for Nitro's central route dispatcher. */
|
|
2859
2990
|
function registerVercelConversationWorkDevConsumer(options) {
|
|
2860
2991
|
return conversationWorkCallback(options).registerDev();
|
|
@@ -2885,6 +3016,10 @@ function workspaceSnapshotJobCallback() {
|
|
|
2885
3016
|
function createVercelWorkspaceSnapshotJobCallback() {
|
|
2886
3017
|
return workspaceSnapshotJobCallback().create();
|
|
2887
3018
|
}
|
|
3019
|
+
/** Return the queue consumer for snapshot builds, for a pulling queue worker. */
|
|
3020
|
+
function workspaceSnapshotJobQueueConsumer() {
|
|
3021
|
+
return workspaceSnapshotJobCallback().consumer;
|
|
3022
|
+
}
|
|
2888
3023
|
/** Receive snapshot build messages during local development. */
|
|
2889
3024
|
function registerVercelWorkspaceSnapshotJobDevConsumer() {
|
|
2890
3025
|
return workspaceSnapshotJobCallback().registerDev();
|
|
@@ -2946,7 +3081,7 @@ function createEventAppPublisher(args) {
|
|
|
2946
3081
|
namespace: event.namespace,
|
|
2947
3082
|
identifier: event.identifier,
|
|
2948
3083
|
state: work.state
|
|
2949
|
-
}), eventAutomationTeamId ? findMatchingEventAutomations(getDb(), event, eventAutomationTeamId) : Promise.resolve([])]);
|
|
3084
|
+
}), eventAutomationTeamId ? findMatchingEventAutomations(getDb(), event, eventAutomationTeamId, getEventCatalog()) : Promise.resolve([])]);
|
|
2950
3085
|
return subscriptions.length > 0 || tasks.length > 0;
|
|
2951
3086
|
},
|
|
2952
3087
|
async neededMatchKeys(input) {
|
|
@@ -2958,6 +3093,8 @@ function createEventAppPublisher(args) {
|
|
|
2958
3093
|
namespace: input.namespace,
|
|
2959
3094
|
state: work.state
|
|
2960
3095
|
}), eventAutomationTeamId ? collectEventAutomationMatchKeys(getDb(), {
|
|
3096
|
+
catalog: getEventCatalog(),
|
|
3097
|
+
...input.data ? { data: input.data } : void 0,
|
|
2961
3098
|
eventTypes: input.eventTypes,
|
|
2962
3099
|
identifiers: input.identifiers,
|
|
2963
3100
|
namespace: input.namespace,
|
|
@@ -2996,7 +3133,7 @@ async function turnTerminalIsReady(args) {
|
|
|
2996
3133
|
state: args.state
|
|
2997
3134
|
});
|
|
2998
3135
|
if (conversation?.execution.pendingMessages.some((message) => message.inboundMessageId === args.messageId)) return false;
|
|
2999
|
-
if (!conversation || !hasRunnableConversationWork
|
|
3136
|
+
if (!conversation || !hasRunnableConversationWork(conversation)) return true;
|
|
3000
3137
|
return (await args.eventStore.query(args.conversationId, {
|
|
3001
3138
|
afterSeq: args.terminalSeq,
|
|
3002
3139
|
limit: 1,
|
|
@@ -3116,6 +3253,33 @@ function createAcpConversations(options) {
|
|
|
3116
3253
|
}
|
|
3117
3254
|
};
|
|
3118
3255
|
}
|
|
3256
|
+
// src/api/public-origin.ts
|
|
3257
|
+
/** Loopback hostnames, in the bracketed form that `URL.hostname` uses. */
|
|
3258
|
+
const LOOPBACK_HOSTNAMES = [
|
|
3259
|
+
"localhost",
|
|
3260
|
+
"127.0.0.1",
|
|
3261
|
+
"[::1]"
|
|
3262
|
+
];
|
|
3263
|
+
/**
|
|
3264
|
+
* Resolve the public origin of this Junior app for browser sign-in and
|
|
3265
|
+
* protocol metadata. A configured origin always wins. The request origin is
|
|
3266
|
+
* used only on loopback, because a deployed request can carry any host.
|
|
3267
|
+
*/
|
|
3268
|
+
function resolvePublicOrigin(request, configured) {
|
|
3269
|
+
const value = configured?.trim();
|
|
3270
|
+
let origin;
|
|
3271
|
+
if (value) origin = /^https?:\/\//.test(value) ? value : `https://${value}`;
|
|
3272
|
+
else {
|
|
3273
|
+
const requestURL = new URL(request.url);
|
|
3274
|
+
if (!LOOPBACK_HOSTNAMES.includes(requestURL.hostname)) return void 0;
|
|
3275
|
+
origin = requestURL.origin;
|
|
3276
|
+
}
|
|
3277
|
+
const url = new URL(origin);
|
|
3278
|
+
url.pathname = "/";
|
|
3279
|
+
url.search = "";
|
|
3280
|
+
url.hash = "";
|
|
3281
|
+
return url;
|
|
3282
|
+
}
|
|
3119
3283
|
// src/api/acp/state.ts
|
|
3120
3284
|
const MUTATION_LOCK_TTL_MS = 1e4;
|
|
3121
3285
|
/** Run one ACP state mutation while its shared lock remains owned. */
|
|
@@ -3859,11 +4023,11 @@ const ACP_AUTH_METHOD = {
|
|
|
3859
4023
|
name: "Sign in to Junior",
|
|
3860
4024
|
description: "Sign in with the Google account allowed by this Junior app."
|
|
3861
4025
|
};
|
|
3862
|
-
function authorizationKey(transactionId) {
|
|
4026
|
+
function authorizationKey$1(transactionId) {
|
|
3863
4027
|
return `junior:acp:v1:authorization:${transactionId}`;
|
|
3864
4028
|
}
|
|
3865
4029
|
function authorizationLockKey(transactionId) {
|
|
3866
|
-
return `${authorizationKey(transactionId)}:lock`;
|
|
4030
|
+
return `${authorizationKey$1(transactionId)}:lock`;
|
|
3867
4031
|
}
|
|
3868
4032
|
function authorizationPointerKey(connectionId) {
|
|
3869
4033
|
return `junior:acp:v1:connection:${connectionId}:authorization`;
|
|
@@ -3923,27 +4087,12 @@ function hasAcpConnectionCredential(request, expectedHash) {
|
|
|
3923
4087
|
}
|
|
3924
4088
|
/** Resolve a trusted browser origin, with request fallback only on loopback. */
|
|
3925
4089
|
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 = "";
|
|
4090
|
+
const url = resolvePublicOrigin(request, configured);
|
|
4091
|
+
if (!url) throw acp.RequestError.internalError(void 0, "Junior ACP sign-in requires a configured public base URL");
|
|
3943
4092
|
return url;
|
|
3944
4093
|
}
|
|
3945
4094
|
async function readAuthorization(state, transactionId) {
|
|
3946
|
-
const value = await state.get(authorizationKey(transactionId));
|
|
4095
|
+
const value = await state.get(authorizationKey$1(transactionId));
|
|
3947
4096
|
return value === null || value === void 0 ? void 0 : acpAuthorizationSchema.parse(value);
|
|
3948
4097
|
}
|
|
3949
4098
|
async function readAuthorizationPointer(state, connectionId) {
|
|
@@ -3964,7 +4113,7 @@ async function deleteAuthorizationPointer(state, connectionId, transactionId) {
|
|
|
3964
4113
|
/** Remove one completed transaction and its matching connection pointer. */
|
|
3965
4114
|
async function deleteAuthorization(state, authorization, transactionId) {
|
|
3966
4115
|
await deleteAuthorizationPointer(state, authorization.connectionId, transactionId);
|
|
3967
|
-
await state.delete(authorizationKey(transactionId));
|
|
4116
|
+
await state.delete(authorizationKey$1(transactionId));
|
|
3968
4117
|
}
|
|
3969
4118
|
function completionReceipt(authorization, response) {
|
|
3970
4119
|
return {
|
|
@@ -4018,7 +4167,7 @@ async function beginAcpAuthorization(args) {
|
|
|
4018
4167
|
userCodeHash: userCodeHash(userCode)
|
|
4019
4168
|
});
|
|
4020
4169
|
await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS);
|
|
4021
|
-
await args.state.set(authorizationKey(transactionId), authorization, ACP_STATE_TTL_MS);
|
|
4170
|
+
await args.state.set(authorizationKey$1(transactionId), authorization, ACP_STATE_TTL_MS);
|
|
4022
4171
|
await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS);
|
|
4023
4172
|
await args.state.set(authorizationPointerKey(args.connectionId), {
|
|
4024
4173
|
expiresAtMs: authorization.expiresAtMs,
|
|
@@ -4608,12 +4757,1093 @@ function createAcpHttpHandler(options) {
|
|
|
4608
4757
|
}
|
|
4609
4758
|
};
|
|
4610
4759
|
}
|
|
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;
|
|
4760
|
+
// src/api/html.ts
|
|
4761
|
+
/** Escape text before it enters server-rendered HTML. */
|
|
4614
4762
|
function escapeHtml(value) {
|
|
4615
4763
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
4616
4764
|
}
|
|
4765
|
+
/** Return true when a browser request comes from its own URL origin. */
|
|
4766
|
+
function requestHasSameOrigin(request) {
|
|
4767
|
+
const origin = request.headers.get("origin");
|
|
4768
|
+
return origin !== null && URL.parse(origin)?.origin === new URL(request.url).origin;
|
|
4769
|
+
}
|
|
4770
|
+
/**
|
|
4771
|
+
* Render one sign-in page that browsers cannot frame or cache. `bodyHtml`
|
|
4772
|
+
* follows the heading, and the caller escapes every value in it.
|
|
4773
|
+
*/
|
|
4774
|
+
function renderHtmlPage(page) {
|
|
4775
|
+
return new Response(`<!DOCTYPE html>
|
|
4776
|
+
<html lang="en">
|
|
4777
|
+
<head>
|
|
4778
|
+
<meta charset="utf-8" />
|
|
4779
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
4780
|
+
<title>${escapeHtml(page.title)}</title>
|
|
4781
|
+
</head>
|
|
4782
|
+
<body style="margin:0;background:#000;color:#fff;font-family:ui-sans-serif,system-ui,sans-serif">
|
|
4783
|
+
<main style="min-height:100vh;display:grid;place-items:center;padding:2rem">
|
|
4784
|
+
<section style="max-width:32rem;border-left:4px solid #a78bfa;padding-left:1rem">
|
|
4785
|
+
<h1 style="margin:0 0 .75rem;font-size:2rem">${escapeHtml(page.title)}</h1>
|
|
4786
|
+
${page.bodyHtml}
|
|
4787
|
+
</section>
|
|
4788
|
+
</main>
|
|
4789
|
+
</body>
|
|
4790
|
+
</html>`, {
|
|
4791
|
+
status: page.status,
|
|
4792
|
+
headers: {
|
|
4793
|
+
"cache-control": "no-store",
|
|
4794
|
+
"content-type": "text/html; charset=utf-8",
|
|
4795
|
+
"x-frame-options": "DENY",
|
|
4796
|
+
...page.headers
|
|
4797
|
+
}
|
|
4798
|
+
});
|
|
4799
|
+
}
|
|
4800
|
+
// src/net/public-address.ts
|
|
4801
|
+
/**
|
|
4802
|
+
* Own the public-address rule for fetches of URLs that any person can submit.
|
|
4803
|
+
*
|
|
4804
|
+
* Such a fetch must not reach loopback, private, link-local, cloud metadata,
|
|
4805
|
+
* or other special addresses. The lookup checks every resolved address at
|
|
4806
|
+
* connect time, so a second DNS answer cannot move the request to a blocked
|
|
4807
|
+
* address.
|
|
4808
|
+
*/
|
|
4809
|
+
/** Special-use ranges from RFC 6890 and the IANA special-purpose registries. */
|
|
4810
|
+
const BLOCKED_SUBNETS = [
|
|
4811
|
+
[
|
|
4812
|
+
"0.0.0.0",
|
|
4813
|
+
8,
|
|
4814
|
+
"ipv4"
|
|
4815
|
+
],
|
|
4816
|
+
[
|
|
4817
|
+
"10.0.0.0",
|
|
4818
|
+
8,
|
|
4819
|
+
"ipv4"
|
|
4820
|
+
],
|
|
4821
|
+
[
|
|
4822
|
+
"100.64.0.0",
|
|
4823
|
+
10,
|
|
4824
|
+
"ipv4"
|
|
4825
|
+
],
|
|
4826
|
+
[
|
|
4827
|
+
"127.0.0.0",
|
|
4828
|
+
8,
|
|
4829
|
+
"ipv4"
|
|
4830
|
+
],
|
|
4831
|
+
[
|
|
4832
|
+
"169.254.0.0",
|
|
4833
|
+
16,
|
|
4834
|
+
"ipv4"
|
|
4835
|
+
],
|
|
4836
|
+
[
|
|
4837
|
+
"172.16.0.0",
|
|
4838
|
+
12,
|
|
4839
|
+
"ipv4"
|
|
4840
|
+
],
|
|
4841
|
+
[
|
|
4842
|
+
"192.0.0.0",
|
|
4843
|
+
24,
|
|
4844
|
+
"ipv4"
|
|
4845
|
+
],
|
|
4846
|
+
[
|
|
4847
|
+
"192.0.2.0",
|
|
4848
|
+
24,
|
|
4849
|
+
"ipv4"
|
|
4850
|
+
],
|
|
4851
|
+
[
|
|
4852
|
+
"192.168.0.0",
|
|
4853
|
+
16,
|
|
4854
|
+
"ipv4"
|
|
4855
|
+
],
|
|
4856
|
+
[
|
|
4857
|
+
"198.18.0.0",
|
|
4858
|
+
15,
|
|
4859
|
+
"ipv4"
|
|
4860
|
+
],
|
|
4861
|
+
[
|
|
4862
|
+
"198.51.100.0",
|
|
4863
|
+
24,
|
|
4864
|
+
"ipv4"
|
|
4865
|
+
],
|
|
4866
|
+
[
|
|
4867
|
+
"203.0.113.0",
|
|
4868
|
+
24,
|
|
4869
|
+
"ipv4"
|
|
4870
|
+
],
|
|
4871
|
+
[
|
|
4872
|
+
"224.0.0.0",
|
|
4873
|
+
4,
|
|
4874
|
+
"ipv4"
|
|
4875
|
+
],
|
|
4876
|
+
[
|
|
4877
|
+
"240.0.0.0",
|
|
4878
|
+
4,
|
|
4879
|
+
"ipv4"
|
|
4880
|
+
],
|
|
4881
|
+
[
|
|
4882
|
+
"::",
|
|
4883
|
+
96,
|
|
4884
|
+
"ipv6"
|
|
4885
|
+
],
|
|
4886
|
+
[
|
|
4887
|
+
"64:ff9b::",
|
|
4888
|
+
96,
|
|
4889
|
+
"ipv6"
|
|
4890
|
+
],
|
|
4891
|
+
[
|
|
4892
|
+
"64:ff9b:1::",
|
|
4893
|
+
48,
|
|
4894
|
+
"ipv6"
|
|
4895
|
+
],
|
|
4896
|
+
[
|
|
4897
|
+
"100::",
|
|
4898
|
+
64,
|
|
4899
|
+
"ipv6"
|
|
4900
|
+
],
|
|
4901
|
+
[
|
|
4902
|
+
"2001::",
|
|
4903
|
+
32,
|
|
4904
|
+
"ipv6"
|
|
4905
|
+
],
|
|
4906
|
+
[
|
|
4907
|
+
"2001:db8::",
|
|
4908
|
+
32,
|
|
4909
|
+
"ipv6"
|
|
4910
|
+
],
|
|
4911
|
+
[
|
|
4912
|
+
"2002::",
|
|
4913
|
+
16,
|
|
4914
|
+
"ipv6"
|
|
4915
|
+
],
|
|
4916
|
+
[
|
|
4917
|
+
"fc00::",
|
|
4918
|
+
7,
|
|
4919
|
+
"ipv6"
|
|
4920
|
+
],
|
|
4921
|
+
[
|
|
4922
|
+
"fe80::",
|
|
4923
|
+
10,
|
|
4924
|
+
"ipv6"
|
|
4925
|
+
],
|
|
4926
|
+
[
|
|
4927
|
+
"fec0::",
|
|
4928
|
+
10,
|
|
4929
|
+
"ipv6"
|
|
4930
|
+
],
|
|
4931
|
+
[
|
|
4932
|
+
"ff00::",
|
|
4933
|
+
8,
|
|
4934
|
+
"ipv6"
|
|
4935
|
+
]
|
|
4936
|
+
];
|
|
4937
|
+
function blockedAddressList() {
|
|
4938
|
+
const list = new net.BlockList();
|
|
4939
|
+
for (const [network, prefix, family] of BLOCKED_SUBNETS) list.addSubnet(network, prefix, family);
|
|
4940
|
+
return list;
|
|
4941
|
+
}
|
|
4942
|
+
const blockedAddresses = blockedAddressList();
|
|
4943
|
+
/** Return true only for an IP address outside every blocked range. */
|
|
4944
|
+
function isPublicAddress(address) {
|
|
4945
|
+
const family = net.isIP(address);
|
|
4946
|
+
if (family === 0) return false;
|
|
4947
|
+
return !blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6");
|
|
4948
|
+
}
|
|
4949
|
+
function blockedAddressError(hostname) {
|
|
4950
|
+
return Object.assign(/* @__PURE__ */ new Error(`${hostname} does not resolve to a public address`), { code: "EADDRNOTPUBLIC" });
|
|
4951
|
+
}
|
|
4952
|
+
/**
|
|
4953
|
+
* Resolve a hostname for an outbound socket. Fail when any answer is not a
|
|
4954
|
+
* public address.
|
|
4955
|
+
*/
|
|
4956
|
+
const publicAddressLookup = (hostname, options, callback) => {
|
|
4957
|
+
dns.lookup(hostname, {
|
|
4958
|
+
all: true,
|
|
4959
|
+
verbatim: true
|
|
4960
|
+
}, (error, addresses) => {
|
|
4961
|
+
if (error) {
|
|
4962
|
+
callback(error, "", 0);
|
|
4963
|
+
return;
|
|
4964
|
+
}
|
|
4965
|
+
const allowed = addresses.filter((entry) => !options.family || entry.family === options.family || `IPv${entry.family}` === options.family);
|
|
4966
|
+
const first = allowed[0];
|
|
4967
|
+
if (!first || !addresses.every((entry) => isPublicAddress(entry.address))) {
|
|
4968
|
+
callback(blockedAddressError(hostname), "", 0);
|
|
4969
|
+
return;
|
|
4970
|
+
}
|
|
4971
|
+
if (options.all) {
|
|
4972
|
+
callback(null, allowed);
|
|
4973
|
+
return;
|
|
4974
|
+
}
|
|
4975
|
+
callback(null, first.address, first.family);
|
|
4976
|
+
});
|
|
4977
|
+
};
|
|
4978
|
+
// src/api/mcp/oauth/state.ts
|
|
4979
|
+
/** Return the SHA-256 hex digest of a secret or unbounded value. */
|
|
4980
|
+
function sha256Hex(value) {
|
|
4981
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
4982
|
+
}
|
|
4983
|
+
/** Build one versioned MCP state key. */
|
|
4984
|
+
function mcpStateKey(kind, id) {
|
|
4985
|
+
return `junior:mcp:v1:${kind}:${id}`;
|
|
4986
|
+
}
|
|
4987
|
+
/** Read and parse one MCP state record. A missing record reads as undefined. */
|
|
4988
|
+
async function readRecord(state, key, schema) {
|
|
4989
|
+
const value = await state.get(key);
|
|
4990
|
+
return value === null ? void 0 : schema.parse(value);
|
|
4991
|
+
}
|
|
4992
|
+
// src/api/mcp/oauth/client-metadata.ts
|
|
4993
|
+
/**
|
|
4994
|
+
* Own MCP OAuth clients. Junior accepts only Client ID Metadata Documents
|
|
4995
|
+
* (CIMD): the `client_id` is an HTTPS URL, and the document at that URL
|
|
4996
|
+
* names the client and its redirect URIs. Junior has no client registration.
|
|
4997
|
+
*/
|
|
4998
|
+
const CLIENT_ID_MAX_LENGTH = 2e3;
|
|
4999
|
+
const CLIENT_METADATA_MAX_BYTES = 5120;
|
|
5000
|
+
const CLIENT_METADATA_TIMEOUT_MS = 5e3;
|
|
5001
|
+
const CLIENT_METADATA_CACHE_TTL_MS = 3e5;
|
|
5002
|
+
const INVALID_CLIENT_CACHE_TTL_MS = 6e4;
|
|
5003
|
+
const clientMetadataDocumentSchema = z.object({
|
|
5004
|
+
client_id: z.string(),
|
|
5005
|
+
client_name: z.string().trim().min(1).max(200).optional(),
|
|
5006
|
+
redirect_uris: z.array(z.string().max(CLIENT_ID_MAX_LENGTH)).min(1).max(20),
|
|
5007
|
+
token_endpoint_auth_method: z.literal("none").optional()
|
|
5008
|
+
});
|
|
5009
|
+
const mcpClientSchema = z.object({
|
|
5010
|
+
clientId: z.string().min(1),
|
|
5011
|
+
name: z.string().min(1).optional(),
|
|
5012
|
+
redirectUris: z.array(z.string()).min(1)
|
|
5013
|
+
}).strict();
|
|
5014
|
+
const invalidClientSchema = z.object({ invalid: z.literal(true) }).strict();
|
|
5015
|
+
const cachedClientSchema = z.union([mcpClientSchema, invalidClientSchema]);
|
|
5016
|
+
/**
|
|
5017
|
+
* Parse a CIMD client ID. It must be a canonical `https:` URL with a path,
|
|
5018
|
+
* no fragment, no credentials, and no loopback or private IP host.
|
|
5019
|
+
*/
|
|
5020
|
+
function parseClientId(value) {
|
|
5021
|
+
if (value.length > CLIENT_ID_MAX_LENGTH || value.includes("#")) return;
|
|
5022
|
+
const url = URL.parse(value);
|
|
5023
|
+
if (!url) return void 0;
|
|
5024
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
5025
|
+
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;
|
|
5026
|
+
return url;
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* Fetch one client metadata document. Redirects, non-200 responses, slow or
|
|
5030
|
+
* large responses, blocked addresses, and invalid JSON all give `undefined`,
|
|
5031
|
+
* because each one only means that the client is invalid.
|
|
5032
|
+
*/
|
|
5033
|
+
function fetchClientMetadataDocument(url) {
|
|
5034
|
+
return new Promise((resolve) => {
|
|
5035
|
+
const request = https.request(url, {
|
|
5036
|
+
headers: { accept: "application/json" },
|
|
5037
|
+
agent: false,
|
|
5038
|
+
lookup: publicAddressLookup,
|
|
5039
|
+
method: "GET",
|
|
5040
|
+
signal: AbortSignal.timeout(CLIENT_METADATA_TIMEOUT_MS)
|
|
5041
|
+
}, (response) => {
|
|
5042
|
+
if (response.statusCode !== 200) {
|
|
5043
|
+
request.destroy();
|
|
5044
|
+
resolve(void 0);
|
|
5045
|
+
return;
|
|
5046
|
+
}
|
|
5047
|
+
const chunks = [];
|
|
5048
|
+
let size = 0;
|
|
5049
|
+
response.on("data", (chunk) => {
|
|
5050
|
+
size += chunk.length;
|
|
5051
|
+
if (size > CLIENT_METADATA_MAX_BYTES) {
|
|
5052
|
+
request.destroy();
|
|
5053
|
+
resolve(void 0);
|
|
5054
|
+
return;
|
|
5055
|
+
}
|
|
5056
|
+
chunks.push(chunk);
|
|
5057
|
+
});
|
|
5058
|
+
response.on("end", () => {
|
|
5059
|
+
try {
|
|
5060
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
5061
|
+
} catch {
|
|
5062
|
+
resolve(void 0);
|
|
5063
|
+
}
|
|
5064
|
+
});
|
|
5065
|
+
response.on("error", () => resolve(void 0));
|
|
5066
|
+
});
|
|
5067
|
+
request.on("error", () => resolve(void 0));
|
|
5068
|
+
request.end();
|
|
5069
|
+
});
|
|
5070
|
+
}
|
|
5071
|
+
/**
|
|
5072
|
+
* Resolve a client from its CIMD URL. Return `undefined` when the client ID or
|
|
5073
|
+
* its document is invalid. Valid documents are cached for 5 minutes, and
|
|
5074
|
+
* failed fetches for 1 minute.
|
|
5075
|
+
*/
|
|
5076
|
+
async function resolveMcpClient(state, clientId) {
|
|
5077
|
+
const url = parseClientId(clientId);
|
|
5078
|
+
if (!url) return void 0;
|
|
5079
|
+
const key = mcpStateKey("client", sha256Hex(clientId));
|
|
5080
|
+
const cached = await readRecord(state, key, cachedClientSchema);
|
|
5081
|
+
if (cached) return "invalid" in cached ? void 0 : cached;
|
|
5082
|
+
const parsed = clientMetadataDocumentSchema.safeParse(await fetchClientMetadataDocument(url));
|
|
5083
|
+
if (!parsed.success || parsed.data.client_id !== clientId) {
|
|
5084
|
+
await state.set(key, { invalid: true }, INVALID_CLIENT_CACHE_TTL_MS);
|
|
5085
|
+
return;
|
|
5086
|
+
}
|
|
5087
|
+
const client = {
|
|
5088
|
+
clientId,
|
|
5089
|
+
name: parsed.data.client_name,
|
|
5090
|
+
redirectUris: parsed.data.redirect_uris
|
|
5091
|
+
};
|
|
5092
|
+
await state.set(key, client, CLIENT_METADATA_CACHE_TTL_MS);
|
|
5093
|
+
return client;
|
|
5094
|
+
}
|
|
5095
|
+
function isLoopbackHttp(url) {
|
|
5096
|
+
return url.protocol === "http:" && LOOPBACK_HOSTNAMES.includes(url.hostname);
|
|
5097
|
+
}
|
|
5098
|
+
/**
|
|
5099
|
+
* Return true when a requested redirect URI is allowed for a client. The URI
|
|
5100
|
+
* must be `https:` or loopback `http:`, and must equal a registered URI. A
|
|
5101
|
+
* loopback URI may use any port (RFC 8252 section 7.3), because native
|
|
5102
|
+
* clients such as Claude Code listen on a random port.
|
|
5103
|
+
*/
|
|
5104
|
+
function redirectUriMatches(registered, requested) {
|
|
5105
|
+
const url = URL.parse(requested);
|
|
5106
|
+
if (!url || requested.includes("#") || url.protocol !== "https:" && !isLoopbackHttp(url)) return false;
|
|
5107
|
+
return registered.some((entry) => {
|
|
5108
|
+
if (entry === requested) return true;
|
|
5109
|
+
if (!isLoopbackHttp(url)) return false;
|
|
5110
|
+
const candidate = URL.parse(entry);
|
|
5111
|
+
return candidate !== null && isLoopbackHttp(candidate) && candidate.hostname === url.hostname && candidate.pathname === url.pathname && candidate.search === url.search;
|
|
5112
|
+
});
|
|
5113
|
+
}
|
|
5114
|
+
// src/api/mcp/oauth/metadata.ts
|
|
5115
|
+
/**
|
|
5116
|
+
* Own the MCP OAuth paths, scope, and discovery documents.
|
|
5117
|
+
*
|
|
5118
|
+
* Every URL comes from the trusted public origin of the app. The documents
|
|
5119
|
+
* advertise Client ID Metadata Documents and no client registration endpoint.
|
|
5120
|
+
*/
|
|
5121
|
+
const MCP_PATH = "/api/mcp";
|
|
5122
|
+
const MCP_SCOPE = "conversations:read";
|
|
5123
|
+
const MCP_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/api/mcp";
|
|
5124
|
+
const MCP_AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
5125
|
+
const MCP_AUTHORIZE_PATH = "/api/mcp/oauth/authorize";
|
|
5126
|
+
const MCP_TOKEN_PATH = "/api/mcp/oauth/token";
|
|
5127
|
+
/** Dashboard-authenticated consent page for one pending authorization. */
|
|
5128
|
+
const MCP_CONSENT_PATH = "/_junior/mcp/authorize/:authorizationId";
|
|
5129
|
+
/** Resolve the MCP URLs for this request from the trusted public origin. */
|
|
5130
|
+
function resolveMcpUrls(request, baseURL) {
|
|
5131
|
+
const origin = resolvePublicOrigin(request, baseURL);
|
|
5132
|
+
if (!origin) throw new Error("Junior MCP requires a configured public base URL");
|
|
5133
|
+
const url = (path) => new URL(path, origin).toString();
|
|
5134
|
+
return {
|
|
5135
|
+
authorize: url(MCP_AUTHORIZE_PATH),
|
|
5136
|
+
issuer: origin.origin,
|
|
5137
|
+
resource: url(MCP_PATH),
|
|
5138
|
+
resourceMetadata: url(MCP_RESOURCE_METADATA_PATH),
|
|
5139
|
+
token: url(MCP_TOKEN_PATH),
|
|
5140
|
+
consent: (authorizationId) => url(MCP_CONSENT_PATH.replace(":authorizationId", encodeURIComponent(authorizationId)))
|
|
5141
|
+
};
|
|
5142
|
+
}
|
|
5143
|
+
/** RFC 9728 protected resource metadata for `/api/mcp`. */
|
|
5144
|
+
function protectedResourceMetadata(urls) {
|
|
5145
|
+
return {
|
|
5146
|
+
resource: urls.resource,
|
|
5147
|
+
authorization_servers: [urls.issuer],
|
|
5148
|
+
bearer_methods_supported: ["header"],
|
|
5149
|
+
scopes_supported: [MCP_SCOPE]
|
|
5150
|
+
};
|
|
5151
|
+
}
|
|
5152
|
+
/** RFC 8414 authorization server metadata. CIMD is the only client model. */
|
|
5153
|
+
function authorizationServerMetadata(urls) {
|
|
5154
|
+
return {
|
|
5155
|
+
issuer: urls.issuer,
|
|
5156
|
+
authorization_endpoint: urls.authorize,
|
|
5157
|
+
token_endpoint: urls.token,
|
|
5158
|
+
scopes_supported: [MCP_SCOPE],
|
|
5159
|
+
response_types_supported: ["code"],
|
|
5160
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
5161
|
+
code_challenge_methods_supported: ["S256"],
|
|
5162
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
5163
|
+
authorization_response_iss_parameter_supported: true,
|
|
5164
|
+
client_id_metadata_document_supported: true
|
|
5165
|
+
};
|
|
5166
|
+
}
|
|
5167
|
+
// src/api/mcp/oauth/page.ts
|
|
5168
|
+
/** Render one MCP sign-in page. It sends no referrer and loads no resources. */
|
|
5169
|
+
function renderMcpPage(page) {
|
|
5170
|
+
const formAction = ["'self'", ...page.formOrigin ? [page.formOrigin] : []];
|
|
5171
|
+
const message = ` <p style="margin:0 0 1rem;color:#b8b8b8;line-height:1.5">${escapeHtml(page.message)}</p>`;
|
|
5172
|
+
return renderHtmlPage({
|
|
5173
|
+
bodyHtml: page.bodyHtml ? `${message}\n${page.bodyHtml}` : message,
|
|
5174
|
+
headers: {
|
|
5175
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; form-action ${formAction.join(" ")}`,
|
|
5176
|
+
"referrer-policy": "no-referrer"
|
|
5177
|
+
},
|
|
5178
|
+
status: page.status,
|
|
5179
|
+
title: page.title
|
|
5180
|
+
});
|
|
5181
|
+
}
|
|
5182
|
+
// src/api/mcp/oauth/authorization.ts
|
|
5183
|
+
/**
|
|
5184
|
+
* Own the OAuth authorization endpoint and its pending requests.
|
|
5185
|
+
*
|
|
5186
|
+
* The endpoint checks the client and redirect URI first. When either one is
|
|
5187
|
+
* invalid, it shows an error page and never redirects, so an attacker cannot
|
|
5188
|
+
* use Junior as an open redirect. Other errors go back to the client as OAuth
|
|
5189
|
+
* error redirects. A valid request waits for consent on a dashboard page.
|
|
5190
|
+
*/
|
|
5191
|
+
const AUTHORIZATION_TTL_MS = 6e5;
|
|
5192
|
+
const STATE_MAX_LENGTH = 1e3;
|
|
5193
|
+
const AUTHORIZATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
5194
|
+
const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
5195
|
+
const mcpAuthorizationSchema = z.object({
|
|
5196
|
+
clientId: z.string().min(1),
|
|
5197
|
+
clientName: z.string().min(1).optional(),
|
|
5198
|
+
codeChallenge: z.string().regex(CODE_CHALLENGE_PATTERN),
|
|
5199
|
+
/** Fixed end of the request. Opening the consent page does not move it. */
|
|
5200
|
+
expiresAtMs: z.number().int().positive(),
|
|
5201
|
+
redirectUri: z.string().min(1),
|
|
5202
|
+
resource: z.string().min(1),
|
|
5203
|
+
scope: z.literal(MCP_SCOPE),
|
|
5204
|
+
state: z.string().max(STATE_MAX_LENGTH).optional()
|
|
5205
|
+
}).strict();
|
|
5206
|
+
/** The user who owns one consent page, and the hash of its form token. */
|
|
5207
|
+
const mcpConsentSchema = z.object({
|
|
5208
|
+
csrfHash: z.string().length(64),
|
|
5209
|
+
userId: z.string().min(1)
|
|
5210
|
+
}).strict();
|
|
5211
|
+
function authorizationKey(authorizationId) {
|
|
5212
|
+
return mcpStateKey("authorization", authorizationId);
|
|
5213
|
+
}
|
|
5214
|
+
function consentKey(authorizationId) {
|
|
5215
|
+
return `${authorizationKey(authorizationId)}:consent`;
|
|
5216
|
+
}
|
|
5217
|
+
function claimedKey(authorizationId) {
|
|
5218
|
+
return `${authorizationKey(authorizationId)}:claimed`;
|
|
5219
|
+
}
|
|
5220
|
+
/**
|
|
5221
|
+
* Read one pending authorization and its consent binding. Malformed ids,
|
|
5222
|
+
* ended requests, and claimed requests read as missing.
|
|
5223
|
+
*/
|
|
5224
|
+
async function readMcpAuthorization(state, authorizationId) {
|
|
5225
|
+
if (!AUTHORIZATION_ID_PATTERN.test(authorizationId)) return void 0;
|
|
5226
|
+
const authorization = await readRecord(state, authorizationKey(authorizationId), mcpAuthorizationSchema);
|
|
5227
|
+
if (!authorization || authorization.expiresAtMs <= Date.now()) return;
|
|
5228
|
+
if (await state.get(claimedKey(authorizationId)) !== null) return void 0;
|
|
5229
|
+
return {
|
|
5230
|
+
authorization,
|
|
5231
|
+
consent: await readRecord(state, consentKey(authorizationId), mcpConsentSchema)
|
|
5232
|
+
};
|
|
5233
|
+
}
|
|
5234
|
+
/**
|
|
5235
|
+
* Bind a pending authorization to the first user who opens its consent page,
|
|
5236
|
+
* and return a new form token for that user. Return `undefined` when another
|
|
5237
|
+
* user owns it. Only the bound user can approve, so a page that an attacker
|
|
5238
|
+
* opened cannot be submitted for a victim. The binding never extends the
|
|
5239
|
+
* request, and a claimed request stays claimed.
|
|
5240
|
+
*/
|
|
5241
|
+
async function bindMcpConsent(args) {
|
|
5242
|
+
const ttlMs = args.authorization.expiresAtMs - Date.now();
|
|
5243
|
+
if (ttlMs <= 0) return void 0;
|
|
5244
|
+
const csrfToken = randomBytes(32).toString("base64url");
|
|
5245
|
+
const consent = {
|
|
5246
|
+
csrfHash: sha256Hex(csrfToken),
|
|
5247
|
+
userId: args.userId
|
|
5248
|
+
};
|
|
5249
|
+
const key = consentKey(args.authorizationId);
|
|
5250
|
+
if (await args.state.setIfNotExists(key, consent, ttlMs)) return csrfToken;
|
|
5251
|
+
if ((await readRecord(args.state, key, mcpConsentSchema))?.userId !== args.userId) return void 0;
|
|
5252
|
+
await args.state.set(key, consent, ttlMs);
|
|
5253
|
+
return csrfToken;
|
|
5254
|
+
}
|
|
5255
|
+
/**
|
|
5256
|
+
* Claim a pending authorization for one consent decision. Only one caller
|
|
5257
|
+
* can claim it, so one consent cannot issue two codes.
|
|
5258
|
+
*/
|
|
5259
|
+
async function claimMcpAuthorization(state, authorizationId) {
|
|
5260
|
+
const claimed = await state.setIfNotExists(claimedKey(authorizationId), true, AUTHORIZATION_TTL_MS);
|
|
5261
|
+
if (claimed) {
|
|
5262
|
+
await state.delete(authorizationKey(authorizationId));
|
|
5263
|
+
await state.delete(consentKey(authorizationId));
|
|
5264
|
+
}
|
|
5265
|
+
return claimed;
|
|
5266
|
+
}
|
|
5267
|
+
/** Build the client redirect for an OAuth authorization response. */
|
|
5268
|
+
function authorizationRedirect(args) {
|
|
5269
|
+
const url = new URL(args.redirectUri);
|
|
5270
|
+
for (const [name, value] of Object.entries(args.params)) url.searchParams.set(name, value);
|
|
5271
|
+
if (args.state !== void 0) url.searchParams.set("state", args.state);
|
|
5272
|
+
url.searchParams.set("iss", args.issuer);
|
|
5273
|
+
return new Response(null, {
|
|
5274
|
+
status: 303,
|
|
5275
|
+
headers: {
|
|
5276
|
+
"cache-control": "no-store",
|
|
5277
|
+
location: url.toString()
|
|
5278
|
+
}
|
|
5279
|
+
});
|
|
5280
|
+
}
|
|
5281
|
+
function invalidClientPage(message) {
|
|
5282
|
+
return renderMcpPage({
|
|
5283
|
+
message,
|
|
5284
|
+
status: 400,
|
|
5285
|
+
title: "This app cannot connect"
|
|
5286
|
+
});
|
|
5287
|
+
}
|
|
5288
|
+
/** Handle `GET /api/mcp/oauth/authorize` and hand off to the consent page. */
|
|
5289
|
+
async function handleMcpAuthorize(args) {
|
|
5290
|
+
const params = new URL(args.request.url).searchParams;
|
|
5291
|
+
const clientId = params.get("client_id") ?? "";
|
|
5292
|
+
const redirectUri = params.get("redirect_uri") ?? "";
|
|
5293
|
+
const client = await resolveMcpClient(args.state, clientId);
|
|
5294
|
+
if (!client) return invalidClientPage("The client ID is not a valid client metadata document URL.");
|
|
5295
|
+
if (!redirectUriMatches(client.redirectUris, redirectUri)) return invalidClientPage("The redirect URI is not registered by this client.");
|
|
5296
|
+
const oauthState = params.get("state") ?? void 0;
|
|
5297
|
+
const fail = (error, description) => authorizationRedirect({
|
|
5298
|
+
issuer: args.urls.issuer,
|
|
5299
|
+
params: {
|
|
5300
|
+
error,
|
|
5301
|
+
error_description: description
|
|
5302
|
+
},
|
|
5303
|
+
redirectUri,
|
|
5304
|
+
state: oauthState
|
|
5305
|
+
});
|
|
5306
|
+
if (params.get("response_type") !== "code") return fail("unsupported_response_type", "Use response_type=code.");
|
|
5307
|
+
const codeChallenge = params.get("code_challenge") ?? "";
|
|
5308
|
+
if (params.get("code_challenge_method") !== "S256" || !CODE_CHALLENGE_PATTERN.test(codeChallenge)) return fail("invalid_request", "PKCE with S256 is required.");
|
|
5309
|
+
if (oauthState !== void 0 && oauthState.length > STATE_MAX_LENGTH) return fail("invalid_request", "The state parameter is too long.");
|
|
5310
|
+
const scope = params.get("scope")?.trim();
|
|
5311
|
+
if (scope && scope !== "conversations:read") return fail("invalid_scope", `The only scope is ${MCP_SCOPE}.`);
|
|
5312
|
+
if (params.get("resource") !== args.urls.resource) return fail("invalid_target", `The resource must be ${args.urls.resource}.`);
|
|
5313
|
+
const authorizationId = randomBytes(32).toString("base64url");
|
|
5314
|
+
const authorization = {
|
|
5315
|
+
clientId: client.clientId,
|
|
5316
|
+
clientName: client.name,
|
|
5317
|
+
codeChallenge,
|
|
5318
|
+
expiresAtMs: Date.now() + AUTHORIZATION_TTL_MS,
|
|
5319
|
+
redirectUri,
|
|
5320
|
+
resource: args.urls.resource,
|
|
5321
|
+
scope: MCP_SCOPE,
|
|
5322
|
+
state: oauthState
|
|
5323
|
+
};
|
|
5324
|
+
await args.state.set(authorizationKey(authorizationId), authorization, AUTHORIZATION_TTL_MS);
|
|
5325
|
+
return new Response(null, {
|
|
5326
|
+
status: 302,
|
|
5327
|
+
headers: {
|
|
5328
|
+
"cache-control": "no-store",
|
|
5329
|
+
location: args.urls.consent(authorizationId)
|
|
5330
|
+
}
|
|
5331
|
+
});
|
|
5332
|
+
}
|
|
5333
|
+
// src/api/mcp/oauth/grants.ts
|
|
5334
|
+
/**
|
|
5335
|
+
* Own MCP OAuth grants: authorization codes, access tokens, and refresh
|
|
5336
|
+
* tokens. Every secret is random, opaque, and stored only as a SHA-256 hash
|
|
5337
|
+
* in shared state with a TTL. A grant ends `GRANT_MAX_AGE_MS` after consent,
|
|
5338
|
+
* and refresh does not extend it.
|
|
5339
|
+
*/
|
|
5340
|
+
const CODE_TTL_MS = 6e4;
|
|
5341
|
+
const ACCESS_TOKEN_TTL_MS = 36e5;
|
|
5342
|
+
const GRANT_MAX_AGE_MS = 2592e6;
|
|
5343
|
+
const REFRESH_REUSE_GRACE_MS = 6e4;
|
|
5344
|
+
const ACCESS_TOKEN_PREFIX = "jr_mcp_at_";
|
|
5345
|
+
const REFRESH_TOKEN_PREFIX = "jr_mcp_rt_";
|
|
5346
|
+
const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
5347
|
+
const authorizationCodeSchema = z.object({
|
|
5348
|
+
clientId: z.string().min(1),
|
|
5349
|
+
codeChallenge: z.string().min(1),
|
|
5350
|
+
/** Verified email of the user who approved consent. */
|
|
5351
|
+
email: z.string().min(1),
|
|
5352
|
+
/** Grant that this code creates. Reuse of the code revokes it. */
|
|
5353
|
+
grantId: z.string().uuid(),
|
|
5354
|
+
redirectUri: z.string().min(1),
|
|
5355
|
+
resource: z.string().min(1),
|
|
5356
|
+
scope: z.literal(MCP_SCOPE)
|
|
5357
|
+
}).strict();
|
|
5358
|
+
const grantSchema = z.object({
|
|
5359
|
+
clientId: z.string().min(1),
|
|
5360
|
+
email: z.string().min(1),
|
|
5361
|
+
/** Absolute end of the grant. Refresh never moves it. */
|
|
5362
|
+
expiresAtMs: z.number().int().positive(),
|
|
5363
|
+
resource: z.string().min(1),
|
|
5364
|
+
scope: z.literal(MCP_SCOPE)
|
|
5365
|
+
}).strict();
|
|
5366
|
+
const tokenSchema = z.object({ grantId: z.string().uuid() }).strict();
|
|
5367
|
+
function codeKey(code) {
|
|
5368
|
+
return mcpStateKey("code", sha256Hex(code));
|
|
5369
|
+
}
|
|
5370
|
+
function grantKey(grantId) {
|
|
5371
|
+
return mcpStateKey("grant", grantId);
|
|
5372
|
+
}
|
|
5373
|
+
function revokedGrantKey(grantId) {
|
|
5374
|
+
return `${grantKey(grantId)}:revoked`;
|
|
5375
|
+
}
|
|
5376
|
+
function accessTokenKey(token) {
|
|
5377
|
+
return mcpStateKey("access", sha256Hex(token));
|
|
5378
|
+
}
|
|
5379
|
+
function refreshTokenKey(token) {
|
|
5380
|
+
return mcpStateKey("refresh", sha256Hex(token));
|
|
5381
|
+
}
|
|
5382
|
+
function invalidGrant(description) {
|
|
5383
|
+
return {
|
|
5384
|
+
description,
|
|
5385
|
+
error: "invalid_grant",
|
|
5386
|
+
ok: false
|
|
5387
|
+
};
|
|
5388
|
+
}
|
|
5389
|
+
/** Issue a single-use authorization code for one approved consent. */
|
|
5390
|
+
async function issueAuthorizationCode(args) {
|
|
5391
|
+
const code = randomBytes(32).toString("base64url");
|
|
5392
|
+
const record = {
|
|
5393
|
+
clientId: args.clientId,
|
|
5394
|
+
codeChallenge: args.codeChallenge,
|
|
5395
|
+
email: args.email,
|
|
5396
|
+
grantId: randomUUID(),
|
|
5397
|
+
redirectUri: args.redirectUri,
|
|
5398
|
+
resource: args.resource,
|
|
5399
|
+
scope: MCP_SCOPE
|
|
5400
|
+
};
|
|
5401
|
+
await args.state.set(codeKey(code), record, CODE_TTL_MS);
|
|
5402
|
+
return code;
|
|
5403
|
+
}
|
|
5404
|
+
/**
|
|
5405
|
+
* Revoke a grant after a reused code or refresh token. The marker lives for
|
|
5406
|
+
* `GRANT_MAX_AGE_MS`, and no grant or token lives longer, so a racing request
|
|
5407
|
+
* that saves the grant again cannot bring it back.
|
|
5408
|
+
*/
|
|
5409
|
+
async function revokeGrant(args) {
|
|
5410
|
+
await args.state.set(revokedGrantKey(args.grantId), true, GRANT_MAX_AGE_MS);
|
|
5411
|
+
await args.state.delete(grantKey(args.grantId));
|
|
5412
|
+
logWarn("mcp.oauth.grant.revoked", {
|
|
5413
|
+
"app.mcp.client_id": args.clientId,
|
|
5414
|
+
"app.mcp.revoke_reason": args.reason
|
|
5415
|
+
});
|
|
5416
|
+
}
|
|
5417
|
+
/** Read a grant that is not revoked and has not ended. */
|
|
5418
|
+
async function readGrant(state, grantId) {
|
|
5419
|
+
if (await state.get(revokedGrantKey(grantId)) !== null) return void 0;
|
|
5420
|
+
const grant = await readRecord(state, grantKey(grantId), grantSchema);
|
|
5421
|
+
return grant && grant.expiresAtMs > Date.now() ? grant : void 0;
|
|
5422
|
+
}
|
|
5423
|
+
/** Return true when the RFC 7636 S256 verifier matches the challenge. */
|
|
5424
|
+
function verifierMatches(verifier, challenge) {
|
|
5425
|
+
return CODE_VERIFIER_PATTERN.test(verifier) && createHash("sha256").update(verifier).digest("base64url") === challenge;
|
|
5426
|
+
}
|
|
5427
|
+
/**
|
|
5428
|
+
* Save a grant and issue a new access and refresh token pair for it. No
|
|
5429
|
+
* record outlives the grant.
|
|
5430
|
+
*/
|
|
5431
|
+
async function issueTokens(state, grantId, grant) {
|
|
5432
|
+
const grantTtlMs = grant.expiresAtMs - Date.now();
|
|
5433
|
+
if (grantTtlMs <= 0) return invalidGrant("The grant has expired.");
|
|
5434
|
+
const accessTtlMs = Math.min(ACCESS_TOKEN_TTL_MS, grantTtlMs);
|
|
5435
|
+
const accessToken = `${ACCESS_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
5436
|
+
const refreshToken = `${REFRESH_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
|
|
5437
|
+
await state.set(grantKey(grantId), grant, grantTtlMs);
|
|
5438
|
+
await state.set(accessTokenKey(accessToken), { grantId }, accessTtlMs);
|
|
5439
|
+
await state.set(refreshTokenKey(refreshToken), { grantId }, grantTtlMs);
|
|
5440
|
+
return {
|
|
5441
|
+
ok: true,
|
|
5442
|
+
tokens: {
|
|
5443
|
+
access_token: accessToken,
|
|
5444
|
+
expires_in: Math.floor(accessTtlMs / 1e3),
|
|
5445
|
+
refresh_token: refreshToken,
|
|
5446
|
+
scope: grant.scope,
|
|
5447
|
+
token_type: "Bearer"
|
|
5448
|
+
}
|
|
5449
|
+
};
|
|
5450
|
+
}
|
|
5451
|
+
/**
|
|
5452
|
+
* Exchange one authorization code for tokens. The code stays stored until it
|
|
5453
|
+
* expires. A second valid use of the code revokes the grant that the first
|
|
5454
|
+
* use created (RFC 6749 section 4.1.2).
|
|
5455
|
+
*/
|
|
5456
|
+
async function exchangeAuthorizationCode(args) {
|
|
5457
|
+
const key = codeKey(args.code);
|
|
5458
|
+
const code = await readRecord(args.state, key, authorizationCodeSchema);
|
|
5459
|
+
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.");
|
|
5460
|
+
if (!await args.state.setIfNotExists(`${key}:used`, true, CODE_TTL_MS)) {
|
|
5461
|
+
await revokeGrant({
|
|
5462
|
+
clientId: code.clientId,
|
|
5463
|
+
grantId: code.grantId,
|
|
5464
|
+
reason: "authorization_code_reused",
|
|
5465
|
+
state: args.state
|
|
5466
|
+
});
|
|
5467
|
+
return invalidGrant("The authorization code was already used.");
|
|
5468
|
+
}
|
|
5469
|
+
return await issueTokens(args.state, code.grantId, {
|
|
5470
|
+
clientId: code.clientId,
|
|
5471
|
+
email: code.email,
|
|
5472
|
+
expiresAtMs: Date.now() + GRANT_MAX_AGE_MS,
|
|
5473
|
+
resource: code.resource,
|
|
5474
|
+
scope: code.scope
|
|
5475
|
+
});
|
|
5476
|
+
}
|
|
5477
|
+
/**
|
|
5478
|
+
* Rotate a refresh token. Each refresh token works once. A second use of a
|
|
5479
|
+
* rotated token within `REFRESH_REUSE_GRACE_MS` fails. A later second use
|
|
5480
|
+
* revokes the whole grant, so a stolen token stops working for both the
|
|
5481
|
+
* thief and the client.
|
|
5482
|
+
*/
|
|
5483
|
+
async function refreshGrant(args) {
|
|
5484
|
+
if (!args.refreshToken.startsWith(REFRESH_TOKEN_PREFIX)) return invalidGrant("The refresh token is not valid.");
|
|
5485
|
+
const key = refreshTokenKey(args.refreshToken);
|
|
5486
|
+
const token = await readRecord(args.state, key, tokenSchema);
|
|
5487
|
+
const grant = token ? await readGrant(args.state, token.grantId) : void 0;
|
|
5488
|
+
if (!token || !grant || grant.clientId !== args.clientId || args.resource !== void 0 && args.resource !== grant.resource) return invalidGrant("The refresh token is not valid.");
|
|
5489
|
+
if (args.scope !== void 0 && args.scope !== grant.scope) return {
|
|
5490
|
+
description: `The only scope is ${MCP_SCOPE}.`,
|
|
5491
|
+
error: "invalid_scope",
|
|
5492
|
+
ok: false
|
|
5493
|
+
};
|
|
5494
|
+
const usedKey = `${key}:used`;
|
|
5495
|
+
if (!await args.state.setIfNotExists(usedKey, Date.now(), grant.expiresAtMs - Date.now())) {
|
|
5496
|
+
const usedAtMs = await readRecord(args.state, usedKey, z.number());
|
|
5497
|
+
if (usedAtMs !== void 0 && Date.now() - usedAtMs < REFRESH_REUSE_GRACE_MS) return invalidGrant("The refresh token was already rotated.");
|
|
5498
|
+
await revokeGrant({
|
|
5499
|
+
clientId: grant.clientId,
|
|
5500
|
+
grantId: token.grantId,
|
|
5501
|
+
reason: "refresh_token_reused",
|
|
5502
|
+
state: args.state
|
|
5503
|
+
});
|
|
5504
|
+
return invalidGrant("The refresh token was already used.");
|
|
5505
|
+
}
|
|
5506
|
+
return await issueTokens(args.state, token.grantId, grant);
|
|
5507
|
+
}
|
|
5508
|
+
/**
|
|
5509
|
+
* Return the user email of one MCP access token for one resource. Personal
|
|
5510
|
+
* tokens and other bearer values never match, because only this module
|
|
5511
|
+
* stores MCP access tokens.
|
|
5512
|
+
*/
|
|
5513
|
+
async function authenticateMcpAccessToken(args) {
|
|
5514
|
+
if (!args.token.startsWith(ACCESS_TOKEN_PREFIX)) return void 0;
|
|
5515
|
+
const token = await readRecord(args.state, accessTokenKey(args.token), tokenSchema);
|
|
5516
|
+
if (!token) return void 0;
|
|
5517
|
+
const grant = await readGrant(args.state, token.grantId);
|
|
5518
|
+
if (!grant || grant.resource !== args.resource) return void 0;
|
|
5519
|
+
return grant.email;
|
|
5520
|
+
}
|
|
5521
|
+
// src/api/mcp/oauth/consent-page.ts
|
|
5522
|
+
/**
|
|
5523
|
+
* Own the dashboard-authenticated MCP consent page.
|
|
5524
|
+
*
|
|
5525
|
+
* The dashboard verifies Google sign-in before it calls this page. A POST must
|
|
5526
|
+
* come from the same origin and carry the form token issued to the same user
|
|
5527
|
+
* on GET. Approve and deny each end the pending authorization.
|
|
5528
|
+
*/
|
|
5529
|
+
function expiredPage() {
|
|
5530
|
+
return renderMcpPage({
|
|
5531
|
+
message: "Return to your MCP client and connect again.",
|
|
5532
|
+
status: 410,
|
|
5533
|
+
title: "This sign-in request expired"
|
|
5534
|
+
});
|
|
5535
|
+
}
|
|
5536
|
+
function blockedPage() {
|
|
5537
|
+
return renderMcpPage({
|
|
5538
|
+
message: "Return to your MCP client and connect again.",
|
|
5539
|
+
status: 403,
|
|
5540
|
+
title: "Request blocked"
|
|
5541
|
+
});
|
|
5542
|
+
}
|
|
5543
|
+
function consentPage(args) {
|
|
5544
|
+
const clientHost = new URL(args.authorization.clientId).host;
|
|
5545
|
+
const clientName = args.authorization.clientName ?? clientHost;
|
|
5546
|
+
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>`;
|
|
5547
|
+
return renderMcpPage({
|
|
5548
|
+
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>
|
|
5549
|
+
<form method="post">
|
|
5550
|
+
<input type="hidden" name="csrf" value="${escapeHtml(args.csrfToken)}">
|
|
5551
|
+
${button("approve", "Allow", true)}${button("deny", "Deny", false)}
|
|
5552
|
+
</form>`,
|
|
5553
|
+
formOrigin: new URL(args.authorization.redirectUri).origin,
|
|
5554
|
+
message: `${clientName} wants to connect to ${args.agentName}. Only continue if you started this connection.`,
|
|
5555
|
+
status: 200,
|
|
5556
|
+
title: `Connect ${clientName}`
|
|
5557
|
+
});
|
|
5558
|
+
}
|
|
5559
|
+
function csrfMatches(value, expectedHash) {
|
|
5560
|
+
const actual = Buffer.from(sha256Hex(value), "hex");
|
|
5561
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
5562
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
5563
|
+
}
|
|
5564
|
+
async function readForm(request) {
|
|
5565
|
+
try {
|
|
5566
|
+
const form = await request.formData();
|
|
5567
|
+
const action = form.get("action");
|
|
5568
|
+
const csrf = form.get("csrf");
|
|
5569
|
+
return {
|
|
5570
|
+
action: typeof action === "string" ? action : void 0,
|
|
5571
|
+
csrf: typeof csrf === "string" ? csrf : void 0
|
|
5572
|
+
};
|
|
5573
|
+
} catch {
|
|
5574
|
+
return {};
|
|
5575
|
+
}
|
|
5576
|
+
}
|
|
5577
|
+
/** Show or complete MCP consent for one dashboard-authenticated user. */
|
|
5578
|
+
async function handleMcpConsentPage(args) {
|
|
5579
|
+
const authorizationId = new URL(args.request.url).pathname.split("/").at(-1) ?? "";
|
|
5580
|
+
const pending = await readMcpAuthorization(args.state, authorizationId);
|
|
5581
|
+
if (!pending) return expiredPage();
|
|
5582
|
+
const { authorization, consent } = pending;
|
|
5583
|
+
if (args.request.method === "GET") {
|
|
5584
|
+
const csrfToken = await bindMcpConsent({
|
|
5585
|
+
authorization,
|
|
5586
|
+
authorizationId,
|
|
5587
|
+
state: args.state,
|
|
5588
|
+
userId: args.user.id
|
|
5589
|
+
});
|
|
5590
|
+
if (!csrfToken) return blockedPage();
|
|
5591
|
+
return consentPage({
|
|
5592
|
+
agentName: args.agentName,
|
|
5593
|
+
authorization,
|
|
5594
|
+
csrfToken
|
|
5595
|
+
});
|
|
5596
|
+
}
|
|
5597
|
+
const form = await readForm(args.request);
|
|
5598
|
+
if (!requestHasSameOrigin(args.request) || consent?.userId !== args.user.id || !form.csrf || !csrfMatches(form.csrf, consent.csrfHash) || form.action !== "approve" && form.action !== "deny") return blockedPage();
|
|
5599
|
+
if (!await claimMcpAuthorization(args.state, authorizationId)) return expiredPage();
|
|
5600
|
+
const { issuer } = resolveMcpUrls(args.request, args.baseURL);
|
|
5601
|
+
if (form.action === "deny") return authorizationRedirect({
|
|
5602
|
+
issuer,
|
|
5603
|
+
params: {
|
|
5604
|
+
error: "access_denied",
|
|
5605
|
+
error_description: "The user denied access."
|
|
5606
|
+
},
|
|
5607
|
+
redirectUri: authorization.redirectUri,
|
|
5608
|
+
state: authorization.state
|
|
5609
|
+
});
|
|
5610
|
+
return authorizationRedirect({
|
|
5611
|
+
issuer,
|
|
5612
|
+
params: { code: await issueAuthorizationCode({
|
|
5613
|
+
clientId: authorization.clientId,
|
|
5614
|
+
codeChallenge: authorization.codeChallenge,
|
|
5615
|
+
email: args.user.email,
|
|
5616
|
+
redirectUri: authorization.redirectUri,
|
|
5617
|
+
resource: authorization.resource,
|
|
5618
|
+
state: args.state
|
|
5619
|
+
}) },
|
|
5620
|
+
redirectUri: authorization.redirectUri,
|
|
5621
|
+
state: authorization.state
|
|
5622
|
+
});
|
|
5623
|
+
}
|
|
5624
|
+
// src/api/mcp/tools/list-conversations.ts
|
|
5625
|
+
/** MCP view of one Conversation. It omits dashboard-only fields. */
|
|
5626
|
+
const mcpConversationSchema = z.object({
|
|
5627
|
+
conversationId: z.string(),
|
|
5628
|
+
title: z.string(),
|
|
5629
|
+
status: conversationReportStatusSchema,
|
|
5630
|
+
surface: conversationSurfaceSchema,
|
|
5631
|
+
channelName: z.string().optional(),
|
|
5632
|
+
locationUrl: z.string().optional(),
|
|
5633
|
+
startedAt: z.string(),
|
|
5634
|
+
lastSeenAt: z.string(),
|
|
5635
|
+
archivedAt: z.string().optional(),
|
|
5636
|
+
url: z.string().optional()
|
|
5637
|
+
}).strict();
|
|
5638
|
+
function mcpConversation(summary) {
|
|
5639
|
+
return {
|
|
5640
|
+
conversationId: summary.conversationId,
|
|
5641
|
+
title: summary.displayTitle,
|
|
5642
|
+
status: summary.status,
|
|
5643
|
+
surface: summary.surface,
|
|
5644
|
+
channelName: summary.channelNameRedacted ? void 0 : summary.channelName,
|
|
5645
|
+
locationUrl: summary.locationUrl,
|
|
5646
|
+
startedAt: summary.startedAt,
|
|
5647
|
+
lastSeenAt: summary.lastSeenAt,
|
|
5648
|
+
archivedAt: summary.archivedAt ?? void 0,
|
|
5649
|
+
url: getDashboardConversationLink(summary.conversationId)
|
|
5650
|
+
};
|
|
5651
|
+
}
|
|
5652
|
+
/** Register `listConversations`, which lists the Conversations of `user`. */
|
|
5653
|
+
function registerListConversationsTool(server, user) {
|
|
5654
|
+
const feedQuery = conversationFeedQuerySchema.shape;
|
|
5655
|
+
server.registerTool("listConversations", {
|
|
5656
|
+
title: "List conversations",
|
|
5657
|
+
description: "List up to 50 of your conversations, newest activity first. Only conversations that you started or take part in are listed.",
|
|
5658
|
+
inputSchema: {
|
|
5659
|
+
q: feedQuery.q.describe("Text to search for in titles and Briefs."),
|
|
5660
|
+
status: feedQuery.status.describe("List conversations that you have not archived (default) or that you archived. A search ignores this filter.")
|
|
5661
|
+
},
|
|
5662
|
+
outputSchema: { conversations: z.array(mcpConversationSchema) },
|
|
5663
|
+
annotations: {
|
|
5664
|
+
readOnlyHint: true,
|
|
5665
|
+
openWorldHint: false
|
|
5666
|
+
}
|
|
5667
|
+
}, async ({ q, status }) => {
|
|
5668
|
+
const result = { conversations: (await readConversationFeed({
|
|
5669
|
+
q,
|
|
5670
|
+
status,
|
|
5671
|
+
viewer: user
|
|
5672
|
+
})).conversations.map(mcpConversation) };
|
|
5673
|
+
return {
|
|
5674
|
+
content: [{
|
|
5675
|
+
type: "text",
|
|
5676
|
+
text: JSON.stringify(result)
|
|
5677
|
+
}],
|
|
5678
|
+
structuredContent: result
|
|
5679
|
+
};
|
|
5680
|
+
});
|
|
5681
|
+
}
|
|
5682
|
+
// src/api/mcp/server.ts
|
|
5683
|
+
/**
|
|
5684
|
+
* Own the MCP server behind `/api/mcp`. Each tool lives in its own file under
|
|
5685
|
+
* `tools/`.
|
|
5686
|
+
*
|
|
5687
|
+
* Each request gets a new server and a stateless transport that answers with
|
|
5688
|
+
* JSON. No MCP session or SSE stream outlives the request, so the route works
|
|
5689
|
+
* on any app instance and is not limited by the live-stream duration.
|
|
5690
|
+
*/
|
|
5691
|
+
/** Build the MCP server for one authenticated user. */
|
|
5692
|
+
function createMcpServer(user) {
|
|
5693
|
+
const server = new McpServer({
|
|
5694
|
+
name: "junior",
|
|
5695
|
+
version: JUNIOR_VERSION
|
|
5696
|
+
});
|
|
5697
|
+
registerListConversationsTool(server, user);
|
|
5698
|
+
return server;
|
|
5699
|
+
}
|
|
5700
|
+
/** Answer one Streamable HTTP POST for an authenticated user. */
|
|
5701
|
+
async function handleMcpServerRequest(request, user) {
|
|
5702
|
+
const server = createMcpServer(user);
|
|
5703
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
5704
|
+
enableJsonResponse: true,
|
|
5705
|
+
sessionIdGenerator: void 0
|
|
5706
|
+
});
|
|
5707
|
+
await server.connect(transport);
|
|
5708
|
+
try {
|
|
5709
|
+
return await transport.handleRequest(request);
|
|
5710
|
+
} finally {
|
|
5711
|
+
await server.close();
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5714
|
+
// src/api/mcp/oauth/token.ts
|
|
5715
|
+
/**
|
|
5716
|
+
* Own the OAuth token endpoint. It accepts public clients only, so a request
|
|
5717
|
+
* names its `client_id` and carries no client secret. Every response uses
|
|
5718
|
+
* `Cache-Control: no-store`.
|
|
5719
|
+
*/
|
|
5720
|
+
const TOKEN_HEADERS = {
|
|
5721
|
+
"cache-control": "no-store",
|
|
5722
|
+
pragma: "no-cache"
|
|
5723
|
+
};
|
|
5724
|
+
function tokenError(error, description, status = 400) {
|
|
5725
|
+
return Response.json({
|
|
5726
|
+
error,
|
|
5727
|
+
error_description: description
|
|
5728
|
+
}, {
|
|
5729
|
+
status,
|
|
5730
|
+
headers: TOKEN_HEADERS
|
|
5731
|
+
});
|
|
5732
|
+
}
|
|
5733
|
+
/** Limit token request bodies. A token request is a few short form fields. */
|
|
5734
|
+
const mcpTokenBodyLimit = bodyLimit({
|
|
5735
|
+
maxSize: 8192,
|
|
5736
|
+
onError: () => tokenError("invalid_request", "The request body is too large.", 413)
|
|
5737
|
+
});
|
|
5738
|
+
function tokenResponse(result) {
|
|
5739
|
+
return result.ok ? Response.json(result.tokens, { headers: TOKEN_HEADERS }) : tokenError(result.error, result.description);
|
|
5740
|
+
}
|
|
5741
|
+
/** Handle `POST /api/mcp/oauth/token` for code exchange and refresh. */
|
|
5742
|
+
async function handleMcpToken(args) {
|
|
5743
|
+
if (!(args.request.headers.get("content-type") ?? "").startsWith("application/x-www-form-urlencoded")) return tokenError("invalid_request", "Send a form-encoded body.");
|
|
5744
|
+
const form = new URLSearchParams(await args.request.text());
|
|
5745
|
+
const value = (name) => form.get(name) ?? void 0;
|
|
5746
|
+
const clientId = value("client_id");
|
|
5747
|
+
if (!clientId) return tokenError("invalid_request", "The client_id is required.");
|
|
5748
|
+
switch (value("grant_type")) {
|
|
5749
|
+
case "authorization_code": {
|
|
5750
|
+
const code = value("code");
|
|
5751
|
+
const codeVerifier = value("code_verifier");
|
|
5752
|
+
const redirectUri = value("redirect_uri");
|
|
5753
|
+
if (!code || !codeVerifier || !redirectUri) return tokenError("invalid_request", "The code, code_verifier, and redirect_uri are required.");
|
|
5754
|
+
return tokenResponse(await exchangeAuthorizationCode({
|
|
5755
|
+
clientId,
|
|
5756
|
+
code,
|
|
5757
|
+
codeVerifier,
|
|
5758
|
+
redirectUri,
|
|
5759
|
+
resource: value("resource"),
|
|
5760
|
+
state: args.state
|
|
5761
|
+
}));
|
|
5762
|
+
}
|
|
5763
|
+
case "refresh_token": {
|
|
5764
|
+
const refreshToken = value("refresh_token");
|
|
5765
|
+
if (!refreshToken) return tokenError("invalid_request", "The refresh_token is required.");
|
|
5766
|
+
return tokenResponse(await refreshGrant({
|
|
5767
|
+
clientId,
|
|
5768
|
+
refreshToken,
|
|
5769
|
+
resource: value("resource"),
|
|
5770
|
+
scope: value("scope"),
|
|
5771
|
+
state: args.state
|
|
5772
|
+
}));
|
|
5773
|
+
}
|
|
5774
|
+
default: return tokenError("unsupported_grant_type", "Use authorization_code or refresh_token.");
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
// src/api/mcp/route.ts
|
|
5778
|
+
/**
|
|
5779
|
+
* Own the remote MCP HTTP edge: discovery metadata, OAuth endpoints, and the
|
|
5780
|
+
* Streamable HTTP route at `/api/mcp`.
|
|
5781
|
+
*/
|
|
5782
|
+
/** Host paths that the app forwards to the MCP handler. */
|
|
5783
|
+
const MCP_ROUTE_PATHS = [
|
|
5784
|
+
MCP_PATH,
|
|
5785
|
+
MCP_RESOURCE_METADATA_PATH,
|
|
5786
|
+
MCP_AUTHORIZATION_SERVER_METADATA_PATH,
|
|
5787
|
+
MCP_AUTHORIZE_PATH,
|
|
5788
|
+
MCP_TOKEN_PATH
|
|
5789
|
+
];
|
|
5790
|
+
/**
|
|
5791
|
+
* Return the RFC 6750 challenge that points clients to resource metadata.
|
|
5792
|
+
* A request that sent a token also gets `invalid_token`.
|
|
5793
|
+
*/
|
|
5794
|
+
function unauthorized(urls, sentToken) {
|
|
5795
|
+
return new Response(null, {
|
|
5796
|
+
status: 401,
|
|
5797
|
+
headers: {
|
|
5798
|
+
"cache-control": "no-store",
|
|
5799
|
+
"www-authenticate": `Bearer ${sentToken ? "error=\"invalid_token\", " : ""}resource_metadata="${urls.resourceMetadata}", scope="${MCP_SCOPE}"`
|
|
5800
|
+
}
|
|
5801
|
+
});
|
|
5802
|
+
}
|
|
5803
|
+
function bearerToken(request) {
|
|
5804
|
+
return (request.headers.get("authorization")?.match(/^Bearer\s+(\S+)$/i))?.[1];
|
|
5805
|
+
}
|
|
5806
|
+
/** Create the fetch handler for every path in `MCP_ROUTE_PATHS`. */
|
|
5807
|
+
function createMcpHttpHandler(options) {
|
|
5808
|
+
const app = new Hono();
|
|
5809
|
+
const urls = (request) => resolveMcpUrls(request, options.baseURL);
|
|
5810
|
+
app.get(MCP_RESOURCE_METADATA_PATH, (c) => Response.json(protectedResourceMetadata(urls(c.req.raw))));
|
|
5811
|
+
app.get(MCP_AUTHORIZATION_SERVER_METADATA_PATH, (c) => Response.json(authorizationServerMetadata(urls(c.req.raw))));
|
|
5812
|
+
app.get(MCP_AUTHORIZE_PATH, (c) => handleMcpAuthorize({
|
|
5813
|
+
request: c.req.raw,
|
|
5814
|
+
state: options.state,
|
|
5815
|
+
urls: urls(c.req.raw)
|
|
5816
|
+
}));
|
|
5817
|
+
app.post(MCP_TOKEN_PATH, mcpTokenBodyLimit, (c) => handleMcpToken({
|
|
5818
|
+
request: c.req.raw,
|
|
5819
|
+
state: options.state
|
|
5820
|
+
}));
|
|
5821
|
+
app.on([
|
|
5822
|
+
"GET",
|
|
5823
|
+
"POST",
|
|
5824
|
+
"DELETE"
|
|
5825
|
+
], MCP_PATH, async (c) => {
|
|
5826
|
+
const requestUrls = urls(c.req.raw);
|
|
5827
|
+
const token = bearerToken(c.req.raw);
|
|
5828
|
+
const email = token ? await authenticateMcpAccessToken({
|
|
5829
|
+
resource: requestUrls.resource,
|
|
5830
|
+
state: options.state,
|
|
5831
|
+
token
|
|
5832
|
+
}) : void 0;
|
|
5833
|
+
const user = email && isAllowedSignInEmail(email, options.allowedDomains) ? await resolveViewerUser(email) : void 0;
|
|
5834
|
+
if (!user) return unauthorized(requestUrls, token !== void 0);
|
|
5835
|
+
if (c.req.method !== "POST") return new Response(null, {
|
|
5836
|
+
status: 405,
|
|
5837
|
+
headers: { allow: "POST" }
|
|
5838
|
+
});
|
|
5839
|
+
return await handleMcpServerRequest(c.req.raw, user);
|
|
5840
|
+
});
|
|
5841
|
+
return app.fetch;
|
|
5842
|
+
}
|
|
5843
|
+
// src/api/acp/authorization-page.ts
|
|
5844
|
+
const ACP_AUTHORIZATION_PATH = "/_junior/acp/auth/:transactionId";
|
|
5845
|
+
const ACP_PAGE_HEADERS = { "content-security-policy": "frame-ancestors 'none'; form-action 'self'" };
|
|
5846
|
+
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
5847
|
function renderResult(agentName, result) {
|
|
4618
5848
|
const content = {
|
|
4619
5849
|
blocked: {
|
|
@@ -4647,66 +5877,24 @@ function renderResult(agentName, result) {
|
|
|
4647
5877
|
message: "Return to your ACP client and enter the code shown there."
|
|
4648
5878
|
}
|
|
4649
5879
|
}[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>`, {
|
|
5880
|
+
return renderHtmlPage({
|
|
5881
|
+
bodyHtml: ` <p style="margin:0;color:#b8b8b8;line-height:1.5">${escapeHtml(content.message)}</p>`,
|
|
5882
|
+
headers: ACP_PAGE_HEADERS,
|
|
4666
5883
|
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
|
-
}
|
|
5884
|
+
title: content.title
|
|
4673
5885
|
});
|
|
4674
5886
|
}
|
|
4675
5887
|
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>
|
|
5888
|
+
return renderHtmlPage({
|
|
5889
|
+
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
5890
|
<form method="post">
|
|
4689
5891
|
<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
5892
|
<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
|
-
}
|
|
5893
|
+
</form>`,
|
|
5894
|
+
headers: ACP_PAGE_HEADERS,
|
|
5895
|
+
status: 200,
|
|
5896
|
+
title: `Connect ${agentName}`
|
|
5897
|
+
});
|
|
4710
5898
|
}
|
|
4711
5899
|
function transactionIdFromRequest(request) {
|
|
4712
5900
|
const transactionId = new URL(request.url).pathname.split("/").at(-1);
|
|
@@ -4805,6 +5993,7 @@ function createBetterAuth(config) {
|
|
|
4805
5993
|
clientSecret: required(config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET, "GOOGLE_CLIENT_SECRET"),
|
|
4806
5994
|
hd: hostedDomain,
|
|
4807
5995
|
prompt: "select_account",
|
|
5996
|
+
disableIdTokenSignIn: true,
|
|
4808
5997
|
async getUserInfo(token) {
|
|
4809
5998
|
if (!token.idToken) return null;
|
|
4810
5999
|
const claims = readGoogleClaims(token.idToken);
|
|
@@ -4893,6 +6082,16 @@ function createSignInAuth(config) {
|
|
|
4893
6082
|
}
|
|
4894
6083
|
// src/dashboard-routes.ts
|
|
4895
6084
|
const DASHBOARD_PACKAGE_NAME = "@sentry/junior-dashboard";
|
|
6085
|
+
/**
|
|
6086
|
+
* Return true when the dashboard requires Google sign-in. Core passes this
|
|
6087
|
+
* value to the dashboard app, so features that need a signed-in user use the
|
|
6088
|
+
* same rule as the dashboard.
|
|
6089
|
+
*/
|
|
6090
|
+
function dashboardRequiresSignIn(dashboard) {
|
|
6091
|
+
if (!dashboard || dashboard.disabled) return false;
|
|
6092
|
+
if (dashboard.authRequired !== void 0) return dashboard.authRequired;
|
|
6093
|
+
return process.env.JUNIOR_DASHBOARD_AUTH_REQUIRED?.trim() !== "false";
|
|
6094
|
+
}
|
|
4896
6095
|
/** Mount the dashboard app on the dashboard-owned host paths. */
|
|
4897
6096
|
async function createDashboardRouteRegistrations(args) {
|
|
4898
6097
|
if (!args.dashboard || args.dashboard.disabled) return [];
|
|
@@ -5050,6 +6249,7 @@ function dashboardRouteRegistrations(args) {
|
|
|
5050
6249
|
...args.dashboard,
|
|
5051
6250
|
agentName: botConfig.userName,
|
|
5052
6251
|
allowedDomains: args.allowedDomains,
|
|
6252
|
+
authRequired: dashboardRequiresSignIn(args.dashboard),
|
|
5053
6253
|
...args.auth ? { auth: args.auth } : void 0,
|
|
5054
6254
|
authenticatedRoutes: args.authenticatedRoutes,
|
|
5055
6255
|
conversationWorkQueue: args.conversationWorkQueue,
|
|
@@ -5064,14 +6264,17 @@ function dashboardRouteRegistrations(args) {
|
|
|
5064
6264
|
path
|
|
5065
6265
|
}));
|
|
5066
6266
|
}
|
|
5067
|
-
/**
|
|
6267
|
+
/**
|
|
6268
|
+
* Reject plugin routes that would shadow dashboard or auth paths, or other
|
|
6269
|
+
* core routes that need the dashboard, such as the MCP routes.
|
|
6270
|
+
*/
|
|
5068
6271
|
function validateDashboardRouteOwnership(args) {
|
|
5069
6272
|
if (!args.dashboard || args.dashboard.disabled) return;
|
|
5070
|
-
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`);
|
|
6273
|
+
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`);
|
|
5071
6274
|
}
|
|
5072
6275
|
// src/app.ts
|
|
5073
|
-
/** Resolve the public deployment URL used by ACP browser authorization. */
|
|
5074
|
-
function
|
|
6276
|
+
/** Resolve the public deployment URL used by ACP and MCP browser authorization. */
|
|
6277
|
+
function resolvePublicBaseURL(dashboard) {
|
|
5075
6278
|
const configured = dashboard?.baseURL?.trim() || process.env.JUNIOR_BASE_URL?.trim();
|
|
5076
6279
|
if (configured) return configured;
|
|
5077
6280
|
const vercelURL = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
|
|
@@ -5149,6 +6352,23 @@ function mountRoutes(app, routes) {
|
|
|
5149
6352
|
else if (explicitMethods.length > 0) app.on(explicitMethods, route.path, handler);
|
|
5150
6353
|
}
|
|
5151
6354
|
}
|
|
6355
|
+
async function startQueueWorker(consumers, backgroundWork) {
|
|
6356
|
+
const provider = getQueueProvider();
|
|
6357
|
+
if (!provider.startWorker) throw new Error(`The ${provider.messagingSystem} queue provider has no queue worker`);
|
|
6358
|
+
const worker = await provider.startWorker(consumers);
|
|
6359
|
+
return { close: async () => {
|
|
6360
|
+
try {
|
|
6361
|
+
await worker.close();
|
|
6362
|
+
} finally {
|
|
6363
|
+
while (backgroundWork.size > 0) await Promise.allSettled(backgroundWork);
|
|
6364
|
+
try {
|
|
6365
|
+
await disconnectStateAdapter();
|
|
6366
|
+
} finally {
|
|
6367
|
+
await closeDb();
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6370
|
+
} };
|
|
6371
|
+
}
|
|
5152
6372
|
/** Create a Hono app with all Junior routes. */
|
|
5153
6373
|
async function createApp(options) {
|
|
5154
6374
|
const virtualConfig = await resolveVirtualConfig();
|
|
@@ -5234,6 +6454,7 @@ async function createApp(options) {
|
|
|
5234
6454
|
sandbox: { tracePropagation },
|
|
5235
6455
|
visionContext: { attachmentStorage }
|
|
5236
6456
|
};
|
|
6457
|
+
const backgroundWork = /* @__PURE__ */ new Set();
|
|
5237
6458
|
let conversationWorkOptions;
|
|
5238
6459
|
const getConversationWorkOptions = () => {
|
|
5239
6460
|
conversationWorkOptions ??= options?.conversationWork ?? createProductionConversationWorkOptions({
|
|
@@ -5241,7 +6462,11 @@ async function createApp(options) {
|
|
|
5241
6462
|
queue: conversationWorkQueue,
|
|
5242
6463
|
...pluginTaskQueue ? { sendPluginTask: (message) => pluginTaskQueue.send(message) } : void 0,
|
|
5243
6464
|
services: runtimeServiceOverrides,
|
|
5244
|
-
waitUntil: (task) =>
|
|
6465
|
+
waitUntil: (task) => {
|
|
6466
|
+
backgroundWork.add(task);
|
|
6467
|
+
task.finally(() => backgroundWork.delete(task)).catch(() => void 0);
|
|
6468
|
+
waitUntil(task);
|
|
6469
|
+
}
|
|
5245
6470
|
});
|
|
5246
6471
|
return conversationWorkOptions;
|
|
5247
6472
|
};
|
|
@@ -5253,7 +6478,7 @@ async function createApp(options) {
|
|
|
5253
6478
|
const state = work.state ?? getStateAdapter();
|
|
5254
6479
|
acpRuntime = {
|
|
5255
6480
|
handleRequest: createAcpHttpHandler({
|
|
5256
|
-
...dashboard && !dashboard.disabled ? { browserAuth: { baseURL:
|
|
6481
|
+
...dashboard && !dashboard.disabled ? { browserAuth: { baseURL: resolvePublicBaseURL(dashboard) } } : void 0,
|
|
5257
6482
|
conversations: createAcpConversations({
|
|
5258
6483
|
conversationStore: work.conversationStore,
|
|
5259
6484
|
eventStore: getConversationEventStore(),
|
|
@@ -5271,6 +6496,19 @@ async function createApp(options) {
|
|
|
5271
6496
|
};
|
|
5272
6497
|
return acpRuntime;
|
|
5273
6498
|
};
|
|
6499
|
+
const mcpEnabled = dashboardRequiresSignIn(dashboard);
|
|
6500
|
+
const getMcpState = () => options?.conversationWork?.state ?? getStateAdapter();
|
|
6501
|
+
const mcpConsentRoute = {
|
|
6502
|
+
handler: (request, user) => handleMcpConsentPage({
|
|
6503
|
+
agentName: botConfig.userName,
|
|
6504
|
+
baseURL: resolvePublicBaseURL(dashboard),
|
|
6505
|
+
request,
|
|
6506
|
+
state: getMcpState(),
|
|
6507
|
+
user
|
|
6508
|
+
}),
|
|
6509
|
+
method: ["GET", "POST"],
|
|
6510
|
+
path: MCP_CONSENT_PATH
|
|
6511
|
+
};
|
|
5274
6512
|
const authenticatedRoutes = dashboard && !dashboard.disabled ? [{
|
|
5275
6513
|
handler: (request, user) => handleAcpAuthorizationPage({
|
|
5276
6514
|
agentName: botConfig.userName,
|
|
@@ -5280,10 +6518,11 @@ async function createApp(options) {
|
|
|
5280
6518
|
}),
|
|
5281
6519
|
method: ["GET", "POST"],
|
|
5282
6520
|
path: ACP_AUTHORIZATION_PATH
|
|
5283
|
-
}] : [];
|
|
6521
|
+
}, ...mcpEnabled ? [mcpConsentRoute] : []] : [];
|
|
5284
6522
|
try {
|
|
5285
6523
|
validateDashboardRouteOwnership({
|
|
5286
6524
|
authenticatedRoutes,
|
|
6525
|
+
coreRoutePaths: mcpEnabled ? MCP_ROUTE_PATHS : [],
|
|
5287
6526
|
dashboard,
|
|
5288
6527
|
routes: pluginRoutes
|
|
5289
6528
|
});
|
|
@@ -5313,6 +6552,21 @@ async function createApp(options) {
|
|
|
5313
6552
|
"POST",
|
|
5314
6553
|
"DELETE"
|
|
5315
6554
|
], "/api/acp", (c) => getAcpRuntime().handleRequest(c.req.raw));
|
|
6555
|
+
if (mcpEnabled) {
|
|
6556
|
+
let handleMcpRequest;
|
|
6557
|
+
app.on([
|
|
6558
|
+
"GET",
|
|
6559
|
+
"POST",
|
|
6560
|
+
"DELETE"
|
|
6561
|
+
], MCP_ROUTE_PATHS, (c) => {
|
|
6562
|
+
handleMcpRequest ??= createMcpHttpHandler({
|
|
6563
|
+
allowedDomains,
|
|
6564
|
+
baseURL: resolvePublicBaseURL(dashboard),
|
|
6565
|
+
state: getMcpState()
|
|
6566
|
+
});
|
|
6567
|
+
return handleMcpRequest(c.req.raw);
|
|
6568
|
+
});
|
|
6569
|
+
}
|
|
5316
6570
|
mountRoutes(app, pluginRoutes);
|
|
5317
6571
|
mountRoutes(app, await createDashboardRouteRegistrations({
|
|
5318
6572
|
allowedDomains,
|
|
@@ -5379,6 +6633,10 @@ async function createApp(options) {
|
|
|
5379
6633
|
app.post("/api/webhooks/:platform", (c) => {
|
|
5380
6634
|
return new Response(`Unknown platform: ${c.req.param("platform")}`, { status: 404 });
|
|
5381
6635
|
});
|
|
5382
|
-
return app
|
|
6636
|
+
return Object.assign(app, { startQueueWorker: () => startQueueWorker([
|
|
6637
|
+
conversationWorkQueueConsumer(getConversationWorkOptions()),
|
|
6638
|
+
pluginTaskQueueConsumer(),
|
|
6639
|
+
workspaceSnapshotJobQueueConsumer()
|
|
6640
|
+
], backgroundWork) });
|
|
5383
6641
|
}
|
|
5384
6642
|
export { JUNIOR_VERSION, createApp, defineJuniorPlugins };
|