@open-everywhere/provider-slack 0.4.0 → 0.5.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 (71) hide show
  1. package/agent/app-manifest.ts +48 -7
  2. package/agent/channels/slack-events.ts +12 -0
  3. package/agent/channels/slack-front.ts +201 -2
  4. package/agent/channels/slack-home.ts +511 -0
  5. package/agent/channels/slack-memory-canvas.ts +382 -0
  6. package/agent/channels/slack-offboarding.ts +89 -0
  7. package/agent/channels/slack-origin-codec.ts +8 -0
  8. package/agent/channels/slack-step-label.ts +177 -0
  9. package/agent/channels/slack.ts +29 -16
  10. package/agent/docs-generated.ts +1 -1
  11. package/agent/ops.ts +5 -0
  12. package/agent/provider-events.ts +1 -1
  13. package/agent/register.ts +9 -0
  14. package/agent/setup/page.ts +3 -1
  15. package/agent/slack-as-you.ts +107 -0
  16. package/agent/surface-parity.ts +3 -0
  17. package/dist/agent/app-manifest.d.ts +11 -0
  18. package/dist/agent/app-manifest.d.ts.map +1 -1
  19. package/dist/agent/app-manifest.js +39 -7
  20. package/dist/agent/app-manifest.js.map +1 -1
  21. package/dist/agent/channels/slack-events.d.ts +8 -0
  22. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  23. package/dist/agent/channels/slack-events.js +11 -0
  24. package/dist/agent/channels/slack-events.js.map +1 -1
  25. package/dist/agent/channels/slack-front.d.ts +52 -0
  26. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  27. package/dist/agent/channels/slack-front.js +134 -2
  28. package/dist/agent/channels/slack-front.js.map +1 -1
  29. package/dist/agent/channels/slack-home.d.ts +95 -0
  30. package/dist/agent/channels/slack-home.d.ts.map +1 -0
  31. package/dist/agent/channels/slack-home.js +445 -0
  32. package/dist/agent/channels/slack-home.js.map +1 -0
  33. package/dist/agent/channels/slack-memory-canvas.d.ts +65 -0
  34. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -0
  35. package/dist/agent/channels/slack-memory-canvas.js +325 -0
  36. package/dist/agent/channels/slack-memory-canvas.js.map +1 -0
  37. package/dist/agent/channels/slack-offboarding.d.ts +31 -0
  38. package/dist/agent/channels/slack-offboarding.d.ts.map +1 -0
  39. package/dist/agent/channels/slack-offboarding.js +75 -0
  40. package/dist/agent/channels/slack-offboarding.js.map +1 -0
  41. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  42. package/dist/agent/channels/slack-origin-codec.js +8 -0
  43. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  44. package/dist/agent/channels/slack-step-label.d.ts +17 -0
  45. package/dist/agent/channels/slack-step-label.d.ts.map +1 -0
  46. package/dist/agent/channels/slack-step-label.js +170 -0
  47. package/dist/agent/channels/slack-step-label.js.map +1 -0
  48. package/dist/agent/channels/slack.d.ts.map +1 -1
  49. package/dist/agent/channels/slack.js +29 -16
  50. package/dist/agent/channels/slack.js.map +1 -1
  51. package/dist/agent/docs-generated.js +1 -1
  52. package/dist/agent/docs-generated.js.map +1 -1
  53. package/dist/agent/ops.d.ts.map +1 -1
  54. package/dist/agent/ops.js +5 -1
  55. package/dist/agent/ops.js.map +1 -1
  56. package/dist/agent/provider-events.js +1 -1
  57. package/dist/agent/provider-events.js.map +1 -1
  58. package/dist/agent/register.d.ts.map +1 -1
  59. package/dist/agent/register.js +9 -1
  60. package/dist/agent/register.js.map +1 -1
  61. package/dist/agent/setup/page.d.ts.map +1 -1
  62. package/dist/agent/setup/page.js +3 -1
  63. package/dist/agent/setup/page.js.map +1 -1
  64. package/dist/agent/slack-as-you.d.ts +38 -0
  65. package/dist/agent/slack-as-you.d.ts.map +1 -0
  66. package/dist/agent/slack-as-you.js +82 -0
  67. package/dist/agent/slack-as-you.js.map +1 -0
  68. package/dist/agent/surface-parity.d.ts.map +1 -1
  69. package/dist/agent/surface-parity.js +3 -0
  70. package/dist/agent/surface-parity.js.map +1 -1
  71. package/package.json +27 -3
