@open-everywhere/provider-slack 0.4.0 → 0.6.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 (76) hide show
  1. package/agent/app-manifest.ts +85 -8
  2. package/agent/channels/slack-events.ts +25 -0
  3. package/agent/channels/slack-front.ts +275 -2
  4. package/agent/channels/slack-home.ts +546 -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-side-panel.ts +104 -0
  9. package/agent/channels/slack-step-label.ts +177 -0
  10. package/agent/channels/slack.ts +29 -16
  11. package/agent/docs-generated.ts +1 -1
  12. package/agent/ops.ts +8 -0
  13. package/agent/provider-events.ts +1 -1
  14. package/agent/register.ts +15 -0
  15. package/agent/setup/page.ts +3 -1
  16. package/agent/slack-as-you.ts +107 -0
  17. package/agent/surface-parity.ts +3 -0
  18. package/dist/agent/app-manifest.d.ts +23 -0
  19. package/dist/agent/app-manifest.d.ts.map +1 -1
  20. package/dist/agent/app-manifest.js +64 -8
  21. package/dist/agent/app-manifest.js.map +1 -1
  22. package/dist/agent/channels/slack-events.d.ts +17 -0
  23. package/dist/agent/channels/slack-events.d.ts.map +1 -1
  24. package/dist/agent/channels/slack-events.js +16 -0
  25. package/dist/agent/channels/slack-events.js.map +1 -1
  26. package/dist/agent/channels/slack-front.d.ts +66 -1
  27. package/dist/agent/channels/slack-front.d.ts.map +1 -1
  28. package/dist/agent/channels/slack-front.js +188 -3
  29. package/dist/agent/channels/slack-front.js.map +1 -1
  30. package/dist/agent/channels/slack-home.d.ts +103 -0
  31. package/dist/agent/channels/slack-home.d.ts.map +1 -0
  32. package/dist/agent/channels/slack-home.js +473 -0
  33. package/dist/agent/channels/slack-home.js.map +1 -0
  34. package/dist/agent/channels/slack-memory-canvas.d.ts +65 -0
  35. package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -0
  36. package/dist/agent/channels/slack-memory-canvas.js +325 -0
  37. package/dist/agent/channels/slack-memory-canvas.js.map +1 -0
  38. package/dist/agent/channels/slack-offboarding.d.ts +31 -0
  39. package/dist/agent/channels/slack-offboarding.d.ts.map +1 -0
  40. package/dist/agent/channels/slack-offboarding.js +75 -0
  41. package/dist/agent/channels/slack-offboarding.js.map +1 -0
  42. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
  43. package/dist/agent/channels/slack-origin-codec.js +8 -0
  44. package/dist/agent/channels/slack-origin-codec.js.map +1 -1
  45. package/dist/agent/channels/slack-side-panel.d.ts +41 -0
  46. package/dist/agent/channels/slack-side-panel.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-side-panel.js +78 -0
  48. package/dist/agent/channels/slack-side-panel.js.map +1 -0
  49. package/dist/agent/channels/slack-step-label.d.ts +17 -0
  50. package/dist/agent/channels/slack-step-label.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-step-label.js +170 -0
  52. package/dist/agent/channels/slack-step-label.js.map +1 -0
  53. package/dist/agent/channels/slack.d.ts.map +1 -1
  54. package/dist/agent/channels/slack.js +29 -16
  55. package/dist/agent/channels/slack.js.map +1 -1
  56. package/dist/agent/docs-generated.js +1 -1
  57. package/dist/agent/docs-generated.js.map +1 -1
  58. package/dist/agent/ops.d.ts.map +1 -1
  59. package/dist/agent/ops.js +7 -1
  60. package/dist/agent/ops.js.map +1 -1
  61. package/dist/agent/provider-events.js +1 -1
  62. package/dist/agent/provider-events.js.map +1 -1
  63. package/dist/agent/register.d.ts.map +1 -1
  64. package/dist/agent/register.js +14 -1
  65. package/dist/agent/register.js.map +1 -1
  66. package/dist/agent/setup/page.d.ts.map +1 -1
  67. package/dist/agent/setup/page.js +3 -1
  68. package/dist/agent/setup/page.js.map +1 -1
  69. package/dist/agent/slack-as-you.d.ts +38 -0
  70. package/dist/agent/slack-as-you.d.ts.map +1 -0
  71. package/dist/agent/slack-as-you.js +82 -0
  72. package/dist/agent/slack-as-you.js.map +1 -0
  73. package/dist/agent/surface-parity.d.ts.map +1 -1
  74. package/dist/agent/surface-parity.js +3 -0
  75. package/dist/agent/surface-parity.js.map +1 -1
  76. package/package.json +33 -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,38 @@ 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;
65
+ /**
66
+ * The side panel's suggested prompts (OPE-2457) — the `slack.suggestedPrompts` setting, which the
67
+ * caller reads (this module imports nothing). Absent or empty ⇒ the manifest offers none. The setup
68
+ * CLI passes none: it runs before the app's settings resolve, and the post-deploy re-paste from the
69
+ * ops tool or `/setup` carries them.
70
+ */
71
+ readonly suggestedPrompts?: readonly { readonly title: string; readonly message: string }[];
72
+ }
73
+
74
+ /** Slack's limit on `features.agent_view.agent_description` (the manifest reference: 300). */
75
+ export const SLACK_AGENT_DESCRIPTION_MAX = 300;
76
+
77
+ /** The side panel's one-line description. Names the configured agent, never the brand on its own. */
78
+ function slackAgentDescription(appName: string): string {
79
+ return `Ask ${appName} beside anything you are reading in Slack. It is your own direct-message conversation, with the same rules as a DM.`.slice(
80
+ 0,
81
+ SLACK_AGENT_DESCRIPTION_MAX,
82
+ );
56
83
  }
