diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index da89f47fbd..24d81bc409 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -115,6 +115,12 @@ the server, because a hidden link does not protect a route. owns browser rendering. - `ctx.agent.dispatch` creates durable agent work with an explicit actor, destination, source, metadata, and idempotency identity. +- In a webhook route, `ctx.agent.dispatchForIdentity` starts private work in a + Slack DM for the person behind one Identity of the plugin's provider. Give + the provider account id that the OAuth account-linking flow stored. Core + returns `not_linked` and starts no work when it cannot find exactly one + current Slack member linked to that Identity. Handles, emails, and display + names never match. - Dispatches may include compact `replyAttribution` for destination-visible context about what produced the reply. Core owns platform rendering; opaque dispatch metadata remains internal. diff --git a/packages/junior-plugin-api/src/dispatch.ts b/packages/junior-plugin-api/src/dispatch.ts index 8c2f79d4e6..98cea5df5b 100644 --- a/packages/junior-plugin-api/src/dispatch.ts +++ b/packages/junior-plugin-api/src/dispatch.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { destinationVisibilitySchema, dispatchOptionsSchema, + identityDispatchOptionsSchema, replyAttributionSchema, taskOutcomeSchema, } from "./schemas"; @@ -10,6 +11,9 @@ export type DestinationVisibility = z.output< typeof destinationVisibilitySchema >; export type DispatchOptions = z.output; +export type IdentityDispatchOptions = z.output< + typeof identityDispatchOptionsSchema +>; /** Compact destination-visible context explaining what produced a reply. */ export type ReplyAttribution = z.output; export type TaskOutcome = z.output; @@ -19,6 +23,12 @@ export interface DispatchResult { status: "created" | "already_exists"; } +/** + * Result of an Identity dispatch. `not_linked` means core found no current + * Slack member linked to the Identity, so it started no work. + */ +export type IdentityDispatchResult = DispatchResult | { status: "not_linked" }; + export interface Dispatch { errorMessage?: string; id: string; diff --git a/packages/junior-plugin-api/src/operations.ts b/packages/junior-plugin-api/src/operations.ts index 78b24d1919..8461d05102 100644 --- a/packages/junior-plugin-api/src/operations.ts +++ b/packages/junior-plugin-api/src/operations.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import type { Identity, PluginContext, User } from "./context"; -import type { Dispatch, DispatchOptions, DispatchResult } from "./dispatch"; +import type { + Dispatch, + DispatchOptions, + DispatchResult, + IdentityDispatchOptions, + IdentityDispatchResult, +} from "./dispatch"; import { nonBlankStringSchema } from "./schemas"; import type { PluginReadState, PluginState } from "./state"; import type { EventPublisher } from "./events"; @@ -156,6 +162,17 @@ export type PluginRouteApp = { }; export interface RouteRegistrationHookContext extends PluginContext { + agent: { + /** + * Start private work in a Slack DM for the person behind one Identity of + * this plugin's provider. Core starts work only when the Identity is linked + * through the OAuth account-linking flow to exactly one current Slack + * member. Otherwise it returns `not_linked` and calls no model. + */ + dispatchForIdentity( + options: IdentityDispatchOptions, + ): Promise; + }; annotations: PluginConversationAnnotations; /** Provider-neutral write boundary for Junior's native code index. */ codeChanges: CodeChangePublisher; diff --git a/packages/junior-plugin-api/src/schemas.ts b/packages/junior-plugin-api/src/schemas.ts index 967898fa05..d64fb19dbd 100644 --- a/packages/junior-plugin-api/src/schemas.ts +++ b/packages/junior-plugin-api/src/schemas.ts @@ -364,6 +364,20 @@ export const replyAttributionSchema = z }) .strict(); +/** + * Plugin route request to start private work for the person behind one + * provider Identity. Core binds the provider to the calling plugin. + */ +export const identityDispatchOptionsSchema = z + .object({ + idempotencyKey: nonBlankStringSchema.pipe(z.string().max(512)), + input: nonBlankStringSchema.pipe(z.string().max(32_000)), + replyAttribution: replyAttributionSchema.optional(), + /** Provider account id stored by the OAuth account-linking flow. */ + subjectId: exactNonBlankStringSchema.pipe(z.string().max(255)), + }) + .strict(); + /** Plugin dispatch request accepted by Junior core. */ export const dispatchOptionsSchema = z .object({ diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 426c3b1f20..c151421630 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -107,6 +107,7 @@ import { getAttachmentStorage } from "@/chat/attachments/storage"; import { publicArtifactGET } from "@/handlers/artifacts"; import type { WaitUntilFn } from "@/handlers/types"; import { createEventAppPublisher } from "@/chat/events/app-publisher"; +import { createIdentityDispatcher } from "@/chat/agent-dispatch/context"; import { receiveLocalOAuthCredential } from "@/chat/local/credential-sync"; import { getStateAdapter } from "@/chat/state/adapter"; import { createAcpConversations } from "@/api/acp/conversations"; @@ -504,7 +505,13 @@ export async function createApp(options?: JuniorAppOptions): Promise { configuredPlugins?.registrations ?? [], ); } - pluginRoutes = getPluginRoutes({ events }); + pluginRoutes = getPluginRoutes({ + dispatchForIdentity: createIdentityDispatcher({ + allowedDomains, + conversationWorkQueue: () => conversationWorkQueue, + }), + events, + }); if (dashboard && !dashboard.disabled) { pluginApiRoutes = getPluginApiRoutes(); } diff --git a/packages/junior/src/chat/agent-dispatch/README.md b/packages/junior/src/chat/agent-dispatch/README.md index 1a52d04584..9d59d7bc24 100644 --- a/packages/junior/src/chat/agent-dispatch/README.md +++ b/packages/junior/src/chat/agent-dispatch/README.md @@ -21,6 +21,40 @@ credential subject, Source, Destination, and plugin metadata from the dispatch record. The Turn saves Actor for later Runs. The dispatch record remains the authority for the other facts until their owning contract changes. +## Identity Dispatch + +A plugin webhook route can start private work for one person with +`ctx.agent.dispatchForIdentity`. The plugin gives the provider account id of +that person. Core uses the plugin name as the provider. A plugin cannot name an +Identity of another provider. + +Core starts work only when all of these rules are true: + +1. Junior has a Slack bot, and Slack `auth.test` gives the bot team id. +2. The provider is not a core Identity namespace: `junior` or a native + provider such as `slack` or `google`. +3. One Identity has this provider, an empty tenant, this exact account id, + kind `user`, and a linked user. +4. That user has exactly one Slack Identity in the bot team. +5. Slack `users.info` shows that Slack user in the bot team. The user is not + deleted and is not a bot. +6. The email of that Slack profile is on the allowed domains, the same rule as + Slack ingress. An empty list allows every domain. + +If one rule is false, core returns `not_linked`. It starts no work and calls no +model. Missing Slack setup also returns `not_linked`, because core cannot find +a current Slack member. Other lookup and storage errors propagate to the route. + +When all rules are true, core opens the bot DM with that Slack user. It then +creates a normal plugin dispatch to the DM with `private` visibility. The +dispatch has the `private-direct-conversation` credential subject for that +Slack user. Core binds and checks this subject with the same code as heartbeat +dispatches. The idempotency key has the same scope as other dispatches of that +plugin. + +Only identities that the OAuth account-linking flow linked count. Provider +handles, emails from content, and display names never start this work. + ## Conversation Title A dispatched run has no human message. The run input is a bot-authored user @@ -54,7 +88,8 @@ holding that lock. ## Boundaries -- `context.ts` exposes the authorized core and plugin dispatch entry points. +- `context.ts` exposes the authorized core and plugin dispatch entry points, + which include Identity dispatch for plugin routes. - `store.ts` owns dispatch records, projections, and mailbox-append recovery receipts. - `work.ts` adapts dispatches to conversation work and projects durable turn @@ -66,4 +101,5 @@ holding that lock. Representative behavior coverage lives in `tests/integration/agent-dispatch-work.test.ts`; local transition and authority -contracts live in `tests/component/agent-dispatch-worker.test.ts`. +contracts live in `tests/component/agent-dispatch-worker.test.ts`. Identity +dispatch coverage lives in `tests/integration/identity-dispatch.test.ts`. diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index 65966f2b62..e78bcb6614 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -1,16 +1,27 @@ import { createPluginCache } from "@/chat/plugins/cache"; -import type { - HeartbeatHookContext, - PluginRegistration, +import { + identityDispatchOptionsSchema, + type HeartbeatHookContext, + type IdentityDispatchOptions, + type IdentityDispatchResult, + type PluginRegistration, } from "@sentry/junior-plugin-api"; import { bindEventAutomationCredentialSubject, bindScheduledAutomationCredentialSubject, bindSlackDirectCredentialSubject, } from "@/chat/credentials/subject"; -import { getDb } from "@/chat/db"; +import { getDb, getSqlExecutor } from "@/chat/db"; +import { createEventTeamIdResolver } from "@/chat/events/workspace"; +import { isNativeIdentityProvider } from "@/chat/identities/identity"; +import { findLinkedSlackUserId } from "@/chat/identities/sql"; +import { isSlackProfileDomainAllowed } from "@/chat/ingress/slack-allowed-domain"; +import { logInfo } from "@/chat/logging"; import { createPluginLogger } from "@/chat/plugins/logging"; import { createPluginState } from "@/chat/plugins/state"; +import { parseSlackUserId } from "@/chat/slack/ids"; +import { openSlackDirectMessage } from "@/chat/slack/outbound"; +import { lookupActiveSlackWorkspaceMember } from "@/chat/slack/users"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { buildDispatchId, @@ -131,6 +142,93 @@ export function createHeartbeatContext(args: { }; } +// Core owns native identities and the email-keyed `junior` web identities. +// They are not accounts from a plugin's OAuth account-linking flow, so they +// never start Identity dispatches. +function isCoreIdentityProvider(provider: string): boolean { + return provider === "junior" || isNativeIdentityProvider(provider); +} + +/** + * Build the plugin route entry point that starts private DM work for the + * person behind one provider Identity. + * + * The provider is always the calling plugin. Work starts only when the + * Identity is linked to exactly one Slack user in the bot's workspace, that + * user is a current human member, and the user's Slack email is on the allowed + * domains, as for Slack ingress. Without a Slack bot, nothing is linked. + */ +export function createIdentityDispatcher(args: { + allowedDomains: readonly string[]; + conversationWorkQueue: () => ConversationWorkQueue; +}): ( + plugin: string, + options: IdentityDispatchOptions, +) => Promise { + const resolveSlackTeamId = createEventTeamIdResolver(); + const notLinked = (plugin: string, reason: string) => { + logInfo("dispatch.identity.not_linked", { + "app.plugin.name": plugin, + "app.dispatch.identity.reason": reason, + }); + return { status: "not_linked" } as const; + }; + + return async (plugin, options) => { + const request = identityDispatchOptionsSchema.parse(options); + if (isCoreIdentityProvider(plugin)) { + return notLinked(plugin, "core_provider"); + } + const teamId = await resolveSlackTeamId(); + if (!teamId) { + return notLinked(plugin, "no_slack_bot"); + } + const slackUserId = parseSlackUserId( + await findLinkedSlackUserId(getSqlExecutor(), { + provider: plugin, + providerSubjectId: request.subjectId, + slackTeamId: teamId, + }), + ); + if (!slackUserId) { + return notLinked(plugin, "no_single_link"); + } + const member = await lookupActiveSlackWorkspaceMember(slackUserId, teamId); + if (!member) { + return notLinked(plugin, "inactive_member"); + } + if (!isSlackProfileDomainAllowed(member, args.allowedDomains)) { + return notLinked(plugin, "domain_not_allowed"); + } + + const dispatchOptions: SlackDispatchOptions = { + idempotencyKey: request.idempotencyKey, + credentialSubject: { + type: "user", + userId: slackUserId, + allowedWhen: "private-direct-conversation", + }, + destination: { + platform: "slack", + teamId, + channelId: await openSlackDirectMessage(slackUserId), + }, + destinationVisibility: "private", + input: request.input, + ...(request.replyAttribution + ? { replyAttribution: request.replyAttribution } + : undefined), + }; + validateDispatchOptions(dispatchOptions); + return await dispatch({ + conversationWorkQueue: args.conversationWorkQueue(), + nowMs: Date.now(), + options: bindDispatchCredentialSubject(dispatchOptions, plugin), + plugin, + }); + }; +} + /** Create and enqueue one core-owned event automation dispatch. */ export async function dispatchEventAutomation(args: { automationId: string; diff --git a/packages/junior/src/chat/events/workspace.ts b/packages/junior/src/chat/events/workspace.ts index f9a287ebb6..9b2cd056c0 100644 --- a/packages/junior/src/chat/events/workspace.ts +++ b/packages/junior/src/chat/events/workspace.ts @@ -2,8 +2,8 @@ import { getSlackBotToken } from "@/chat/config"; import { getSlackClient } from "@/chat/slack/client"; /** - * Resolve the single-bot Slack team id for event-automation indexing and - * Event people mentions. + * Resolve the single-bot Slack team id for event-automation indexing, Event + * people mentions, and Identity dispatches. * * Watch matching does not use this. Event automations still key by * destination team until that store is conversation-owned. Event delivery diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index b2b3ca58bb..f1203dbd36 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -57,6 +57,10 @@ Provider handles and subject IDs always remain identity-scoped. Display names are presentation data and must never be used to link identities or grant authority. +Identity dispatch lets a plugin route start private work for the person behind +a linked provider account. It matches only the exact account id from this +linking flow. See `chat/agent-dispatch/README.md`. + ## Allowed Domains `chat/sign-in/allowlist.ts` owns one list of allowed domains for Slack and the @@ -64,7 +68,8 @@ dashboard. An empty list allows every domain. - Slack ingress starts a Turn from a mention or DM only when the email of the author's Slack profile is on an allowed domain - (`chat/ingress/slack-allowed-domain.ts`). + (`chat/ingress/slack-allowed-domain.ts`). Identity dispatch applies the + same rule to the linked Slack user. - `chat/sign-in/auth.ts` owns the one Better Auth instance of the dashboard. It accepts a Google sign-in only when the verified `hd` claim is an allowed domain. After each Google callback, a Better Auth hook saves the Google diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 7e79a830d1..7d6cfc8043 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db"; import { juniorIdentities, juniorUsers } from "@/db/schema"; import { @@ -265,6 +266,46 @@ export async function findUserByVerifiedEmails( return rows.length === 1 ? rows[0] : undefined; } +const linkedSlackIdentity = alias(juniorIdentities, "linked_slack_identity"); + +/** + * Find the one Slack user in a workspace who is linked to an exact provider + * Identity. + * + * Only the provider account id from an account-linking flow matches. Handles, + * emails, and display names never match. Return undefined when no Slack user + * or more than one Slack user matches. + */ +export async function findLinkedSlackUserId( + executor: JuniorSqlDatabase, + args: { provider: string; providerSubjectId: string; slackTeamId: string }, +): Promise { + const rows = await executor + .db() + .select({ slackUserId: linkedSlackIdentity.providerSubjectId }) + .from(juniorIdentities) + // The join needs a user on both sides, so unlinked identities drop out. + .innerJoin( + linkedSlackIdentity, + and( + eq(linkedSlackIdentity.userId, juniorIdentities.userId), + eq(linkedSlackIdentity.kind, "user"), + eq(linkedSlackIdentity.provider, "slack"), + eq(linkedSlackIdentity.providerTenantId, args.slackTeamId), + ), + ) + .where( + and( + eq(juniorIdentities.kind, "user"), + eq(juniorIdentities.provider, args.provider), + eq(juniorIdentities.providerTenantId, ""), + eq(juniorIdentities.providerSubjectId, args.providerSubjectId), + ), + ) + .limit(2); + return rows.length === 1 ? rows[0]?.slackUserId : undefined; +} + /** Remove one exact provider identity owned by a workspace Slack user. */ export async function deleteProviderIdentityForSlackUser( executor: JuniorSqlDatabase, diff --git a/packages/junior/src/chat/ingress/slack-allowed-domain.ts b/packages/junior/src/chat/ingress/slack-allowed-domain.ts index 60200750a4..b41f7e88cd 100644 --- a/packages/junior/src/chat/ingress/slack-allowed-domain.ts +++ b/packages/junior/src/chat/ingress/slack-allowed-domain.ts @@ -4,7 +4,10 @@ import { logWarn } from "@/chat/logging"; import { createCache } from "@/chat/state/cache"; import { isAllowedDomain } from "@/chat/sign-in/allowlist"; import { parseSlackUserId } from "@/chat/slack/ids"; -import { lookupSlackUserProfile } from "@/chat/slack/users"; +import { + lookupSlackUserProfile, + type SlackUserProfile, +} from "@/chat/slack/users"; // Like workspace membership: avoid a lookup per message, and let a changed // profile email apply within five minutes. @@ -32,17 +35,9 @@ export async function isSlackAuthorDomainAllowed(args: { `slack:allowed-domain:${args.teamId}:${userId}`, async () => { const profile = await lookupSlackUserProfile(userId); - const email = profile.email?.toLowerCase(); - const separator = email?.lastIndexOf("@") ?? -1; - const domain = separator > 0 ? email?.slice(separator + 1) : undefined; const allowed = - profile.id === userId && isAllowedDomain(domain, args.allowedDomains); - if (!allowed) { - // A Slack app without `users:read.email` sees no email and blocks all. - logWarn("slack.allowed_domain.rejected", { - "app.reason": email ? "domain_not_allowed" : "missing_email", - }); - } + profile.id === userId && + isSlackProfileDomainAllowed(profile, args.allowedDomains); return { value: allowed, ttlMs: allowed ? ALLOWED_TTL_MS : NOT_ALLOWED_TTL_MS, @@ -50,3 +45,25 @@ export async function isSlackAuthorDomainAllowed(args: { }, ); } + +/** + * Return whether the email of a Slack profile is on the allowed domains. + * Without allowed domains, every profile is allowed. + */ +export function isSlackProfileDomainAllowed( + profile: SlackUserProfile, + allowedDomains: readonly string[], +): boolean { + if (allowedDomains.length === 0) return true; + const email = profile.email?.toLowerCase(); + const separator = email?.lastIndexOf("@") ?? -1; + const domain = separator > 0 ? email?.slice(separator + 1) : undefined; + const allowed = isAllowedDomain(domain, allowedDomains); + if (!allowed) { + // A Slack app without `users:read.email` sees no email and blocks all. + logWarn("slack.allowed_domain.rejected", { + "app.reason": email ? "domain_not_allowed" : "missing_email", + }); + } + return allowed; +} diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index b3b333e952..3f3f8bf6a1 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -9,6 +9,8 @@ import { eventInputSchema, } from "@sentry/junior-plugin-api"; import type { + IdentityDispatchOptions, + IdentityDispatchResult, InvocationContext, PluginMcp, PluginMcpContent, @@ -909,6 +911,10 @@ function requirePublishedEvent( /** Collect route handlers exposed by plugins for app-level mounting. */ export function getPluginRoutes(options: { + dispatchForIdentity( + plugin: string, + request: IdentityDispatchOptions, + ): Promise; events: { hasMatch?(event: Event): Promise; neededMatchKeys?(input: { @@ -931,6 +937,10 @@ export function getPluginRoutes(options: { } const pluginRoutes = hook({ ...basePluginContext(plugin), + agent: { + dispatchForIdentity: (dispatchOptions) => + options.dispatchForIdentity(pluginName, dispatchOptions), + }, annotations: { forConversation: (conversationId) => createPluginAnnotations({ diff --git a/packages/junior/src/chat/slack/users.ts b/packages/junior/src/chat/slack/users.ts index 8e5814bebe..1fa6dd48f5 100644 --- a/packages/junior/src/chat/slack/users.ts +++ b/packages/junior/src/chat/slack/users.ts @@ -122,6 +122,36 @@ export async function lookupSlackUserProfile( return normalizeUser(user); } +/** + * Look up one Slack user who is a current human member of a workspace. + * + * Returns undefined for a user that Slack cannot find, a deleted user, a bot, + * or a user of another workspace. Other lookup errors propagate. + */ +export async function lookupActiveSlackWorkspaceMember( + userId: SlackUserId, + teamId: string, +): Promise { + let user: SlackUserProfile; + try { + user = await lookupSlackUserProfile(userId); + } catch (error: unknown) { + if ( + error instanceof SlackActionError && + error.apiError === "user_not_found" + ) { + return undefined; + } + throw error; + } + return user.id === userId && + user.team_id === teamId && + !user.is_deleted && + !user.is_bot + ? user + : undefined; +} + /** Look up a Slack user by email. Returns null when no user matches. */ export async function lookupSlackUserByEmail( email: string, diff --git a/packages/junior/tests/integration/identity-dispatch.test.ts b/packages/junior/tests/integration/identity-dispatch.test.ts new file mode 100644 index 0000000000..5e3c3640b7 --- /dev/null +++ b/packages/junior/tests/integration/identity-dispatch.test.ts @@ -0,0 +1,214 @@ +import { + defineJuniorPlugin, + type RouteRegistrationHookContext, +} from "@sentry/junior-plugin-api"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createApp, defineJuniorPlugins } from "@/app"; +import { getDispatchRecord } from "@/chat/agent-dispatch/store"; +import { getSqlExecutor } from "@/chat/db"; +import { upsertIdentity, upsertLinkedIdentity } from "@/chat/identities/sql"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { usersInfoOk } from "../fixtures/slack/factories/api"; +import { + createConversationWorkQueueTestAdapter, + type ConversationWorkQueueTestAdapter, +} from "../fixtures/conversation-work"; +import { + getCapturedSlackApiCalls, + queueSlackApiResponse, +} from "../msw/handlers/slack-api"; + +/** Seed one Slack member and, optionally, a provider account linked by OAuth. */ +async function seedSlackUser(args: { + email: string; + linked?: { handle?: string; provider: string; subjectId: string }; + slackUserId: string; +}): Promise { + const slack = await upsertIdentity(getSqlExecutor(), { + kind: "user", + provider: "slack", + providerTenantId: "T123", + providerSubjectId: args.slackUserId, + email: args.email, + emailVerified: true, + }); + if (!args.linked) return; + await upsertLinkedIdentity(getSqlExecutor(), slack.userId!, { + kind: "user", + provider: args.linked.provider, + providerSubjectId: args.linked.subjectId, + ...(args.linked.handle ? { handle: args.linked.handle } : undefined), + }); +} + +describe("plugin route Identity dispatch", () => { + let agent: RouteRegistrationHookContext["agent"]; + let googleAgent: RouteRegistrationHookContext["agent"]; + let queue: ConversationWorkQueueTestAdapter; + + async function startApp(allowedDomains?: string[]): Promise { + await disconnectStateAdapter(); + queue = createConversationWorkQueueTestAdapter(); + await createApp({ + ...(allowedDomains ? { allowedDomains } : undefined), + conversationWorkQueue: () => queue, + plugins: defineJuniorPlugins([ + defineJuniorPlugin({ + manifest: { name: "demo", displayName: "Demo", description: "Demo" }, + hooks: { + routes(ctx) { + agent = ctx.agent; + return []; + }, + }, + }), + // A plugin that shares a native provider name. + defineJuniorPlugin({ + manifest: { + name: "google", + displayName: "Google", + description: "Google", + }, + hooks: { + routes(ctx) { + googleAgent = ctx.agent; + return []; + }, + }, + }), + ]), + }); + } + + beforeEach(async () => { + await startApp(); + }); + + it("starts one private DM dispatch for a linked current Slack member", async () => { + await seedSlackUser({ + email: "requester@example.com", + linked: { provider: "demo", subjectId: "1001" }, + slackUserId: "U123", + }); + const request = { + idempotencyKey: "delivery-1", + input: "Look at demo issue 7.", + subjectId: "1001", + }; + + const result = await agent.dispatchForIdentity(request); + + expect(result).toEqual({ id: expect.any(String), status: "created" }); + if (result.status !== "created") throw new Error("Expected a dispatch"); + expect( + getCapturedSlackApiCalls("conversations.open").map( + (call) => call.params.users, + ), + ).toEqual(["U123"]); + expect(queue.sentRecords()).toHaveLength(1); + await expect(getDispatchRecord(result.id)).resolves.toMatchObject({ + credentialSubject: { + type: "user", + userId: "U123", + allowedWhen: "private-direct-conversation", + binding: { + type: "slack-direct-conversation", + teamId: "T123", + channelId: "D0TEST", + signature: expect.any(String), + }, + }, + destination: { platform: "slack", teamId: "T123", channelId: "D0TEST" }, + destinationVisibility: "private", + input: "Look at demo issue 7.", + plugin: "demo", + source: { kind: "plugin_dispatch" }, + }); + + // A redelivered webhook reuses its idempotency key. + await expect(agent.dispatchForIdentity(request)).resolves.toEqual({ + id: result.id, + status: "already_exists", + }); + }); + + it("drops identities without exactly one current linked Slack member", async () => { + await seedSlackUser({ + email: "requester@example.com", + linked: { handle: "octocat", provider: "demo", subjectId: "1001" }, + slackUserId: "U123", + }); + // Another provider's account id does not name a demo Identity. + await seedSlackUser({ + email: "other@example.com", + linked: { provider: "github", subjectId: "2001" }, + slackUserId: "U222", + }); + await seedSlackUser({ + email: "former@example.com", + linked: { provider: "demo", subjectId: "1003" }, + slackUserId: "U456", + }); + // One person with two Slack accounts in the workspace is ambiguous. + await seedSlackUser({ email: "twice@example.com", slackUserId: "U777" }); + await seedSlackUser({ + email: "twice@example.com", + linked: { provider: "demo", subjectId: "1004" }, + slackUserId: "U778", + }); + // Core links native Google sign-in identities, not plugin OAuth. + await seedSlackUser({ + email: "signed-in@example.com", + linked: { provider: "google", subjectId: "google-sub-1" }, + slackUserId: "U333", + }); + queueSlackApiResponse("users.info", { + body: usersInfoOk({ userId: "U456", deleted: true }), + }); + + await expect( + googleAgent.dispatchForIdentity({ + idempotencyKey: "delivery-google", + input: "Look at demo issue 7.", + subjectId: "google-sub-1", + }), + ).resolves.toEqual({ status: "not_linked" }); + for (const subjectId of ["2001", "octocat", "1003", "1004"]) { + await expect( + agent.dispatchForIdentity({ + idempotencyKey: `delivery-${subjectId}`, + input: "Look at demo issue 7.", + subjectId, + }), + ).resolves.toEqual({ status: "not_linked" }); + } + + expect( + getCapturedSlackApiCalls("users.info").map((call) => call.params.user), + ).toEqual(["U456"]); + expect(getCapturedSlackApiCalls("conversations.open")).toEqual([]); + expect(queue.sentRecords()).toEqual([]); + }); + + it("drops a linked member whose Slack email is not on the allowed domains", async () => { + await startApp(["example.com"]); + await seedSlackUser({ + email: "someone@contractor.dev", + linked: { provider: "demo", subjectId: "1005" }, + slackUserId: "U555", + }); + queueSlackApiResponse("users.info", { + body: usersInfoOk({ userId: "U555", email: "someone@contractor.dev" }), + }); + + await expect( + agent.dispatchForIdentity({ + idempotencyKey: "delivery-1005", + input: "Look at demo issue 7.", + subjectId: "1005", + }), + ).resolves.toEqual({ status: "not_linked" }); + expect(getCapturedSlackApiCalls("conversations.open")).toEqual([]); + expect(queue.sentRecords()).toEqual([]); + }); +}); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index ecee58ce06..d388229ae7 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -96,6 +96,8 @@ const TEST_EGRESS = { }, }; +const dispatchForIdentity = async () => ({ status: "not_linked" as const }); + const SLACK_DESTINATION = { platform: "slack", teamId: "T123", @@ -1119,7 +1121,10 @@ describe("agent plugin hooks", () => { ]); try { const publish = vi.fn(async (_event: Event) => {}); - const routes = getPluginRoutes({ events: { publish } }); + const routes = getPluginRoutes({ + dispatchForIdentity, + events: { publish }, + }); expect(routes).toHaveLength(1); expect(routes[0]?.pluginName).toBe("agent-demo"); @@ -1175,7 +1180,10 @@ describe("agent plugin hooks", () => { ]); try { const publish = vi.fn(async () => {}); - const [route] = getPluginRoutes({ events: { publish } }); + const [route] = getPluginRoutes({ + dispatchForIdentity, + events: { publish }, + }); await expect( route!.handler(new Request("http://localhost/demo")), @@ -1239,7 +1247,10 @@ describe("agent plugin hooks", () => { ]); try { const publish = vi.fn(async () => {}); - const [route] = getPluginRoutes({ events: { publish } }); + const [route] = getPluginRoutes({ + dispatchForIdentity, + events: { publish }, + }); await expect( route!.handler(new Request("http://localhost/demo")), @@ -1274,6 +1285,7 @@ describe("agent plugin hooks", () => { try { expect(() => getPluginRoutes({ + dispatchForIdentity, events: { publish: async () => {} }, }), ).toThrow( @@ -1308,6 +1320,7 @@ describe("agent plugin hooks", () => { try { expect(() => getPluginRoutes({ + dispatchForIdentity, events: { publish: async () => {} }, }), ).toThrow( @@ -1347,6 +1360,7 @@ describe("agent plugin hooks", () => { try { expect(() => getPluginRoutes({ + dispatchForIdentity, events: { publish: async () => {} }, }), ).toThrow(