Skip to content

Diagnostic Slack

Socket Mode apps cannot be listed on the Marketplace

The app has been running happily for a year. It was built on Socket Mode on the first afternoon, because Socket Mode is genuinely the fastest way from nothing to a working Slack app, and it has never given anybody a moment's trouble.

Then a customer asks whether they can install it in their workspace. Then two more do. Then somebody starts the Marketplace submission and discovers that the answer is not a form to fill in: the delivery architecture the app was built on is not one the Marketplace accepts, and the work is not a submission, it is a migration.

Read-only token Python and Node.js Tests included
Two smiling women walk towards a modern green house.
Photo by Cabri Caldwell on Unsplash
The short answer

Socket Mode apps cannot be listed on the Slack Marketplace. A publicly distributed app has to expose a public HTTPS Request URL and receive events over the Events API. Socket Mode exists for apps that live behind a firewall and for internal or org-deployed use, and that is a product boundary rather than a scope, a setting or anything you can negotiate in code.

Because the constraint is about the transport rather than the logic, it is readable ahead of time. apps.manifest.export gives you settings.socket_mode_enabled; your own OAuth configuration says whether the app is meant to leave this workspace; and auth.test across the installations you have stored says whether it already has, by counting distinct team_id values. Socket Mode plus more than one workspace is an app that is already distributed on a transport that cannot be listed.

It compounds, too. Marketplace approval is also what lifts the clamp on conversations.history for non-Marketplace apps, so a Socket Mode app that reads history is held at the clamped limits with no route out that does not go through the same rewrite. And the false positive is worth stating plainly: Socket Mode in a single internal workspace is the supported use of the feature and must never be reported as a fault.

The problem in plain words

The reason this catches good teams is that Socket Mode is the correct choice right up until the moment it is not, and nothing marks the moment. There is no deprecation warning, no ok: false, no rate limit, no email. The app works exactly as well on the day it becomes commercially interesting as it did on the day it was written, and the constraint only surfaces when somebody reads the distribution requirements, which is usually after a customer has been promised a date.

What makes it expensive is that the change is infrastructural rather than logical. Bolt supports both receivers, so most of the application code survives a move to HTTP unchanged. What does not survive is the deployment: an app that has never needed an inbound public endpoint now needs one, with TLS, a hostname, a load balancer if it matters, signature verification on every request, and a three-second response budget that a socket consumer never had to think about. That is a platform project appearing in the middle of a sales conversation.

The org-deployment case is the one people get wrong in the other direction. Socket Mode is allowed for apps deployed across an Enterprise Grid organisation, so an app installed in eleven workspaces inside one enterprise is not evidence of anything. Eleven workspaces across eleven unrelated enterprises is. The difference is enterprise_id, and a check that counts team_id alone reports the healthy Grid case as a violation.

Then there is the quiet compounding with rate limits. Slack tightened conversations.history for apps that are not on the Marketplace, and Marketplace listing is the route back to the old limits. An app on Socket Mode is not eligible for that listing, so if it polls history it is stuck at the clamped rate permanently, and the two problems have exactly one shared fix. Discovering them separately, six months apart, is how one architectural decision gets rediscovered twice.

Socket Mode,day onethe fastest way toworkingA year of itworkingno error, everA customer asksto installthen two more doThe submissionis readlisting needs apublic URLA platformprojectin the middle of asale
Nothing here goes wrong. The app works, the customers arrive, and the delivery architecture chosen on the first afternoon turns out to be the one thing that cannot come with it.

Why it happens

Eligibility is a fact about the transport, not about the code. Nothing in the handler, the scopes or the manifest's feature list moves it. That is what makes it checkable early and cheaply: two fields and a count, months before anybody opens the submission form.

Intent is readable before it is acted on. An OAuth redirect configuration, org deployment being enabled, and a public install flow are all signs that the app is meant to leave this workspace. Crossing those with the socket switch turns "we might distribute this one day" into a decision with a known cost attached today.

Installations you already hold are the strongest evidence there is. More than one unrelated team_id in your installation store means the app is distributed in fact, whatever the plan said. The script counts enterprise_id alongside it, because a Grid organisation is many workspaces and one customer.

The correct use of Socket Mode must not be reported as a fault. One workspace, no redirect URLs, no distribution intent: that is the feature working as designed, and a checker that flags it teaches people to ignore the checker. internal-fine is a verdict here, printed as such.

The clamp and the listing share one fix, so they should be found together. If the app reads conversations.history, the transport decision has already cost it the un-clamped rate limits as well as the listing. Reporting both at once is the difference between one architecture conversation and two.

The migration is countable, so count it. Every surface that needs a public URL after the move — events, interactivity, options, each slash command — is in the manifest already. Printing that list turns "we would have to rewrite it" into a specific number of endpoints, which is a much better sentence to bring to a planning meeting.

The fix, as a flow

The break is at the very first arrow here, on the afternoon somebody chose Socket Mode, and every box after it is a year of the app working perfectly. That is what makes this one expensive: there is no failure to notice, no error to log and no warning to heed, only a road that ends when somebody finally reads the distribution requirements. The fix branch sorts an app into what it can become rather than what is wrong with it, and the fourth row is the supported use of the feature, printed as a verdict so that nobody reports it as a fault.