57
84
 
85
+ /** The path the person's OAuth sign-in returns to — core's `OE_OAUTH_CALLBACK_PATH`
86
+ * (connections/oe-oauth-callback.ts). Spelled here because this module imports nothing from core;
87
+ * `scripts/slack-as-you-test.ts` fails if the two drift. */
88
+ export const SLACK_PERSONAL_SIGN_IN_CALLBACK_PATH = "/oauth/v1/callback";
89
+
58
90
  /** The app name a manifest carries: the explicit name, else the configured name, else the default.
59
91
  * A configured name past Slack's limit is cut to fit, because Slack refuses the whole paste. */
60
92
  export function slackManifestAppName(opts: SlackAppManifestOptions = {}): string {
@@ -76,6 +108,7 @@ function shortcutName(prefix: string, appName: string, neutral: string): string
76
108
  export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): string {
77
109
  const appName = slackManifestAppName(opts);
78
110
  const commandName = opts.commandName?.trim() || SLACK_DEFAULT_COMMAND_NAME;
111
+ const personalMcp = opts.personalMcp === true;
79
112
  return JSON.stringify(
80
113
  {
81
114
  display_information: { name: appName },
@@ -83,8 +116,25 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
83
116
  // THE MESSAGES TAB (OPE-2222). With no `app_home` block, Slack creates the app with the
84
117
  // Messages tab OFF, and a person who opens a DM with the bot sees "Sending messages to this
85
118
  // 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 },
119
+ // on and writable.
120
+ // THE HOME TAB (OPE-2451) is on too: it is the person's private page — what the agent
121
+ // remembers about them, with Edit, Forget and Export, and what their domains know. Opening
122
+ // it delivers `app_home_opened` (below), and the front route publishes the page
123
+ // (channels/slack-home.ts).
124
+ app_home: { home_tab_enabled: true, messages_tab_enabled: true, messages_tab_read_only_enabled: false },
125
+ // THE SIDE PANEL (OPE-2457) — Slack's agent experience. With `agent_view` on, a person can
126
+ // open the app's Messages tab beside any channel, and what they send there is an ordinary
127
+ // `message.im` to their own DM seat (channels/slack-side-panel.ts). `agent_view`, not the
128
+ // older `assistant_view`: Slack's manifest reference says a NEW app can only use `agent_view`.
129
+ // 🔴 FOR AN EXISTING APP THE SWITCH IS ONE-WAY: Slack cannot move an app from `assistant_view`
130
+ // back, and its people need a hard refresh. An app that never had `assistant_view` (every
131
+ // install of this manifest before OPE-2457) has nothing to switch from.
132
+ agent_view: {
133
+ agent_description: slackAgentDescription(appName),
134
+ ...(opts.suggestedPrompts && opts.suggestedPrompts.length > 0
135
+ ? { suggested_prompts: opts.suggestedPrompts.map((p) => ({ title: p.title, message: p.message })) }
136
+ : {}),
137
+ },
88
138
  bot_user: { display_name: appName, always_online: true },
89
139
  // The "/oe" ZERO-INFERENCE control command (agent/commands/core.ts). Declared here rather
90
140
  // than left to a manual setup step because one of its subcommands is `/oe stop` (OPE-418),
@@ -100,7 +150,7 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
100
150
  description: `${appName} control-plane commands (stop, status, budget) — no model turn.`,
101
151
  // Every subcommand `parseOeCommandTokens` accepts (packages/core/agent/commands/core.ts).
102
152
  // scripts/slack-app-manifest-test.ts fails if one is missing here.
103
- usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | help",
153
+ usage_hint: "stop | status | budget [raise <usd> | clear] | rate <n> | optout [on | off] | help",
104
154
  should_escape: false,
105
155
  },
106
156
  ],
@@ -135,7 +185,11 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
135
185
  // line in the manifest, not just in the code that handles the callback. It is harmless on the
136
186
  // hand-paste path: an app created from this manifest and installed from the Slack console
137
187
  // never visits the URL.
138
- redirect_urls: [`https://${SLACK_DEPLOY_HOST_PLACEHOLDER}/setup/callback`],
188
+ redirect_urls: [
189
+ `https://${SLACK_DEPLOY_HOST_PLACEHOLDER}/setup/callback`,
190
+ // "SEARCH SLACK AS YOU" (OPE-2421): a person's own sign-in returns here, to core's OAuth callback.
191
+ ...(personalMcp ? [`https://${SLACK_DEPLOY_HOST_PLACEHOLDER}${SLACK_PERSONAL_SIGN_IN_CALLBACK_PATH}`] : []),
192
+ ],
139
193
  scopes: {
140
194
  bot: [
141
195
  "app_mentions:read",
@@ -184,7 +238,22 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
184
238
  // users.info — the DM-partner trust check. Without this, EVERY DM binds `external`
185
239
  // (fail-closed) on a fresh install, silently (OPE-175).
186
240
  "users:read",
241
+ // conversations.canvases.create + canvases.edit — the domain memory canvas (OPE-2451,
242
+ // channels/slack-memory-canvas.ts): a domain's internal channels show what the domain
243
+ // knows. Without it the canvas refresh fails with `missing_scope`, logged and audited
244
+ // per channel, and memory itself is unaffected.
245
+ "canvases:write",
246
+ // The side panel (OPE-2457): Slack requires it for `app_context_changed`, the event whose
247
+ // subscription makes Slack attach the viewed room to each side-panel `message.im`.
248
+ "assistant:write",
249
+ // Slack requires this bot scope on an app whose people connect to Slack's MCP server
250
+ // (OPE-2421). Declared only when the app declares "search Slack as you".
251
+ ...(personalMcp ? [SLACK_MCP_BOT_SCOPE] : []),
187
252
  ],
253
+ // The USER scopes a person consents to for "search Slack as you" (OPE-2421). The install
254
+ // asks for none of them: `/setup` requests bot scopes only, and each person grants these for
255
+ // themselves, in the moment, through Slack's user-only consent.
256
+ ...(personalMcp ? { user: [...slackAsYouUserScopes()] } : {}),
188
257
  },
189
258
  },
