@open-everywhere/provider-slack 0.3.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.
- package/agent/app-manifest.ts +49 -7
- package/agent/channels/slack-events.ts +31 -19
- package/agent/channels/slack-front.ts +209 -11
- package/agent/channels/slack-home.ts +511 -0
- package/agent/channels/slack-memory-canvas.ts +382 -0
- package/agent/channels/slack-offboarding.ts +89 -0
- package/agent/channels/slack-origin-codec.ts +8 -0
- package/agent/channels/slack-step-label.ts +177 -0
- package/agent/channels/slack-wake-backscroll.ts +3 -2
- package/agent/channels/slack.ts +31 -18
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +5 -0
- package/agent/provider-events.ts +1 -1
- package/agent/register.ts +9 -0
- package/agent/setup/page.ts +3 -1
- package/agent/slack-as-you.ts +107 -0
- package/agent/surface-parity.ts +3 -0
- package/dist/agent/app-manifest.d.ts +11 -0
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +40 -7
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +13 -5
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +28 -17
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-front.d.ts +52 -0
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +142 -11
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-home.d.ts +95 -0
- package/dist/agent/channels/slack-home.d.ts.map +1 -0
- package/dist/agent/channels/slack-home.js +445 -0
- package/dist/agent/channels/slack-home.js.map +1 -0
- package/dist/agent/channels/slack-memory-canvas.d.ts +65 -0
- package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -0
- package/dist/agent/channels/slack-memory-canvas.js +325 -0
- package/dist/agent/channels/slack-memory-canvas.js.map +1 -0
- package/dist/agent/channels/slack-offboarding.d.ts +31 -0
- package/dist/agent/channels/slack-offboarding.d.ts.map +1 -0
- package/dist/agent/channels/slack-offboarding.js +75 -0
- package/dist/agent/channels/slack-offboarding.js.map +1 -0
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/slack-origin-codec.js +8 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -1
- package/dist/agent/channels/slack-step-label.d.ts +17 -0
- package/dist/agent/channels/slack-step-label.d.ts.map +1 -0
- package/dist/agent/channels/slack-step-label.js +170 -0
- package/dist/agent/channels/slack-step-label.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +2 -2
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
- package/dist/agent/channels/slack-wake-backscroll.js +3 -3
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +31 -18
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +5 -1
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/provider-events.js +1 -1
- package/dist/agent/provider-events.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +9 -1
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +3 -1
- package/dist/agent/setup/page.js.map +1 -1
- package/dist/agent/slack-as-you.d.ts +38 -0
- package/dist/agent/slack-as-you.d.ts.map +1 -0
- package/dist/agent/slack-as-you.js +82 -0
- package/dist/agent/slack-as-you.js.map +1 -0
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +3 -0
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +27 -3
package/agent/app-manifest.ts
CHANGED
|
@@ -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
|
|
10
|
-
// document alone, so a leaf module costs it no transitive weight — not the adapter-api
|
|
11
|
-
// eve, not the Slack Web API client. `agent/ops.ts` does the typed registration; this file
|
|
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.
|
|
87
|
-
|
|
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: [
|
|
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
|
|
@@ -240,6 +281,7 @@ export const SLACK_APP_MANIFEST_STEPS: readonly string[] = [
|
|
|
240
281
|
"Pick the workspace, choose JSON, and paste the manifest below.",
|
|
241
282
|
"Next → Create.",
|
|
242
283
|
"Install to Workspace, and approve the scopes Slack lists.",
|
|
284
|
+
"Unless you set identity.name and chose your own icon, give the app the project mark: Basic Information → Display Information → App icon, and upload https://raw.githubusercontent.com/open-everywhere/open-everywhere/main/brand/png/mark-square-1024.png. Slack's manifest has no icon field, so this step stays by hand.",
|
|
243
285
|
"Before the first deploy, Slack cannot verify the Event Subscriptions URL: it answers only once a deploy that holds SLACK_SIGNING_SECRET is up. After the deploy, paste the manifest again with the real host (App Manifest → Save Changes), then retry the verification under Event Subscriptions.",
|
|
244
286
|
];
|
|
245
287
|
|
|
@@ -1433,8 +1433,8 @@ export async function routeMessageEvent(ev: ParsedMessageEvent, deps: MessageRou
|
|
|
1433
1433
|
...(ev.enterpriseId === undefined ? {} : { enterpriseId: ev.enterpriseId }),
|
|
1434
1434
|
});
|
|
1435
1435
|
// ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 / OPE-504) ────────────────────────────────────────
|
|
1436
|
-
// Inert on the
|
|
1437
|
-
//
|
|
1436
|
+
// Inert only on the `taint` / `engage` opt-out, where it is byte-identical to before it
|
|
1437
|
+
// existed; the shipped grains suppress here. It sits here because this is where the author evidence first exists — see the
|
|
1438
1438
|
// block comment on `slackExternalVerdict` for why that is lower than Discord's gate.
|
|
1439
1439
|
{
|
|
1440
1440
|
const verdict = await applySlackExternalPolicy(ev, p, { trust: wakeTrust, refuted: authorFacts.refuted }, "routeMessageEvent");
|
|
@@ -1862,7 +1862,7 @@ export async function auditDmBotAuthorDrop(
|
|
|
1862
1862
|
dmKind,
|
|
1863
1863
|
botId: ev.botId,
|
|
1864
1864
|
team: ev.teamId ?? null,
|
|
1865
|
-
// TRUE ⇒ the `
|
|
1865
|
+
// TRUE ⇒ the `wake.driverAllowlist` setting vouches for this bot at the engagement and mention
|
|
1866
1866
|
// doors, and the DM door dropped it anyway. That is not a defect: the DM door reads its OWN,
|
|
1867
1867
|
// narrower lever (`wake.e2eDrivers`, {@link dmDoorAdmitsDriver}), so a bot on the channel
|
|
1868
1868
|
// allowlist has no DM standing. The row carries the distinction as data.
|
|
@@ -1884,7 +1884,7 @@ export async function auditDmBotAuthorDrop(
|
|
|
1884
1884
|
* DM leg in the pack exercised a synthetic envelope and a regression in Slack's own DM delivery to
|
|
1885
1885
|
* us was invisible. This carve-out lets the driver in through the real front door.
|
|
1886
1886
|
*
|
|
1887
|
-
* WHY IT IS NOT THE `
|
|
1887
|
+
* WHY IT IS NOT THE `wake.driverAllowlist` SETTING, which already lifts the same bot at the
|
|
1888
1888
|
* engagement and mention doors. A DM seat holds a window token, delegation and memory, so admitting
|
|
1889
1889
|
* a bot into a DM is a strictly larger grant than admitting one into a watched channel. Reusing the
|
|
1890
1890
|
* channel allowlist would have widened the DM door for every deployment that already sets it, on
|
|
@@ -2268,7 +2268,7 @@ export interface NameDropRouteDeps {
|
|
|
2268
2268
|
readonly backscroll?: (input: { channelId: string; ts: string; threadTs?: string; threadAdmissionSlice?: boolean }) => Promise<WakeBackscrollLines | null>;
|
|
2269
2269
|
/** The per-author membership probe the hydrated-context withhold uses (OPE-525,
|
|
2270
2270
|
* {@link withholdNameDropContext}). Defaults to the live {@link slackAuthorIsMember}; injectable
|
|
2271
|
-
* so the withhold is testable without Slack. Never called on the
|
|
2271
|
+
* so the withhold is testable without Slack. Never called on the `taint` opt-out grain. */
|
|
2272
2272
|
readonly authorIsMember?: SlackMemberProbe;
|
|
2273
2273
|
/** Start the judged turn in a thread rooted at the triggering message. `context` (when present)
|
|
2274
2274
|
* is the backscroll — ADDITIONAL history the channel appends before the delivery turn. */
|
|
@@ -2482,7 +2482,7 @@ export async function routeNameDropEvent(ev: ParsedMessageEvent, deps: NameDropR
|
|
|
2482
2482
|
// reference-only and marks the taint join too — not just the trigger's own author. Fail-soft:
|
|
2483
2483
|
// a regrade failure keeps the fetched (ungraded) lines, exactly like every other backscroll leg.
|
|
2484
2484
|
// 🔴 THE REGRADE KEEPS THE LIVE PROBE, and `deps.authorIsMember` must NOT be threaded here. That
|
|
2485
|
-
// dep is the WITHHOLD's probe and its contract is "never called on the
|
|
2485
|
+
// dep is the WITHHOLD's probe and its contract is "never called on the `taint` opt-out grain"
|
|
2486
2486
|
// (OPE-525); this regrade runs on every hydration, whatever the grain, so sharing the dep would
|
|
2487
2487
|
// break that contract and make a grain-gated lookup look grain-blind. The two share the
|
|
2488
2488
|
// TTL-cached probe underneath, which is where the call is actually saved.
|
|
@@ -2605,7 +2605,7 @@ export async function routeNameDropEvent(ev: ParsedMessageEvent, deps: NameDropR
|
|
|
2605
2605
|
});
|
|
2606
2606
|
// ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 / OPE-504) ────────────────────────────────────────
|
|
2607
2607
|
// A name-drop is AMBIENT by construction (the gate above disqualifies an @mention), so an excluded
|
|
2608
|
-
// author's name-drop is owed nothing and is suppressed in silence. Inert on the
|
|
2608
|
+
// author's name-drop is owed nothing and is suppressed in silence. Inert only on the opt-out grains.
|
|
2609
2609
|
{
|
|
2610
2610
|
const verdict = await applySlackExternalPolicy(ev, p, { trust: wakeTrust, refuted: authorFacts.refuted }, "routeNameDropEvent");
|
|
2611
2611
|
if (verdict !== "proceed") return { deliver: false, reason: externalPolicyDropReason(verdict), woke: false };
|
|
@@ -2622,7 +2622,7 @@ export async function routeNameDropEvent(ev: ParsedMessageEvent, deps: NameDropR
|
|
|
2622
2622
|
// about the people quoted in that hydration: a member name-drops the bot in a channel where a
|
|
2623
2623
|
// refuted guest has been talking, and without this the guest's bytes reach the model verbatim.
|
|
2624
2624
|
// `withholdNameDropContext` runs the shared transform and returns the wake BY REFERENCE on the
|
|
2625
|
-
//
|
|
2625
|
+
// `taint` opt-out grain, so nothing moves for a deployment that opted out.
|
|
2626
2626
|
const rendered = renderWake(
|
|
2627
2627
|
await withholdNameDropContext(
|
|
2628
2628
|
slackNameDropWake(ev, name!, {
|
|
@@ -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 —
|
|
@@ -3205,7 +3217,7 @@ function externalPolicyDropReason(verdict: SlackExternalVerdict): string {
|
|
|
3205
3217
|
* other in the plane. A `bot` line carries a bot id, and a machine author is never excluded, so only
|
|
3206
3218
|
* `senderType: "user"` lines are asked about.
|
|
3207
3219
|
*
|
|
3208
|
-
* COSTS NOTHING ON THE
|
|
3220
|
+
* COSTS NOTHING ON THE OPT-OUT GRAIN: `taint` returns the wake by reference before any probe runs.
|
|
3209
3221
|
* On `withhold` it costs one TTL-cached `users.info` per DISTINCT hydrated author, resolved in
|
|
3210
3222
|
* parallel, and the render budget caps the hydration at ten lines.
|
|
3211
3223
|
*/
|
|
@@ -3824,8 +3836,8 @@ export function liveNameDropDeps(send: WakeSend): NameDropRouteDeps {
|
|
|
3824
3836
|
|
|
3825
3837
|
// --- channel-supervisor delivery (Slack Claude-Tag parity, T1.3) ---------------
|
|
3826
3838
|
//
|
|
3827
|
-
// The "always-on teammate" leg. When a channel's domain
|
|
3828
|
-
// (memory/domain.ts,
|
|
3839
|
+
// The "always-on teammate" leg. When a channel's domain has ambientSupervisor on
|
|
3840
|
+
// (memory/domain.ts, default ON since OPE-2409) AND the channel is POSITIVELY confirmed internal,
|
|
3829
3841
|
// an unowned message (top-level, or in-thread but not engaged) is delivered to a single
|
|
3830
3842
|
// long-lived CHANNEL-SUPERVISOR session instead of the name-drop-only path. The supervisor
|
|
3831
3843
|
// is one durable eve session per channel, addressed by a RESERVED continuation token
|
|
@@ -4003,7 +4015,7 @@ export interface SupervisorRouteDeps {
|
|
|
4003
4015
|
* {@link withholdNameDropContext}) — the same dep, spelled the same way, as the native route it
|
|
4004
4016
|
* replaces ({@link NameDropRouteDeps.authorIsMember}). Defaults to the live
|
|
4005
4017
|
* {@link slackAuthorIsMember}; injectable so the withhold is testable without Slack. Never called
|
|
4006
|
-
* on the
|
|
4018
|
+
* on the `taint` opt-out grain, and never on a wake that carries no hydrated lines. */
|
|
4007
4019
|
readonly authorIsMember?: SlackMemberProbe;
|
|
4008
4020
|
/**
|
|
4009
4021
|
* Start/continue the supervisor session under its reserved token. `window` is present ONLY on
|
|
@@ -4070,7 +4082,7 @@ export interface SupervisorRouteDeps {
|
|
|
4070
4082
|
* Route one unowned message to the channel supervisor: pure disqualifiers, then the
|
|
4071
4083
|
* fail-closed internal-channel confirmation (which also surfaces the member count for the
|
|
4072
4084
|
* envelope), then create-or-fetch the supervisor session and deliver the judged wake. The
|
|
4073
|
-
* caller (slack-front) only reaches here once the domain has
|
|
4085
|
+
* caller (slack-front) only reaches here once the domain has ambientSupervisor on (the default).
|
|
4074
4086
|
*
|
|
4075
4087
|
* `opts.directMention` (SINGLE-WINDOW @mention routing — the GitHub reference's
|
|
4076
4088
|
* `treatMentionAsDirect`/`directMention` shape): the trigger is the `message.channels` SIBLING of
|
|
@@ -4223,7 +4235,7 @@ export async function routeSupervisorEvent(
|
|
|
4223
4235
|
});
|
|
4224
4236
|
// ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 / OPE-504) ────────────────────────────────────────
|
|
4225
4237
|
// ABOVE THE SESSION MINT, deliberately: a suppressed wake must not rotate a generation or claim a
|
|
4226
|
-
// fresh generation's first-wake backscroll. Inert on the
|
|
4238
|
+
// fresh generation's first-wake backscroll. Inert only on the `taint` / `engage` opt-out.
|
|
4227
4239
|
const externalPolicy = slackExternalPolicyForRoom(domainKeyOf(p));
|
|
4228
4240
|
{
|
|
4229
4241
|
const verdict = await applySlackExternalPolicy(ev, p, { trust: wakeTrust, refuted: authorFacts.refuted }, "routeSupervisorEvent");
|
|
@@ -4358,8 +4370,8 @@ export async function routeSupervisorEvent(
|
|
|
4358
4370
|
// BEHAVIOUR 3 (OPE-507) — the ARRIVED-MARKER over the first-wake backscroll. The trigger author
|
|
4359
4371
|
// cleared the gate above, so this wake fires; a SIBLING the probe refuted has not, and under
|
|
4360
4372
|
// `withhold` their bytes must not enter. The transform replaces the body, the pre-edit body and the
|
|
4361
|
-
// attachments, and it returns the SAME ARRAY by reference on the
|
|
4362
|
-
// golden moves on a deployment that
|
|
4373
|
+
// attachments, and it returns the SAME ARRAY by reference on the `taint` opt-out grain, so no
|
|
4374
|
+
// golden moves on a deployment that opted out.
|
|
4363
4375
|
const backscroll = priorContext.map((m) => (withholdsBody(m, externalPolicy) ? withheldWakeMessage(m) : m));
|
|
4364
4376
|
|
|
4365
4377
|
// POSITIVELY confirmed internal → stamp the trust verdict into the wake auth, exactly like the
|
|
@@ -4374,7 +4386,7 @@ export async function routeSupervisorEvent(
|
|
|
4374
4386
|
//
|
|
4375
4387
|
// 🔴 A WITHHELD SIBLING NEVER KEYS TAINT. `ingestedAfterWithholding` REMOVES the entries the
|
|
4376
4388
|
// transform above stripped, because their bytes did not enter — a join over a message that did not
|
|
4377
|
-
// ingest would claim an exposure that did not happen. On the
|
|
4389
|
+
// ingest would claim an exposure that did not happen. On the `taint` opt-out grain it returns the
|
|
4378
4390
|
// SAME ARRAY by reference, so the auth bag is byte-identical.
|
|
4379
4391
|
//
|
|
4380
4392
|
// 🔴 THE HYDRATED NAME-DROP LINES JOIN TOO (OPE-475/OPE-525, carried onto the seat road by
|
|
@@ -4479,8 +4491,8 @@ export async function routeSupervisorEvent(
|
|
|
4479
4491
|
// and without this the guest's bytes reach the model verbatim. It is the SAME function the
|
|
4480
4492
|
// native route calls, over the WHOLE wake — so the envelope backscroll's own withholding (the
|
|
4481
4493
|
// `priorContext` map above) and the hydrated lines are decided by one transform, and it returns
|
|
4482
|
-
// the wake BY REFERENCE on the
|
|
4483
|
-
//
|
|
4494
|
+
// the wake BY REFERENCE on the `taint` opt-out grain, so nothing moves for a deployment that
|
|
4495
|
+
// opted out. Run only on the name-drop tier, which is the only tier that carries the slot.
|
|
4484
4496
|
const wake = slackChannelWatchWake(ev, {
|
|
4485
4497
|
mode: "supervisor",
|
|
4486
4498
|
trust: wakeTrust,
|
|
@@ -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;
|
|
@@ -1156,16 +1226,16 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1156
1226
|
const notYetSeen = (env: SlackEventEnvelope, ev: ParsedMessageEvent): Promise<boolean> =>
|
|
1157
1227
|
notYetSeenKey(wakeKey(env, ev.channelId, ev.ts), ev);
|
|
1158
1228
|
|
|
1159
|
-
// UNOWNED message dispatch (top-level, or in-thread but not engaged):
|
|
1160
|
-
// name-drop path (per-domain opt-in, fail-closed). When the channel's domain
|
|
1161
|
-
// ambientSupervisor, the SAME unowned traffic routes to the channel-supervisor session instead
|
|
1229
|
+
// UNOWNED message dispatch (top-level, or in-thread but not engaged): the fallback is the
|
|
1230
|
+
// name-drop path (per-domain opt-in, fail-closed). When the channel's domain has
|
|
1231
|
+
// ambientSupervisor on (the default since OPE-2409), the SAME unowned traffic routes to the channel-supervisor session instead
|
|
1162
1232
|
// (slack-events.ts routeSupervisorEvent) — the "always-on teammate" leg. `directMention` is the
|
|
1163
1233
|
// SINGLE-WINDOW addressed-mention divert (an app_mention event routed here from the mention
|
|
1164
1234
|
// branch): it forces the supervisor path (the only road into the unified window) and renders an
|
|
1165
1235
|
// addressed `direct-mention` wake. The supervisor wake serializes on its OWN FIFO key (the
|
|
1166
1236
|
// supervisor token), never shared with a thread FIFO. Fail-closed: any partition/config error,
|
|
1167
1237
|
// or the flag off, falls through to the UNCHANGED name-drop dispatch — so ambientSupervisor-off
|
|
1168
|
-
// is byte-identical to
|
|
1238
|
+
// is byte-identical to the pre-ambient path. Parametrised on `ev` so both the app_mention divert and a plain
|
|
1169
1239
|
// message.channels wake feed one dispatcher.
|
|
1170
1240
|
// `onWake` runs SYNCHRONOUSLY, once, immediately before a wake is scheduled — and never on the
|
|
1171
1241
|
// addressed-drop path below. The shortcut route (OPE-1442) uses it to seed the attachment
|
|
@@ -1424,10 +1494,9 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1424
1494
|
// 🔴 A GROUP DM IS SUPPRESSIBLE ON THE AUTHOR GRAIN TOO (OPE-1486). It was not before, because
|
|
1425
1495
|
// an mpim resolved no refutation at all — the limit the old note here recorded. The author leg
|
|
1426
1496
|
// above now resolves one from the same probe a channel uses, so an operator who set
|
|
1427
|
-
// `externalPolicy.authorGrain: "withhold"` gets that rule in a group
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
// here — which is the rule it asked for. That is a claim about SUPPRESSION alone: on `taint`
|
|
1497
|
+
// `externalPolicy.authorGrain: "withhold"` (shipped since 2026-10-10) gets that rule in a group
|
|
1498
|
+
// DM as well. `withholdsBody` is inert only under the `taint` opt-out, so a deployment that opted
|
|
1499
|
+
// out is never suppressed here. That is a claim about SUPPRESSION alone: on `taint`
|
|
1431
1500
|
// the same refutation still newly stamps `oe_chat_nonmember` and still newly fences the body,
|
|
1432
1501
|
// which is the fix. The room leg stays off for the whole DM family by the note above: on a DM
|
|
1433
1502
|
// the trust plane asks about the PARTNER, not about the room.
|
|
@@ -1967,7 +2036,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1967
2036
|
//
|
|
1968
2037
|
// 🔴 IT COSTS A PROBE, SO IT IS SKIPPED WHEN IT CANNOT MATTER. Unlike the three routes, this
|
|
1969
2038
|
// leg resolves no author facts of its own, so gating it means one `users.info` call. The inert
|
|
1970
|
-
// check runs first: on the
|
|
2039
|
+
// check runs first: on the `taint` / `engage` opt-out pair no evidence is resolved at all and
|
|
1971
2040
|
// this whole block is a single config read. The probe is TTL-cached per author on top of that.
|
|
1972
2041
|
//
|
|
1973
2042
|
// FAIL-OPEN on anything unreadable — an unparseable event, an unresolvable partition, a probe
|
|
@@ -2099,6 +2168,35 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2099
2168
|
return new Response(null, { status: 200 });
|
|
2100
2169
|
}
|
|
2101
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
|
+
|
|
2102
2200
|
if (decision.kind === "setkey-submit") {
|
|
2103
2201
|
// THE TENANT FENCE (OPE-1680), above the handler — this is the branch that WRITES A SECRET into
|
|
2104
2202
|
// this deployment's store, keyed to the intake the payload names. A `view_submission` carries
|
|
@@ -2121,6 +2219,43 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2121
2219
|
return forwardNative();
|
|
2122
2220
|
}
|
|
2123
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
|
+
|
|
2124
2259
|
if (decision.kind === "join") {
|
|
2125
2260
|
// Channel-join intro. Only the BOT's own join is an invitation — a human joining a
|
|
2126
2261
|
// channel we're in fires the same event and must be dropped. Fail-CLOSED here, the
|
|
@@ -2130,7 +2265,38 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2130
2265
|
const jev = parseChannelJoinEvent(decision.env);
|
|
2131
2266
|
if (!jev) return OK();
|
|
2132
2267
|
const { userId: joinBotUserId } = await getBotIdentity();
|
|
2133
|
-
if (!joinBotUserId || jev.userId !== joinBotUserId)
|
|
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
|
+
}
|
|
2134
2300
|
|
|
2135
2301
|
// Same two-layer idempotency as the message wake: Slack retries a slow ack, and a
|
|
2136
2302
|
// double intro is exactly the annoyance the durable half exists to prevent.
|
|
@@ -2153,6 +2319,38 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2153
2319
|
return OK();
|
|
2154
2320
|
}
|
|
2155
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
|
+
|
|
2156
2354
|
if (decision.kind === "reaction") {
|
|
2157
2355
|
// Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
|
|
2158
2356
|
const rev = parseSlackReactionEvent(decision.env);
|