The manifest, and yourinstallsthe switch, redirects, team idsSocket, and unrelated workspacesdistributed alreadySocket, and a redirect setupa migration, not a submissionSocket across one enterpriseorg deployment, a ceilingSocket, one workspace, no plansthe feature, used correctlyHistory reads, on eitherthe clamp shares the repair
Intent and reality disagree more often than anybody expects, so the plan and the installation records are read separately. The last row is the second half of the same decision, and it is cheaper to find it in the same run.

How to fix it

Read the socket switch out of the manifest, not out of memory

apps.manifest.export with an app configuration token, or a manifest JSON passed with --manifest. A bot token cannot read app configuration, so this is one of the few checks in this section that needs a second credential class.

Decide whether this app is even meant to leave the workspace

distribution_intent returns distributed, preparing, internal or unreadable from the OAuth redirect configuration and the org deployment flag. An internal app on Socket Mode is not a finding and the script has to be able to say so.

Count workspaces and enterprises, not just installs

install_spread takes the auth.test responses you already store per installation and separates multi-workspace from grid-org. Eleven workspaces in one enterprise is org deployment, which Socket Mode supports; eleven unrelated ones are distribution, which it does not.

Cross the transport with the intent

eligibility returns blocked-and-shipped, blocked, org-only, internal-fine or eligible. The first is the emergency: the app is already installed in unrelated workspaces on a transport that cannot be listed.

Add up the clamp while you are here

clamp_exposure asks whether the app reads conversations.history. If it does, the same transport decision has already cost it the un-clamped rate limits, and the two findings share one repair. Finding them separately, six months apart, is the expensive way.

Print the endpoints the migration needs, and change nothing

migration_surfaces lists every surface that will need a public URL. Editing a manifest, creating an app and submitting to the Marketplace are all writes and all human decisions; the script produces the list and stops.

How to check it worked

After the migration the manifest has no socket and a URL on every surface, and the same command comes back eligible.

python3 slack_distribution_block.py --manifest app.json --installs installs.json \
    --reads-history
# manifest   socket mode on
# intent     preparing      an OAuth redirect configuration is present, so this app is
#                           set up to be installed somewhere other than here
# installs   multi-workspace  4 workspace(s) across 0 enterprise(s): they do not
#                           all share one enterprise, so this app is already distributed
# verdict    blocked-and-shipped  already installed in unrelated workspaces on a
#                           transport the Marketplace does not accept
# clamp      compounded     listing is also what lifts the conversations.history clamp,
#                           so the same rewrite is the only route to either
# migrate    needs-url      event subscriptions
# migrate    needs-url      interactivity
# migrate    needs-url      slash /report
#   repair: stand up a public HTTPS endpoint, configure the 3 surface(s) above
#   repair: switch Socket Mode off, re-verify, and keep it on a separate dev app

The full code

A manifest read, your own installation records, and five pure functions that never touch the network. distribution_intent and install_spread answer the plan and the reality separately, because those disagree more often than anybody expects. eligibility is the cross, and it has a verdict for the case that is not a fault at all. clamp_exposure pulls in the rate-limit half of the same decision, and migration_surfaces turns a vague rewrite into a countable list of endpoints.

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_distribution_block.py
"""Check whether this app can ever be listed, before somebody promises it.

Read only. apps.manifest.export and auth.test are reads; nothing here edits a
manifest, creates an app, submits anything or opens a socket. Every repair is
printed for a human to carry out.

Socket Mode apps cannot be listed on the Slack Marketplace: a publicly
distributed app must expose a public HTTPS Request URL. That is a property of
the transport rather than of the code, which means it can be checked months
before the submission form is opened, from two manifest fields and a count of
the workspaces your own installation store already holds.
"""
import argparse
import json
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_distribution_block")

API = "https://slack.com/api/"


def socket_enabled(manifest):
    """Is Socket Mode on? Pure. None when the manifest could not be read."""
    if not isinstance(manifest, dict) or not manifest:
        return None
    settings = manifest.get("settings")
    if not isinstance(settings, dict):
        return False
    return bool(settings.get("socket_mode_enabled"))


def distribution_intent(manifest):
    """Is this app meant to leave this workspace? Pure. Returns (intent, detail).

    There is no single manifest field that says "this app is distributed", so
    this reads the configuration that distribution requires: an OAuth redirect
    setup, and org deployment. An internal app on Socket Mode is the supported
    use of the feature and this function exists so it can be said out loud.
    """
    if not isinstance(manifest, dict) or not manifest:
        return ("unreadable", "no manifest, so the intent cannot be read")
    settings = manifest.get("settings") if isinstance(manifest.get("settings"),
                                                      dict) else {}
    oauth = manifest.get("oauth_config") if isinstance(manifest.get("oauth_config"),
                                                       dict) else {}
    redirects = [r for r in (oauth.get("redirect_urls") or []) if r]
    if settings.get("is_distributed") is True:
        return ("distributed", "the manifest reports the app as distributed")
    if settings.get("org_deploy_enabled"):
        return ("preparing", "org deployment is enabled, so this app is set up to be "
                             "installed across an organisation")
    if redirects:
        return ("preparing", "%d OAuth redirect URL(s) are configured, so this app is "
                             "set up to be installed somewhere other than here"
                             % len(redirects))
    return ("internal", "no redirect URLs and no org deployment: this app installs "
                        "here and nowhere else")


