Skip to content

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.

Read-only token Python and Node.js Tests included
Grayscale photo of people standing on stage
Photo by NATHAN MULLET on Unsplash
The short answer

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.

Installed broadgroups:historygrantedSecurity passthe scope istrimmedReinstallmonths latermessage.groupsstopsno error anywhereBlamed onprivatemembership auditedtwice
There is no artefact anywhere. Not a missing_scope, because nobody called a method; not a delivery failure, because there was no delivery. Only a subscription that has never produced traffic.

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.

Subscribed against grantedtwo switches, four statesSubscribed, scope absentnever sent, never errorsAnd handled in codea feature that never ranScope held, not subscribedthe note next doorEvent not in the tablechecked by handBoth switches ondelivered
Subscription and scope do not imply each other, so there are four states rather than two. Reporting the middle two as one thing produces the wrong repair half the time.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_event_scope_gap.py
"""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())
slack-event-scope-gap.mjs
/**
 * 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.

test_slack_event_scope_gap.py
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"
slack-event-scope-gap.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.