@@ -6,10 +6,12 @@
6
6
  // scope array below against the runbook's documented scopes. It used to live in the setup CLI, where
7
7
  // the ops server could not reach it at all.
8
8
  //
9
- // 🔴 THIS MODULE IMPORTS NOTHING, DELIBERATELY. The setup CLI depends on the bundle for this
10
- // document alone, so a leaf module costs it no transitive weight — not the adapter-api barrel, not
11
- // eve, not the Slack Web API client. `agent/ops.ts` does the typed registration; this file holds
12
- // only the vendor's own knowledge.
9
+ // 🔴 THIS MODULE IMPORTS NOTHING BUT ANOTHER LEAF, DELIBERATELY. The setup CLI depends on the bundle
10
+ // for this document alone, so a leaf module costs it no transitive weight — not the adapter-api
11
+ // barrel, not eve, not the Slack Web API client. `agent/ops.ts` does the typed registration; this file
12
+ // holds only the vendor's own knowledge. `./slack-as-you.ts` is a leaf too (it imports only types).
13
+
14
+ import { SLACK_MCP_BOT_SCOPE, slackAsYouUserScopes } from "./slack-as-you.js";
13
15
 
14
16
  /** The token the request URLs carry where the deployment's own host belongs. The ops plane
15
17
  * substitutes it (packages/core/agent/ops/surface-manifest.ts); the setup check leaves it for the
@@ -53,8 +55,20 @@ export interface SlackAppManifestOptions {
53
55
  /** The slash command's name without the slash — the `identity.commandName` setting. Absent ⇒
54
56
  * {@link SLACK_DEFAULT_COMMAND_NAME}. */
55
57
  readonly commandName?: string;
58
+ /**
59
+ * The app declares "search Slack as you" (`slackAsYou` in `agent/access/mcp-servers.ts`, OPE-2421). Adds
60
+ * the `mcp:connect` bot scope, the user scopes a person consents to, and the redirect URL their sign-in
61
+ * returns to. Absent ⇒ none of them: an app that does not use Slack's MCP server asks for no user scope.
62
+ * The MCP toggle itself (the app config's Agents section) has no manifest field, so it stays a click.
63
+ */
64
+ readonly personalMcp?: boolean;
56
65
  }
57
66
 
67
+ /** The path the person's OAuth sign-in returns to — core's `OE_OAUTH_CALLBACK_PATH`
68
+ * (connections/oe-oauth-callback.ts). Spelled here because this module imports nothing from core;
69
+ * `scripts/slack-as-you-test.ts` fails if the two drift. */
70
+ export const SLACK_PERSONAL_SIGN_IN_CALLBACK_PATH = "/oauth/v1/callback";
71
+
58
72
  /** The app name a manifest carries: the explicit name, else the configured name, else the default.
59
73
  * A configured name past Slack's limit is cut to fit, because Slack refuses the whole paste. */