def install_spread(rows):
    """How far has this app already spread? Pure.

    rows are auth.test-shaped dicts from your own installation store. Counting
    team_id alone is the mistake: an Enterprise Grid organisation is many
    workspaces and one customer, and Socket Mode is permitted there. Unrelated
    enterprises are the finding.

    Returns (spread, teams, orgs, detail).
    """
    teams, orgs, loose = set(), set(), 0
    for row in rows or []:
        if not isinstance(row, dict) or not row.get("team_id"):
            continue
        teams.add(str(row["team_id"]))
        if row.get("enterprise_id"):
            orgs.add(str(row["enterprise_id"]))
        else:
            loose += 1
    if not teams:
        return ("unknown", 0, 0, "no installation records were supplied, so the spread "
                                 "is unmeasured")
    if len(teams) == 1:
        return ("single-workspace", 1, len(orgs), "one workspace holds this app")
    if len(orgs) == 1 and not loose:
        return ("grid-org", len(teams), 1, "%d workspace(s) inside one enterprise, "
                                           "which is org deployment rather than public "
                                           "distribution" % len(teams))
    return ("multi-workspace", len(teams), len(orgs),
            "%d workspace(s) across %d enterprise(s): they do not all share one "
            "enterprise, so this app is already distributed"
            % (len(teams), len(orgs)))


def eligibility(socket, intent, spread):
    """Can this app be listed on the Marketplace? Pure. Returns (verdict, detail).

    internal-fine is here on purpose. Socket Mode in one workspace with no
    distribution configured is the feature working as designed, and a checker
    that reports it as a violation is a checker people switch off.
    """
    if socket is None:
        return ("unreadable", "the manifest was not read, so the transport is unknown")
    if not socket:
        return ("eligible", "delivery is over HTTPS, so the transport is not what "
                            "stands between this app and a listing")
    if spread == "multi-workspace":
        return ("blocked-and-shipped", "already installed in unrelated workspaces on a "
                                       "transport the Marketplace does not accept. The "
                                       "migration is overdue rather than upcoming")
    if intent in ("distributed", "preparing"):
        return ("blocked", "Socket Mode and a distribution configuration. The listing "
                           "cannot happen on this transport, and the work is a "
                           "migration rather than a submission")
    if spread == "grid-org":
        return ("org-only", "Socket Mode across one enterprise is supported. It is "
                            "public listing that this transport rules out, so this is "
                            "a ceiling rather than a fault")
    return ("internal-fine", "one workspace, no distribution configured: this is the "
                             "supported use of Socket Mode and not a finding")


def clamp_exposure(socket, intent, reads_history):
    """Does the transport choice also cost the history rate limits? Pure.

    Marketplace approval is what lifts the clamp on conversations.history for
    non-Marketplace apps, so an app that cannot be listed cannot be unclamped
    either. Two problems, one repair, and finding them six months apart is how
    one decision gets rediscovered twice.
    """
    if not reads_history:
        return ("not-exposed", "this app does not read conversations.history, so the "
                               "clamp is not part of the cost")
    if socket and intent in ("distributed", "preparing"):
        return ("compounded", "listing is also what lifts the conversations.history "
                              "clamp, so the same rewrite is the only route to either")
    if socket:
        return ("clamped-internally", "an internal app is still subject to the clamp "
                                      "unless it is reclassified, which is a separate "
                                      "conversation from listing")
    return ("listable", "the transport is not in the way; approval is the remaining "
                        "step for the clamp as well")


def migration_surfaces(manifest):
    """What has to gain a public URL before submission? Pure.

    Returns [(surface, state), ...] with state in (needs-url, has-url). The
    point is a number: "we would have to rewrite it" is a worse sentence to
    bring to a planning meeting than "four endpoints".
    """
    m = manifest if isinstance(manifest, dict) else {}
    settings = m.get("settings") if isinstance(m.get("settings"), dict) else {}
    features = m.get("features") if isinstance(m.get("features"), dict) else {}
    events = settings.get("event_subscriptions") if isinstance(
        settings.get("event_subscriptions"), dict) else {}
    inter = settings.get("interactivity") if isinstance(
        settings.get("interactivity"), dict) else {}
    out = []
    subscribed = list(events.get("bot_events") or []) + list(
        events.get("user_events") or [])
    if subscribed:
        out.append(("event subscriptions",
                    "has-url" if events.get("request_url") else "needs-url"))
    if inter.get("is_enabled") or inter.get("request_url"):
        out.append(("interactivity",
                    "has-url" if inter.get("request_url") else "needs-url"))
        if inter.get("message_menu_options_url"):
            out.append(("menu options", "has-url"))
    for cmd in features.get("slash_commands") or []:
        if not isinstance(cmd, dict):
            continue
        out.append(("slash %s" % (cmd.get("command") or "?"),
                    "has-url" if cmd.get("url") else "needs-url"))
    return out


