@sentry/junior 0.266.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.
Files changed (75) hide show
  1. package/dist/{actor-tlxzqaRe.js → actor-DZSokpwj.js} +8 -1
  2. package/dist/{adapter-eRFA_naf.js → adapter-BybMEezj.js} +2 -2
  3. package/dist/{admin-tILpP51p.js → admin-DOs1_EiW.js} +1 -1
  4. package/dist/{agent-runner-BtYK7bht.js → agent-runner-DgNATN4D.js} +872 -226
  5. package/dist/api/schema.d.ts +198 -23
  6. package/dist/api/schema.js +5 -4
  7. package/dist/api.d.ts +6 -1
  8. package/dist/api.js +130 -1205
  9. package/dist/{app-Csbi9dW7.d.ts → app-DAB3fzyp.d.ts} +21 -3
  10. package/dist/app.d.ts +2 -2
  11. package/dist/app.js +1610 -353
  12. package/dist/{assistant-reply-UyzAfZQo.js → assistant-reply-CT7VcXQe.js} +1 -1
  13. package/dist/{automation-BKpY9LPa.js → automation-CmeFBSEh.js} +42 -14
  14. package/dist/{briefs-DgfWMU7e.js → briefs-CWKjka7v.js} +4 -4
  15. package/dist/{catalog-runtime-DAJjAbSx.js → catalog-runtime-cMlAI5LY.js} +1 -1
  16. package/dist/cli/chat.js +15 -15
  17. package/dist/cli/check.js +3 -3
  18. package/dist/cli/init.js +0 -1
  19. package/dist/cli/main.js +3 -3
  20. package/dist/cli/plugins.js +4 -4
  21. package/dist/cli/snapshot-create.js +4 -4
  22. package/dist/cli/upgrade.d.ts +1 -1
  23. package/dist/cli/upgrade.js +3 -3
  24. package/dist/{client-B0YZfxNy.js → client-BjgbMawj.js} +2 -2
  25. package/dist/{config-C0UwYqDK.js → config-CdB6yJj5.js} +32 -10
  26. package/dist/{connections-Bwu3VORi.js → connections-RE_65jzK.js} +13 -12
  27. package/dist/{conversation-DF4rUcjb.js → conversation-DSo310Ps.js} +8 -1
  28. package/dist/{conversation-privacy-CY7JVjxn.js → conversation-privacy-BzgJC5k0.js} +1 -1
  29. package/dist/{credential-sync-VGAMUTGp.js → credential-sync-DhEGEpgq.js} +15 -15
  30. package/dist/{credentialed-enF5Bf3y.js → credentialed-CIGwoLU2.js} +2905 -1024
  31. package/dist/{db-BnRqQ7G5.d.ts → db-Bg-zC3r5.d.ts} +1 -1
  32. package/dist/{executor-TnWbvL09.js → executor-VYrx_K4w.js} +7 -4
  33. package/dist/{images-BKQqSi5W.js → images-CecjXAAz.js} +3 -3
  34. package/dist/instrumentation.js +6 -2
  35. package/dist/{job-queue-DBpHmKnd.js → job-queue-C0BZSa7r.js} +128 -3
  36. package/dist/{web-input-B-bMjD1V.js → list-1XQP-o6K.js} +1374 -71
  37. package/dist/{logging-Ce0mXZ8J.js → logging-DuZ1A4ab.js} +11 -3
  38. package/dist/{message-VZ3DFpGb.js → message-NKSbbnA6.js} +4 -4
  39. package/dist/nitro.d.ts +1 -1
  40. package/dist/nitro.js +3 -3
  41. package/dist/{oauth-callback-server-CpqWLjVY.js → oauth-callback-server-qAHLPtml.js} +2 -2
  42. package/dist/{prompt-BcCGlmg2.js → prompt-BW9jFsqZ.js} +1 -1
  43. package/dist/{registry-1BEIPdbT.js → registry-FQutO926.js} +2 -2
  44. package/dist/{resolve-BSHHkEGE.js → resolve-Bk4yeuHy.js} +3 -3
  45. package/dist/{result-CwOpzqdh.js → result-B9e9ATOU.js} +9 -9
  46. package/dist/{runner-CEaX04kz.js → runner-C3S2ObH-.js} +11 -11
  47. package/dist/{sandbox-egress-signals-BwtGHwqa.js → sandbox-egress-signals-DqpGzzMB.js} +1 -1
  48. package/dist/{schema-RsaBHxRH.js → schema-X7Fj4fv1.js} +3 -2
  49. package/dist/{service-account-Dg4lIbHm.js → service-account-BQasl-qq.js} +3 -3
  50. package/dist/{service-account-6wC76eBZ.js → service-account-Do30HUNv.js} +3 -3
  51. package/dist/{session-stop-tZOw9AJD.js → session-stop-DkOjt46_.js} +45 -128
  52. package/dist/{skills-DzyGN7wd.js → skills-CQHrwfOa.js} +2 -2
  53. package/dist/{spawn-6rokq7l_.js → spawn-BHuqN14f.js} +2 -2
  54. package/dist/{store-DcKkzrdt.js → store-6EetVP-m.js} +3 -3
  55. package/dist/{store-CG0VJyye.d.ts → store-B8CABbmR.d.ts} +10 -2
  56. package/dist/store-DvNZIsCr.js +295 -0
  57. package/dist/{task-queue-Bg4RS2Gy.js → task-queue-B0DLgd2P.js} +10 -5
  58. package/dist/{task-runner-8N5LIkSg.js → task-runner-B-PD_JOj.js} +25 -13
  59. package/dist/{tasks-DDT-J3Rf.js → tasks-0ilmuCux.js} +1438 -44
  60. package/dist/{thread-state-cprEaxgl.js → thread-state-DRmdJ2QN.js} +5 -5
  61. package/dist/{turn-failure-response-C8sMdsdX.js → turn-failure-response-G11cs2gE.js} +9 -9
  62. package/dist/{turn-lifecycle-DaBVoz7h.js → turn-lifecycle-OzLIwvi1.js} +1 -1
  63. package/dist/{turn-session-routing-a9CHX-w6.js → turn-session-routing-Dyut9l7Y.js} +1 -1
  64. package/dist/user-input-request-BDFBdF8r.js +116 -0
  65. package/dist/{validation-C2lllinq.js → validation-CxEmt1id.js} +1 -1
  66. package/dist/{version-oaLfxxnf.js → version-Bapedn4A.js} +1 -1
  67. package/dist/version.js +1 -1
  68. package/dist/{viewer-BpE6Z3qf.js → viewer-DrN3oC2X.js} +21 -10
  69. package/dist/{work-VX1SHWha.js → work-DUQoNcqP.js} +10 -10
  70. package/dist/{worker-hook-y_Y.js → worker-D1l4Ugk0.js} +30 -19
  71. package/migrations/0064_any_resource_event_triggers.sql +2 -0
  72. package/migrations/meta/0064_snapshot.json +3505 -0
  73. package/migrations/meta/_journal.json +7 -0
  74. package/package.json +20 -5
  75. package/dist/store-FALy-JQb.js +0 -1424