190
259
  settings: {
@@ -198,6 +267,10 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
198
267
  // message.mpim → mpim:history (group DMs; without it only an @mention there is heard)
199
268
  // member_joined_channel → channels:read, groups:read, mpim:read (the channel-join intro)
200
269
  // reaction_added, reaction_removed → reactions:read (reaction feedback)
270
+ // app_home_opened → no scope (the Home tab page, OPE-2451)
271
+ // channel_shared → channels:read, groups:read (re-run the memory canvas gate, OPE-2451)
272
+ // user_change → users:read (automatic offboarding on a deactivated member, OPE-2423)
273
+ // app_context_changed → assistant:write (the side panel's viewed room, OPE-2457)
201
274
  // scripts/slack-app-manifest-test.ts derives the handled set from the code and fails if
202
275
  // this list drops one (OPE-2223).
203
276
  bot_events: [
@@ -209,6 +282,10 @@ export function slackAppManifestTemplate(opts: SlackAppManifestOptions = {}): st
209
282
  "member_joined_channel",
210
283
  "reaction_added",
211
284
  "reaction_removed",
285
+ "app_home_opened",
286
+ "channel_shared",
287
+ "user_change",
288
+ "app_context_changed",
212
289
  ],
213
290
  },
214
291
  // The INTERACTIVITY Request URL — the SAME public route Event Subscriptions uses. Every
@@ -17,6 +17,7 @@
17
17
  // envelope reaches `routeMessageEvent`. Until then this is unit-tested but unmounted.
18
18
 
19
19
  import { slackContinuationToken } from "eve/channels/slack";
20
+ import { parseSlackAppContext, type SlackAppContextRoom } from "./slack-side-panel.js";
20
21
  import { callSlackWebApi } from "./slack-web-api.js";
21
22
  import { slackBotToken } from "../setup/credentials.js";
22
23
  import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
@@ -265,6 +266,10 @@ export interface ParsedMessageEvent {
265
266
  * the wake envelope so the agent knows an image exists and can `describe_image` it. Empty/
266
267
  * absent when the message carries no files. Never the bytes — just id/name/mimetype/url. */
267
268
  readonly attachments?: readonly WakeAttachment[];
269
+ /** The room the person was viewing in the side panel (OPE-2457), from the event's `app_context`.
270
+ * UNVERIFIED — slack-side-panel.ts `sidePanelViewedRoom` gates it before any use. Absent on every
271
+ * event that carries no channel entity, which is every non-panel message. */
272
+ readonly appContext?: SlackAppContextRoom;
268
273
  }
269
274
 
270
275
  /** Parse Slack's `files[]` (on a message event's inner message) into attachment references.
@@ -710,6 +715,7 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
710
715
  const teamId = envelope.team_id ?? (typeof e.team === "string" ? e.team : undefined);
711
716
  const enterpriseId = slackEnterpriseIdFromEnvelope(envelope);
712
717
  const attachments = parseSlackFiles(inner.files);
718
+ const appContext = parseSlackAppContext(e);
713
719
  // TRUSTED PROVENANCE: record each IMAGE attachment's url_private under its Slack file id so
714
720
  // describe_image's `attachmentId` path (via the attachment plane's Slack resolver) can resolve the
715
721
  // model-named id → this event's url_private and fetch it with the bot token
@@ -738,6 +744,8 @@ export function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEv
738
744
  // Image/file attachments (docs Part B) — read off the same inner message the text came
739
745
  // from (so an edit's post-edit files win). Absent for the common no-file message.
740
746
  attachments,
747
+ // The side panel's viewed room (OPE-2457) rides the OUTER event, never an edit's inner message.
748
+ ...(appContext === undefined ? {} : { appContext }),
741
749
  };
742
750
  }
743
751
 
@@ -1058,6 +1066,10 @@ export interface SlackWakeOpts {
1058
1066
  * (channels/seat-catch-up.ts `catchUpUnavailableReason`). Rendered as a trusted `<system-note>`;
1059
1067
  * unset ⇒ no note, which is every Slack wake, because Slack registers a fetch. */
1060
1068
  readonly catchUpUnavailable?: string;