def load_manifest(args):
    """From a file, or from apps.manifest.export with a configuration token."""
    if args.manifest:
        raw = json.loads(open(args.manifest, encoding="utf-8").read())
        return raw.get("manifest", raw)
    token = os.environ.get(args.config_token_env)
    if not token or not args.app_id:
        log.warning("manifest   supply --manifest, or set %s and pass --app-id; a bot "
                    "token cannot read app configuration", args.config_token_env)
        return None
    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    body = s.get(API + "apps.manifest.export", timeout=30,
                 params={"app_id": args.app_id}).json()
    if body.get("ok") is not True:
        log.error("manifest   unavailable    %s", body.get("error"))
        return None
    return body.get("manifest") or {}


def load_installs(args):
    """Your own installation records, or one auth.test for the token at hand."""
    if args.installs:
        rows = json.loads(open(args.installs, encoding="utf-8").read())
        return rows if isinstance(rows, list) else [rows]
    token = os.environ.get(args.token_env)
    if not token:
        return []
    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    body = s.get(API + "auth.test", timeout=30).json()
    if body.get("ok") is not True:
        log.warning("auth.test  unavailable    %s", body.get("error"))
        return []
    return [body]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--manifest", default="", help="path to an exported manifest")
    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("--installs", default="",
                    help="path to a JSON array of auth.test-shaped install records")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding a bot token")
    ap.add_argument("--reads-history", action="store_true",
                    help="this app calls conversations.history")
    args = ap.parse_args()

    manifest = load_manifest(args)
    socket = socket_enabled(manifest)
    log.info("manifest   socket mode %s",
             "unknown" if socket is None else ("on" if socket else "off"))

    intent, intent_detail = distribution_intent(manifest)
    log.info("intent     %-14s %s", intent, intent_detail)

    spread, teams, orgs, spread_detail = install_spread(load_installs(args))
    log.info("installs   %-14s %s", spread, spread_detail)

    verdict, detail = eligibility(socket, intent, spread)
    findings = ("blocked", "blocked-and-shipped")
    (log.warning if verdict in findings else log.info)("verdict    %-14s %s", verdict,
                                                       detail)

    clamp, clamp_detail = clamp_exposure(socket, intent, args.reads_history)
    (log.warning if clamp == "compounded" else log.info)("clamp      %-14s %s", clamp,
                                                         clamp_detail)

    if verdict in findings:
        needed = [s for s, state in migration_surfaces(manifest) if state == "needs-url"]
        for surface, state in migration_surfaces(manifest):
            log.warning("migrate    %-14s %s", state, surface)
        log.warning("  repair: stand up a public HTTPS endpoint and configure the %d "
                    "surface(s) that need one", len(needed))
        log.warning("  repair: switch Socket Mode off, re-verify each URL, and keep "
                    "Socket Mode on a separate development app")
        log.warning("  repair: budget signature verification and a three second "
                    "response on every request; a socket consumer never needed either")
        log.info("  note: %d workspace(s) and %d enterprise(s) in the records read",
                 teams, orgs)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-distribution-block.mjs
/**
 * Check whether this app can ever be listed, before somebody promises it.
 *
 * Read only. apps.manifest.export and auth.test are reads; nothing here edits a
 * manifest, creates an app, submits anything or opens a socket.
 *
 * Socket Mode apps cannot be listed on the Slack Marketplace: a publicly
 * distributed app must expose a public HTTPS Request URL. That is a property of
 * the transport rather than of the code, so it is checkable months before the
 * submission form is opened.
 */

import { readFileSync } from 'node:fs';

const API = 'https://slack.com/api/';

/** Is Socket Mode on? Pure. null when the manifest could not be read. */
export function socketEnabled(manifest) {
  if (!manifest || typeof manifest !== 'object' || !Object.keys(manifest).length) {
    return null;
  }
  const settings = manifest.settings;
  if (!settings || typeof settings !== 'object') return false;
  return Boolean(settings.socket_mode_enabled);
}

/**
 * Is this app meant to leave this workspace? Pure. Returns [intent, detail].
 * No single manifest field says "distributed", so this reads the configuration
 * distribution requires: OAuth redirects, and org deployment.
 */
export function distributionIntent(manifest) {
  if (!manifest || typeof manifest !== 'object' || !Object.keys(manifest).length) {
    return ['unreadable', 'no manifest, so the intent cannot be read'];
  }
  const settings = (manifest.settings && typeof manifest.settings === 'object')
    ? manifest.settings : {};
  const oauth = (manifest.oauth_config && typeof manifest.oauth_config === 'object')
    ? manifest.oauth_config : {};
  const redirects = (oauth.redirect_urls ?? []).filter(Boolean);
  if (settings.is_distributed === true) {
    return ['distributed', 'the manifest reports the app as distributed'];
  }
  if (settings.org_deploy_enabled) {
    return ['preparing', 'org deployment is enabled, so this app is set up to be '
      + 'installed across an organisation'];
  }
  if (redirects.length) {
    return ['preparing', `${redirects.length} OAuth redirect URL(s) are configured, so `
      + 'this app is set up to be installed somewhere other than here'];
  }
  return ['internal', 'no redirect URLs and no org deployment: this app installs here '
    + 'and nowhere else'];
}

