@open-everywhere/provider-slack 0.1.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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/slack-attachment-fetch.ts +168 -0
  5. package/agent/app-manifest.ts +149 -0
  6. package/agent/channels/slack-approval-card.ts +275 -0
  7. package/agent/channels/slack-attachments.ts +195 -0
  8. package/agent/channels/slack-commands.ts +227 -0
  9. package/agent/channels/slack-context.ts +185 -0
  10. package/agent/channels/slack-events.ts +5406 -0
  11. package/agent/channels/slack-front.ts +2349 -0
  12. package/agent/channels/slack-identity.ts +520 -0
  13. package/agent/channels/slack-input-response.ts +162 -0
  14. package/agent/channels/slack-mrkdwn.ts +61 -0
  15. package/agent/channels/slack-origin-codec.ts +62 -0
  16. package/agent/channels/slack-post-arm.ts +314 -0
  17. package/agent/channels/slack-read.ts +248 -0
  18. package/agent/channels/slack-route-hold.ts +107 -0
  19. package/agent/channels/slack-shortcut.ts +336 -0
  20. package/agent/channels/slack-signature.ts +53 -0
  21. package/agent/channels/slack-spend-gate.ts +62 -0
  22. package/agent/channels/slack-tenant-fence.ts +99 -0
  23. package/agent/channels/slack-wake-backscroll.ts +604 -0
  24. package/agent/channels/slack-web-api.ts +178 -0
  25. package/agent/channels/slack-webhook-verifier.ts +116 -0
  26. package/agent/channels/slack.ts +1266 -0
  27. package/agent/contributions.ts +32 -0
  28. package/agent/docs-generated.ts +15 -0
  29. package/agent/ops.ts +50 -0
  30. package/agent/provider-events.ts +91 -0
  31. package/agent/register.ts +144 -0
  32. package/agent/surface-parity.ts +99 -0
  33. package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
  34. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
  35. package/dist/agent/access/slack-attachment-fetch.js +151 -0
  36. package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
  37. package/dist/agent/app-manifest.d.ts +15 -0
  38. package/dist/agent/app-manifest.d.ts.map +1 -0
  39. package/dist/agent/app-manifest.js +141 -0
  40. package/dist/agent/app-manifest.js.map +1 -0
  41. package/dist/agent/channels/slack-approval-card.d.ts +71 -0
  42. package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
  43. package/dist/agent/channels/slack-approval-card.js +232 -0
  44. package/dist/agent/channels/slack-approval-card.js.map +1 -0
  45. package/dist/agent/channels/slack-attachments.d.ts +66 -0
  46. package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-attachments.js +174 -0
  48. package/dist/agent/channels/slack-attachments.js.map +1 -0
  49. package/dist/agent/channels/slack-commands.d.ts +27 -0
  50. package/dist/agent/channels/slack-commands.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-commands.js +208 -0
  52. package/dist/agent/channels/slack-commands.js.map +1 -0
  53. package/dist/agent/channels/slack-context.d.ts +26 -0
  54. package/dist/agent/channels/slack-context.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-context.js +178 -0
  56. package/dist/agent/channels/slack-context.js.map +1 -0
  57. package/dist/agent/channels/slack-events.d.ts +1654 -0
  58. package/dist/agent/channels/slack-events.d.ts.map +1 -0
  59. package/dist/agent/channels/slack-events.js +4240 -0
  60. package/dist/agent/channels/slack-events.js.map +1 -0
  61. package/dist/agent/channels/slack-front.d.ts +387 -0
  62. package/dist/agent/channels/slack-front.d.ts.map +1 -0
  63. package/dist/agent/channels/slack-front.js +1958 -0
  64. package/dist/agent/channels/slack-front.js.map +1 -0
  65. package/dist/agent/channels/slack-identity.d.ts +245 -0
  66. package/dist/agent/channels/slack-identity.d.ts.map +1 -0
  67. package/dist/agent/channels/slack-identity.js +405 -0
  68. package/dist/agent/channels/slack-identity.js.map +1 -0
  69. package/dist/agent/channels/slack-input-response.d.ts +26 -0
  70. package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
  71. package/dist/agent/channels/slack-input-response.js +127 -0
  72. package/dist/agent/channels/slack-input-response.js.map +1 -0
  73. package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
  74. package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
  75. package/dist/agent/channels/slack-mrkdwn.js +53 -0
  76. package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
  77. package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
  78. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
  79. package/dist/agent/channels/slack-origin-codec.js +55 -0
  80. package/dist/agent/channels/slack-origin-codec.js.map +1 -0
  81. package/dist/agent/channels/slack-post-arm.d.ts +54 -0
  82. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
  83. package/dist/agent/channels/slack-post-arm.js +268 -0
  84. package/dist/agent/channels/slack-post-arm.js.map +1 -0
  85. package/dist/agent/channels/slack-read.d.ts +84 -0
  86. package/dist/agent/channels/slack-read.d.ts.map +1 -0
  87. package/dist/agent/channels/slack-read.js +205 -0
  88. package/dist/agent/channels/slack-read.js.map +1 -0
  89. package/dist/agent/channels/slack-route-hold.d.ts +34 -0
  90. package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
  91. package/dist/agent/channels/slack-route-hold.js +97 -0
  92. package/dist/agent/channels/slack-route-hold.js.map +1 -0
  93. package/dist/agent/channels/slack-shortcut.d.ts +162 -0
  94. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
  95. package/dist/agent/channels/slack-shortcut.js +262 -0
  96. package/dist/agent/channels/slack-shortcut.js.map +1 -0
  97. package/dist/agent/channels/slack-signature.d.ts +19 -0
  98. package/dist/agent/channels/slack-signature.d.ts.map +1 -0
  99. package/dist/agent/channels/slack-signature.js +50 -0
  100. package/dist/agent/channels/slack-signature.js.map +1 -0
  101. package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
  102. package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
  103. package/dist/agent/channels/slack-spend-gate.js +48 -0
  104. package/dist/agent/channels/slack-spend-gate.js.map +1 -0
  105. package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
  106. package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
  107. package/dist/agent/channels/slack-tenant-fence.js +88 -0
  108. package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
  109. package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
  111. package/dist/agent/channels/slack-wake-backscroll.js +473 -0
  112. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
  113. package/dist/agent/channels/slack-web-api.d.ts +46 -0
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
  115. package/dist/agent/channels/slack-web-api.js +147 -0
  116. package/dist/agent/channels/slack-web-api.js.map +1 -0
  117. package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
  118. package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
  119. package/dist/agent/channels/slack-webhook-verifier.js +112 -0
  120. package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
  121. package/dist/agent/channels/slack.d.ts +37 -0
  122. package/dist/agent/channels/slack.d.ts.map +1 -0
  123. package/dist/agent/channels/slack.js +1169 -0
  124. package/dist/agent/channels/slack.js.map +1 -0
  125. package/dist/agent/contributions.d.ts +17 -0
  126. package/dist/agent/contributions.d.ts.map +1 -0
  127. package/dist/agent/contributions.js +32 -0
  128. package/dist/agent/contributions.js.map +1 -0
  129. package/dist/agent/docs-generated.d.ts +3 -0
  130. package/dist/agent/docs-generated.d.ts.map +1 -0
  131. package/dist/agent/docs-generated.js +13 -0
  132. package/dist/agent/docs-generated.js.map +1 -0
  133. package/dist/agent/ops.d.ts +3 -0
  134. package/dist/agent/ops.d.ts.map +1 -0
  135. package/dist/agent/ops.js +38 -0
  136. package/dist/agent/ops.js.map +1 -0
  137. package/dist/agent/provider-events.d.ts +3 -0
  138. package/dist/agent/provider-events.d.ts.map +1 -0
  139. package/dist/agent/provider-events.js +84 -0
  140. package/dist/agent/provider-events.js.map +1 -0
  141. package/dist/agent/register.d.ts +5 -0
  142. package/dist/agent/register.d.ts.map +1 -0
  143. package/dist/agent/register.js +118 -0
  144. package/dist/agent/register.js.map +1 -0
  145. package/dist/agent/surface-parity.d.ts +3 -0
  146. package/dist/agent/surface-parity.d.ts.map +1 -0
  147. package/dist/agent/surface-parity.js +95 -0
  148. package/dist/agent/surface-parity.js.map +1 -0
  149. package/package.json +209 -0
  150. package/tsconfig.build.json +22 -0
  151. package/tsconfig.json +15 -0