1069
+ /** OE's own trusted note for this wake (`<system-note>`): fixed words around host-checked ids only.
1070
+ * Set by the DM door alone — the side panel's viewed room and the person's opt-out
1071
+ * (slack-side-panel.ts `slackDmSystemNote`). Unset ⇒ no note, byte-identical to before. */
1072
+ readonly systemNote?: string;
1061
1073
  /**
1062
1074
  * THE NAME-DROP HYDRATION, ON THE SEAT ROAD (OPE-1725). The surrounding conversation
1063
1075
  * {@link routeNameDropEvent} fetches for a name-drop, as RAW {@link ContextLine}s — renderWake
@@ -1142,6 +1154,7 @@ function slackJudgedWake(
1142
1154
  ...(addressing === "name-drop" ? { nameDrop: { followUp: "follows-on-reply" as const } } : {}),
1143
1155
  // The catch-up bound, stated on the wake that could not take one ({@link SlackWakeOpts.catchUpUnavailable}).
1144
1156
  ...(opts.catchUpUnavailable !== undefined ? { catchUpUnavailable: opts.catchUpUnavailable } : {}),
1157
+ ...(opts.systemNote !== undefined ? { systemNote: opts.systemNote } : {}),
1145
1158
  // The name-drop hydration's side-channel slot ({@link SlackWakeOpts.contextLines}). Spread
1146
1159
  // conditionally so a wake that states none carries no key at all — the shape every
1147
1160
  // characterization golden was taken against.
@@ -2762,6 +2775,18 @@ export async function confirmChannelInternal(channelId: string): Promise<boolean
2762
2775
  return (await channelInternalAndMeta(channelId)).internal;
2763
2776
  }
2764
2777
 
2778
+ /**
2779
+ * {@link confirmChannelInternal} read FRESH: the cached verdict is dropped first, so the answer is
2780
+ * today's `conversations.info`, and the cache is refilled with it. For the one consumer that must not
2781
+ * act on a verdict up to a TTL old — the domain memory canvas (slack-memory-canvas.ts, OPE-2451),
2782
+ * which decides whether every line of a domain's memory stays on screen in this channel. A wake keeps
2783
+ * the cached read; the canvas is written rarely enough to pay for one call.
2784
+ */
2785
+ export async function confirmChannelInternalFresh(channelId: string): Promise<boolean> {
2786
+ channelTrustCache.delete(channelId);
2787
+ return (await channelInternalAndMeta(channelId)).internal;
2788
+ }
2789
+
2765
2790
  /**
2766
2791
  * Positively confirm a channel is internal (fail-closed, same TTL-cached
2767
2792
  * 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";
@@ -118,7 +120,13 @@ import {
118
120
  tailAuthorGrain,
119
121
  dmWindowRegistryKey,
120
122
  stampSurfaceEventVerified,
123
+ readPersonOptOut,
124
+ auditPersonOptOutRefusal,
125
+ PERSON_OPT_OUT_SHORTCUT_NOTICE,
126
+ PERSON_OPT_OUT_UNREADABLE_NOTICE,
127
+ type PersonOptOutState,
121
128
  } from "@open-everywhere/core/agent/adapter-api.js";
129
+ import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
122
130
  import {
123
131
  renderWakeMessage,
124
132
  slackChannelFactsFor,
@@ -129,10 +137,24 @@ import {
129
137
  applySlackExternalPolicy,
130
138
  stripUnfencedSlackAttachments,
131
139
  slackEnterpriseIdFromEnvelope,
140
+ slackForm,
132
141
  _resetAuthorMemberCache,
133
142
  } from "./slack-events.js";
134
143
  import { buildSlackAuth, withForwardedSlackEnvelope, type ForwardedSlackEnvelope } from "./slack-identity.js";
135
144
  import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
145
+ import { liveMemoryCanvasDeps, recheckChannelCanvas } from "./slack-memory-canvas.js";
146
+ import {
147
+ handleHomeClick,
148
+ handleHomeOpened,
149
+ handleHomeSubmission,
150
+ liveSlackHomeDeps,
151
+ parseHomeClick,
152
+ parseHomeOpened,
153
+ parseHomeSubmission,
154
+ type ParsedHomeClick,
155
+ type ParsedHomeOpen,
156
+ type ParsedHomeSubmission,
157
+ } from "./slack-home.js";
136
158
  import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
137
159
  import {
138
160
  parseSlackShortcutPayload,
@@ -158,6 +180,9 @@ export type FrontDecision =
158
180
  | { readonly kind: "forward"; readonly reason: string }
159
181
  | { readonly kind: "wake"; readonly reason: string; readonly env: SlackEventEnvelope }
160
182
  | { readonly kind: "join"; readonly reason: string; readonly env: SlackEventEnvelope }
183
+ /** OPE-2423 — a `user_change` event. Ours: a `deleted` user is a deactivation report for core's
184
+ * offboarding lever (slack-offboarding.ts); every other profile edit is acked with no work. */
185
+ | { readonly kind: "user-change"; readonly reason: string; readonly env: SlackEventEnvelope }
161
186
  | { readonly kind: "reaction"; readonly reason: string; readonly env: SlackEventEnvelope }
162
187
  | { readonly kind: "approval-click"; readonly reason: string; readonly payload: unknown }
163
188
  /** A "Set API key" button click (credentials/intake.ts) — ours: verify the clicker is the intake's
@@ -191,7 +216,19 @@ export type FrontDecision =
191
216
  * a forward is a guaranteed no-op, and eve's shortcut context carries no session to wake from
192
217
  * anyway. The decoded shortcut rides along so the handler needn't re-parse it — same pattern as
193
218
  * the click branches, which carry a raw `payload` because their handlers own the decode. */