/**
 * How far has this app already spread? Pure. Returns [spread, teams, orgs, detail].
 * Counting team_id alone is the mistake: a Grid organisation is many workspaces
 * and one customer, and Socket Mode is permitted there.
 */
export function installSpread(rows) {
  const teams = new Set();
  const orgs = new Set();
  let loose = 0;
  for (const row of rows ?? []) {
    if (!row || typeof row !== 'object' || !row.team_id) continue;
    teams.add(String(row.team_id));
    if (row.enterprise_id) orgs.add(String(row.enterprise_id)); else loose += 1;
  }
  if (!teams.size) {
    return ['unknown', 0, 0, 'no installation records were supplied, so the spread is '
      + 'unmeasured'];
  }
  if (teams.size === 1) {
    return ['single-workspace', 1, orgs.size, 'one workspace holds this app'];
  }
  if (orgs.size === 1 && !loose) {
    return ['grid-org', teams.size, 1, `${teams.size} workspace(s) inside one `
      + 'enterprise, which is org deployment rather than public distribution'];
  }
  return ['multi-workspace', teams.size, orgs.size, `${teams.size} workspace(s) across `
    + `${orgs.size} enterprise(s): they do not all share one enterprise, so this app is `
    + 'already distributed'];
}

/**
 * Can this app be listed on the Marketplace? Pure. Returns [verdict, detail].
 * internal-fine is here on purpose: Socket Mode in one workspace with no
 * distribution configured is the feature working as designed.
 */
export function eligibility(socket, intent, spread) {
  if (socket === null || socket === undefined) {
    return ['unreadable', 'the manifest was not read, so the transport is unknown'];
  }
  if (!socket) {
    return ['eligible', 'delivery is over HTTPS, so the transport is not what stands '
      + 'between this app and a listing'];
  }
  if (spread === 'multi-workspace') {
    return ['blocked-and-shipped', 'already installed in unrelated workspaces on a '
      + 'transport the Marketplace does not accept. The migration is overdue rather '
      + 'than upcoming'];
  }
  if (intent === 'distributed' || intent === 'preparing') {
    return ['blocked', 'Socket Mode and a distribution configuration. The listing '
      + 'cannot happen on this transport, and the work is a migration rather than a '
      + 'submission'];
  }
  if (spread === 'grid-org') {
    return ['org-only', 'Socket Mode across one enterprise is supported. It is public '
      + 'listing that this transport rules out, so this is a ceiling rather than a '
      + 'fault'];
  }
  return ['internal-fine', 'one workspace, no distribution configured: this is the '
    + 'supported use of Socket Mode and not a finding'];
}

/**
 * Does the transport choice also cost the history rate limits? Pure.
 * Marketplace approval is what lifts the clamp on conversations.history, so an
 * app that cannot be listed cannot be unclamped either.
 */
export function clampExposure(socket, intent, readsHistory) {
  if (!readsHistory) {
    return ['not-exposed', 'this app does not read conversations.history, so the clamp '
      + 'is not part of the cost'];
  }
  if (socket && (intent === 'distributed' || intent === 'preparing')) {
    return ['compounded', 'listing is also what lifts the conversations.history clamp, '
      + 'so the same rewrite is the only route to either'];
  }
  if (socket) {
    return ['clamped-internally', 'an internal app is still subject to the clamp unless '
      + 'it is reclassified, which is a separate conversation from listing'];
  }
  return ['listable', 'the transport is not in the way; approval is the remaining step '
    + 'for the clamp as well'];
}

/**
 * What has to gain a public URL before submission? Pure.
 * Returns [[surface, state], ...]. The point is a number: "four endpoints" is a
 * better sentence in a planning meeting than "we would have to rewrite it".
 */
export function migrationSurfaces(manifest) {
  const m = (manifest && typeof manifest === 'object') ? manifest : {};
  const settings = (m.settings && typeof m.settings === 'object') ? m.settings : {};
  const features = (m.features && typeof m.features === 'object') ? m.features : {};
  const events = (settings.event_subscriptions
    && typeof settings.event_subscriptions === 'object')
    ? settings.event_subscriptions : {};
  const inter = (settings.interactivity && typeof settings.interactivity === 'object')
    ? settings.interactivity : {};
  const out = [];
  const subscribed = [...(events.bot_events ?? []), ...(events.user_events ?? [])];
  if (subscribed.length) {
    out.push(['event subscriptions', events.request_url ? 'has-url' : 'needs-url']);
  }
  if (inter.is_enabled || inter.request_url) {
    out.push(['interactivity', inter.request_url ? 'has-url' : 'needs-url']);
    if (inter.message_menu_options_url) out.push(['menu options', 'has-url']);
  }
  for (const cmd of features.slash_commands ?? []) {
    if (!cmd || typeof cmd !== 'object') continue;
    out.push([`slash ${cmd.command ?? '?'}`, cmd.url ? 'has-url' : 'needs-url']);
  }
  return out;
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function loadManifest(args) {
  const path = arg(args, '--manifest', '');
  if (path) {
    const raw = JSON.parse(readFileSync(path, 'utf-8'));
    return raw.manifest ?? raw;
  }
  const tokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_TOKEN');
  const token = process.env[tokenEnv];
  const appId = arg(args, '--app-id', '');
  if (!token || !appId) {
    console.warn(`manifest   supply --manifest, or set ${tokenEnv} and pass --app-id; a `
      + 'bot token cannot read app configuration');
    return null;
  }
  const params = new URLSearchParams({ app_id: appId });
  const body = await (await fetch(`${API}apps.manifest.export?${params}`,
    { headers: { Authorization: `Bearer ${token}` } })).json();
  if (body.ok !== true) {
    console.error(`manifest   unavailable    ${body.error}`);
    return null;
  }
  return body.manifest ?? {};
}

async function loadInstalls(args) {
  const path = arg(args, '--installs', '');
  if (path) {
    const rows = JSON.parse(readFileSync(path, 'utf-8'));
    return Array.isArray(rows) ? rows : [rows];
  }
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) return [];
  const body = await (await fetch(`${API}auth.test`,
    { headers: { Authorization: `Bearer ${token}` } })).json();
  if (body.ok !== true) {
    console.warn(`auth.test  unavailable    ${body.error}`);
    return [];
  }
  return [body];
}