@@ -0,0 +1,520 @@
1
+ // Pure Slackโ†’OpenEverywhere identity capture AND interpretation. The Slack channel (slack.ts)
2
+ // calls the capture half to stow RAW Slack facts (team/channel/user ids + channel_type +
3
+ // is_private) into the session auth context's `attributes`; the INTERPRETATION of those facts into
4
+ // mode/visibility/trust (slackMode/slackVisibility/slackTrust + the bindSlack mapper) moved here
5
+ // from core identity/binding.ts in OPE-114 stage 4 โ€” still one tested place for the
6
+ // security-critical "is this private?" decision, now on the bundle side of the boundary.
7
+
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import {
10
+ declaredWorkspaceTrust,
11
+ rootDmGrainAttrs,
12
+ surfaceKeySegment,
13
+ type BindableContext,
14
+ type IdentityMapper,
15
+ type RawIdentity,
16
+ type SessionMode,
17
+ type Visibility,
18
+ } from "@open-everywhere/core/agent/adapter-api.js";
19
+
20
+ /** Structural match for eve's SessionAuthContext (attributes + principal). */
21
+ export interface OpenEverywhereSlackAuth {
22
+ readonly authenticator: string;
23
+ readonly principalId: string;
24
+ readonly principalType: string;
25
+ readonly attributes: Record<string, string>;
26
+ }
27
+
28
+ export interface SlackIdentityInput {
29
+ readonly userId: string;
30
+ readonly teamId?: string;
31
+ /**
32
+ * The Enterprise Grid organisation the event's workspace belongs to (`Eโ€ฆ`), when the event
33
+ * envelope carried one (OPE-1508). Absent on an ordinary single-workspace install, and absent on
34
+ * any ingress that never sees the envelope โ€” `slackEnterpriseIdFromEnvelope` (slack-events.ts) is
35
+ * the one reader, and slack-events.ts's own note says which ingresses reach it.
36
+ */
37
+ readonly enterpriseId?: string;
38
+ readonly channelId: string;
39
+ readonly threadTs?: string;
40
+ /** "channel" | "group" | "im" | "mpim" โ€” as known. */
41
+ readonly channelType?: string;
42
+ readonly isPrivate?: boolean;
43
+ /**
44
+ * Whether this conversation is externally shared (Slack Connect / cross-org). When
45
+ * we positively determined it (a conversations.info fetch succeeded), this is a
46
+ * boolean; `undefined` means we never determined it โ€” the memory trust gate fails
47
+ * closed on `undefined` for real channels (see binding.ts slackTrust).
48
+ */
49
+ readonly isExternal?: boolean;
50
+ /** True iff a conversations.info lookup succeeded, so `isExternal` is authoritative. */
51
+ readonly trustChecked?: boolean;
52
+ /**
53
+ * For a DM only: whether the PARTNER is an internal member, positively determined via a
54
+ * users.info lookup ("internal"|"external"). `undefined` = not determined โ†’ the binding
55
+ * fails closed (external), so a DM's shared read layer is `public`, never org/dm-shared.
56
+ */
57
+ readonly dmPartnerTrust?: "internal" | "external";
58
+ }
59
+
60
+ /** Build the auth context OpenEverywhere dispatches with โ€” raw facts as string attributes. */
61
+ export function buildSlackAuth(input: SlackIdentityInput): OpenEverywhereSlackAuth {
62
+ const attributes: Record<string, string> = {
63
+ channel_id: input.channelId,
64
+ user_id: input.userId,
65
+ };
66
+ if (input.teamId) attributes.team_id = input.teamId;
67
+ if (input.enterpriseId) attributes.enterprise_id = input.enterpriseId;
68
+ if (input.threadTs) attributes.thread_ts = input.threadTs;
69
+ if (input.channelType) attributes.channel_type = input.channelType;
70
+ if (typeof input.isPrivate === "boolean") attributes.is_private = String(input.isPrivate);
71
+ if (typeof input.isExternal === "boolean") attributes.is_ext_shared = String(input.isExternal);
72
+ if (typeof input.trustChecked === "boolean") attributes.trust_checked = String(input.trustChecked);
73
+ if (input.dmPartnerTrust) attributes.dm_partner_trust = input.dmPartnerTrust;
74
+ // Root-DM grain stamp (window-tool-gating ยงD-DM, widened to group DMs): a 1:1 DM OR an mpim (group
75
+ // DM) is a STANDING seat โ‡’ narrowed hands โ€” both are closed multi-party conversations with no
76
+ // channel identity. A DM is unambiguously channelType "im" (the DM path always sets it); the
77
+ // id-prefix "D" is the slackMode fallback for a DM with no type captured. mpim is ONLY matched via
78
+ // the explicit channelType โ€” Slack's legacy "G"-prefixed ids are ambiguous between mpim and a
79
+ // private "group" channel, so there is no safe id-prefix fallback for it (unlike "D"/DM);
80
+ // channelType undefined โ‡’ never inferred as mpim (fails closed to unstamped/full, not over-narrowed).
81
+ const isDmFamily = input.channelType === "im" || input.channelType === "mpim" || (input.channelType === undefined && input.channelId.startsWith("D"));
82
+ Object.assign(attributes, rootDmGrainAttrs(isDmFamily));
83
+ return { authenticator: "slack", principalId: input.userId, principalType: "slack-user", attributes };
84
+ }
85
+
86
+ // โ”€โ”€ THE FORWARDED ENVELOPE โ€” how the NATIVE FORWARD keeps its enterprise rung (OPE-1559) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87
+ //
88
+ // THE PROBLEM. `enterprise_id` sits on the Slack `event_callback` ENVELOPE, never on the inner
89
+ // event (slack-events.ts `slackEnterpriseIdFromEnvelope` states that, and it is the one reader).
90
+ // `slack-front.ts` forwards an ordinary `app_mention` to eve's native `slackChannel`, and eve hands
91
+ // the handler a `SlackMessage` whose `raw` is the INNER event. The envelope is gone by then, and
92
+ // eve's inbound context exposes none, so `slack.ts` had no envelope to read the rung off.
93
+ //
94
+ // THE CARRIER. The front route stows the envelope's rung for the extent of ONE forward, and
95
+ // `slack.ts` reads it back inside that forward. Nothing else may write it.
96
+ //
97
+ // WHY AsyncLocalStorage AND NOT A MODULE GLOBAL. One process serves many Slack deliveries at once.
98
+ // A module global would let workspace A's Grid id reach workspace B's mention โ€” the one direction
99
+ // that WIDENS trust, because a stamped rung can vouch a room the operator never declared. An
100
+ // AsyncLocalStorage scopes the value to the async call tree of the single forward that wrote it,
101
+ // which is exactly the extent of one delivery. Same primitive and same reasoning as
102
+ // approvals/arming-context.ts and access/connector-scope.ts.
103
+ //
104
+ // ๐Ÿ”ด AND THE READ STILL MATCHES THE MESSAGE. Async scoping alone would already be correct, so this
105
+ // is belt-and-braces on a value that can only ever widen trust: the stow carries the message the
106
+ // envelope described, and {@link forwardedSlackEnterpriseId} answers only for THAT message. A
107
+ // carrier that somehow outlived its forward therefore stamps nothing, rather than stamping the
108
+ // wrong room. The key is `(channelId, ts)` โ€” a Slack channel id is unique across Slack and a `ts` is
109
+ // unique within a channel, so the pair names one message. `teamId` is deliberately NOT in the key:
110
+ // `slack.ts` heals a missing team from the domain config, so a team-keyed read would refuse a
111
+ // healed message and lose the rung it just carried.
112
+ //
113
+ // FAIL-CLOSED IN BOTH DIRECTIONS. No carrier, a non-Grid envelope, or a mismatched message all
114
+ // answer `undefined`, and `buildSlackAuth` then stamps no `enterprise_id` โ€” which is precisely the
115
+ // behaviour before this existed: the room declares nothing and the member plane refuses.
116
+ export interface ForwardedSlackEnvelope {
117
+ /** The Enterprise Grid organisation the forwarded envelope named (`Eโ€ฆ`). */
118
+ readonly enterpriseId: string;
119
+ /** The channel of the inner event the envelope carried. */
120
+ readonly channelId: string;
121
+ /** The `ts` of the inner event the envelope carried. */
122
+ readonly ts: string;
123
+ }
124
+
125
+ const forwardedEnvelope = new AsyncLocalStorage<ForwardedSlackEnvelope>();
126
+
127
+ /**
128
+ * Run `fn` with `envelope` readable by anything it awaits. ONE caller โ€” `slack-front.ts`'s
129
+ * `forwardNative`, around the hand-off to eve's native `slackChannel`. Do not wrap anything else:
130
+ * the value means "the envelope of the delivery being forwarded right now", and a second writer
131
+ * would make that false.
132
+ *
133
+ * `undefined` runs `fn` with NO carrier, so an ordinary single-workspace install (and any body the
134
+ * front could not parse) costs one `AsyncLocalStorage.run` and stows nothing.
135
+ */
136
+ export function withForwardedSlackEnvelope<T>(envelope: ForwardedSlackEnvelope | undefined, fn: () => T): T {
137
+ return envelope === undefined ? fn() : forwardedEnvelope.run(envelope, fn);
138
+ }
139
+
140
+ /**
141
+ * The Enterprise Grid organisation of the message being handled on the native forward, or
142
+ * `undefined` outside a forward, on a non-Grid delivery, or for any message the carrier does not
143
+ * describe. See the block above for why the match is checked rather than assumed.
144
+ */
145
+ export function forwardedSlackEnterpriseId(message: { readonly channelId: string; readonly ts: string }): string | undefined {
146
+ const carried = forwardedEnvelope.getStore();
147
+ if (!carried) return undefined;
148
+ return carried.channelId === message.channelId && carried.ts === message.ts ? carried.enterpriseId : undefined;
149
+ }
150
+
151
+ export interface ChannelFacts {
152
+ readonly channelType?: string;
153
+ readonly isPrivate?: boolean;
154
+ /** Externally shared (Slack Connect / cross-org). undefined = not determined. */
155
+ readonly isExternal?: boolean;
156
+ /** True iff a conversations.info lookup succeeded (so isExternal is authoritative). */
157
+ readonly trustChecked?: boolean;
158
+ /** THE APP's OWN membership of this conversation, from `conversations.info`'s `is_member`.
159
+ * undefined = the response did not say (Slack omits the field on a DM, and a private room the
160
+ * app cannot see answers `channel_not_found` instead of a channel object). It is the app's
161
+ * membership, never a person's โ€” {@link ChannelFacts} carries no person. */
162
+ readonly appInChannel?: boolean;
163
+ /** DM partner trust (internal|external), positively determined via users.info; undefined = not determined. */
164
+ readonly dmPartnerTrust?: "internal" | "external";
165
+ }
166
+
167
+ /**
168
+ * The Enterprise Grid organisation a `users.info` user object belongs to (`Eโ€ฆ`), or `undefined`.
169
+ * Grid nests it as `enterprise_user: { enterprise_id }`; an ordinary workspace's user object carries
170
+ * no such field, so every level is probed rather than assumed (Slack's raw JSON).
171
+ */
172
+ function enterpriseIdOfUser(u: Record<string, unknown>): string | undefined {
173
+ const eu = u["enterprise_user"];
174
+ if (!eu || typeof eu !== "object") return undefined;
175
+ const id = (eu as Record<string, unknown>)["enterprise_id"];
176
+ return typeof id === "string" && id.length > 0 ? id : undefined;
177
+ }
178
+
179
+ /**
180
+ * Does the operator's tracked config vouch this Slack WORKSPACE โ€” the rung walk a person's home
181
+ * workspace goes through when it is NOT the workspace the event arrived in (OPE-1508, Tommy's ruling
182
+ * of 2026-09-07).
183
+ *
184
+ * ๐Ÿ”ด THE PROBLEM IT SOLVES. On Enterprise Grid a person's home `team_id` is their own workspace, not
185
+ * the workspace the message was posted in, so the plain `team_id === expectedTeamId` test read every
186
+ * Grid colleague as a stranger. The fix is NOT to stop comparing: it is to ask the register whether
187
+ * the author's OWN workspace is vouched, and the enterprise rung is what lets one line answer for
188
+ * every workspace in the Grid โ€”
189
+ * `defineSurface({ key: "slack/E0GRID", trust: "internal" })`.
190
+ *
191
+ * ๐Ÿ”ด ABSENCE INFERS NON-MEMBER, which is the ruling's other half. An undeclared sibling workspace is
192
+ * a GUEST here, exactly as a Slack Connect stranger from an unrelated org is. The operator vouches by
193
+ * writing a line in `agent/identity/surfaces.ts`; there is no probe that could infer it, and a Grid
194
+ * an operator has not named is not one this deployment has asserted anything about.
195
+ *
196
+ * PURE and SYNCHRONOUS โ€” the register read is (identity/trust.ts's own contract), so this adds no
197
+ * I/O to a leg that already pays for one `users.info`.
198
+ */
199
+ export function slackWorkspaceVouched(teamId: string | undefined, enterpriseId: string | undefined): boolean {
200
+ const team = surfaceKeySegment(teamId);
201
+ if (team.length === 0) return false; // no workspace named โ‡’ nothing to ask about โ‡’ not vouched
202
+ const enterprise = surfaceKeySegment(enterpriseId);
203
+ return (
204
+ declaredWorkspaceTrust(`slack/${team}`, enterprise.length === 0 ? undefined : `slack/${enterprise}`) === "internal"
205
+ );
206
+ }
207
+
208
+ /**
209
+ * DM-partner trust from a `users.info` response โ€” the authoritative source for "is this person an
210
+ * internal member?". Defensive (Slack's raw JSON) and FAIL-CLOSED: a missing/failed lookup, a guest
211
+ * (restricted / ultra_restricted), a Connect stranger, a deleted account, or a workspace this
212
+ * deployment does not admit all yield `external`. Mirrors channelFactsFromInfo for the DM universe,
213
+ * and โ€” since OPE-466 โ€” it is also the ONE reader behind the per-author membership probe
214
+ * (`slackAuthorIsMember`), so a DM partner and a channel/group-DM author cannot drift apart on what
215
+ * a member is.
216
+ *
217
+ * ๐Ÿ”ด THE WORKSPACE TEST IS A RUNG WALK, NOT AN EQUALITY (OPE-1508). The author's OWN workspace
218
+ * answers first and needs no declaration: `team_id === expectedTeamId` is a member, which is what
219
+ * every single-workspace deployment has always relied on. A DIFFERENT workspace then asks the
220
+ * operator's register through {@link slackWorkspaceVouched}, so an Enterprise Grid colleague whose
221
+ * home `team_id` is a sibling workspace is a member when the operator declared the Grid, and a guest
222
+ * when they did not. Read the ruling in {@link slackWorkspaceVouched}.
223
+ *
224
+ * ๐Ÿ”ด AN ABSENT `expectedTeamId` IS A GUEST QUESTION, NOT A SKIPPED ONE. A caller that knows no
225
+ * workspace gets the register walk alone, so an undeclared author is a guest. The body says which
226
+ * live envelope produces that absence and what admitting it cost.
227
+ *
228
+ * ๐Ÿ”ด A BOT IS JUDGED BY ITS WORKSPACE, WITH NO CARVE-OUT (OPE-1508, the same ruling). `is_bot` used
229
+ * to refute on its own, which marked the deployment's OWN admitted apps as guests โ€” including the
230
+ * e2e driver bot in a group DM. It is gone from the refutation: the workspace's own app carries that
231
+ * workspace's `team_id` and is a member, a foreign app carries another and is a guest unless the
232
+ * operator vouched its workspace. `deleted` still refutes, because a deleted account is not a member
233
+ * of anything.
234
+ *
235
+ * ๐Ÿ”ด WHAT THIS WIDENS โ€” EVERY CONSUMER, NOT THE FIRST ONE. This verdict has four readers, and both
236
+ * halves of the ruling (a bot is not refuted; a declared foreign workspace is admitted) reach all
237
+ * four. Naming one and calling it plain is how a wrong claim survives:
238
+ * 1. the 1:1 DM's SHARED READ LAYER โ€” `internal` selects the org brain, so a DM with the
239
+ * workspace's own app now binds `internal` where it bound `external`;
240
+ * 2. the CHAT AUTHOR TAINT โ€” `slackAuthorIsMember` (slack-events.ts), which stamps
241
+ * `oe_chat_nonmember`, fences the author's words, and is what the member plane refuses on;
242
+ * 3. the APPROVAL CLICKER FLOOR leg D (access/../approvals/clicker-floor.ts), through
243
+ * `slackClickerIsGuest` โ€” who may EXECUTE a member-tier approval card;
244
+ * 4. the HITL ANSWER FLOOR leg B (approvals/input-response-floor.ts), same probe โ€” who may resume
245
+ * a parked turn, which is a spend commitment.
246
+ * So a workspace-installed app's bot user, and a person in a partner workspace the operator declared
247
+ * internal, now clear (3) and (4) where they were refused as guests. That is the ruling applied
248
+ * consistently rather than a side effect โ€” the bot rule was asked and answered at the author leg,
249
+ * and one function is what keeps the four readers from disagreeing โ€” but it is a wider change than
250
+ * the DM layer alone, so it is written here in full.
251
+ *
252
+ * The guest flags are unchanged and still refute on every one of the four. Consumers (3) and (4)
253
+ * describe this same rule in their own headers, corrected under OPE-1558 โ€” they sit behind the merge
254
+ * fence's approvals-plane rule, which is why the correction landed in its own reviewed change rather
255
+ * than in this one.
256
+ */
257
+ export function partnerFactsFromInfo(info: unknown, expectedTeamId?: string): { dmPartnerTrust: "internal" | "external" } {
258
+ const u = (info as { user?: Record<string, unknown> } | null)?.user;
259
+ if (!u || typeof u !== "object") return { dmPartnerTrust: "external" }; // lookup failed โ†’ fail closed
260
+ const guestOrStranger = Boolean(u["is_restricted"] || u["is_ultra_restricted"] || u["is_stranger"]);
261
+ const deleted = Boolean(u["deleted"]);
262
+ const homeTeamId = typeof u["team_id"] === "string" ? (u["team_id"] as string) : undefined;
263
+ // ๐Ÿ”ด NO EXPECTED WORKSPACE IS NOT AN OWN-WORKSPACE ANSWER. This used to read the absence as
264
+ // "member", which admitted EVERY author the guest flags did not refute. The absence is reachable
265
+ // from the callers, not hypothetical: `slack-events.ts` builds its `teamId` as
266
+ // `envelope.team_id ?? e.team` and `slack-input-response.ts` reads the raw `team_id` attribute, so
267
+ // an inbound payload that names no workspace hands `undefined` down here. The shape expected to
268
+ // produce it is a Grid ORG-WIDE install, which carries an `enterprise_id`; that vendor detail is
269
+ // NOT verified here, and nothing below rests on it โ€” the arm is fail-closed either way. With
270
+ // `is_bot` gone from the refutation (the ruling above), a
271
+ // FOREIGN app's bot user carrying no `is_restricted` / `is_stranger` flag then resolved as a
272
+ // MEMBER โ€” no `oe_chat_nonmember` stamp, no untrusted fence, and the same wrong verdict on both
273
+ // approval floors. So absence falls to the register walk below, exactly as a differing workspace
274
+ // does: ABSENCE INFERS NON-MEMBER. A Grid the operator declared still answers `internal` here,
275
+ // because the walk reads the author's OWN home workspace and enterprise off `users.info` and needs
276
+ // nothing from the envelope.
277
+ const ownWorkspace = expectedTeamId !== undefined && expectedTeamId.length > 0 && homeTeamId === expectedTeamId;
278
+ const admittedWorkspace = ownWorkspace || slackWorkspaceVouched(homeTeamId, enterpriseIdOfUser(u));
279
+ const internal = !guestOrStranger && !deleted && admittedWorkspace;
280
+ return { dmPartnerTrust: internal ? "internal" : "external" };
281
+ }
282
+
283
+ /** What an app_mention envelope tells us directly (often just channel_type). */
284
+ export function channelFactsFromRaw(raw: Record<string, unknown>): ChannelFacts {
285
+ const channelType = typeof raw["channel_type"] === "string" ? (raw["channel_type"] as string) : undefined;
286
+ return { channelType };
287
+ }
288
+
289
+ /**
290
+ * Facts from a `conversations.info` response โ€” the authoritative source for the
291
+ * public/private bit AND the external-sharing bit that app_mention events don't carry.
292
+ * Defensive: the response is Slack's raw JSON, so everything is probed, never assumed.
293
+ *
294
+ * External sharing is the memory trust signal: a Slack Connect / shared / org-shared
295
+ * channel may contain people outside the org, so its memory is gated off entirely
296
+ * (binding.ts slackTrust). We treat ANY of is_shared / is_ext_shared / is_org_shared /
297
+ * is_pending_ext_shared as external. A successful fetch sets `trustChecked: true` so the
298
+ * binding can distinguish "checked, internal" from "never checked" (which fails closed).
299
+ */
300
+ export function channelFactsFromInfo(info: unknown): ChannelFacts {
301
+ const ch = (info as { channel?: Record<string, unknown> } | null)?.channel;
302
+ if (!ch || typeof ch !== "object") return {};
303
+ const isPrivate = typeof ch["is_private"] === "boolean" ? (ch["is_private"] as boolean) : undefined;
304
+ const isExternal = Boolean(
305
+ ch["is_ext_shared"] || ch["is_org_shared"] || ch["is_shared"] || ch["is_pending_ext_shared"],
306
+ );
307
+ let channelType: string | undefined;
308
+ if (ch["is_im"]) channelType = "im";
309
+ else if (ch["is_mpim"]) channelType = "mpim";
310
+ else if (ch["is_group"] || ch["is_private"]) channelType = "group";
311
+ else if (ch["is_channel"]) channelType = "channel";
312
+ // `is_member` is the APP's own membership of the room. Slack sends it for a channel/group and
313
+ // omits it for a DM, so a missing field stays undefined rather than becoming false โ€” the
314
+ // readability rule (slack-events.ts appCanReadConversation) decides what a missing field means
315
+ // per channel type, and this function only reports what Slack said.
316
+ //
317
+ // ๐Ÿ”ด THE PRIVATE-CHANNEL HALF IS MEASURED (OPE-1447, 2026-09-08). A live `conversations.info` on a
318
+ // private fixture channel the caller is in answered `is_member: true` โ€” the field is PRESENT for a
319
+ // private channel, so `appCanReadConversation` returns readable and the shortcut route keeps that
320
+ // room's files. It also answered `is_private: true, is_channel: true, is_group: false`: Slack no
321
+ // longer returns the legacy `is_group` shape for a private channel, which is why the `is_private`
322
+ // arm above is load-bearing rather than belt-and-braces. Measured with a USER token, not the app's
323
+ // bot token, so what stays unproven is narrow: whether a BOT token gets the same field.
324
+ const appInChannel = typeof ch["is_member"] === "boolean" ? (ch["is_member"] as boolean) : undefined;
325
+ // The fetch succeeded (we have a channel object), so trust is authoritatively known.
326
+ return { channelType, isPrivate, isExternal, trustChecked: true, ...(appInChannel === undefined ? {} : { appInChannel }) };
327
+ }
328
+
329
+ /**
330
+ * Can THIS APP read the conversation {@link channelFactsFromInfo} just described? Positive and
331
+ * FAIL-CLOSED: it says yes only on a probe that SUCCEEDED and said the app is in the room.
332
+ *
333
+ * The rule differs by channel type because Slack's answer does:
334
+ * โ€ข a `channel` or `group` carries `is_member`, which is exactly "is this app in the room" โ€” the
335
+ * fact `conversations.history` enforces when it refuses a non-member with `not_in_channel`. A
336
+ * public channel the app never joined answers `is_member: false` on a lookup that otherwise
337
+ * SUCCEEDS, so `trustChecked` alone would admit it;
338
+ * โ€ข an `im` or `mpim` carries no `is_member`, so this arm rests on an INFERENCE, not on a checked
339
+ * fact: Slack is documented to answer `conversations.info` for a DM only to an app that is in
340
+ * it, and a room the app cannot see answers `channel_not_found` โ€” which reaches here as no
341
+ * channel object at all, hence no `trustChecked`. STILL UNMEASURED (OPE-1447), in the same sense
342
+ * `shortcutWakeKey`'s trigger-id assumption is: no offline tier can drive a live
343
+ * `conversations.info` against a DM the app is not in. A 2026-09-08 attempt got as far as a
344
+ * `channel_not_found` for a non-member caller, which POINTS the right way but proves nothing:
345
+ * neither e2e Slack token holds `im:read`, so the member CONTROL could not be driven, and
346
+ * `channel_not_found` without it may be the no-scope answer rather than the no-membership one.
347
+ * Settling it needs one probe under the app's own BOT token, on a ship soak. If the inference is
348
+ * wrong, this arm admits that DM's files. The channel/group arm below does not depend on it;
349
+ * โ€ข anything else โ€” a failed probe, a missing token, an unrecognised channel type โ€” is NO.
350
+ *
351
+ * The Slack shortcut route reads it (OPE-1442): Slack delivers a `message_action` for a room the
352
+ * app is not in, so this is what keeps the target message's FILES from being fetched with the bot
353
+ * token out of a room the agent's own channel reads refuse.
354
+ */
355
+ export function appCanReadConversation(facts: ChannelFacts): boolean {
356
+ if (facts.trustChecked !== true) return false;
357
+ if (facts.channelType === "im" || facts.channelType === "mpim") return true;
358
+ return facts.appInChannel === true;
359
+ }
360
+
361
+ // --- the identity MAPPER (moved out of core identity/binding.ts in OPE-114 stage 4) ---------
362
+ // Interprets the raw facts the Slack channel stowed in auth attributes:
363
+ // team_id, channel_id, channel_type ("channel"|"group"|"im"|"mpim"),
364
+ // is_private ("true"|"false"), user_id.
365
+ // Registered by registerSlackProvider() via registerIdentityMapper("slack", bindSlack).
366
+
367
+ /** The trust verdict a mapper contributes โ€” mirrors core binding.ts's Trust union. */
368
+ type Trust = NonNullable<RawIdentity["trust"]>;
369
+
370
+ /** Slack channel_type / id โ†’ session mode (channel | dm | mpim). */
371
+ export function slackMode(channelType: string | undefined, channelId: string | undefined): SessionMode {
372
+ if (channelType === "im") return "dm";
373
+ if (channelType === "mpim") return "mpim";
374
+ if (channelType === "channel" || channelType === "group") return "channel";
375
+ // No type captured โ€” the `D` id prefix reliably marks a DM; otherwise assume a channel.
376
+ if (channelId && channelId.startsWith("D")) return "dm";
377
+ return "channel";
378
+ }
379
+
380
+ /**
381
+ * Slack facts โ†’ visibility. FAIL CLOSED: anything we can't positively confirm as
382
+ * a public channel is treated as `private`, so a surface's learnings never leak
383
+ * up to shared memory on an under-specified event. (A correctly-wired channel
384
+ * always sets is_private, so the fail-closed branch is the misconfigured/unknown
385
+ * case, where private is the safe default.)
386
+ */
387
+ export function slackVisibility(
388
+ channelType: string | undefined,
389
+ isPrivate: string | undefined,
390
+ mode: SessionMode,
391
+ ): Visibility {
392
+ if (isPrivate === "true") return "private";
393
+ if (isPrivate === "false") return "public";
394
+ // DMs / group-DMs are never "public" surfaces.
395
+ if (mode === "dm" || mode === "mpim") return "private";
396
+ if (channelType === "channel") return "public"; // an explicit public channel
397
+ return "private"; // unknown -> fail closed
398
+ }
399
+
400
+ /**
401
+ * Slack sharing facts โ†’ trust. FAIL CLOSED for shared memory: a positively
402
+ * externally-shared channel (`is_ext_shared="true"`) is `external`; so is a channel we
403
+ * never managed to check (`trust_checked` not "true") โ€” because on a real Slack channel
404
+ * we always attempt conversations.info, so an absent check means the lookup failed and
405
+ * we can't vouch for the membership.
406
+ *
407
+ * ๐Ÿ”ด A GROUP DM READS THE PLATFORM'S POSITIVE EXTERNAL SIGNAL, EXACTLY AS A CHANNEL DOES
408
+ * (OPE-1486, Tommy's 2026-09-07 ruling). A group DM is a shared room with several people in it,
409
+ * so `binding.trust` on an mpim is the DEMOTION half of the member plane's G4b vouch
410
+ * (access/member-plane.ts `roomVouchedInternalFrom`): the operator's declaration vouches the room
411
+ * at any rung, and the platform's `is_ext_shared` is what forbids that declaration re-promoting a
412
+ * Slack Connect group DM. Returning a blanket `internal` here made that demotion unreachable, so a
413
+ * cross-org group DM was vouched under a workspace-wide `internal` line.
414
+ *
415
+ * ๐Ÿ”ด THE mpim DEMOTION IS POSITIVE-ONLY โ€” the channel's `trust_checked` fail-close does NOT apply
416
+ * to it, and that asymmetry is the ruling rather than an oversight. A group DM has no shared-memory
417
+ * silo to protect (it is DM-family), and its `conversations.info` needs the separate `mpim:read`
418
+ * scope, so failing an unchecked mpim closed would demote every group DM in a workspace that never
419
+ * granted that scope โ€” a deployment-wide regression bought for no signal. What demotes an mpim is
420
+ * evidence, never the absence of it. ๐Ÿ”ด THE LIMIT THAT BUYS: a workspace WITHOUT `mpim:read` resolves
421
+ * neither fact, so a Slack Connect group DM there is still vouched under a coarse declaration. The
422
+ * runbook (docs/deploy/surfaces/slack.md) states that precondition to the operator.
423
+ *
424
+ * `binding.trust` on an mpim has READERS BEYOND G4b, and every one of them TIGHTENS on this
425
+ * demotion: the role resolve falls to the `public` domain, domain skills drop, and the
426
+ * access-request, agent-create and identity-link paths refuse. None widens, and memory is untouched
427
+ * โ€” a group DM reads no shared silo at any trust (memory/resolve.ts).
428
+ *
429
+ * A 1:1 DM never reaches the sharing question at all: `bindSlack` binds a `dm` from the PARTNER's
430
+ * verified trust, so `mode === "dm"` here answers the personal silo's `internal` unchanged.
431
+ */
432
+ export function slackTrust(
433
+ isExternal: string | undefined,
434
+ trustChecked: string | undefined,
435
+ mode: SessionMode,
436
+ ): Trust {
437
+ if (mode === "dm") return "internal"; // personal silo; the partner's trust is bindSlack's own read
438
+ if (isExternal === "true") return "external"; // Slack Connect / shared / org-shared โ€” a group DM too
439
+ if (mode === "mpim") return "internal"; // DM-family: demoted by evidence only, never by its absence
440
+ if (trustChecked !== "true") return "external"; // never confirmed โ†’ fail closed
441
+ return "internal";
442
+ }
443
+
444
+ // The two attribute helpers are LOCAL COPIES of core binding.ts's module-private probes (attr /
445
+ // authPrincipalId) โ€” deliberately not barrel ports (the notion bundle set the precedent): they are
446
+ // three-line structural reads over the public BindableContext shape, and a mapper author outside
447
+ // core is expected to read its own platform's attributes itself.
448
+
449
+ type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
450
+
451
+ function slackAuthOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
452
+ return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
453
+ }
454
+
455
+ function slackAttr(a: AuthAttrs, keys: readonly string[]): string | undefined {
456
+ if (!a) return undefined;
457
+ for (const k of keys) {
458
+ const v = a[k];
459
+ if (typeof v === "string" && v.length > 0) return v;
460
+ if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
461
+ }
462
+ return undefined;
463
+ }
464
+
465
+ // Auth principalId, treating empty string as absent so it falls through to the user_id
466
+ // attribute rather than binding a blank id.
467
+ function slackAuthPrincipalId(ctx: BindableContext): string | undefined {
468
+ const pid = slackAuthOf(ctx)?.principalId;
469
+ return pid && pid.length > 0 ? pid : undefined;
470
+ }
471
+
472
+ // Eve's own auth identities (e.g. the approval-resume step's ctx, rebuilt from the
473
+ // approval click rather than our wake stamp) carry the composite "slack:<team>:<user>"
474
+ // AuthKey form, where buildSlackAuth stamps the RAW user id. Every scopeKey (grants,
475
+ // partition, memory) is keyed on the raw id, so a composite resolving here splits the
476
+ // caller's identity across the approval boundary (live 2026-07-21: the post-approval
477
+ // connector gate refused "ungranted" โ€” scopeKey dm:slack:Tโ€ฆ:Uโ€ฆ vs the granted dm:Uโ€ฆ).
478
+ // Raw Slack ids never contain ":", so collapsing to the last segment is loss-free.
479
+ function rawSlackPrincipal(pid: string | undefined): string | undefined {
480
+ if (!pid || !pid.startsWith("slack:")) return pid;
481
+ const last = pid.slice(pid.lastIndexOf(":") + 1);
482
+ return last.length > 0 ? last : undefined;
483
+ }
484
+
485
+ export const bindSlack: IdentityMapper = (ctx): RawIdentity => {
486
+ const a = slackAuthOf(ctx)?.attributes;
487
+ const channelId = slackAttr(a, ["channel_id", "channelId"]);
488
+ const channelType = slackAttr(a, ["channel_type", "channelType"]);
489
+ const isPrivate = slackAttr(a, ["is_private", "isPrivate"]);
490
+ const mode = slackMode(channelType, channelId);
491
+ // For a DM, trust = the verified PARTNER trust (users.info, stowed by slack.ts). Absent โ†’
492
+ // undefined โ†’ bindFrom fails the DM closed. For a channel, the conversations.info verdict.
493
+ const dmRaw = slackAttr(a, ["dm_partner_trust", "dmPartnerTrust"]);
494
+ const dmPartnerTrust: Trust | undefined = dmRaw === "internal" || dmRaw === "external" ? dmRaw : undefined;
495
+ return {
496
+ workspace: slackAttr(a, ["team_id", "teamId"]),
497
+ // The ENTERPRISE rung above the workspace (OPE-1508). Present only on an Enterprise Grid event
498
+ // whose envelope carried `enterprise_id`, so an ordinary install contributes nothing and its
499
+ // trust walk is unchanged. Core spells it and composes `slack/<enterpriseId>`; one declaration
500
+ // at that key vouches every workspace in the Grid, and the workspace's own line still wins.
501
+ trustEnterprise: slackAttr(a, ["enterprise_id", "enterpriseId"]),
502
+ surface: channelId,
503
+ mode,
504
+ visibility: slackVisibility(channelType, isPrivate, mode),
505
+ trust: mode === "dm" ? dmPartnerTrust : slackTrust(slackAttr(a, ["is_ext_shared", "isExtShared"]), slackAttr(a, ["trust_checked", "trustChecked"]), mode),
506
+ trustVerified: mode === "dm" ? dmPartnerTrust !== undefined : undefined,
507
+ principalId: rawSlackPrincipal(slackAuthPrincipalId(ctx)) ?? slackAttr(a, ["user_id", "userId"]),
508
+ // Routine post-back targets the channel (a fresh message), not an old thread.
509
+ target: channelId ? { channelId } : undefined,
510
+ // Eve's adapter sets thread_ts to the triggering message's own ts when it isn't a
511
+ // thread reply, so a channel mention always carries the thread its reply lands in.
512
+ // The unified divert forwards the RAW event auth, which for a top-level mention has
513
+ // no thread_ts โ€” its anchor rides the oe_reply_anchor stamp instead (unified-window.ts
514
+ // OE_REPLY_ANCHOR_ATTR), so approval cards/post_file/notices thread under the mention
515
+ // on the divert path exactly as they do natively (live-found 2026-07-19: J3's card
516
+ // posted to the channel root). The wake ORIGIN stays anchor-free โ€” ยงS2/ownership
517
+ // still read a top-level wake as top-level.
518
+ threadTs: slackAttr(a, ["thread_ts", "threadTs"]) ?? slackAttr(a, ["oe_reply_anchor", "oeReplyAnchor"]),
519
+ };
520
+ };