@sentry/junior 0.266.0 → 0.268.0

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