async function main() {
  const args = process.argv.slice(2);
  const manifest = await loadManifest(args);
  const socket = socketEnabled(manifest);
  console.log(`manifest   socket mode ${socket === null ? 'unknown' : (socket ? 'on' : 'off')}`);

  const [intent, intentDetail] = distributionIntent(manifest);
  console.log(`intent     ${intent.padEnd(14)} ${intentDetail}`);

  const [spread, teams, orgs, spreadDetail] = installSpread(await loadInstalls(args));
  console.log(`installs   ${spread.padEnd(14)} ${spreadDetail}`);

  const [verdict, detail] = eligibility(socket, intent, spread);
  const findings = ['blocked', 'blocked-and-shipped'];
  const line = `verdict    ${verdict.padEnd(14)} ${detail}`;
  if (findings.includes(verdict)) console.warn(line); else console.log(line);

  const [clamp, clampDetail] = clampExposure(socket, intent,
    args.includes('--reads-history'));
  const clampLine = `clamp      ${clamp.padEnd(14)} ${clampDetail}`;
  if (clamp === 'compounded') console.warn(clampLine); else console.log(clampLine);

  if (findings.includes(verdict)) {
    const surfaces = migrationSurfaces(manifest);
    const needed = surfaces.filter(([, state]) => state === 'needs-url');
    for (const [surface, state] of surfaces) {
      console.warn(`migrate    ${state.padEnd(14)} ${surface}`);
    }
    console.warn(`  repair: stand up a public HTTPS endpoint and configure the `
      + `${needed.length} surface(s) that need one`);
    console.warn('  repair: switch Socket Mode off, re-verify each URL, and keep Socket '
      + 'Mode on a separate development app');
    console.log(`  note: ${teams} workspace(s) and ${orgs} enterprise(s) in the records`);
    process.exitCode = 1;
  }
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing manifest.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Two assertions here are worth more than the rest. Socket Mode in a single workspace with nothing configured for distribution has to come back internal-fine, because that is the feature being used correctly and a check that calls it a violation gets switched off within a week. And a Grid organisation with eleven workspaces and one enterprise_id has to come back grid-org rather than multi-workspace, since counting team_id alone turns the one supported multi-workspace case into a false alarm.

test_slack_distribution_block.py
from slack_distribution_block import (clamp_exposure, distribution_intent,
                                       eligibility, install_spread,
                                       migration_surfaces, socket_enabled)

SOCKET_APP = {"settings": {"socket_mode_enabled": True}}
DISTRIBUTED = {"settings": {"socket_mode_enabled": True},
               "oauth_config": {"redirect_urls": ["https://acme.dev/oauth"]}}


def test_the_socket_switch_is_read_from_the_manifest():
    assert socket_enabled(SOCKET_APP) is True
    assert socket_enabled({"settings": {}}) is False


def test_an_absent_manifest_leaves_the_transport_unknown():
    assert socket_enabled({}) is None
    assert socket_enabled(None) is None


def test_a_redirect_configuration_means_the_app_is_meant_to_travel():
    intent, detail = distribution_intent(DISTRIBUTED)
    assert intent == "preparing"
    assert "somewhere other than here" in detail


def test_org_deployment_also_counts_as_preparing():
    assert distribution_intent({"settings": {"org_deploy_enabled": True}})[0] == \
        "preparing"


def test_an_explicit_distributed_flag_is_taken_at_its_word():
    assert distribution_intent({"settings": {"is_distributed": True}})[0] == \
        "distributed"


def test_no_redirects_and_no_org_deployment_is_an_internal_app():
    assert distribution_intent(SOCKET_APP)[0] == "internal"
    assert distribution_intent({})[0] == "unreadable"


def test_unrelated_workspaces_mean_the_app_is_already_distributed():
    spread, teams, orgs, detail = install_spread([
        {"team_id": "T1"}, {"team_id": "T2"}, {"team_id": "T3", "enterprise_id": "E9"},
    ])
    assert spread == "multi-workspace"
    assert (teams, orgs) == (3, 1)
    assert "already distributed" in detail


def test_one_enterprise_with_many_workspaces_is_org_deployment():
    spread, teams, orgs, detail = install_spread([
        {"team_id": "T1", "enterprise_id": "E1"},
        {"team_id": "T2", "enterprise_id": "E1"},
    ])
    assert spread == "grid-org"
    assert (teams, orgs) == (2, 1)
    assert "rather than public distribution" in detail


def test_one_workspace_is_one_workspace():
    assert install_spread([{"team_id": "T1"}])[0] == "single-workspace"


def test_no_records_leaves_the_spread_unmeasured():
    assert install_spread([])[0] == "unknown"
    assert install_spread([None, {"user_id": "U1"}])[0] == "unknown"


def test_socket_mode_in_one_internal_workspace_is_not_a_fault():
    verdict, detail = eligibility(True, "internal", "single-workspace")
    assert verdict == "internal-fine"
    assert "not a finding" in detail


def test_socket_mode_with_a_distribution_setup_is_blocked():
    assert eligibility(True, "preparing", "single-workspace")[0] == "blocked"


def test_socket_mode_already_in_unrelated_workspaces_is_the_emergency():
    verdict, detail = eligibility(True, "internal", "multi-workspace")
    assert verdict == "blocked-and-shipped"
    assert "overdue" in detail


def test_socket_mode_across_one_enterprise_is_a_ceiling_not_a_fault():
    assert eligibility(True, "internal", "grid-org")[0] == "org-only"


def test_an_http_app_is_not_blocked_by_its_transport():
    assert eligibility(False, "preparing", "multi-workspace")[0] == "eligible"


def test_an_unread_manifest_produces_no_verdict():
    assert eligibility(None, "unreadable", "unknown")[0] == "unreadable"


def test_reading_history_on_a_blocked_app_compounds_the_problem():
    state, detail = clamp_exposure(True, "preparing", True)
    assert state == "compounded"
    assert "same rewrite" in detail


def test_an_internal_socket_app_that_reads_history_is_still_clamped():
    assert clamp_exposure(True, "internal", True)[0] == "clamped-internally"


def test_an_app_that_does_not_read_history_is_not_exposed_to_the_clamp():
    assert clamp_exposure(True, "preparing", False)[0] == "not-exposed"


def test_an_http_app_that_reads_history_only_needs_approval():
    assert clamp_exposure(False, "preparing", True)[0] == "listable"


def test_the_migration_is_a_countable_list_of_endpoints():
    got = migration_surfaces({
        "settings": {
            "event_subscriptions": {"bot_events": ["app_mention"]},
            "interactivity": {"is_enabled": True},
        },
        "features": {"slash_commands": [{"command": "/report"}]},
    })
    assert got == [("event subscriptions", "needs-url"), ("interactivity", "needs-url"),
                   ("slash /report", "needs-url")]


def test_surfaces_that_already_have_a_url_are_not_counted_as_work():
    got = migration_surfaces({
        "settings": {"event_subscriptions": {"bot_events": ["app_mention"],
                                             "request_url": "https://acme.dev/e"}},
    })
    assert got == [("event subscriptions", "has-url")]


def test_an_app_with_nothing_configured_needs_nothing_migrated():
    assert migration_surfaces({}) == []
    assert migration_surfaces(None) == []
slack-distribution-block.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  clampExposure, distributionIntent, eligibility, installSpread, migrationSurfaces,
  socketEnabled,
} from './slack-distribution-block.mjs';

