Diagnostic Slack
A subscribed event whose scope the token never received
The app handles messages in public channels perfectly. In private channels it is deaf. The subscription is there, on the same screen, one line below the one that works; the handler is the same function; the logs have nothing in them because there is nothing to log.
No method refused anything. No error was returned, because no request was made. The Events API is gated by the same scopes as the Web API, and an event whose scope the token does not hold is simply never sent.
Subscribing to an event does not entitle you to receive it. Delivery is gated by the authorizing token's scopes: message.groups needs groups:history, message.im needs im:history, reaction_added needs reactions:read. Subscribe without the scope and the event is quietly not delivered, forever, with nothing anywhere to read.
That silence is why this is a diff and not a probe. Set A is the subscribed events, from apps.manifest.export. Set B is the granted scopes, from the X-OAuth-Scopes header that Slack returns on every Web API response. The script maps each event to the scopes that would satisfy it, treats that list as an OR, and reports the subscriptions that can never fire. Without a configuration token it runs the same check against the list of events your code handles, where a handler for an unscoped event is dead code.
The problem in plain words
What makes this one expensive is that the app mostly works. If nothing arrived, somebody would question the whole setup on day one. Instead, mentions arrive and reactions do not; public channels are fine and private ones are not; the app answers direct messages and ignores group DMs. A partial failure gets attributed to the part of the system that varies, and the part that varies looks like the channel type, so the investigation goes into channel visibility, membership and Slack Connect before it gets anywhere near the token.
The two ways in are both ordinary. The first is drift: an app is installed with a broad grant, somebody later trims the scope list as part of a security pass, the subscription stays behind, and delivery of that one event stops on the next reinstall with nothing to connect the two events months apart. The second is that the app configuration will let you subscribe to an event whose scope you never requested, and the install proceeds normally, and the feature has simply never worked since the day it shipped.
Either way there is no artefact. No missing_scope, because missing_scope comes from a method call and this is not a method call. No delivery failure, because there was no delivery. No entry in any log on your side or anything you can query on Slack's. The only evidence is a subscription that has never produced traffic, and absence of traffic is not something an application notices about itself.
Why it happens
This is not missing_scope. That error is Slack refusing a method you called and telling you exactly what was needed and provided. Here nobody calls anything: Slack decides not to send an event, and there is no request to answer. Same underlying grant, opposite direction, and no error string to search for.
The mapping is per event and lives on each event's reference page. There is no method that returns "the scopes this event requires", so a detector has to carry a table. The script's table is explicit and reports an event it does not recognise as unknown rather than assuming it is fine, because a silent pass is exactly the failure mode being audited.
The requirement is frequently an OR, and treating it as an AND creates false findings. member_joined_channel is satisfied by channels:read or groups:read depending on the channel type. An audit that demands both will tell a team to widen a grant that was already correct.
Subscription and scope are two switches and neither implies the other. That gives four states, not two: both present is live, both absent is simply not a feature, subscribed without the scope is this note, and scoped without the subscription is the note next door. Reporting the last two as one thing produces the wrong repair half the time.
An undeliverable event that your code handles is worse than one nobody handles. The first is a feature somebody wrote, tested against a mock, shipped and believes is running. The script ranks it above the other, because that is the row that has a person behind it.
The fix, as a flow
The cause here is three steps and several months away from the symptom, and the two are joined by a reinstall that nobody connected to either of them. The chain ends on a misattribution rather than on the failure, because the part of the system that varies is the channel type and that is where the search goes. The fix branch is a two by two: two switches, four states, and only one of them is this note.
How to fix it
Read the grant from a header rather than from the app config screen
X-OAuth-Scopes comes back on every Web API response and carries the calling token's complete current scope list. One auth.test gets it. The scope list on the configuration screen describes what will be requested at the next install, which is a different thing and is precisely the gap that produces this failure.
Read the subscribed events, or supply what your code handles
With an app configuration token, apps.manifest.export gives you settings.event_subscriptions.bot_events[]. Without one, pass --handles for each event type your code has a handler for. The second list is often the more interesting one anyway, because it is what somebody believed was running.
Resolve each event against the table, as an OR
pair_state takes one event and asks two questions: is it subscribed, and is any of its scopes granted. Any single scope in the list satisfies the event. An event the table does not know is returned as unknown-event and is never quietly counted as deliverable.
Sort the four states apart and keep them apart
live, undeliverable, idle-scope and absent. Only undeliverable is this note. idle-scope is a scope you hold with no subscription behind it, which belongs to the note on an app that subscribes to nothing, and the script says so instead of merging them.
Rank by whether somebody wrote code for it
severity puts an undeliverable event with a handler above one without, then unknown events, then everything else. A feature that was written, reviewed and shipped and has never once run is a different conversation from a subscription somebody left behind.
Print the scope, and then the reinstall
Adding the scope in the app configuration changes nothing about tokens already issued. The printed repair names the scope, names the reinstall, and says to put the scope and the subscription in the manifest together so they cannot drift apart again.
How to check it worked
Re-run after the reinstall. The scope header should have changed, and every subscribed event should resolve to live.
python3 slack_event_scope_gap.py --app-id A01ABCDE9 --handles message.groups
# identity U0APPBOT11 in acme
# grant 11 scope(s) on this token
# events 4 subscribed, 1 handled by your code
# message.groups live groups:history is granted and the event is subscribed
# app_mention live app_mentions:read is granted and the event is subscribed
# verdict clear no subscribed event is missing the scope it needs
The full code
One GET for the header, one optional GET for the manifest, and a table. pair_state is the function to read first: it takes the two switches that govern whether an event ever arrives and returns the four states they produce, which is the difference between this note and the one next door written down in code. audit walks the union of what is subscribed and what your code handles, and severity ranks the result by whether a person is waiting on it.
"""Find subscribed events that can never be delivered to this token.
Read only. One auth.test to read the X-OAuth-Scopes header, and one optional
apps.manifest.export on an app configuration token to read the subscribed
events. Nothing is subscribed, granted or reinstalled here: this diffs two
lists and prints the scope you would add.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_event_scope_gap")
API = "https://slack.com/api/"
# Each event mapped to the scopes that would satisfy its delivery. The list is
# an OR: any one of them is enough. member_joined_channel is the row that makes
# that matter, because which scope applies depends on the channel type, and an
# audit that demanded both would tell a team to widen a correct grant.
EVENT_SCOPES = {
"app_mention": ["app_mentions:read"],
"message.channels": ["channels:history"],
"message.groups": ["groups:history"],
"message.im": ["im:history"],
"message.mpim": ["mpim:history"],
"reaction_added": ["reactions:read"],
"reaction_removed": ["reactions:read"],
"file_created": ["files:read"],
"file_shared": ["files:read"],
"file_public": ["files:read"],
"member_joined_channel": ["channels:read", "groups:read"],
"member_left_channel": ["channels:read", "groups:read"],
"channel_created": ["channels:read"],
"channel_archive": ["channels:read"],
"channel_unarchive": ["channels:read"],
"channel_rename": ["channels:read"],
"group_open": ["groups:read"],
"team_join": ["users:read"],
"user_change": ["users:read"],
"pin_added": ["pins:read"],
"pin_removed": ["pins:read"],
"emoji_changed": ["emoji:read"],
"app_home_opened": [],
"app_uninstalled": [],
"tokens_revoked": [],
}
# Ordering for the report. An undeliverable event somebody wrote a handler for
# outranks one nobody uses, because that row has a person behind it who thinks
# the feature is running.
RANK = {"undeliverable-handled": 0, "undeliverable": 1, "unknown-event": 2,
"idle-scope": 3, "live": 4, "absent": 5}
def scope_set(header):
"""Split the X-OAuth-Scopes header into a set. Pure."""
return {p.strip() for p in str(header or "").split(",") if p.strip()}
def pair_state(event, subscribed, scopes):
"""The two switches that govern whether an event ever arrives. Pure.
Returns (state, detail). Subscription and scope are independent, so there
are four states and not two, and only one of them is this note:
live subscribed and scoped, so delivery happens
undeliverable subscribed with the scope missing, silently never sent
idle-scope scoped with no subscription, which is a different note
absent neither, which is simply not a feature of this app
"""
name = str(event or "").strip()
if not name:
return ("absent", "no event name was given")
needed = EVENT_SCOPES.get(name)
if needed is None:
return ("unknown-event",
"%s is not in this script's table, so its scope requirement was "
"not checked. Read the event's reference page rather than "
"assuming it is fine" % name)
have = {str(s).strip() for s in scopes or [] if str(s).strip()}
satisfied = [s for s in needed if s in have]
if not needed:
return ("live" if subscribed else "absent",
"%s requires no scope; it is %s" % (
name, "subscribed" if subscribed else "not subscribed"))
if subscribed and satisfied:
return ("live", "%s is granted and the event is subscribed" % satisfied[0])
if subscribed and not satisfied:
return ("undeliverable",
"subscribed, and none of %s is on this token, so %s is never "
"delivered and never errors" % (" or ".join(needed), name))
if satisfied:
return ("idle-scope",
"%s is granted and %s is not subscribed, which is a subscription "
"gap rather than a scope gap" % (satisfied[0], name))
return ("absent", "%s is neither subscribed nor scoped" % name)
def audit(subscribed, handled, scopes, subscriptions_known=True):
"""Walk every event either configured or handled by your code. Pure.
Returns rows of (event, state, detail, handled). subscriptions_known is
the difference between the script's two modes. With a configuration token
the subscription list is a fact, and an event you handle but never
subscribed to is the note next door. Without one there is no list, so a
handler is taken as the declaration of intent it obviously is.
"""
subs = {str(e).strip() for e in subscribed or [] if str(e).strip()}
mine = {str(e).strip() for e in handled or [] if str(e).strip()}
rows = []
for name in sorted(subs | mine):
claimed = name in subs or (not subscriptions_known and name in mine)
state, detail = pair_state(name, claimed, scopes)
if state == "undeliverable" and name in mine:
detail += ". Your code handles this event, so that handler has never run"
rows.append((name, state, detail, name in mine))
return rows
def severity(rows):
"""Order the rows so the worst finding is read first. Pure."""
def key(row):
name, state, _, handled = row
bucket = "undeliverable-handled" if (state == "undeliverable" and handled) \
else state
return (RANK.get(bucket, 6), name)
return sorted(rows or [], key=key)
def get(session, method, params, label):
"""One GET, asserting on the body rather than the status line."""
r = session.get(API + method, params=params or {}, timeout=30)
body = r.json()
if body.get("ok") is not True:
log.warning("%-10s %-14s %s", label, "unavailable", body.get("error"))
return None, r.headers.get("X-OAuth-Scopes", "")
return body, r.headers.get("X-OAuth-Scopes", "")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--config-token-env", default="SLACK_CONFIG_TOKEN",
help="environment variable holding an app configuration token")
ap.add_argument("--app-id", default="", help="app ID, for the manifest read")
ap.add_argument("--handles", action="append", default=[],
help="an event type your code has a handler for; repeatable")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s; any read scope works, the header is what matters",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
who, header = get(s, "auth.test", {}, "auth.test")
if not who:
return 2
scopes = scope_set(header)
log.info("identity %s in %s", who.get("user_id"), who.get("team"))
log.info("grant %d scope(s) on this token", len(scopes))
subscribed, known = [], False
config_token = os.environ.get(args.config_token_env)
if config_token and args.app_id:
c = requests.Session()
c.headers.update({"Authorization": "Bearer " + config_token})
body, _ = get(c, "apps.manifest.export", {"app_id": args.app_id}, "manifest")
if body:
settings = ((body or {}).get("manifest") or {}).get("settings") or {}
subs = settings.get("event_subscriptions") or {}
subscribed = list(subs.get("bot_events") or []) + list(subs.get("user_events") or [])
known = True
if not known:
log.info("manifest skipped no subscription list was read, so each "
"--handles event is taken as an intent to receive it")
rows = severity(audit(subscribed, args.handles, scopes, known))
log.info("events %d subscribed, %d handled by your code",
len(subscribed), len(args.handles))
bad = 0
for name, state, detail, handled in rows:
if state in ("undeliverable", "unknown-event"):
bad += 1 if state == "undeliverable" else 0
log.warning("%-21s %-14s %s", name, state, detail)
else:
log.info("%-21s %-14s %s", name, state, detail)
if bad:
needed = sorted({sc for name, state, _, _ in rows if state == "undeliverable"
for sc in EVENT_SCOPES.get(name, [])})
log.warning(" repair: add one of %s under OAuth and Permissions, Bot Token "
"Scopes", ", ".join(needed))
log.warning(" repair: reinstall the app and replace the stored token; a "
"token is a snapshot of the grant at install time")
log.warning(" repair: keep the scope and the subscription together in the "
"manifest, because neither one implies the other")
return 1
log.info("verdict clear no subscribed event is missing the scope "
"it needs")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find subscribed events that can never be delivered to this token.
*
* Read only. One auth.test to read the X-OAuth-Scopes header, and one optional
* apps.manifest.export on an app configuration token to read the subscribed
* events. Nothing is subscribed, granted or reinstalled here: this diffs two
* lists and prints the scope you would add.
*/
const API = 'https://slack.com/api/';
// Each event mapped to the scopes that would satisfy its delivery. The list is
// an OR: any one of them is enough. member_joined_channel is the row that makes
// that matter, because which scope applies depends on the channel type.
const EVENT_SCOPES = new Map([
['app_mention', ['app_mentions:read']],
['message.channels', ['channels:history']],
['message.groups', ['groups:history']],
['message.im', ['im:history']],
['message.mpim', ['mpim:history']],
['reaction_added', ['reactions:read']],
['reaction_removed', ['reactions:read']],
['file_created', ['files:read']],
['file_shared', ['files:read']],
['file_public', ['files:read']],
['member_joined_channel', ['channels:read', 'groups:read']],
['member_left_channel', ['channels:read', 'groups:read']],
['channel_created', ['channels:read']],
['channel_archive', ['channels:read']],
['channel_unarchive', ['channels:read']],
['channel_rename', ['channels:read']],
['group_open', ['groups:read']],
['team_join', ['users:read']],
['user_change', ['users:read']],
['pin_added', ['pins:read']],
['pin_removed', ['pins:read']],
['emoji_changed', ['emoji:read']],
['app_home_opened', []],
['app_uninstalled', []],
['tokens_revoked', []],
]);
const RANK = {
'undeliverable-handled': 0, undeliverable: 1, 'unknown-event': 2,
'idle-scope': 3, live: 4, absent: 5,
};
/** Split the X-OAuth-Scopes header into a set. Pure. */
export function scopeSet(header) {
return new Set(String(header ?? '').split(',').map((p) => p.trim()).filter(Boolean));
}
/**
* The two switches that govern whether an event ever arrives. Pure.
* Subscription and scope are independent, so there are four states and not
* two, and only undeliverable is this note.
*/
export function pairState(event, subscribed, scopes) {
const name = String(event ?? '').trim();
if (!name) return ['absent', 'no event name was given'];
const needed = EVENT_SCOPES.get(name);
if (needed === undefined) {
return ['unknown-event',
`${name} is not in this script's table, so its scope requirement was not ` +
'checked. Read the event\u2019s reference page rather than assuming it is fine'];
}
const have = new Set([...(scopes ?? [])].map((s) => String(s).trim()).filter(Boolean));
const satisfied = needed.filter((s) => have.has(s));
if (!needed.length) {
return [subscribed ? 'live' : 'absent',
`${name} requires no scope; it is ${subscribed ? 'subscribed' : 'not subscribed'}`];
}
if (subscribed && satisfied.length) {
return ['live', `${satisfied[0]} is granted and the event is subscribed`];
}
if (subscribed) {
return ['undeliverable',
`subscribed, and none of ${needed.join(' or ')} is on this token, so ${name} ` +
'is never delivered and never errors'];
}
if (satisfied.length) {
return ['idle-scope',
`${satisfied[0]} is granted and ${name} is not subscribed, which is a ` +
'subscription gap rather than a scope gap'];
}
return ['absent', `${name} is neither subscribed nor scoped`];
}
/**
* Walk every event either configured or handled by your code. Pure.
* subscriptionsKnown is the difference between the script's two modes: with a
* configuration token the subscription list is a fact, and an event you handle
* but never subscribed to is the note next door; without one, a handler is
* taken as the declaration of intent it obviously is.
*/
export function audit(subscribed, handled, scopes, subscriptionsKnown = true) {
const subs = new Set((subscribed ?? []).map((e) => String(e).trim()).filter(Boolean));
const mine = new Set((handled ?? []).map((e) => String(e).trim()).filter(Boolean));
const rows = [];
for (const name of [...new Set([...subs, ...mine])].sort()) {
const claimed = subs.has(name) || (!subscriptionsKnown && mine.has(name));
const [state, base] = pairState(name, claimed, scopes);
const detail = (state === 'undeliverable' && mine.has(name))
? `${base}. Your code handles this event, so that handler has never run`
: base;
rows.push([name, state, detail, mine.has(name)]);
}
return rows;
}
/** Order the rows so the worst finding is read first. Pure. */
export function severity(rows) {
const bucket = ([, state, , handled]) => (
state === 'undeliverable' && handled ? 'undeliverable-handled' : state);
return [...(rows ?? [])].sort((a, b) => {
const ra = RANK[bucket(a)] ?? 6;
const rb = RANK[bucket(b)] ?? 6;
return ra - rb || a[0].localeCompare(b[0]);
});
}
async function get(token, method, params, label) {
const qs = new URLSearchParams(params ?? {});
const res = await fetch(`${API}${method}?${qs}`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
if (body.ok !== true) {
console.warn(`${label.padEnd(10)} ${'unavailable'.padEnd(14)} ${body.error}`);
return [null, res.headers.get('x-oauth-scopes') ?? ''];
}
return [body, res.headers.get('x-oauth-scopes') ?? ''];
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
function argAll(args, name) {
const out = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
}
return out;
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}; any read scope works, the header is what matters`);
process.exitCode = 2;
return;
}
const configTokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_TOKEN');
const appId = arg(args, '--app-id', '');
const handles = argAll(args, '--handles');
const [who, header] = await get(token, 'auth.test', {}, 'auth.test');
if (!who) { process.exitCode = 2; return; }
const scopes = scopeSet(header);
console.log(`identity ${who.user_id} in ${who.team}`);
console.log(`grant ${scopes.size} scope(s) on this token`);
let subscribed = [];
let known = false;
const configToken = process.env[configTokenEnv];
if (configToken && appId) {
const [body] = await get(configToken, 'apps.manifest.export', { app_id: appId },
'manifest');
if (body) {
const subs = body?.manifest?.settings?.event_subscriptions ?? {};
subscribed = [...(subs.bot_events ?? []), ...(subs.user_events ?? [])];
known = true;
}
}
if (!known) {
console.log('manifest skipped no subscription list was read, so each ' +
'--handles event is taken as an intent to receive it');
}
const rows = severity(audit(subscribed, handles, scopes, known));
console.log(`events ${subscribed.length} subscribed, ${handles.length} ` +
'handled by your code');
let bad = 0;
for (const [name, state, detail] of rows) {
if (state === 'undeliverable' || state === 'unknown-event') {
if (state === 'undeliverable') bad += 1;
console.warn(`${name.padEnd(21)} ${state.padEnd(14)} ${detail}`);
} else {
console.log(`${name.padEnd(21)} ${state.padEnd(14)} ${detail}`);
}
}
if (bad) {
const needed = [...new Set(rows
.filter(([, state]) => state === 'undeliverable')
.flatMap(([name]) => EVENT_SCOPES.get(name) ?? []))].sort();
console.warn(` repair: add one of ${needed.join(', ')} under OAuth and ` +
'Permissions, Bot Token Scopes');
console.warn(' repair: reinstall the app and replace the stored token; a token ' +
'is a snapshot of the grant at install time');
console.warn(' repair: keep the scope and the subscription together in the ' +
'manifest, because neither one implies the other');
process.exitCode = 1;
} else {
console.log('verdict clear no subscribed event is missing the scope ' +
'it needs');
}
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Three assertions do the work. The scope requirement is an OR, so member_joined_channel has to pass on channels:read alone and on groups:read alone. A scope held with no subscription behind it has to come back as idle-scope and not as a finding, because that row belongs to a different note and a different repair. And an event the table does not recognise has to be reported rather than waved through, since waving it through is the exact behaviour this script exists to catch Slack doing.
from slack_event_scope_gap import audit, pair_state, scope_set, severity
def test_the_scope_header_is_split_and_trimmed():
assert scope_set("channels:read, groups:history ,users:read") == {
"channels:read", "groups:history", "users:read"}
assert scope_set("") == set()
assert scope_set(None) == set()
def test_subscribed_and_scoped_is_live():
state, detail = pair_state("message.groups", True, {"groups:history"})
assert state == "live"
assert "groups:history is granted" in detail
def test_subscribed_without_the_scope_is_the_finding():
state, detail = pair_state("message.groups", True, {"channels:history"})
assert state == "undeliverable"
assert "never delivered and never errors" in detail
def test_a_scope_with_no_subscription_belongs_to_the_other_note():
state, detail = pair_state("message.groups", False, {"groups:history"})
assert state == "idle-scope"
assert "subscription gap rather than a scope gap" in detail
def test_neither_switch_is_simply_not_a_feature():
assert pair_state("message.groups", False, set())[0] == "absent"
def test_the_scope_requirement_is_an_or_and_either_option_satisfies_it():
assert pair_state("member_joined_channel", True, {"channels:read"})[0] == "live"
assert pair_state("member_joined_channel", True, {"groups:read"})[0] == "live"
assert pair_state("member_joined_channel", True, {"users:read"})[0] == "undeliverable"
def test_an_or_list_is_printed_as_a_choice_not_a_requirement():
_, detail = pair_state("member_joined_channel", True, set())
assert "channels:read or groups:read" in detail
def test_an_event_needing_no_scope_is_live_on_subscription_alone():
assert pair_state("app_home_opened", True, set())[0] == "live"
assert pair_state("app_home_opened", False, set())[0] == "absent"
def test_an_unrecognised_event_is_reported_rather_than_waved_through():
state, detail = pair_state("message.channels.somethingnew", True, set())
assert state == "unknown-event"
assert "reference page" in detail
assert pair_state("", True, set())[0] == "absent"
def test_the_audit_covers_the_union_of_subscribed_and_handled():
rows = audit(["app_mention"], ["message.groups"], {"app_mentions:read"})
assert [r[0] for r in rows] == ["app_mention", "message.groups"]
assert rows[0][1] == "live"
def test_without_a_subscription_list_a_handler_stands_in_for_one():
rows = audit([], ["message.groups"], set(), subscriptions_known=False)
assert rows[0][1] == "undeliverable"
assert "has never run" in rows[0][2]
def test_with_a_subscription_list_a_handler_is_not_evidence_of_a_subscription():
rows = audit([], ["message.groups"], {"groups:history"})
assert rows[0][1] == "idle-scope"
def test_a_handled_undeliverable_event_says_the_handler_has_never_run():
rows = audit(["message.groups"], ["message.groups"], set())
assert "has never run" in rows[0][2]
assert rows[0][3] is True
def test_an_unhandled_undeliverable_event_does_not_claim_a_handler():
rows = audit(["message.groups"], [], set())
assert "has never run" not in rows[0][2]
def test_the_handled_gap_is_ranked_above_the_unhandled_one():
rows = severity(audit(["message.groups", "reaction_added"],
["reaction_added"], set()))
assert [r[0] for r in rows] == ["reaction_added", "message.groups"]
def test_healthy_rows_sort_below_every_finding():
rows = severity(audit(["app_mention", "message.groups"], [],
{"app_mentions:read"}))
assert rows[0][1] == "undeliverable"
assert rows[-1][1] == "live"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { audit, pairState, scopeSet, severity } from './slack-event-scope-gap.mjs';
test('the scope header is split and trimmed', () => {
assert.deepEqual([...scopeSet('channels:read, groups:history ,users:read')].sort(),
['channels:read', 'groups:history', 'users:read']);
assert.equal(scopeSet('').size, 0);
assert.equal(scopeSet(null).size, 0);
});
test('subscribed and scoped is live', () => {
const [state, detail] = pairState('message.groups', true, ['groups:history']);
assert.equal(state, 'live');
assert.match(detail, /groups:history is granted/);
});
test('subscribed without the scope is the finding', () => {
const [state, detail] = pairState('message.groups', true, ['channels:history']);
assert.equal(state, 'undeliverable');
assert.match(detail, /never delivered and never errors/);
});
test('a scope with no subscription belongs to the other note', () => {
const [state, detail] = pairState('message.groups', false, ['groups:history']);
assert.equal(state, 'idle-scope');
assert.match(detail, /subscription gap rather than a scope gap/);
});
test('neither switch is simply not a feature', () => {
assert.equal(pairState('message.groups', false, [])[0], 'absent');
});
test('the scope requirement is an or and either option satisfies it', () => {
assert.equal(pairState('member_joined_channel', true, ['channels:read'])[0], 'live');
assert.equal(pairState('member_joined_channel', true, ['groups:read'])[0], 'live');
assert.equal(pairState('member_joined_channel', true, ['users:read'])[0],
'undeliverable');
});
test('an or list is printed as a choice not a requirement', () => {
const [, detail] = pairState('member_joined_channel', true, []);
assert.match(detail, /channels:read or groups:read/);
});
test('an event needing no scope is live on subscription alone', () => {
assert.equal(pairState('app_home_opened', true, [])[0], 'live');
assert.equal(pairState('app_home_opened', false, [])[0], 'absent');
});
test('an unrecognised event is reported rather than waved through', () => {
const [state, detail] = pairState('message.channels.somethingnew', true, []);
assert.equal(state, 'unknown-event');
assert.match(detail, /reference page/);
assert.equal(pairState('', true, [])[0], 'absent');
});
test('the audit covers the union of subscribed and handled', () => {
const rows = audit(['app_mention'], ['message.groups'], ['app_mentions:read']);
assert.deepEqual(rows.map((r) => r[0]), ['app_mention', 'message.groups']);
assert.equal(rows[0][1], 'live');
});
test('without a subscription list a handler stands in for one', () => {
const rows = audit([], ['message.groups'], [], false);
assert.equal(rows[0][1], 'undeliverable');
assert.match(rows[0][2], /has never run/);
});
test('with a subscription list a handler is not evidence of a subscription', () => {
const rows = audit([], ['message.groups'], ['groups:history']);
assert.equal(rows[0][1], 'idle-scope');
});
test('a handled undeliverable event says the handler has never run', () => {
const rows = audit(['message.groups'], ['message.groups'], []);
assert.match(rows[0][2], /has never run/);
assert.equal(rows[0][3], true);
});
test('an unhandled undeliverable event does not claim a handler', () => {
const rows = audit(['message.groups'], [], []);
assert.doesNotMatch(rows[0][2], /has never run/);
});
test('the handled gap is ranked above the unhandled one', () => {
const rows = severity(audit(['message.groups', 'reaction_added'],
['reaction_added'], []));
assert.deepEqual(rows.map((r) => r[0]), ['reaction_added', 'message.groups']);
});
test('healthy rows sort below every finding', () => {
const rows = severity(audit(['app_mention', 'message.groups'], [],
['app_mentions:read']));
assert.equal(rows[0][1], 'undeliverable');
assert.equal(rows[rows.length - 1][1], 'live');
});
FAQ
Why is there no missing_scope error for an event that never arrives?
Because missing_scope is a response, and a response needs a request. When you call a method without the scope, Slack refuses the call and names what was needed and what was provided. When an event lacks the scope, Slack simply does not send it: there is no call, no response and nothing to attach an error to. Same grant, opposite direction, and only one of the two directions is loud.
Where is the scope for each event documented?
On the event's own reference page, which names the scopes that make it deliverable. There is no method that returns the mapping, which is why any detector has to carry a table and why this one reports an event it does not recognise instead of assuming it is fine. If you add an event type this script has not heard of, it will say so rather than pass it.
I removed a scope during a security review. Could that have done this?
Yes, and it is the most common way in, because the two halves are separated by months. Trimming the grant does not touch the subscription list, so the subscription stays and delivery of that event stops at the next reinstall. Nothing logs it on either side. That is why the repair is to keep the scope and the subscription together in the manifest, where a diff will show one moving without the other.
The subscription is there and the scope is there. Why is the event still missing?
Then this is not your problem and the next place to look is delivery rather than entitlement. Either the app subscribes to nothing that fires in that conversation type, or the request URL is not receiving, or Slack disabled delivery after sustained failures. Each of those is a separate note, and the script's clear verdict is meant to move you on rather than leave you rereading the scope list.
Does adding the scope fix it immediately?
No. A token carries the grant it was issued with, so the app has to be reinstalled and the stored token replaced before anything changes. For a distributed app that means every installation re-authorizes, which is a rollout rather than a config change. Check the X-OAuth-Scopes header afterwards, because that header is the only thing that tells you the new grant actually landed.
Related field notes
- the same gap on a method call, where Slack tells you
- the other half of the pair: scope held, nothing subscribed
- the same grant read for surplus rather than for gaps
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Events API - Slack Docs
- Event types - Slack Docs
- Scopes - Slack Docs
- apps.manifest.export method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.