194
- | { readonly kind: "shortcut"; readonly reason: string; readonly shortcut: ParsedSlackShortcut };
219
+ | { readonly kind: "shortcut"; readonly reason: string; readonly shortcut: ParsedSlackShortcut }
220
+ /** A `channel_shared` (the channel became Slack Connect shared) — ours: re-run the memory canvas gate
221
+ * on that channel, which clears the app's canvas when the channel no longer passes (OPE-2451). */
222
+ | { readonly kind: "channel-shared"; readonly reason: string; readonly env: SlackEventEnvelope }
223
+ /** An `app_home_opened` for the HOME tab (OPE-2451) — ours: publish the person's own page. */
224
+ | { readonly kind: "home-open"; readonly reason: string; readonly open: ParsedHomeOpen }
225
+ /** An `oe_home:` click on the Home tab (OPE-2451) — ours: run a button, or open an item's modal. */
226
+ | { readonly kind: "home-click"; readonly reason: string; readonly click: ParsedHomeClick }
227
+ /** A submission of the Home tab's own modal (OPE-2451) — ours: run the action, republish the page. */
228
+ | { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
229
+ /** An `app_context_changed` (OPE-2457) — ours, and nothing to do. The subscription exists only so
230
+ * Slack attaches the viewed room to each side-panel `message.im`; the change itself wakes nothing. */
231
+ | { readonly kind: "context-change"; readonly reason: string };
195
232
 
196
233
  /**
197
234
  * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
@@ -365,6 +402,17 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
365
402
  if (grantClick !== undefined) {
366
403
  return { kind: "grant-click", reason: "block_actions oe_grant: click — ours, never forwarded", payload: grantClick };
367
404
  }
405
+ // THE HOME TAB (OPE-2451). Its clicks carry an `oe_home:` action id and its one modal a callback
406
+ // id of its own, so neither can be mistaken for another door's payload, nor for eve's.
407
+ const homePayload = decodeInteractivityPayload(rawBody);
408
+ const homeClick = parseHomeClick(homePayload);
409
+ if (homeClick !== undefined) {
410
+ return { kind: "home-click", reason: `block_actions oe_home: ${homeClick.action} — ours, never forwarded`, click: homeClick };
411
+ }
412
+ const homeSubmission = parseHomeSubmission(homePayload);
413
+ if (homeSubmission !== undefined) {
414
+ return { kind: "home-submit", reason: `view_submission for the Home tab modal (${homeSubmission.action}) — ours, never forwarded`, submission: homeSubmission };
415
+ }
368
416
  // A SHORTCUT (OPE-1376) — a `message_action` or `shortcut` payload. Ours, never forwarded; see
369
417
  // the `shortcut` arm of FrontDecision for why a forward could not do anything with it. It is
370
418
  // matched LAST among the urlencoded branches and cannot race any of them: every branch above
@@ -397,6 +445,27 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
397
445
  if (eventType === "member_joined_channel") {
398
446
  return { kind: "join", reason: "member_joined_channel — channel-join intro candidate", env };
399
447
  }
448
+ if (eventType === "channel_shared") {
449
+ return { kind: "channel-shared", reason: "channel_shared — recheck the domain memory canvas", env };
450
+ }
451
+ if (eventType === "app_home_opened") {
452
+ // Only the HOME tab is ours (OPE-2451). The same event fires for the Messages tab, which has no
453
+ // page to publish, so that one forwards — eve acknowledges an event it has no handler for.
454
+ const open = parseHomeOpened(env);
455
+ if (open) return { kind: "home-open", reason: "app_home_opened (home tab) — publish the person's page", open };
456
+ return { kind: "forward", reason: "app_home_opened for a tab other than home — nothing to publish" };
457
+ }
458
+ if (eventType === "app_context_changed") {
459
+ // THE SIDE PANEL (OPE-2457). Slack fires this each time the person moves the panel to another
460
+ // room. The room that matters arrives on the `message.im` they then send (`app_context`), so a
461
+ // change alone wakes nothing — but it is acknowledged HERE rather than forwarded, because a
462
+ // forward costs eve a parse and a re-verify for every click the person makes in Slack.
463
+ return { kind: "context-change", reason: "app_context_changed — the room rides the next side-panel message; nothing to wake" };
464
+ }
465
+ if (eventType === "user_change") {
466
+ // OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours — automatic offboarding.
467
+ return { kind: "user-change", reason: "user_change — a deactivation report candidate", env };
468
+ }
400
469
  if (eventType === "reaction_added" || eventType === "reaction_removed") {
401
470
  // Inbound reactions: slackChannel drops them (no handler), so they're ours to ingest
402
471
  // (reaction-ingest.ts — a feedback signal on the bot's own replies + volume telemetry).
@@ -553,10 +622,21 @@ export interface FrontOptions {
553
622
  * re-verifies standing host-side, then mints an ephemeral OAuth URL / opens the api-key intake.
554
623
  * Injectable for tests. */
555
624
  readonly handleConnectClick?: (payload: unknown) => Promise<boolean>;
625
+ /** OPE-2423 — hand one deactivation report to core's offboarding lever. Defaults to
626
+ * {@link routeSlackUserDeactivation} with a live `users.info` re-read. Injectable for tests. */
627
+ readonly routeUserDeactivation?: (d: SlackUserDeactivation) => Promise<OffboardOutcome>;
556
628
  /** Injected in tests; the live default is core's `handleGrantCardClick`. The signed grant-click
557
629
  * branch schedules it through the route's `waitUntil`, so this handler may outlive the empty
558
630
  * Slack acknowledgement. */
559
631
  readonly handleGrantCardClick?: (payload: unknown) => Promise<boolean>;
632
+ /** The three Home-tab handlers (OPE-2451). Injected in tests; the live defaults are slack-home.ts's,
633
+ * over the live Slack deps. Each runs after the empty acknowledgement, through `waitUntil`. */
634
+ readonly handleHomeOpened?: (open: ParsedHomeOpen) => Promise<boolean>;
635
+ readonly handleHomeClick?: (click: ParsedHomeClick) => Promise<boolean>;
636
+ readonly handleHomeSubmission?: (submission: ParsedHomeSubmission) => Promise<boolean>;
637
+ /** The domain memory canvas recheck a trust event runs (OPE-2451): a person joined the channel, or it
638
+ * was shared. Injected in tests; the live default is slack-memory-canvas.ts's, over the live deps. */
639
+ readonly recheckChannelCanvas?: (input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => Promise<unknown>;
560
640
  /** SINGLE-WINDOW mention policy for one (channel, surface) — should the unified window own an
561
641
  * @mention here (suppress the native forward + keep the sibling as an addressed wake)?
562
642
  * Defaults to the live {@link slackSingleWindowMentionPolicy} (grain + fail-closed
@@ -603,6 +683,13 @@ export interface FrontOptions {
603
683
  * governance DM-delivery arm. Defaults to the live call; FAIL-CLOSED: undefined ⇒ the shortcut
604
684
  * wakes nothing and says so, never a wake in a guessed room. Injectable for tests. */
605
685
  readonly openDirectMessageChannel?: (userId: string) => Promise<string | undefined>;
686
+ /** The person opt-out read (OPE-2436): may the agent act on this person's content? Defaults to
687
+ * core's `readPersonOptOut`, which is fail-CLOSED (an unreadable store reads as opted out).
688
+ * Injectable for tests. */
689
+ readonly personOptOutFor?: (org: string, principalId: string) => Promise<PersonOptOutState>;
690
+ /** Tell one person, privately in their own DM, why a shortcut they pressed did nothing (OPE-2436).
691
+ * Best-effort. Defaults to `conversations.open` + `chat.postMessage`. Injectable for tests. */
692
+ readonly postPrivateNotice?: (userId: string, text: string) => Promise<void>;
606
693
  /** The trigger author's two membership facts, from ONE `users.info` probe — the exact pair every
607
694
  * wake route in slack-events.ts resolves. Two readers: the native `app_mention` forward's
608
695
  * external-policy gate, which calls it only when that policy is not inert (so a deployment on the
@@ -991,11 +1078,24 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
991
1078
  const handleSetKeyClick = opts.handleSetKeyClick ?? liveHandleSetKeyClick;
992
1079
  const handleSetKeyViewSubmission = opts.handleSetKeyViewSubmission ?? liveHandleSetKeyViewSubmission;
993
1080
  const handleConnectClick = opts.handleConnectClick ?? liveHandleConnectClick;
1081
+ const routeUserDeactivation =
1082
+ opts.routeUserDeactivation ?? ((d: SlackUserDeactivation) => routeSlackUserDeactivation(d, { usersInfo: (userId) => slackForm("users.info", `user=${encodeURIComponent(userId)}`) }));
994
1083
  const handleGrantCardClick = opts.handleGrantCardClick ?? liveHandleGrantCardClick;
1084
+ const homeOpened = opts.handleHomeOpened ?? ((open: ParsedHomeOpen) => handleHomeOpened(open, liveSlackHomeDeps()));
1085
+ const homeClicked = opts.handleHomeClick ?? ((click: ParsedHomeClick) => handleHomeClick(click, liveSlackHomeDeps()));
1086
+ const recheckCanvas = opts.recheckChannelCanvas ?? ((input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => recheckChannelCanvas(input, liveMemoryCanvasDeps()));
1087
+ const homeSubmitted = opts.handleHomeSubmission ?? ((submission: ParsedHomeSubmission) => handleHomeSubmission(submission, liveSlackHomeDeps()));
995
1088
  const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey: string) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
996
1089
  const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
997
1090
  const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
998
1091
  const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
1092
+ const personOptOutFor = opts.personOptOutFor ?? readPersonOptOut;
1093
+ const postPrivateNotice =
1094
+ opts.postPrivateNotice ??
1095
+ (async (userId: string, text: string) => {
1096
+ const dm = await openDirectMessageChannel(userId);
1097
+ if (dm) await slackForm("chat.postMessage", new URLSearchParams({ channel: dm, text }).toString());
1098
+ });
999
1099
  const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
1000
1100
  const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
1001
1101
  const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
@@ -1502,9 +1602,25 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1502
1602
  // by design (the seat is one long-lived session over the whole DM, so replaying history a live
1503
1603
  // session already holds would duplicate it) — the tail is exactly the case that reasoning does
1504
1604
  // not cover, because a rotation is when the session stops holding it.
1605
+ // THE SIDE PANEL (OPE-2457) AND THE PERSON OPT-OUT (OPE-2436) — two host facts for a 1:1 DM, in
1606
+ // one trusted note. The viewed room passes every strictest-default gate first
1607
+ // (slack-side-panel.ts); the opt-out tells the seat not to promise memory the write road will
1608
+ // refuse anyway. A group DM states neither: it has no single person, and no panel.
1609
+ const systemNote = isGroup
1610
+ ? undefined
1611
+ : slackDmSystemNote({
1612
+ viewedRoom: await sidePanelViewedRoom({
1613
+ room: ev.appContext,
1614
+ teamId: ev.teamId,
1615
+ partnerTrust,
1616
+ channelInternal: async (id) => (await channelFactsFor(id)).internal === true,
1617
+ }),
1618
+ personOptOut: ev.userId ? await personOptOutFor(p.org, ev.userId) : undefined,
1619
+ });
1505
1620
  const message = renderWakeMessage(ev, () => atIso, {
1506
1621
  dm: true,
1507
1622
  trust: wakeTrust,
1623
+ ...(systemNote === undefined ? {} : { systemNote }),
1508
1624
  // The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
1509
1625
  // and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
1510
1626
  // no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
@@ -1842,6 +1958,32 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
1842
1958
  // Keyed on the click, not the message — a person may invoke the same shortcut on the same
1843
1959
  // message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
1844
1960
  if (!(await notYetSeenKey(shortcutWakeKey(sc), sev))) return new Response(null, { status: 200 });
1961
+ // 🔴 THE PERSON OPT-OUT (OPE-2436). A message shortcut hands the agent somebody's message to act
1962
+ // on — summarise it, answer it, file it. When the message's AUTHOR opted out of agent actions on
1963
+ // their content and somebody ELSE pressed the button, nothing wakes: one refusal row, and a
1964
+ // private line to the person who pressed it. The author's own shortcut on their own message is
1965
+ // their own request, so it wakes as before. The read is fail-closed (core `readPersonOptOut`).
1966
+ const shortcutAuthor = sc.kind === "message" ? sc.message?.userId : undefined;
1967
+ if (shortcutAuthor && shortcutAuthor !== sc.userId) {
1968
+ const p = resolvePartition(bindableFor(sev));
1969
+ const authorOptOut = await personOptOutFor(p.org, shortcutAuthor);
1970
+ if (authorOptOut.optedOut) {
1971
+ console.log(`[open-everywhere:slack-front] message shortcut "${sc.callbackId}" in ${shortcutChannelId}: refused — the message's author opted out`);
1972
+ args.waitUntil(
1973
+ Promise.allSettled([
1974
+ auditPersonOptOutRefusal(
1975
+ { org: p.org, platform: p.platform, workspace: p.workspace, surface: p.surface, principalKey: shortcutAuthor },
1976
+ "message-shortcut",
1977
+ { requestedBy: sc.userId, storeReadable: authorOptOut.readable },
1978
+ ),
1979
+ // An unreadable store is the fail-closed GUESS, never the author's decision, so the presser
1980
+ // is not told the author asked for anything.
1981
+ postPrivateNotice(sc.userId, authorOptOut.readable ? PERSON_OPT_OUT_SHORTCUT_NOTICE : PERSON_OPT_OUT_UNREADABLE_NOTICE),
1982
+ ]),
1983
+ );
1984
+ return new Response(null, { status: 200 });
1985
+ }
1986
+ }
1845
1987
  // BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
1846
1988
  // the in-memory one for this process's seat, the durable one for a delegated worker in
1847
1989
  // another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
@@ -2098,6 +2240,35 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2098
2240
  return new Response(null, { status: 200 });
2099
2241
  }
2100
2242
 
2243
+ if (decision.kind === "home-open" || decision.kind === "home-click" || decision.kind === "home-submit") {
2244
+ // THE HOME TAB (OPE-2451). THE TENANT FENCE FIRST (OPE-1680), for the reason the approval branch
2245
+ // states: the page reads and changes a person's memory, keyed to a bare per-workspace member id,
2246
+ // so an unadmitted workspace reaching it is a foreign tenant reading our store.
2247
+ const ids = decision.kind === "home-open" ? decision.open : decision.kind === "home-click" ? decision.click : decision.submission;
2248
+ const fenceIds = {
2249
+ ...(ids.teamId === undefined ? {} : { teamId: ids.teamId }),
2250
+ ...(ids.enterpriseId === undefined ? {} : { enterpriseId: ids.enterpriseId }),
2251
+ userId: ids.userId,
2252
+ };
2253
+ if (slackInteractivityTenantFenceDeclined(fenceIds, decision.kind, decision.kind === "home-open" ? "front-home-event-branch" : "front-interactivity-branch")) {
2254
+ return fencedAck(new Response(null, { status: 200 }));
2255
+ }
2256
+ // ACK FIRST, WORK AFTER. Building the page reads memory and asks Slack who the person is and
2257
+ // which channels they sit in, which can cross Slack's three-second budget. An empty 200 also
2258
+ // CLOSES a submitted modal, which is what the person expects after Save or Forget; the page
2259
+ // they return to is republished with a one-line notice saying what happened.
2260
+ const run =
2261
+ decision.kind === "home-open" ? homeOpened(decision.open) : decision.kind === "home-click" ? homeClicked(decision.click) : homeSubmitted(decision.submission);
2262
+ args.waitUntil(
2263
+ run
2264
+ .then((handled) => {
2265
+ if (!handled) console.log(`[open-everywhere:slack-front] ${decision.kind} for ${ids.userId} did not complete`);
2266
+ })
2267
+ .catch(() => console.warn(`[open-everywhere:slack-front] ${decision.kind} failed after acknowledgement`)),
2268
+ );
2269
+ return new Response(null, { status: 200 });
2270
+ }
2271
+
2101
2272
  if (decision.kind === "setkey-submit") {
2102
2273
  // THE TENANT FENCE (OPE-1680), above the handler — this is the branch that WRITES A SECRET into
2103
2274
  // this deployment's store, keyed to the intake the payload names. A `view_submission` carries
@@ -2120,6 +2291,43 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2120
2291
  return forwardNative();
2121
2292
  }
2122
2293
 
2294
+ if (decision.kind === "channel-shared") {
2295
+ // THE CHANNEL WAS SHARED (OPE-2451): re-run the memory canvas gate at once, which clears the app's
2296
+ // own canvas there. The canvas reads the room verdict fresh, so the wake path's cached "internal"
2297
+ // cannot carry a pre-share answer into this decision.
2298
+ const ev = decision.env.event as Record<string, unknown> | undefined;
2299
+ const channelId = typeof ev?.channel === "string" ? ev.channel : undefined;
2300
+ const teamId = typeof decision.env.team_id === "string" ? decision.env.team_id : undefined;
2301
+ if (!channelId || !teamId) return OK();
2302
+ const sharedEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
2303
+ if (
2304
+ slackInteractivityTenantFenceDeclined(
2305
+ { teamId, ...(sharedEnterpriseId === undefined ? {} : { enterpriseId: sharedEnterpriseId }), channelId },
2306
+ "channel-shared",
2307
+ "front-channel-shared-branch",
2308
+ )
2309
+ ) {
2310
+ return fencedAck(OK());
2311
+ }
2312
+ let sharedOrg: string | undefined;
2313
+ try {
2314
+ sharedOrg = resolvePartition({
2315
+ channel: { kind: "slack" },
2316
+ session: { auth: { current: { attributes: { team_id: teamId, channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
2317
+ } as Parameters<typeof resolvePartition>[0]).org;
2318
+ } catch {
2319
+ sharedOrg = undefined;
2320
+ }
2321
+ if (sharedOrg !== undefined) {
2322
+ args.waitUntil(
2323
+ recheckCanvas({ org: sharedOrg, teamId, channelId, trigger: "channel-shared" }).catch(() =>
2324
+ console.warn("[open-everywhere:slack-front] memory canvas recheck after a share failed"),
2325
+ ),
2326
+ );
2327
+ }
2328
+ return OK();
2329
+ }
2330
+
2123
2331
  if (decision.kind === "join") {
2124
2332
  // Channel-join intro. Only the BOT's own join is an invitation — a human joining a
2125
2333
  // channel we're in fires the same event and must be dropped. Fail-CLOSED here, the
@@ -2129,7 +2337,38 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2129
2337
  const jev = parseChannelJoinEvent(decision.env);
2130
2338
  if (!jev) return OK();
2131
2339
  const { userId: joinBotUserId } = await getBotIdentity();
2132
- if (!joinBotUserId || jev.userId !== joinBotUserId) return OK();
2340
+ if (!joinBotUserId || jev.userId !== joinBotUserId) {
2341
+ // A PERSON JOINED (OPE-2451). No intro, but the domain memory canvas re-runs its gate: the
2342
+ // newcomer may be a guest, and a canvas written before they joined would show them every line
2343
+ // of the domain's memory. The recheck can only CLEAR the app's own canvas, never write memory,
2344
+ // so an unresolved bot identity rechecks too. Fenced like every other door: a foreign tenant
2345
+ // never reaches this deployment's Slack calls.
2346
+ if (jev.teamId === undefined) return OK();
2347
+ if (
2348
+ slackInteractivityTenantFenceDeclined(
2349
+ { teamId: jev.teamId, ...(jev.enterpriseId === undefined ? {} : { enterpriseId: jev.enterpriseId }), channelId: jev.channelId, userId: jev.userId },
2350
+ "member-joined",
2351
+ "front-join-branch",
2352
+ )
2353
+ ) {
2354
+ return fencedAck(OK());
2355
+ }
2356
+ let joinOrg: string | undefined;
2357
+ try {
2358
+ joinOrg = resolvePartition(bindableForJoin(jev)).org;
2359
+ } catch {
2360
+ joinOrg = undefined;
2361
+ }
2362
+ if (joinOrg !== undefined) {
2363
+ const teamId = jev.teamId;
2364
+ args.waitUntil(
2365
+ recheckCanvas({ org: joinOrg, teamId, channelId: jev.channelId, trigger: "member-joined", joinedUserId: jev.userId }).catch(() =>
2366
+ console.warn("[open-everywhere:slack-front] memory canvas recheck after a join failed"),
2367
+ ),
2368
+ );
2369
+ }
2370
+ return OK();
2371
+ }
2133
2372
 
2134
2373
  // Same two-layer idempotency as the message wake: Slack retries a slow ack, and a
2135
2374
  // double intro is exactly the annoyance the durable half exists to prevent.
@@ -2152,6 +2391,40 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
2152
2391
  return OK();
2153
2392
  }
2154
2393
 
2394
+ if (decision.kind === "context-change") return OK();
2395
+
2396
+ if (decision.kind === "user-change") {
2397
+ // AUTOMATIC OFFBOARDING (OPE-2423). The signature above has authenticated the envelope, so its
2398
+ // `team_id` is the workspace that delivered this report. Every other decision — the
2399
+ // same-workspace gate, the tenant fence, the live users.info re-read, the terminal revoke and
2400
+ // its audit row — runs in core's offboardDeactivatedAccount, AFTER the ack: Slack wants a quick
2401
+ // 200, and a revoke is idempotent, so a Slack retry that races it changes nothing.
2402
+ const deactivation = parseSlackUserDeactivation(decision.env);
2403
+ if (!deactivation) return OK();
2404
+ // THE TENANT FENCE (OPE-1680), the same front-door refusal every other deciding branch takes.
2405
+ // Core's gate 2 ALSO requires a positive admission, because this fence is inert when config
2406
+ // declares no tenant — and a terminal revoke must not run on an inert answer.
2407
+ if (
2408
+ slackInteractivityTenantFenceDeclined(
2409
+ {
2410
+ teamId: deactivation.deliveringTeamId,
2411
+ ...(deactivation.enterpriseId === undefined ? {} : { enterpriseId: deactivation.enterpriseId }),
2412
+ userId: deactivation.userId,
2413
+ },
2414
+ "user-change",
2415
+ "front-user-change-branch",
2416
+ )
2417
+ ) {
2418
+ return fencedAck(OK());
2419
+ }
2420
+ args.waitUntil(
2421
+ routeUserDeactivation(deactivation).then((outcome) =>
2422
+ console.log(`[open-everywhere:slack-front] user_change ${deactivation.userId}: offboarding ${outcome.kind}${outcome.kind === "refused" ? ` (${outcome.reason})` : ""}`),
2423
+ ),
2424
+ );
2425
+ return OK();
2426
+ }
2427
+
2155
2428
  if (decision.kind === "reaction") {
2156
2429
  // Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
2157
2430
  const rev = parseSlackReactionEvent(decision.env);