const SOCKET_APP = { settings: { socket_mode_enabled: true } };
const DISTRIBUTED = {
  settings: { socket_mode_enabled: true },
  oauth_config: { redirect_urls: ['https://acme.dev/oauth'] },
};

test('the socket switch is read from the manifest', () => {
  assert.equal(socketEnabled(SOCKET_APP), true);
  assert.equal(socketEnabled({ settings: {} }), false);
});

test('an absent manifest leaves the transport unknown', () => {
  assert.equal(socketEnabled({}), null);
  assert.equal(socketEnabled(null), null);
});

test('a redirect configuration means the app is meant to travel', () => {
  const [intent, detail] = distributionIntent(DISTRIBUTED);
  assert.equal(intent, 'preparing');
  assert.match(detail, /somewhere other than here/);
});

test('org deployment also counts as preparing', () => {
  assert.equal(distributionIntent({ settings: { org_deploy_enabled: true } })[0],
    'preparing');
});

test('an explicit distributed flag is taken at its word', () => {
  assert.equal(distributionIntent({ settings: { is_distributed: true } })[0],
    'distributed');
});

test('no redirects and no org deployment is an internal app', () => {
  assert.equal(distributionIntent(SOCKET_APP)[0], 'internal');
  assert.equal(distributionIntent({})[0], 'unreadable');
});

test('unrelated workspaces mean the app is already distributed', () => {
  const [spread, teams, , detail] = installSpread([{ team_id: 'T1' },
    { team_id: 'T2' }, { team_id: 'T3', enterprise_id: 'E9' }]);
  assert.equal(spread, 'multi-workspace');
  assert.equal(teams, 3);
  assert.match(detail, /already distributed/);
});