package/dist/app.js CHANGED
@@ -1,37 +1,38 @@
1
- import { C as automationVersionOf, Cn as getConfigDefaults, Q as projectConversationMessages, Y as appendConversationMessages, _n as isExperimentalFeatureEnabled, b as admitEventAutomationRun, c as withScheduledAutomationLock, fn as getThreadStopDecision, gn as getExperimentalFeatures, i as readScheduledAutomation, l as blockEventAutomation, ln as SubscribedReplyReason, n as listScheduledAutomationsForTeam, o as saveScheduledAutomationInLock, p as findMatchingEventAutomations, s as scheduledRunId, tt as settleConversationTitleWork, u as collectEventAutomationMatchKeys, vn as setExperimentalFeatures, wn as setConfigDefaults, x as eventAutomationRunLimitReason } from "./tasks-DDT-J3Rf.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-C0UwYqDK.js";
3
- import { C as withContext, M as JUNIOR_WORKSPACE_SNAPSHOT_JOB_CALLBACK_ROUTE, R as normalizeIdentityEmail, T as withSpan, f as logException, k as JUNIOR_PLUGIN_TASK_CALLBACK_ROUTE, m as logWarn, p as logInfo, r as createRequestContext, w as withLogContext, y as setSpanAttributes } from "./logging-Ce0mXZ8J.js";
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-oaLfxxnf.js";
6
- import { _ as slackMessageTsSchema, d as parseSlackTeamId, f as parseSlackUserId, h as slackUserIdSchema, m as slackTeamIdSchema, o as parseActorUserId, p as slackChannelIdSchema } from "./actor-tlxzqaRe.js";
7
- import { P as recordGoogleSignIn, d as getConversationStore, f as getDb, h as withConversationEventLock, l as getConversationEventStore, p as getSqlExecutor, r as readActorIdentity, v as withConversationMutationLock } from "./viewer-BpE6Z3qf.js";
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-TnWbvL09.js";
10
- import { n as pluginCatalogRuntime } from "./catalog-runtime-DAJjAbSx.js";
11
- import { C as requestAttachmentDeletion, H as recordSubagentEnded, J as processWorkspaceSnapshotJob, L as effectiveTaskOutcomes, M as logScheduledAutomationRunSkipped, N as scheduledAutomationRunAttributes, T as parseSlackThreadId, V as stopConversationSession, Y as ingestEvent, _ as unpublishedArtifactHeaders, g as readPublicArtifact, h as publicArtifactHeaders, n as executeAgentRun, t as createAgentRunner, v as lookupSlackUserProfile, x as collectAttachmentGarbage } from "./agent-runner-BtYK7bht.js";
12
- import { An as createPluginLogger, Dn as createPluginState, Ft as collectEventMatchKeys, G as isVercelSandboxEgressRequest, Gt as bindSlackDirectCredentialSubject, Ht as takeDueTimerWatches, J as consumeSandboxEgressAuthRequiredSignal, Jn as resolveRootVisibility, Jt as verifySlackDirectCredentialSubject, K as proxyVercelSandboxEgressRequest, Kt as verifyEventAutomationCredentialSubject, St as slackExternalRefIdSchema, Ut as bindEventAutomationCredentialSubject, Wt as bindScheduledAutomationCredentialSubject, Y as consumeSandboxEgressPermissionDeniedSignal, Z as parseSandboxEgressCredentialToken, Zn as listConversationAnnotations, at as formatProviderLabel, c as getPluginRoutes, cn as runWithWorkspaceTeamId, ct as startOAuthFlow, en as getSlackClient, fn as getDashboardTaskLink, h as validatePlugins, jn as readConversationAccessFromSql, m as setPlugins, n as createUserTokenStore, nn as isDmChannel, nr as createPluginCache, p as getPlugins, pn as setDashboardConversationLinkOptions, pt as slackMention, q as clearSandboxEgressSignals, qt as verifyScheduledAutomationCredentialSubject, rr as createCache, rt as normalizeSandboxEgressTracePropagationDomains, s as getPluginApiRoutes, sn as getWorkspaceTeamId, st as resolveBaseUrl, un as getDashboardConversationLink, v as setBriefsConfig, xt as slackEntitySchema, zt as findMatchingWatches } from "./credentialed-enF5Bf3y.js";
13
- import { r as getStateAdapter, s as withLock$1 } from "./adapter-eRFA_naf.js";
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 { M as annotationCard } from "./conversation-DF4rUcjb.js";
16
- import { b as listPendingAgentInvocationMailboxAppends, x as listPendingAgentInvocationResults } from "./store-DcKkzrdt.js";
17
- import { C as eventGuidance, N as renderTaskInput, d as getEventCatalog, f as SLACK_EVENT_NAMESPACE, g as slackChannelMessageRunsAutomation, h as slackChannelMessageReplyThreadTs, m as slackChannelMessageEvent } from "./session-stop-tZOw9AJD.js";
18
- import { c as setSandboxResourceConfig, o as getSandboxResourceConfig } from "./resolve-BSHHkEGE.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-DBpHmKnd.js";
20
- import { A as hasRunnableConversationWork$1, M as isInvalidConversationRecordError, O as CONVERSATION_WORK_STALE_ENQUEUE_MS, S as removeActiveConversation, _ as hasRunnableConversationWork, c as clearExpiredConversationLease, g as getConversationWorkState, h as getConversation, m as ensureConversationWake, r as appendAndEnqueueInboundMessage, v as listActiveConversationIds } from "./store-FALy-JQb.js";
21
- import { D as scheduledRunSchema } from "./automation-BKpY9LPa.js";
22
- import { n as validatePluginRegistrations, t as validatePluginEgressCredentialHooks } from "./validation-C2lllinq.js";
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 { A as THREAD_OPTOUT_ACK, C as buildSlackInboundMessage, D as normalizeIncomingSlackThreadId, E as stopSlackThread, F as renderSlackObjectCard, I as getAttachmentStorage, M as parseContent, N as runWithSlackInstallation, O as withNormalizedThreadId, P as verifySlackSignature, R as enqueueAgentDispatch, S as recordSkippedConversationMessage, T as isSlackMessageStopped, a as recordWebConversationActivity, c as createProductionSlackWebhookServices, f as createSlackWebApiStatusSender, j as startProcessingReaction, k as stopConversationTurn, l as getProductionSlackAdapter, n as conversationTurnIdForMessage, o as webActorFromEmail, s as createProductionConversationWorkOptions, t as appendAndEnqueueWebMessage, v as readViewerAutomationSummary, w as clearSlackPendingReactions, z as GET$3 } from "./web-input-B-bMjD1V.js";
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-8N5LIkSg.js";
26
- import { o as getAuthPausedConversationTurnId, t as enqueueAgentInvocationResult } from "./result-CwOpzqdh.js";
27
- import { r as enqueueAgentInvocation } from "./work-VX1SHWha.js";
28
- import { s as coerceThreadConversationState } from "./thread-state-cprEaxgl.js";
29
- import { o as unlinkProvider } from "./connections-Bwu3VORi.js";
30
- import { a as GET$4, i as publishAppHomeView, r as GET$5, t as receiveLocalOAuthCredential } from "./credential-sync-VGAMUTGp.js";
31
- import { a as registerVercelPluginTaskDevConsumer, n as createVercelPluginTaskCallback, s as queueCallback } from "./task-queue-Bg4RS2Gy.js";
32
- import { t as processConversationWork } from "./worker-hook-y_Y.js";
33
- import { t as bindMessageAgent } from "./message-VZ3DFpGb.js";
34
- import { t as bindSpawnAgent } from "./spawn-6rokq7l_.js";
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 { ChannelImpl, ThreadImpl } from "chat";
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
- // src/chat/ingress/slash-command.ts
1647
- async function postEphemeral(event, text) {
1648
- await event.channel.postEphemeral(event.userId, text, { fallbackToDM: false });
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
- function getCommandName() {
1651
- return getChatConfig().slack.slashCommand;
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 handleLink(event, provider) {
1654
- if (!pluginCatalogRuntime.isProvider(provider)) {
1655
- await postEphemeral(event, `Unknown provider: \`${provider}\``);
1656
- return;
1657
- }
1658
- if (!pluginCatalogRuntime.getOAuthConfig(provider)) {
1659
- await postEphemeral(event, `${formatProviderLabel(provider)} doesn't support account linking.`);
1660
- return;
1661
- }
1662
- const result = await startOAuthFlow(provider, {
1663
- actorId: event.userId,
1664
- actor: {
1665
- platform: "slack",
1666
- teamId: event.teamId,
1667
- userId: event.userId
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
- channelId: event.channelId
1771
+ text: parsed.text,
1772
+ threadId: parsed.threadId
1670
1773
  });
1671
- if (!result.ok) {
1672
- await postEphemeral(event, `Failed to start linking: ${result.error}`);
1673
- return;
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
- if (result.delivery === "fallback_dm") await postEphemeral(event, `Check your DMs for a ${formatProviderLabel(provider)} authorization link.`);
1676
- else if (result.delivery === false) await postEphemeral(event, "I wasn't able to send you a private authorization link. Please try again in a direct message.");
1783
+ await closeSlackUserInput({
1784
+ note: slackUserInputAnswerNote({
1785
+ answer,
1786
+ request: open.request,
1787
+ userId
1788
+ }),
1789
+ open
1790
+ });
1677
1791
  }
1678
- async function handleUnlink(event, provider) {
1679
- if (!pluginCatalogRuntime.isProvider(provider)) {
1680
- await postEphemeral(event, `Unknown provider: \`${provider}\``);
1681
- return;
1682
- }
1683
- if (!(Boolean(pluginCatalogRuntime.getOAuthConfig(provider)) || Boolean(pluginCatalogRuntime.getDefinition(provider)?.manifest.mcp))) {
1684
- await postEphemeral(event, `${formatProviderLabel(provider)} doesn't support account unlinking.`);
1685
- return;
1686
- }
1687
- const tokenStore = createUserTokenStore();
1688
- await unlinkProvider(event.userId, provider, tokenStore, event.teamId);
1689
- logInfo("slash_command.credential.unlinked", { "app.credential.provider": provider });
1690
- await postEphemeral(event, `Your ${formatProviderLabel(provider)} account has been unlinked.`);
1691
- }
1692
- /** Route link and unlink slash commands to the appropriate OAuth flow. */
1693
- async function handleSlashCommand(event) {
1694
- const [subcommand, provider, ...rest] = event.text.trim().split(/\s+/);
1695
- if (!subcommand || !["link", "unlink"].includes(subcommand)) {
1696
- await postEphemeral(event, `Usage: \`${getCommandName()} link <provider>\` or \`${getCommandName()} unlink <provider>\``);
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 (!provider || rest.length > 0) {
1700
- await postEphemeral(event, `Usage: \`${getCommandName()} ${subcommand} <provider>\``);
1701
- return;
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 normalized = provider.toLowerCase();
1704
- if (subcommand === "link") await handleLink(event, normalized);
1705
- else await handleUnlink(event, normalized);
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 slash command or interactive payload form. */
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: () => handleInteractivePayload({
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(getEventCatalog(), event.namespace, task.trigger.resourceType, event.eventType);
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
- /** Match a normalized event and dispatch every matching automation. */
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 tasks = (await findMatchingEventAutomations(db, event, options.teamId)).filter((task) => slackChannelMessageRunsAutomation(event, task));
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
- if (!(await getConversationWorkState({
2440
+ const kept = (await getConversationWorkState({
2377
2441
  conversationId,
2378
2442
  state: args.state
2379
- }))?.execution.inboundMessageIds.includes(inbound.inboundMessageId)) await receipt?.stop();
2380
- throw new SlackEventPersistenceError(error);
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
- await persistSlackMessage({
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")) return await handleSlackForm({
2673
- adapter,
2674
- body,
2675
- getUserTokenStore: () => getUserTokenStore(args.services),
2676
- state: args.services.state ?? getStateAdapter(),
2677
- waitUntil: args.waitUntil
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$1(conversation)) return true;
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 value = configured?.trim();
3927
- let origin;
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/acp/authorization-page.ts
4612
- const ACP_AUTHORIZATION_PATH = "/_junior/acp/auth/:transactionId";
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#039;");
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 new Response(`<!DOCTYPE html>
4651
- <html lang="en">
4652
- <head>
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
- headers: {
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 new Response(`<!DOCTYPE html>
4677
- <html lang="en">
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
- </section>
4693
- </main>
4694
- </body>
4695
- </html>`, { headers: {
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);
@@ -4894,6 +6082,16 @@ function createSignInAuth(config) {
4894
6082
  }
4895
6083
  // src/dashboard-routes.ts
4896
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
+ }
4897
6095
  /** Mount the dashboard app on the dashboard-owned host paths. */
4898
6096
  async function createDashboardRouteRegistrations(args) {
4899
6097
  if (!args.dashboard || args.dashboard.disabled) return [];
@@ -5051,6 +6249,7 @@ function dashboardRouteRegistrations(args) {
5051
6249
  ...args.dashboard,
5052
6250
  agentName: botConfig.userName,
5053
6251
  allowedDomains: args.allowedDomains,
6252
+ authRequired: dashboardRequiresSignIn(args.dashboard),
5054
6253
  ...args.auth ? { auth: args.auth } : void 0,
5055
6254
  authenticatedRoutes: args.authenticatedRoutes,
5056
6255
  conversationWorkQueue: args.conversationWorkQueue,
@@ -5065,14 +6264,17 @@ function dashboardRouteRegistrations(args) {
5065
6264
  path
5066
6265
  }));
5067
6266
  }
5068
- /** Reject plugin routes that would shadow dashboard or auth paths. */
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
+ */
5069
6271
  function validateDashboardRouteOwnership(args) {
5070
6272
  if (!args.dashboard || args.dashboard.disabled) return;
5071
- for (const route of args.routes) if (dashboardOwnedRoutePath(route.path, args.dashboard, args.authenticatedRoutes)) throw new Error(`Plugin "${route.pluginName}" route "${route.path}" conflicts with core dashboard routes`);
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`);
5072
6274
  }
5073
6275
  // src/app.ts
5074
- /** Resolve the public deployment URL used by ACP browser authorization. */
5075
- function resolveAcpBaseURL(dashboard) {
6276
+ /** Resolve the public deployment URL used by ACP and MCP browser authorization. */
6277
+ function resolvePublicBaseURL(dashboard) {
5076
6278
  const configured = dashboard?.baseURL?.trim() || process.env.JUNIOR_BASE_URL?.trim();
5077
6279
  if (configured) return configured;
5078
6280
  const vercelURL = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
@@ -5150,6 +6352,23 @@ function mountRoutes(app, routes) {
5150
6352
  else if (explicitMethods.length > 0) app.on(explicitMethods, route.path, handler);
5151
6353
  }
5152
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
+ }
5153
6372
  /** Create a Hono app with all Junior routes. */
5154
6373
  async function createApp(options) {
5155
6374
  const virtualConfig = await resolveVirtualConfig();
@@ -5235,6 +6454,7 @@ async function createApp(options) {
5235
6454
  sandbox: { tracePropagation },
5236
6455
  visionContext: { attachmentStorage }
5237
6456
  };
6457
+ const backgroundWork = /* @__PURE__ */ new Set();
5238
6458
  let conversationWorkOptions;
5239
6459
  const getConversationWorkOptions = () => {
5240
6460
  conversationWorkOptions ??= options?.conversationWork ?? createProductionConversationWorkOptions({
@@ -5242,7 +6462,11 @@ async function createApp(options) {
5242
6462
  queue: conversationWorkQueue,
5243
6463
  ...pluginTaskQueue ? { sendPluginTask: (message) => pluginTaskQueue.send(message) } : void 0,
5244
6464
  services: runtimeServiceOverrides,
5245
- waitUntil: (task) => waitUntil(task)
6465
+ waitUntil: (task) => {
6466
+ backgroundWork.add(task);
6467
+ task.finally(() => backgroundWork.delete(task)).catch(() => void 0);
6468
+ waitUntil(task);
6469
+ }
5246
6470
  });
5247
6471
  return conversationWorkOptions;
5248
6472
  };
@@ -5254,7 +6478,7 @@ async function createApp(options) {
5254
6478
  const state = work.state ?? getStateAdapter();
5255
6479
  acpRuntime = {
5256
6480
  handleRequest: createAcpHttpHandler({
5257
- ...dashboard && !dashboard.disabled ? { browserAuth: { baseURL: resolveAcpBaseURL(dashboard) } } : void 0,
6481
+ ...dashboard && !dashboard.disabled ? { browserAuth: { baseURL: resolvePublicBaseURL(dashboard) } } : void 0,
5258
6482
  conversations: createAcpConversations({
5259
6483
  conversationStore: work.conversationStore,
5260
6484
  eventStore: getConversationEventStore(),
@@ -5272,6 +6496,19 @@ async function createApp(options) {
5272
6496
  };
5273
6497
  return acpRuntime;
5274
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
+ };
5275
6512
  const authenticatedRoutes = dashboard && !dashboard.disabled ? [{
5276
6513
  handler: (request, user) => handleAcpAuthorizationPage({
5277
6514
  agentName: botConfig.userName,
@@ -5281,10 +6518,11 @@ async function createApp(options) {
5281
6518
  }),
5282
6519
  method: ["GET", "POST"],
5283
6520
  path: ACP_AUTHORIZATION_PATH
5284
- }] : [];
6521
+ }, ...mcpEnabled ? [mcpConsentRoute] : []] : [];
5285
6522
  try {
5286
6523
  validateDashboardRouteOwnership({
5287
6524
  authenticatedRoutes,
6525
+ coreRoutePaths: mcpEnabled ? MCP_ROUTE_PATHS : [],
5288
6526
  dashboard,
5289
6527
  routes: pluginRoutes
5290
6528
  });
@@ -5314,6 +6552,21 @@ async function createApp(options) {
5314
6552
  "POST",
5315
6553
  "DELETE"
5316
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
+ }
5317
6570
  mountRoutes(app, pluginRoutes);
5318
6571
  mountRoutes(app, await createDashboardRouteRegistrations({
5319
6572
  allowedDomains,
@@ -5380,6 +6633,10 @@ async function createApp(options) {
5380
6633
  app.post("/api/webhooks/:platform", (c) => {
5381
6634
  return new Response(`Unknown platform: ${c.req.param("platform")}`, { status: 404 });
5382
6635
  });
5383
- return app;
6636
+ return Object.assign(app, { startQueueWorker: () => startQueueWorker([
6637
+ conversationWorkQueueConsumer(getConversationWorkOptions()),
6638
+ pluginTaskQueueConsumer(),
6639
+ workspaceSnapshotJobQueueConsumer()
6640
+ ], backgroundWork) });
5384
6641
  }
5385
6642
  export { JUNIOR_VERSION, createApp, defineJuniorPlugins };