@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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +47 -0
- package/agent/access/slack-attachment-fetch.ts +168 -0
- package/agent/app-manifest.ts +149 -0
- package/agent/channels/slack-approval-card.ts +275 -0
- package/agent/channels/slack-attachments.ts +195 -0
- package/agent/channels/slack-commands.ts +227 -0
- package/agent/channels/slack-context.ts +185 -0
- package/agent/channels/slack-events.ts +5406 -0
- package/agent/channels/slack-front.ts +2349 -0
- package/agent/channels/slack-identity.ts +520 -0
- package/agent/channels/slack-input-response.ts +162 -0
- package/agent/channels/slack-mrkdwn.ts +61 -0
- package/agent/channels/slack-origin-codec.ts +62 -0
- package/agent/channels/slack-post-arm.ts +314 -0
- package/agent/channels/slack-read.ts +248 -0
- package/agent/channels/slack-route-hold.ts +107 -0
- package/agent/channels/slack-shortcut.ts +336 -0
- package/agent/channels/slack-signature.ts +53 -0
- package/agent/channels/slack-spend-gate.ts +62 -0
- package/agent/channels/slack-tenant-fence.ts +99 -0
- package/agent/channels/slack-wake-backscroll.ts +604 -0
- package/agent/channels/slack-web-api.ts +178 -0
- package/agent/channels/slack-webhook-verifier.ts +116 -0
- package/agent/channels/slack.ts +1266 -0
- package/agent/contributions.ts +32 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/ops.ts +50 -0
- package/agent/provider-events.ts +91 -0
- package/agent/register.ts +144 -0
- package/agent/surface-parity.ts +99 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
- package/dist/agent/access/slack-attachment-fetch.js +151 -0
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
- package/dist/agent/app-manifest.d.ts +15 -0
- package/dist/agent/app-manifest.d.ts.map +1 -0
- package/dist/agent/app-manifest.js +141 -0
- package/dist/agent/app-manifest.js.map +1 -0
- package/dist/agent/channels/slack-approval-card.d.ts +71 -0
- package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
- package/dist/agent/channels/slack-approval-card.js +232 -0
- package/dist/agent/channels/slack-approval-card.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +66 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
- package/dist/agent/channels/slack-attachments.js +174 -0
- package/dist/agent/channels/slack-attachments.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +27 -0
- package/dist/agent/channels/slack-commands.d.ts.map +1 -0
- package/dist/agent/channels/slack-commands.js +208 -0
- package/dist/agent/channels/slack-commands.js.map +1 -0
- package/dist/agent/channels/slack-context.d.ts +26 -0
- package/dist/agent/channels/slack-context.d.ts.map +1 -0
- package/dist/agent/channels/slack-context.js +178 -0
- package/dist/agent/channels/slack-context.js.map +1 -0
- package/dist/agent/channels/slack-events.d.ts +1654 -0
- package/dist/agent/channels/slack-events.d.ts.map +1 -0
- package/dist/agent/channels/slack-events.js +4240 -0
- package/dist/agent/channels/slack-events.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +387 -0
- package/dist/agent/channels/slack-front.d.ts.map +1 -0
- package/dist/agent/channels/slack-front.js +1958 -0
- package/dist/agent/channels/slack-front.js.map +1 -0
- package/dist/agent/channels/slack-identity.d.ts +245 -0
- package/dist/agent/channels/slack-identity.d.ts.map +1 -0
- package/dist/agent/channels/slack-identity.js +405 -0
- package/dist/agent/channels/slack-identity.js.map +1 -0
- package/dist/agent/channels/slack-input-response.d.ts +26 -0
- package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
- package/dist/agent/channels/slack-input-response.js +127 -0
- package/dist/agent/channels/slack-input-response.js.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
- package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
- package/dist/agent/channels/slack-mrkdwn.js +53 -0
- package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
- package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/slack-origin-codec.js +55 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -0
- package/dist/agent/channels/slack-post-arm.d.ts +54 -0
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/slack-post-arm.js +268 -0
- package/dist/agent/channels/slack-post-arm.js.map +1 -0
- package/dist/agent/channels/slack-read.d.ts +84 -0
- package/dist/agent/channels/slack-read.d.ts.map +1 -0
- package/dist/agent/channels/slack-read.js +205 -0
- package/dist/agent/channels/slack-read.js.map +1 -0
- package/dist/agent/channels/slack-route-hold.d.ts +34 -0
- package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
- package/dist/agent/channels/slack-route-hold.js +97 -0
- package/dist/agent/channels/slack-route-hold.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +162 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
- package/dist/agent/channels/slack-shortcut.js +262 -0
- package/dist/agent/channels/slack-shortcut.js.map +1 -0
- package/dist/agent/channels/slack-signature.d.ts +19 -0
- package/dist/agent/channels/slack-signature.d.ts.map +1 -0
- package/dist/agent/channels/slack-signature.js +50 -0
- package/dist/agent/channels/slack-signature.js.map +1 -0
- package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
- package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
- package/dist/agent/channels/slack-spend-gate.js +48 -0
- package/dist/agent/channels/slack-spend-gate.js.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
- package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
- package/dist/agent/channels/slack-tenant-fence.js +88 -0
- package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.js +473 -0
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
- package/dist/agent/channels/slack-web-api.d.ts +46 -0
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
- package/dist/agent/channels/slack-web-api.js +147 -0
- package/dist/agent/channels/slack-web-api.js.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/slack-webhook-verifier.js +112 -0
- package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/slack.d.ts +37 -0
- package/dist/agent/channels/slack.d.ts.map +1 -0
- package/dist/agent/channels/slack.js +1169 -0
- package/dist/agent/channels/slack.js.map +1 -0
- package/dist/agent/contributions.d.ts +17 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +32 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/ops.d.ts +3 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +38 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +84 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +118 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +95 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +209 -0
- package/tsconfig.build.json +22 -0
- 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
|
+
};
|