test('one enterprise with many workspaces is org deployment', () => {
  const [spread, teams, orgs, detail] = installSpread([
    { team_id: 'T1', enterprise_id: 'E1' },
    { team_id: 'T2', enterprise_id: 'E1' },
  ]);
  assert.equal(spread, 'grid-org');
  assert.equal(teams, 2);
  assert.equal(orgs, 1);
  assert.match(detail, /rather than public distribution/);
});

test('one workspace is one workspace', () => {
  assert.equal(installSpread([{ team_id: 'T1' }])[0], 'single-workspace');
});

test('no records leaves the spread unmeasured', () => {
  assert.equal(installSpread([])[0], 'unknown');
  assert.equal(installSpread([null, { user_id: 'U1' }])[0], 'unknown');
});

test('socket mode in one internal workspace is not a fault', () => {
  const [verdict, detail] = eligibility(true, 'internal', 'single-workspace');
  assert.equal(verdict, 'internal-fine');
  assert.match(detail, /not a finding/);
});

test('socket mode with a distribution setup is blocked', () => {
  assert.equal(eligibility(true, 'preparing', 'single-workspace')[0], 'blocked');
});

test('socket mode already in unrelated workspaces is the emergency', () => {
  const [verdict, detail] = eligibility(true, 'internal', 'multi-workspace');
  assert.equal(verdict, 'blocked-and-shipped');
  assert.match(detail, /overdue/);
});

test('socket mode across one enterprise is a ceiling not a fault', () => {
  assert.equal(eligibility(true, 'internal', 'grid-org')[0], 'org-only');
});

test('an http app is not blocked by its transport', () => {
  assert.equal(eligibility(false, 'preparing', 'multi-workspace')[0], 'eligible');
});

test('an unread manifest produces no verdict', () => {
  assert.equal(eligibility(null, 'unreadable', 'unknown')[0], 'unreadable');
});

test('reading history on a blocked app compounds the problem', () => {
  const [state, detail] = clampExposure(true, 'preparing', true);
  assert.equal(state, 'compounded');
  assert.match(detail, /same rewrite/);
});

test('an internal socket app that reads history is still clamped', () => {
  assert.equal(clampExposure(true, 'internal', true)[0], 'clamped-internally');
});

test('an app that does not read history is not exposed to the clamp', () => {
  assert.equal(clampExposure(true, 'preparing', false)[0], 'not-exposed');
});

test('an http app that reads history only needs approval', () => {
  assert.equal(clampExposure(false, 'preparing', true)[0], 'listable');
});

test('the migration is a countable list of endpoints', () => {
  const got = migrationSurfaces({
    settings: {
      event_subscriptions: { bot_events: ['app_mention'] },
      interactivity: { is_enabled: true },
    },
    features: { slash_commands: [{ command: '/report' }] },
  });
  assert.deepEqual(got, [['event subscriptions', 'needs-url'],
    ['interactivity', 'needs-url'], ['slash /report', 'needs-url']]);
});

test('surfaces that already have a url are not counted as work', () => {
  const got = migrationSurfaces({
    settings: {
      event_subscriptions: { bot_events: ['app_mention'],
        request_url: 'https://acme.dev/e' },
    },
  });
  assert.deepEqual(got, [['event subscriptions', 'has-url']]);
});

test('an app with nothing configured needs nothing migrated', () => {
  assert.deepEqual(migrationSurfaces({}), []);
  assert.deepEqual(migrationSurfaces(null), []);
});

FAQ

Is this a rule or just a recommendation?

It is a requirement of listing. A publicly distributed app has to receive events at a public HTTPS Request URL, and Socket Mode does not provide one, so an app built on it is not eligible for the Marketplace regardless of how well it works. Socket Mode is intended for apps behind a firewall and for internal or org-deployed use, and those remain entirely supported. What is ruled out is the public listing.

We have eleven workspaces on Socket Mode and nobody has complained. Are we fine?

Check whether those eleven share an enterprise_id. Many workspaces inside one Enterprise Grid organisation is org deployment, which Socket Mode supports, and that is why the script separates grid-org from multi-workspace instead of counting installs. Eleven unrelated workspaces is a different story: the app is distributed in fact, on a transport that cannot be listed, and the migration is already overdue.

How much of our code has to change?

Usually very little. Bolt supports both a socket receiver and an HTTP receiver, so the handlers, the routing and the business logic normally survive the switch untouched. What changes is the deployment: a public hostname with TLS, signature verification on every inbound request, and a three second response budget that a socket consumer never had to meet. It is a platform project rather than a rewrite, which is a much better thing to discover early.

What does this have to do with rate limits?

Marketplace approval is also the route back to the un-clamped conversations.history limits for apps that are not on the Marketplace. So an app that cannot be listed cannot be unclamped either, and if it polls history it is stuck at the clamped rate permanently. The two constraints have one shared repair, which is why the script reports them together rather than leaving the second to be rediscovered six months later.

Can we keep Socket Mode for development after we migrate?

Yes, and you should - on a separate app. A development app with Socket Mode on and no Request URL, and a production app on HTTPS with Socket Mode off, is the configuration that keeps both benefits without either one changing where events go. Keeping both switches on one app is a different problem with its own note, and it is the one that produces duplicate messages and silent staging environments.

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.