60
74
  export function slackManifestAppName(opts: SlackAppManifestOptions = {}): string {
@@ -76,6 +90,7 @@ function shortcutName(prefix: string, appName: string, neutral: string): string
76
90
  export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): string {
77
91
  const appName = slackManifestAppName(opts);
78
92
  const commandName = opts.commandName?.trim() || SLACK_DEFAULT_COMMAND_NAME;
93
+ const personalMcp = opts.personalMcp === true;
79
94
  return JSON.stringify(
80
95
  {
81
96
  display_information: { name: appName },
@@ -83,8 +98,12 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
83
98
  // THE MESSAGES TAB (OPE-2222). With no `app_home` block, Slack creates the app with the
84
99
  // Messages tab OFF, and a person who opens a DM with the bot sees "Sending messages to this
85
100
  // app has been turned off". DMs are the primary wake (`message.im` below), so the tab is
86
- // on and writable. The product has no Home tab (`app_home_opened` is not routed).
87
- app_home: { home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false },
101
+ // on and writable.
102
+ // THE HOME TAB (OPE-2451) is on too: it is the person's private page — what the agent
103
+ // remembers about them, with Edit, Forget and Export, and what their domains know. Opening
104
+ // it delivers `app_home_opened` (below), and the front route publishes the page
105
+ // (channels/slack-home.ts).
106
+ app_home: { home_tab_enabled: true, messages_tab_enabled: true, messages_tab_read_only_enabled: false },
88
107
  bot_user: { display_name: appName, always_online: true },
89
108
  // The "/oe" ZERO-INFERENCE control command (agent/commands/core.ts). Declared here rather
90
109
  // than left to a manual setup step because one of its subcommands is `/oe stop` (OPE-418),
@@ -135,7 +154,11 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
135
154
  // line in the manifest, not just in the code that handles the callback. It is harmless on the
136
155
  // hand-paste path: an app created from this manifest and installed from the Slack console
137
156
  // never visits the URL.
138
- redirect_urls: [`https://${SLACK_DEPLOY_HOST_PLACEHOLDER}/setup/callback`],
157
+ redirect_urls: [
158
+ `https://${SLACK_DEPLOY_HOST_PLACEHOLDER}/setup/callback`,
159
+ // "SEARCH SLACK AS YOU" (OPE-2421): a person's own sign-in returns here, to core's OAuth callback.
160
+ ...(personalMcp ? [`https://${SLACK_DEPLOY_HOST_PLACEHOLDER}${SLACK_PERSONAL_SIGN_IN_CALLBACK_PATH}`] : []),
161
+ ],
139
162
  scopes: {
140
163
  bot: [
141
164
  "app_mentions:read",
@@ -184,7 +207,19 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
184
207
  // users.info — the DM-partner trust check. Without this, EVERY DM binds `external`
185
208
  // (fail-closed) on a fresh install, silently (OPE-175).
186
209
  "users:read",
210
+ // conversations.canvases.create + canvases.edit — the domain memory canvas (OPE-2451,
211
+ // channels/slack-memory-canvas.ts): a domain's internal channels show what the domain
212
+ // knows. Without it the canvas refresh fails with `missing_scope`, logged and audited
213
+ // per channel, and memory itself is unaffected.
214
+ "canvases:write",
215
+ // Slack requires this bot scope on an app whose people connect to Slack's MCP server
216
+ // (OPE-2421). Declared only when the app declares "search Slack as you".
217
+ ...(personalMcp ? [SLACK_MCP_BOT_SCOPE] : []),
187
218
  ],
219
+ // The USER scopes a person consents to for "search Slack as you" (OPE-2421). The install
220
+ // asks for none of them: `/setup` requests bot scopes only, and each person grants these for
221
+ // themselves, in the moment, through Slack's user-only consent.
222
+ ...(personalMcp ? { user: [...slackAsYouUserScopes()] } : {}),
188
223
  },
189
224
  },
190
225
  settings: {
@@ -198,6 +233,9 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
198
233
  // message.mpim → mpim:history (group DMs; without it only an @mention there is heard)
199
234
  // member_joined_channel → channels:read, groups:read, mpim:read (the channel-join intro)
200
235
  // reaction_added, reaction_removed → reactions:read (reaction feedback)
236
+ // app_home_opened → no scope (the Home tab page, OPE-2451)
237
+ // channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
238
+ // user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
201
239
  // scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
202
240
  // this list drops one (OPE-2223).
203
241
  bot_events: [
@@ -209,6 +247,9 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
209
247
  "member_joined_channel",
210
248
  "reaction_added",
211
249
  "reaction_removed",
250
+ "app_home_opened",
251
+ "channel_shared",
252
+ "user_change",
212
253
  ],
213
254
  },
214
255
  // The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
@@ -2762,6 +2762,18 @@ export async function confirmChannelInternal(channelId: string): Promise<boolean
2762
2762
  return (await channelInternalAndMeta(channelId)).internal;
2763
2763
  }
2764
2764
 
2765
+ /**
2766
+ * {@link confirmChannelInternal} read FRESH: the cached verdict is dropped first, so the answer is
2767
+ * today's `conversations.info`, and the cache is refilled with it. For the one consumer that must not
2768
+ * act on a verdict up to a TTL old — the domain memory canvas (slack-memory-canvas.ts, OPE-2451),
2769
+ * which decides whether every line of a domain's memory stays on screen in this channel. A wake keeps
2770
+ * the cached read; the canvas is written rarely enough to pay for one call.
2771
+ */
2772
+ export async function confirmChannelInternalFresh(channelId: string): Promise<boolean> {
2773
+ channelTrustCache.delete(channelId);
2774
+ return (await channelInternalAndMeta(channelId)).internal;
2775
+ }
2776
+
2765
2777
  /**
2766
2778
  * Positively confirm a channel is internal (fail-closed, same TTL-cached
2767
2779
  * conversations.info as {@link confirmChannelInternal}) AND surface its member count —
@@ -39,6 +39,8 @@
39
39
  // commands) still forwards exactly as before.
40
40
  // 4. Idempotency + per-thread FIFO guard the wake (see EventDedupe / ThreadFifo below).
41
41
 
42
+ import { parseSlackUserDeactivation, routeSlackUserDeactivation, type SlackUserDeactivation } from "./slack-offboarding.js";
43
+ import type { OffboardOutcome } from "@open-everywhere/core/agent/adapter-api.js";
42
44
  import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
43
45
  import { SLACK_SIGNATURE_HEADER, SLACK_TIMESTAMP_HEADER, slackSignature, slackSignatureMatches } from "./slack-signature.js";
44
46
  import { slackSigningSecret } from "../setup/credentials.js";
@@ -129,10 +131,24 @@ import {
129
131
  applySlackExternalPolicy,
130
132
  stripUnfencedSlackAttachments,
131
133
  slackEnterpriseIdFromEnvelope,
134
+ slackForm,
132
135
  _resetAuthorMemberCache,
133
136
  } from "./slack-events.js";
134
137
  import { buildSlackAuth, withForwardedSlackEnvelope, type ForwardedSlackEnvelope } from "./slack-identity.js";
135
138
  import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
139
+ import { liveMemoryCanvasDeps, recheckChannelCanvas } from "./slack-memory-canvas.js";
140
+ import {
141
+ handleHomeClick,
142
+ handleHomeOpened,
143
+ handleHomeSubmission,
144
+ liveSlackHomeDeps,
145
+ parseHomeClick,
146
+ parseHomeOpened,
147
+ parseHomeSubmission,
148
+ type ParsedHomeClick,
149
+ type ParsedHomeOpen,
150
+ type ParsedHomeSubmission,
151
+ } from "./slack-home.js";
136
152
  import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
137
153
  import {
138
154
  parseSlackShortcutPayload,
@@ -158,6 +174,9 @@ export type FrontDecision =
158
174
  | { readonly kind: "forward"; readonly reason: string }
159
175
  | { readonly kind: "wake"; readonly reason: string; readonly env: SlackEventEnvelope }
160
176
  | { readonly kind: "join"; readonly reason: string; readonly env: SlackEventEnvelope }
177
+ /** OPE-2423 — a `user_change` event. Ours: a `deleted` user is a deactivation report for core's
178
+ * offboarding lever (slack-offboarding.ts); every other profile edit is acked with no work. */
179
+ | { readonly kind: "user-change"; readonly reason: string; readonly env: SlackEventEnvelope }
161
180
  | { readonly kind: "reaction"; readonly reason: string; readonly env: SlackEventEnvelope }
162
181
  | { readonly kind: "approval-click"; readonly reason: string; readonly payload: unknown }
163
182
  /** A "Set API key" button click (credentials/intake.ts) — ours: verify the clicker is the intake's
@@ -191,7 +210,16 @@ export type FrontDecision =
191
210
  * a forward is a guaranteed no-op, and eve's shortcut context carries no session to wake from
192
211
  * anyway. The decoded shortcut rides along so the handler needn't re-parse it — same pattern as
193
212
  * the click branches, which carry a raw `payload` because their handlers own the decode. */
194
- | { readonly kind: "shortcut"; readonly reason: string; readonly shortcut: ParsedSlackShortcut };
213
+ | { readonly kind: "shortcut"; readonly reason: string; readonly shortcut: ParsedSlackShortcut }
214
+ /** A `channel_shared` (the channel became Slack Connect shared) — ours: re-run the memory canvas gate
215
+ * on that channel, which clears the app's canvas when the channel no longer passes (OPE-2451). */
216
+ | { readonly kind: "channel-shared"; readonly reason: string; readonly env: SlackEventEnvelope }
217
+ /** An `app_home_opened` for the HOME tab (OPE-2451) — ours: publish the person's own page. */
218
+ | { readonly kind: "home-open"; readonly reason: string; readonly open: ParsedHomeOpen }
219
+ /** An `oe_home:` click on the Home tab (OPE-2451) — ours: run a button, or open an item's modal. */
220
+ | { readonly kind: "home-click"; readonly reason: string; readonly click: ParsedHomeClick }
221
+ /** A submission of the Home tab's own modal (OPE-2451) — ours: run the action, republish the page. */
222
+ | { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission };
195
223
 
196
224
  /**
197
225
  * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
@@ -365,6 +393,17 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
365
393
  if (grantClick !== undefined) {
366
394
  return { kind: "grant-click", reason: "block_actions oe_grant: click — ours, never forwarded", payload: grantClick };
367
395
  }
396
+ // THE HOME TAB (OPE-2451). Its clicks carry an `oe_home:` action id and its one modal a callback
397
+ // id of its own, so neither can be mistaken for another door's payload, nor for eve's.
398
+ const homePayload = decodeInteractivityPayload(rawBody);
399
+ const homeClick = parseHomeClick(homePayload);
400
+ if (homeClick !== undefined) {
401
+ return { kind: "home-click", reason: `block_actions oe_home: ${homeClick.action} — ours, never forwarded`, click: homeClick };
402
+ }
403
+ const homeSubmission = parseHomeSubmission(homePayload);
404
+ if (homeSubmission !== undefined) {
405
+ return { kind: "home-submit", reason: `view_submission for the Home tab modal (${homeSubmission.action}) — ours, never forwarded`, submission: homeSubmission };
406
+ }
368
407
  // A SHORTCUT (OPE-1376) — a `message_action` or `shortcut` payload. Ours, never forwarded; see
369
408
  // the `shortcut` arm of FrontDecision for why a forward could not do anything with it. It is
370
409
  // matched LAST among the urlencoded branches and cannot race any of them: every branch above
@@ -397,6 +436,20 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
397
436
  if (eventType === "member_joined_channel") {
398
437
  return { kind: "join", reason: "member_joined_channel — channel-join intro candidate", env };
399
438
  }
439
+ if (eventType === "channel_shared") {
440
+ return { kind: "channel-shared", reason: "channel_shared — recheck the domain memory canvas", env };
441
+ }
442
+ if (eventType === "app_home_opened") {
443
+ // Only the HOME tab is ours (OPE-2451). The same event fires for the Messages tab, which has no
444
+ // page to publish, so that one forwards — eve acknowledges an event it has no handler for.
445
+ const open = parseHomeOpened(env);
446
+ if (open) return { kind: "home-open", reason: "app_home_opened (home tab) — publish the person's page", open };
447
+ return { kind: "forward", reason: "app_home_opened for a tab other than home — nothing to publish" };
448
+ }
449
+ if (eventType === "user_change") {
450
+ // OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours — automatic offboarding.
451
+ return { kind: "user-change", reason: "user_change — a deactivation report candidate", env };
452
+ }
400
453
  if (eventType === "reaction_added" || eventType === "reaction_removed") {
401
454
  // Inbound reactions: slackChannel drops them (no handler), so they're ours to ingest
402
455
  // (reaction-ingest.ts — a feedback signal on the bot's own replies + volume telemetry).
@@ -553,10 +606,21 @@ export interface FrontOptions {
553
606
  * re-verifies standing host-side, then mints an ephemeral OAuth URL / opens the api-key intake.
554
607
  * Injectable for tests. */
555
608
  readonly handleConnectClick?: (payload: unknown) => Promise<boolean>;
609
+ /** OPE-2423 — hand one deactivation report to core's offboarding lever. Defaults to
610
+ * {@link routeSlackUserDeactivation} with a live `users.info` re-read. Injectable for tests. */
611
+ readonly routeUserDeactivation?: (d: SlackUserDeactivation) => Promise<OffboardOutcome>;
556
612
  /** Injected in tests; the live default is core's `handleGrantCardClick`. The signed grant-click
557
613
  * branch schedules it through the route's `waitUntil`, so this handler may outlive the empty
558
614
  * Slack acknowledgement. */
559
615
  readonly handleGrantCardClick?: (payload: unknown) => Promise<boolean>;
616
+ /** The three Home-tab handlers (OPE-2451). Injected in tests; the live defaults are slack-home.ts's,
617
+ * over the live Slack deps. Each runs after the empty acknowledgement, through `waitUntil`. */
618
+ readonly handleHomeOpened?: (open: ParsedHomeOpen) => Promise<boolean>;
619
+ readonly handleHomeClick?: (click: ParsedHomeClick) => Promise<boolean>;
620
+ readonly handleHomeSubmission?: (submission: ParsedHomeSubmission) => Promise<boolean>;
621
+ /** The domain memory canvas recheck a trust event runs (OPE-2451): a person joined the channel, or it
622
+ * was shared. Injected in tests; the live default is slack-memory-canvas.ts's, over the live deps. */
623
+ readonly recheckChannelCanvas?: (input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => Promise<unknown>;
560
624
  /** SINGLE-WINDOW mention policy for one (channel, surface) — should the unified window own an
561
625
  * @mention here (suppress the native forward + keep the sibling as an addressed wake)?
562
626
  * Defaults to the live {@link slackSingleWindowMentionPolicy} (grain + fail-closed
@@ -991,7 +1055,13 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
991
1055
  const handleSetKeyClick = opts.handleSetKeyClick ?? liveHandleSetKeyClick;
992
1056
  const handleSetKeyViewSubmission = opts.handleSetKeyViewSubmission ?? liveHandleSetKeyViewSubmission;
993
1057
  const handleConnectClick = opts.handleConnectClick ?? liveHandleConnectClick;
1058
+ const routeUserDeactivation =
1059
+ opts.routeUserDeactivation ?? ((d: SlackUserDeactivation) => routeSlackUserDeactivation(d, { usersInfo: (userId) => slackForm("users.info", `user=${encodeURIComponent(userId)}`) }));
994
1060
  const handleGrantCardClick = opts.handleGrantCardClick ?? liveHandleGrantCardClick;
1061
+ const homeOpened = opts.handleHomeOpened ?? ((open: ParsedHomeOpen) => handleHomeOpened(open, liveSlackHomeDeps()));
1062
+ const homeClicked = opts.handleHomeClick ?? ((click: ParsedHomeClick) => handleHomeClick(click, liveSlackHomeDeps()));
1063
+ const recheckCanvas = opts.recheckChannelCanvas ?? ((input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => recheckChannelCanvas(input, liveMemoryCanvasDeps()));
1064
+ const homeSubmitted = opts.handleHomeSubmission ?? ((submission: ParsedHomeSubmission) => handleHomeSubmission(submission, liveSlackHomeDeps()));
995
1065
  const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey: string) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
996
1066
  const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
997
1067
  const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
@@ -2098,6 +2168,35 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2098
2168
  return new Response(null, { status: 200 });
2099
2169
  }
2100
2170
 
2171
+ if (decision.kind === "home-open" || decision.kind === "home-click" || decision.kind === "home-submit") {
2172
+ // THE HOME TAB (OPE-2451). THE TENANT FENCE FIRST (OPE-1680), for the reason the approval branch
2173
+ // states: the page reads and changes a person's memory, keyed to a bare per-workspace member id,
2174
+ // so an unadmitted workspace reaching it is a foreign tenant reading our store.
2175
+ const ids = decision.kind === "home-open" ? decision.open : decision.kind === "home-click" ? decision.click : decision.submission;
2176
+ const fenceIds = {
2177
+ ...(ids.teamId === undefined ? {} : { teamId: ids.teamId }),
2178
+ ...(ids.enterpriseId === undefined ? {} : { enterpriseId: ids.enterpriseId }),
2179
+ userId: ids.userId,
2180
+ };
2181
+ if (slackInteractivityTenantFenceDeclined(fenceIds, decision.kind, decision.kind === "home-open" ? "front-home-event-branch" : "front-interactivity-branch")) {
2182
+ return fencedAck(new Response(null, { status: 200 }));
2183
+ }
2184
+ // ACK FIRST, WORK AFTER. Building the page reads memory and asks Slack who the person is and
2185
+ // which channels they sit in, which can cross Slack's three-second budget. An empty 200 also
2186
+ // CLOSES a submitted modal, which is what the person expects after Save or Forget; the page
2187
+ // they return to is republished with a one-line notice saying what happened.
2188
+ const run =
2189
+ decision.kind === "home-open" ? homeOpened(decision.open) : decision.kind === "home-click" ? homeClicked(decision.click) : homeSubmitted(decision.submission);
2190
+ args.waitUntil(
2191
+ run
2192
+ .then((handled) => {
2193
+ if (!handled) console.log(`[open-everywhere:slack-front] ${decision.kind} for ${ids.userId} did not complete`);
2194
+ })
2195
+ .catch(() => console.warn(`[open-everywhere:slack-front] ${decision.kind} failed after acknowledgement`)),
2196
+ );
2197
+ return new Response(null, { status: 200 });
2198
+ }
2199
+
2101
2200
  if (decision.kind === "setkey-submit") {
2102
2201
  // THE TENANT FENCE (OPE-1680), above the handler — this is the branch that WRITES A SECRET into
2103
2202
  // this deployment's store, keyed to the intake the payload names. A `view_submission` carries
@@ -2120,6 +2219,43 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2120
2219
  return forwardNative();
2121
2220
  }
2122
2221
 
2222
+ if (decision.kind === "channel-shared") {
2223
+ // THE CHANNEL WAS SHARED (OPE-2451): re-run the memory canvas gate at once, which clears the app's
2224
+ // own canvas there. The canvas reads the room verdict fresh, so the wake path's cached "internal"
2225
+ // cannot carry a pre-share answer into this decision.
2226
+ const ev = decision.env.event as Record<string, unknown> | undefined;
2227
+ const channelId = typeof ev?.channel === "string" ? ev.channel : undefined;
2228
+ const teamId = typeof decision.env.team_id === "string" ? decision.env.team_id : undefined;
2229
+ if (!channelId || !teamId) return OK();
2230
+ const sharedEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
2231
+ if (
2232
+ slackInteractivityTenantFenceDeclined(
2233
+ { teamId, ...(sharedEnterpriseId === undefined ? {} : { enterpriseId: sharedEnterpriseId }), channelId },
2234
+ "channel-shared",
2235
+ "front-channel-shared-branch",
2236
+ )
2237
+ ) {
2238
+ return fencedAck(OK());
2239
+ }
2240
+ let sharedOrg: string | undefined;
2241
+ try {
2242
+ sharedOrg = resolvePartition({
2243
+ channel: { kind: "slack" },
2244
+ session: { auth: { current: { attributes: { team_id: teamId, channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
2245
+ } as Parameters<typeof resolvePartition>[0]).org;
2246
+ } catch {
2247
+ sharedOrg = undefined;
2248
+ }
2249
+ if (sharedOrg !== undefined) {
2250
+ args.waitUntil(
2251
+ recheckCanvas({ org: sharedOrg, teamId, channelId, trigger: "channel-shared" }).catch(() =>
2252
+ console.warn("[open-everywhere:slack-front] memory canvas recheck after a share failed"),
2253
+ ),
2254
+ );
2255
+ }
2256
+ return OK();
2257
+ }
2258
+
2123
2259
  if (decision.kind === "join") {
2124
2260
  // Channel-join intro. Only the BOT's own join is an invitation — a human joining a
2125
2261
  // channel we're in fires the same event and must be dropped. Fail-CLOSED here, the
@@ -2129,7 +2265,38 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2129
2265
  const jev = parseChannelJoinEvent(decision.env);
2130
2266
  if (!jev) return OK();
2131
2267
  const { userId: joinBotUserId } = await getBotIdentity();
2132
- if (!joinBotUserId || jev.userId !== joinBotUserId) return OK();
2268
+ if (!joinBotUserId || jev.userId !== joinBotUserId) {
2269
+ // A PERSON JOINED (OPE-2451). No intro, but the domain memory canvas re-runs its gate: the
2270
+ // newcomer may be a guest, and a canvas written before they joined would show them every line
2271
+ // of the domain's memory. The recheck can only CLEAR the app's own canvas, never write memory,
2272
+ // so an unresolved bot identity rechecks too. Fenced like every other door: a foreign tenant
2273
+ // never reaches this deployment's Slack calls.
2274
+ if (jev.teamId === undefined) return OK();
2275
+ if (
2276
+ slackInteractivityTenantFenceDeclined(
2277
+ { teamId: jev.teamId, ...(jev.enterpriseId === undefined ? {} : { enterpriseId: jev.enterpriseId }), channelId: jev.channelId, userId: jev.userId },
2278
+ "member-joined",
2279
+ "front-join-branch",
2280
+ )
2281
+ ) {
2282
+ return fencedAck(OK());
2283
+ }
2284
+ let joinOrg: string | undefined;
2285
+ try {
2286
+ joinOrg = resolvePartition(bindableForJoin(jev)).org;
2287
+ } catch {
2288
+ joinOrg = undefined;
2289
+ }
2290
+ if (joinOrg !== undefined) {
2291
+ const teamId = jev.teamId;
2292
+ args.waitUntil(
2293
+ recheckCanvas({ org: joinOrg, teamId, channelId: jev.channelId, trigger: "member-joined", joinedUserId: jev.userId }).catch(() =>
2294
+ console.warn("[open-everywhere:slack-front] memory canvas recheck after a join failed"),
2295
+ ),
2296
+ );
2297
+ }
2298
+ return OK();
2299
+ }
2133
2300
 
2134
2301
  // Same two-layer idempotency as the message wake: Slack retries a slow ack, and a
2135
2302
  // double intro is exactly the annoyance the durable half exists to prevent.
@@ -2152,6 +2319,38 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2152
2319
  return OK();
2153
2320
  }
2154
2321
 
2322
+ if (decision.kind === "user-change") {
2323
+ // AUTOMATIC OFFBOARDING (OPE-2423). The signature above has authenticated the envelope, so its
2324
+ // `team_id` is the workspace that delivered this report. Every other decision — the
2325
+ // same-workspace gate, the tenant fence, the live users.info re-read, the terminal revoke and
2326
+ // its audit row — runs in core's offboardDeactivatedAccount, AFTER the ack: Slack wants a quick
2327
+ // 200, and a revoke is idempotent, so a Slack retry that races it changes nothing.
2328
+ const deactivation = parseSlackUserDeactivation(decision.env);
2329
+ if (!deactivation) return OK();
2330
+ // THE TENANT FENCE (OPE-1680), the same front-door refusal every other deciding branch takes.
2331
+ // Core's gate 2 ALSO requires a positive admission, because this fence is inert when config
2332
+ // declares no tenant — and a terminal revoke must not run on an inert answer.
2333
+ if (
2334
+ slackInteractivityTenantFenceDeclined(
2335
+ {
2336
+ teamId: deactivation.deliveringTeamId,
2337
+ ...(deactivation.enterpriseId === undefined ? {} : { enterpriseId: deactivation.enterpriseId }),
2338
+ userId: deactivation.userId,
2339
+ },
2340
+ "user-change",
2341
+ "front-user-change-branch",
2342
+ )
2343
+ ) {
2344
+ return fencedAck(OK());
2345
+ }
2346
+ args.waitUntil(
2347
+ routeUserDeactivation(deactivation).then((outcome) =>
2348
+ console.log(`[open-everywhere:slack-front] user_change ${deactivation.userId}: offboarding ${outcome.kind}${outcome.kind === "refused" ? ` (${outcome.reason})` : ""}`),
2349
+ ),
2350
+ );
2351
+ return OK();
2352
+ }
2353
+
2155
2354
  if (decision.kind === "reaction") {
2156
2355
  // Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
2157
2356
  const rev = parseSlackReactionEvent(decision.env);