Skip to content

Diagnostic Slack

Add to Slack works in one workspace and nowhere else

The pilot customer said yes on a Tuesday and asked for the install link. Somebody pasted the “Add to Slack” URL from the README, the customer's admin clicked it, and Slack showed an error page instead of a permissions screen.

Everything about the app is finished. It has an HTTPS Request URL, verified. It has events, interactivity, three slash commands and a home tab. It has been running in the company's own workspace for eight months without a single incident. What it has never had is a redirect URL, an installation store, or a single click on Activate Public Distribution — and none of those is something you discover by testing the app, because the app works perfectly in the one place it has ever been installed.

Read-only token Python and Node.js Tests included
A pile of items sitting on top of a table
Photo by Growtika on Unsplash
The short answer

A new Slack app is installable only in the workspace it was created in until public distribution is activated, and activation is a gate with its own checklist rather than a switch you flip at the end. It needs a valid OAuth redirect URL, an install URL with nothing workspace-specific baked into it, and — for Enterprise Grid customers — org-wide installation enabled on top of that.

Most of the checklist is readable ahead of time from apps.manifest.export. An app with an empty oauth_config.redirect_urls[] cannot run a public OAuth flow at all, because there is nowhere for Slack to send the authorisation code. settings.org_deploy_enabled says whether an org admin can install once for the whole enterprise. Read together, they classify the app's audience: its development workspace only, any workspace, or a whole org.

This is not the note about an app that cannot be distributed. A Socket Mode app is barred from the Marketplace by its transport, and no checklist fixes that; the repair there is an architectural change and it has its own note. This note is the opposite case and the commoner one: the transport is already public HTTPS, nothing is blocking anything, and the gate was simply never walked through. The script checks the transport first and hands over rather than reporting a checklist that cannot be completed.

One prerequisite lives in your code rather than the manifest, and it is the one that breaks in production rather than at the install screen: a distributed app receives a different bot token per workspace, and an app that reads its token from SLACK_BOT_TOKEN has nowhere to put the second one.

The problem in plain words

Nothing about single-workspace development feels provisional. You create the app, install it in your own workspace, get a bot token, put it in the environment, and build for months against a completely realistic Slack. Every API call is the real API. Every event is a real event. The only thing that is not real is the assumption underneath all of it, which is that there is exactly one workspace and exactly one token, and that assumption is invisible precisely because it has never been contradicted.

The gate is discovered at the worst moment. Distribution is not something the app fails at gradually — it fails at the first attempt by an outside party, which is normally a customer, normally after a commitment, and normally in front of somebody. And the error is unhelpfully generic: an install URL with no valid redirect configured produces a Slack error page rather than a message naming the missing prerequisite, so the first hour goes into checking the link for typos.

The deeper problem is the token, and it does not surface at the install screen at all. Public distribution means every workspace that installs the app gets its own bot token, its own bot user id, its own team id. Code that reads one token from an environment variable will serve the second customer with the first customer's credentials, which is not an error you want to find in production: the app will happily post into the wrong company's channels. This is why implementing an installation store belongs before activation and not after — the gate is the last safe moment to still be single-tenant.

Enterprise Grid adds a third shape underneath. An org admin who wants the app across every workspace in the enterprise needs org-wide installation enabled, which is a separate setting with a separate consent flow and an install record keyed by enterprise rather than by team. An app that activated public distribution and stopped there will install workspace by workspace, which works, and will confuse the first Grid customer who expected one click.

A customer saysyesand asks for thelinkNo redirect URLso the flow cannotstartAn error pagenot a consentscreenThe gate neveropenednothing was everblockingOne token, oneworkspaceand nowhere for asecond
Public distribution is a separate gate with its own checklist, not a consequence of the app being finished. Nothing blocks this app. Nobody ever walked it through.

Why it happens

The first thing to establish is whether a checklist is even the right answer. Socket Mode bars an app from the Marketplace outright, and running a readiness checklist on a Socket Mode app produces a tidy list of items to complete that will not help, because the transport has to change first. So blocking_transport runs first and stops the script. Two notes, one boundary: that one is about an app that cannot be distributed, this one is about an app that was not.

The audience is a fact about the manifest, not an opinion. Empty redirect_urls[] means no public OAuth flow can complete, which means the app is installable in exactly one workspace regardless of what anybody intended. Present redirect URLs mean any workspace can install. org_deploy_enabled on top of that means an org admin can install once for the enterprise. Three audiences, read directly, before a customer discovers which one you have.

This is a presence check on the redirect URLs and deliberately nothing more. Whether a particular URL matches the one your code sends is a different failure with a different error — bad_redirect_uri — and it belongs to whichever of your environments got the trailing slash wrong. Here the only question is whether the list is empty, because an empty list is the difference between “the flow can start” and “the flow cannot start”.

The token storage check is the one that catches a bug rather than a setting. Everything else in this note is a click somebody has not made yet. The installation store is code that does not exist, and its absence is invisible until the second workspace installs and starts receiving the first workspace's bot. The script asks which environment variables hold tokens and whether a store is implemented, and calls the combination of public distribution with a single environment token exactly what it is.

Nothing here installs anything. There is no read-only way to test an install flow, and the write that would test it is an install — a real app in a real workspace with real consent. Every item is read from the manifest and from your own description of your deployment, which is enough to answer the question before the customer's admin clicks the link rather than after.

The checklist reports states rather than a pass or fail count. An item can pass, fail, be blocked by something upstream, or be genuinely optional for this app — org-wide install is not a defect in a product with no Grid customers. Flattening those into a percentage would produce a number that is either alarming or reassuring for the wrong reason.

The fix, as a flow

The red arrow is the first one, which is what a gate looks like: everything after it is a consequence of one thing never having been done, and not one of those steps is a fault in the app. The app works. It has worked for eight months. The fix branch is a checklist rather than a diagnosis, and its first row is the boundary this note shares with its neighbour: an app on Socket Mode is barred outright, so the script stops instead of printing a tidy list of items that cannot help. The row that matters most is the token, because it is the only one that is code, and the only one whose failure arrives after a customer has already installed.

Read the gate, not the appmanifest, then your codeSocket Mode is onbarred outright, and another noteNo redirect URL at allone workspace, and only ever oneOne token in the environmentthe second install has nowhereOrg-wide not enabledoptional until a Grid customerEvery prerequisite metand the link works for anyone
A checklist with real states rather than a score. Blocked, optional and failing are three different answers, and flattening them into a percentage would be alarming or reassuring for the wrong reason.

How to fix it

Check whether the transport bars distribution before anything else

blocking_transport reads settings.socket_mode_enabled. If Socket Mode is on, this note does not apply and the script says so: a Socket Mode app cannot be listed at all, and the repair is a rewrite of the delivery layer rather than a checklist. Everything below assumes a public HTTPS transport already exists.

Establish who can install this app today

install_audience combines oauth_config.redirect_urls[] with settings.org_deploy_enabled and returns dev-workspace-only, any-workspace or org-wide. The first of those is the finding, and it is a statement about the configuration rather than about anybody's intentions.

Check the redirect list is not empty, and stop there

redirect_presence counts the configured redirect URLs. Zero means the OAuth flow has nowhere to return to and cannot complete for anybody outside your own workspace. Which URL and whether it matches your code is a separate failure with its own error string, and this script deliberately does not go looking for it.

Ask where the second workspace's token would go

token_storage_shape takes the names of the environment variables that hold tokens and whether an installation store is implemented. A single environment token is correct for a single-workspace app and a live bug for a distributed one, and it is the only item on this list that is code rather than configuration.

Produce the checklist with real states

gate_checklist returns each prerequisite as pass, fail, blocked or optional. Org-wide installation is optional for a product with no Enterprise Grid customers and required the day the first one arrives, and saying optional is more honest than a red cross that everybody learns to ignore.

Activate in the right order

The printed repair puts the installation store first, then the redirect URLs, then activation, because that order is the one where nothing is briefly true and dangerous. Activating distribution on an app that still reads one token from the environment is the arrangement where a second customer's install quietly points your app at the first customer's workspace.

How to check it worked

Add the redirect URLs, ship the installation store, then activate. Run it again and the audience should read any-workspace with nothing left failing.

python3 slack_distribution_readiness.py --app-id A05DEPB0T --token-envs SLACK_BOT_TOKEN
# transport  http             Socket Mode is off, so the transport does not bar
#                             distribution and the checklist below is the right question
# audience   dev-workspace-only  no redirect URL is configured, so a public OAuth flow
#                             cannot start; this app installs in its own workspace only
# redirects  none             0 redirect URL(s) configured
# storage    single-env-token 1 token in an environment variable, which is correct
#                             until the day the app is distributed
# identity   T04NORTHW (Northwind) - the one workspace this app has ever lived in
# gate       public transport                 pass      Socket Mode is off
# gate       redirect URL configured          fail      the OAuth flow cannot return
# gate       scopes requested                 pass      12 bot scope(s) declared
# gate       bot user declared                pass      releasebot
# gate       per-workspace token storage      fail      one token in the environment
# gate       org-wide installation            optional  no Grid customer yet
# verdict    never-activated  the app is finished and has never been let out
#   repair: implement an installationStore first, before activation, so the second
#           workspace has somewhere to put its own token
#   repair: add every environment's redirect URL, then complete Manage Distribution

The full code

The order of the functions is the argument. blocking_transport runs first and can end the script, because a checklist for an app that is barred from distribution by its transport is a tidy list of things that will not help. install_audience is then three lines of manifest reading that answer the actual question. token_storage_shape is the odd one out and the important one: it is the only item that describes your code rather than your configuration, and the only one whose failure shows up after a customer has already installed.

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_readiness.py
"""Check whether this app could be installed anywhere but the workspace it was built in.

Read only. apps.manifest.export and auth.test are reads. Nothing here installs
anything, activates anything, or writes a manifest: there is no read-only way to
test an install flow, and the write that would test it is a real installation in
somebody's workspace.

Two neighbouring failures, one boundary. An app on Socket Mode cannot be listed
on the Marketplace at all, whatever its checklist says, and that is a different
note with an architectural repair. This script establishes the transport first
and stops if it is the socket, so what follows is only ever about an app that
could have been distributed and simply never was.
"""
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_readiness")

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


def blocking_transport(manifest):
    """Does the delivery transport bar distribution outright? Pure.

    Returns (state, detail): socket-mode, http or unknown. Run first, because a
    readiness checklist for a Socket Mode app is a list of items that will not
    help - the Marketplace does not accept one, and the repair is a rewrite of
    the delivery layer rather than a click on a settings page.
    """
    if manifest is None:
        return ("unknown", "no manifest was read, so the transport is unknown; an app "
                           "configuration token is what reads it")
    if bool((manifest.get("settings") or {}).get("socket_mode_enabled")):
        return ("socket-mode", "Socket Mode is on, and a Socket Mode app cannot be "
                               "listed on the Marketplace at all. That is a different "
                               "note with a different repair")
    return ("http", "Socket Mode is off, so the transport does not bar distribution "
                    "and the checklist below is the right question")


def redirect_presence(manifest):
    """Can a public OAuth flow return at all? Pure.

    Returns (state, count, detail): none, one, several or unknown.

    A presence check and nothing more. Whether a particular URL matches the one
    your code sends is bad_redirect_uri, a different failure with its own error
    string; the only question here is whether the list is empty, because an
    empty list means the flow cannot complete for anybody outside your own
    workspace.
    """
    if manifest is None:
        return ("unknown", 0, "no manifest was read")
    oauth = manifest.get("oauth_config") or {}
    urls = [str(u).strip() for u in (oauth.get("redirect_urls") or []) if str(u).strip()]
    if not urls:
        return ("none", 0, "0 redirect URL(s) configured, so the authorisation code "
                           "has nowhere to be delivered")
    if len(urls) == 1:
        return ("one", 1, "1 redirect URL configured: %s" % urls[0])
    return ("several", len(urls), "%d redirect URL(s) configured" % len(urls))


def install_audience(manifest):
    """Who can install this app today? Pure.

    Returns (audience, detail): dev-workspace-only, any-workspace, org-wide or
    unknown. A statement about the configuration rather than about anybody's
    plans: with no redirect URL the app is installable in exactly one workspace
    whatever the roadmap says.
    """
    if manifest is None:
        return ("unknown", "no manifest was read, so the audience cannot be "
                           "established")
    settings = manifest.get("settings") or {}
    state, _count, _detail = redirect_presence(manifest)
    if state == "none":
        return ("dev-workspace-only",
                "no redirect URL is configured, so a public OAuth flow cannot start; "
                "this app installs in the workspace it was created in and nowhere else")
    if bool(settings.get("org_deploy_enabled")):
        return ("org-wide", "org-wide installation is enabled, so a Grid admin can "
                            "install once for every workspace in the enterprise")
    return ("any-workspace", "a public OAuth flow can complete, so any workspace can "
                             "install this app one at a time")


def token_storage_shape(token_env_names, has_store, audience):
    """Where would a second workspace's token go? Pure.

    Returns (state, detail): installation-store, single-env-token,
    env-token-but-public or unknown.

    The only item in this note that is code rather than configuration, and the
    only one whose failure appears after a customer has already installed. A
    distributed app receives a different bot token per workspace; an app that
    reads one token from the environment will serve the second customer with
    the first customer's credentials.
    """
    names = sorted(str(n) for n in (token_env_names or []) if str(n).strip())
    if has_store:
        return ("installation-store", "an installation store is implemented, so each "
                                      "workspace's token has somewhere to live")
    if not names:
        return ("unknown", "no token variables were named and no installation store "
                           "was declared, so the storage shape is unknown")
    if audience in ("any-workspace", "org-wide"):
        return ("env-token-but-public",
                "%d token(s) in environment variables while distribution is on: the "
                "next workspace to install has nowhere to put its own token"
                % len(names))
    return ("single-env-token", "%d token(s) in an environment variable, which is "
                                "correct until the day the app is distributed"
            % len(names))


def gate_checklist(manifest, storage_state):
    """The distribution prerequisites, as far as they are readable. Pure.

    Returns [(item, state, detail)] with state pass, fail, blocked or optional.

    Optional is a real answer rather than a softened failure: org-wide install
    is not a defect in a product with no Enterprise Grid customers, and a red
    cross that every team learns to ignore is worse than no check.
    """
    if manifest is None:
        return [("manifest readable", "blocked", "no manifest was read")]
    settings = manifest.get("settings") or {}
    features = manifest.get("features") or {}
    oauth = manifest.get("oauth_config") or {}
    scopes = oauth.get("scopes") or {}
    socket = bool(settings.get("socket_mode_enabled"))
    redirects, count, _detail = redirect_presence(manifest)
    bot_scopes = list(scopes.get("bot") or [])
    user_scopes = list(scopes.get("user") or [])
    bot_user = ((features.get("bot_user") or {}).get("display_name") or "").strip()

    out = [("public transport", "fail" if socket else "pass",
            "Socket Mode is on" if socket else "Socket Mode is off")]
    out.append(("redirect URL configured", "fail" if redirects == "none" else "pass",
                "the OAuth flow cannot return" if redirects == "none"
                else "%d configured" % count))
    if bot_scopes or user_scopes:
        out.append(("scopes requested", "pass", "%d bot and %d user scope(s) declared"
                    % (len(bot_scopes), len(user_scopes))))
    else:
        out.append(("scopes requested", "fail", "no scopes are declared, so the consent "
                                                "screen has nothing to ask for"))
    if bot_user:
        out.append(("bot user declared", "pass", bot_user))
    elif user_scopes:
        out.append(("bot user declared", "optional", "no bot user, and user scopes are "
                                                     "declared; a user-token app is a "
                                                     "valid shape"))
    else:
        out.append(("bot user declared", "fail", "neither a bot user nor user scopes"))
    if storage_state == "installation-store":
        out.append(("per-workspace token storage", "pass", "an installation store is "
                                                           "implemented"))
    elif storage_state == "unknown":
        out.append(("per-workspace token storage", "blocked", "not described to this "
                                                              "script"))
    else:
        out.append(("per-workspace token storage", "fail",
                    "one token in the environment, which cannot hold a second "
                    "workspace"))
    if bool(settings.get("org_deploy_enabled")):
        out.append(("org-wide installation", "pass", "a Grid admin can install once for "
                                                     "the whole enterprise"))
    else:
        out.append(("org-wide installation", "optional", "not enabled; needed the day "
                                                         "an Enterprise Grid customer "
                                                         "arrives"))
    return out


def distribution_verdict(transport, audience, storage, checklist):
    """One sentence from the transport, the audience and the checklist. Pure.

    Returns (state, detail): unproven, blocked-by-transport, never-activated,
    no-store, incomplete or distributable.
    """
    if transport == "unknown":
        return ("unproven", "the manifest could not be read, so nothing is claimed")
    if transport == "socket-mode":
        return ("blocked-by-transport", "this app cannot be distributed at all while "
                                        "it is on Socket Mode; the checklist below is "
                                        "not the problem to solve first")
    if audience == "dev-workspace-only":
        return ("never-activated", "the app is finished and has never been let out of "
                                   "the workspace it was created in")
    if storage == "env-token-but-public":
        return ("no-store", "distribution is on and there is one token in the "
                            "environment; the next workspace to install will be served "
                            "with another workspace's credentials")
    failed = [item for item, state, _detail in checklist or [] if state == "fail"]
    if failed:
        return ("incomplete", "%d prerequisite(s) still failing: %s"
                % (len(failed), ", ".join(failed)))
    return ("distributable", "every readable prerequisite is met for a %s install"
            % audience)


def load_manifest(args):
    """Read the live manifest. A read: export returns it and changes nothing."""
    if args.manifest:
        return json.loads(open(args.manifest, encoding="utf-8").read())
    token = os.environ.get(args.config_token_env)
    if not token or not args.app_id:
        log.warning("manifest   unavailable      set %s and --app-id, or pass "
                    "--manifest", args.config_token_env)
        return None
    body = requests.get(API + "apps.manifest.export",
                        headers={"Authorization": "Bearer " + token},
                        params={"app_id": args.app_id}, timeout=30).json()
    if body.get("ok") is not True:
        log.error("manifest   unavailable      %s", body.get("error"))
        return None
    return body.get("manifest") or {}


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("--token-envs", default="SLACK_BOT_TOKEN",
                    help="comma separated names of variables that hold bot tokens")
    ap.add_argument("--installation-store", action="store_true",
                    help="the app implements a per-workspace installation store")
    args = ap.parse_args()

    manifest = load_manifest(args)
    transport, transport_detail = blocking_transport(manifest)
    (log.warning if transport == "socket-mode" else log.info)(
        "transport  %-16s %s", transport, transport_detail)
    if transport == "socket-mode":
        log.warning("verdict    blocked-by-transport  read the note on Socket Mode and "
                    "the Marketplace; no checklist item below will change this")
        return 1

    audience, audience_detail = install_audience(manifest)
    (log.warning if audience == "dev-workspace-only" else log.info)(
        "audience   %-16s %s", audience, audience_detail)
    redirects, count, redirect_detail = redirect_presence(manifest)
    (log.warning if redirects == "none" else log.info)(
        "redirects  %-16s %s", redirects, redirect_detail)

    # Names only. Whether each variable is set is read from the environment and
    # never printed, and no token value is read, logged or transmitted here.
    names = [n.strip() for n in args.token_envs.split(",") if n.strip()]
    present = [n for n in names if os.environ.get(n)]
    storage, storage_detail = token_storage_shape(present, args.installation_store,
                                                  audience)
    (log.warning if storage == "env-token-but-public" else log.info)(
        "storage    %-16s %s", storage, storage_detail)

    if present:
        reply = requests.get(API + "auth.test",
                             headers={"Authorization": "Bearer "
                                                       + os.environ[present[0]]},
                             timeout=30).json()
        if reply.get("ok") is True:
            log.info("identity   %-16s %s%s", reply.get("team_id"), reply.get("team"),
                     " (enterprise install)" if reply.get("is_enterprise_install")
                     else " - one workspace")
        else:
            log.warning("identity   unavailable      %s", reply.get("error"))

    checklist = gate_checklist(manifest, storage)
    for item, item_state, item_detail in checklist:
        (log.warning if item_state == "fail" else log.info)(
            "gate       %-32s %-9s %s", item, item_state, item_detail)

    state, why = distribution_verdict(transport, audience, storage, checklist)
    findings = ("never-activated", "no-store", "incomplete", "blocked-by-transport")
    (log.warning if state in findings else log.info)("verdict    %-16s %s", state, why)
    if state in ("never-activated", "no-store", "incomplete"):
        log.warning("  repair: implement an installation store first, before "
                    "activation, so each workspace's token has somewhere to live")
        log.warning("  repair: add every environment's OAuth redirect URL, then "
                    "complete the Manage Distribution checklist and activate")
        log.warning("  repair: enable org-wide installation as well if an Enterprise "
                    "Grid customer needs one install for the whole org")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-distribution-readiness.mjs
/**
 * Check whether this app could be installed anywhere but the workspace it was
 * built in.
 *
 * Read only. apps.manifest.export and auth.test are reads. Nothing here
 * installs anything, activates anything, or writes a manifest.
 *
 * Two neighbouring failures, one boundary. An app on Socket Mode cannot be
 * listed on the Marketplace at all, and that is a different note with an
 * architectural repair. This script establishes the transport first and stops
 * if it is the socket, so what follows is only ever about an app that could
 * have been distributed and simply never was.
 */

import { readFileSync } from 'node:fs';

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

/**
 * Does the delivery transport bar distribution outright? Pure.
 * Returns [state, detail]; socket-mode, http or unknown. Run first.
 */
export function blockingTransport(manifest) {
  if (manifest === null || manifest === undefined) {
    return ['unknown', 'no manifest was read, so the transport is unknown; an app '
      + 'configuration token is what reads it'];
  }
  if (Boolean((manifest.settings ?? {}).socket_mode_enabled)) {
    return ['socket-mode', 'Socket Mode is on, and a Socket Mode app cannot be listed '
      + 'on the Marketplace at all. That is a different note with a different repair'];
  }
  return ['http', 'Socket Mode is off, so the transport does not bar distribution and '
    + 'the checklist below is the right question'];
}

/**
 * Can a public OAuth flow return at all? Pure.
 * Returns [state, count, detail]; none, one, several or unknown.
 */
export function redirectPresence(manifest) {
  if (manifest === null || manifest === undefined) {
    return ['unknown', 0, 'no manifest was read'];
  }
  const oauth = manifest.oauth_config ?? {};
  const urls = (oauth.redirect_urls ?? []).map((u) => String(u).trim()).filter(Boolean);
  if (!urls.length) {
    return ['none', 0, '0 redirect URL(s) configured, so the authorisation code has '
      + 'nowhere to be delivered'];
  }
  if (urls.length === 1) return ['one', 1, `1 redirect URL configured: ${urls[0]}`];
  return ['several', urls.length, `${urls.length} redirect URL(s) configured`];
}

/**
 * Who can install this app today? Pure.
 * Returns [audience, detail]; dev-workspace-only, any-workspace, org-wide, unknown.
 */
export function installAudience(manifest) {
  if (manifest === null || manifest === undefined) {
    return ['unknown', 'no manifest was read, so the audience cannot be established'];
  }
  const settings = manifest.settings ?? {};
  const [state] = redirectPresence(manifest);
  if (state === 'none') {
    return ['dev-workspace-only', 'no redirect URL is configured, so a public OAuth '
      + 'flow cannot start; this app installs in the workspace it was created in and '
      + 'nowhere else'];
  }
  if (Boolean(settings.org_deploy_enabled)) {
    return ['org-wide', 'org-wide installation is enabled, so a Grid admin can install '
      + 'once for every workspace in the enterprise'];
  }
  return ['any-workspace', 'a public OAuth flow can complete, so any workspace can '
    + 'install this app one at a time'];
}

/**
 * Where would a second workspace's token go? Pure.
 * Returns [state, detail]; installation-store, single-env-token,
 * env-token-but-public or unknown.
 */
export function tokenStorageShape(tokenEnvNames, hasStore, audience) {
  const names = (tokenEnvNames ?? []).map(String).filter((n) => n.trim()).sort();
  if (hasStore) {
    return ['installation-store', 'an installation store is implemented, so each '
      + "workspace's token has somewhere to live"];
  }
  if (!names.length) {
    return ['unknown', 'no token variables were named and no installation store was '
      + 'declared, so the storage shape is unknown'];
  }
  if (audience === 'any-workspace' || audience === 'org-wide') {
    return ['env-token-but-public', `${names.length} token(s) in environment variables `
      + 'while distribution is on: the next workspace to install has nowhere to put its '
      + 'own token'];
  }
  return ['single-env-token', `${names.length} token(s) in an environment variable, `
    + 'which is correct until the day the app is distributed'];
}

/**
 * The distribution prerequisites, as far as they are readable. Pure.
 * Returns [[item, state, detail], ...] with pass, fail, blocked or optional.
 */
export function gateChecklist(manifest, storageState) {
  if (manifest === null || manifest === undefined) {
    return [['manifest readable', 'blocked', 'no manifest was read']];
  }
  const settings = manifest.settings ?? {};
  const features = manifest.features ?? {};
  const scopes = (manifest.oauth_config ?? {}).scopes ?? {};
  const socket = Boolean(settings.socket_mode_enabled);
  const [redirects, count] = redirectPresence(manifest);
  const botScopes = scopes.bot ?? [];
  const userScopes = scopes.user ?? [];
  const botUser = String((features.bot_user ?? {}).display_name ?? '').trim();

  const out = [['public transport', socket ? 'fail' : 'pass',
    socket ? 'Socket Mode is on' : 'Socket Mode is off']];
  out.push(['redirect URL configured', redirects === 'none' ? 'fail' : 'pass',
    redirects === 'none' ? 'the OAuth flow cannot return' : `${count} configured`]);
  if (botScopes.length || userScopes.length) {
    out.push(['scopes requested', 'pass',
      `${botScopes.length} bot and ${userScopes.length} user scope(s) declared`]);
  } else {
    out.push(['scopes requested', 'fail', 'no scopes are declared, so the consent '
      + 'screen has nothing to ask for']);
  }
  if (botUser) out.push(['bot user declared', 'pass', botUser]);
  else if (userScopes.length) {
    out.push(['bot user declared', 'optional', 'no bot user, and user scopes are '
      + 'declared; a user-token app is a valid shape']);
  } else {
    out.push(['bot user declared', 'fail', 'neither a bot user nor user scopes']);
  }
  if (storageState === 'installation-store') {
    out.push(['per-workspace token storage', 'pass', 'an installation store is '
      + 'implemented']);
  } else if (storageState === 'unknown') {
    out.push(['per-workspace token storage', 'blocked', 'not described to this script']);
  } else {
    out.push(['per-workspace token storage', 'fail', 'one token in the environment, '
      + 'which cannot hold a second workspace']);
  }
  if (Boolean(settings.org_deploy_enabled)) {
    out.push(['org-wide installation', 'pass', 'a Grid admin can install once for the '
      + 'whole enterprise']);
  } else {
    out.push(['org-wide installation', 'optional', 'not enabled; needed the day an '
      + 'Enterprise Grid customer arrives']);
  }
  return out;
}

/**
 * One sentence from the transport, the audience and the checklist. Pure.
 * Returns [state, detail].
 */
export function distributionVerdict(transport, audience, storage, checklist) {
  if (transport === 'unknown') {
    return ['unproven', 'the manifest could not be read, so nothing is claimed'];
  }
  if (transport === 'socket-mode') {
    return ['blocked-by-transport', 'this app cannot be distributed at all while it is '
      + 'on Socket Mode; the checklist below is not the problem to solve first'];
  }
  if (audience === 'dev-workspace-only') {
    return ['never-activated', 'the app is finished and has never been let out of the '
      + 'workspace it was created in'];
  }
  if (storage === 'env-token-but-public') {
    return ['no-store', 'distribution is on and there is one token in the environment; '
      + 'the next workspace to install will be served with another workspace credentials'];
  }
  const failed = (checklist ?? []).filter(([, state]) => state === 'fail')
    .map(([item]) => item);
  if (failed.length) {
    return ['incomplete', `${failed.length} prerequisite(s) still failing: `
      + failed.join(', ')];
  }
  return ['distributable',
    `every readable prerequisite is met for a ${audience} install`];
}

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) return JSON.parse(readFileSync(path, 'utf8'));
  const tokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_TOKEN');
  const appId = arg(args, '--app-id', '');
  const token = process.env[tokenEnv];
  if (!token || !appId) {
    console.warn(`manifest   unavailable      set ${tokenEnv} and --app-id, or pass `
      + '--manifest');
    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 main() {
  const args = process.argv.slice(2);
  const manifest = await loadManifest(args);
  const [transport, transportDetail] = blockingTransport(manifest);
  const transportLine = `transport  ${transport.padEnd(16)} ${transportDetail}`;
  if (transport === 'socket-mode') console.warn(transportLine);
  else console.log(transportLine);
  if (transport === 'socket-mode') {
    console.warn('verdict    blocked-by-transport  read the note on Socket Mode and the '
      + 'Marketplace; no checklist item below will change this');
    process.exitCode = 1;
    return;
  }

  const [audience, audienceDetail] = installAudience(manifest);
  const audienceLine = `audience   ${audience.padEnd(16)} ${audienceDetail}`;
  if (audience === 'dev-workspace-only') console.warn(audienceLine);
  else console.log(audienceLine);
  const [redirects, , redirectDetail] = redirectPresence(manifest);
  const redirectLine = `redirects  ${redirects.padEnd(16)} ${redirectDetail}`;
  if (redirects === 'none') console.warn(redirectLine); else console.log(redirectLine);

  // Names only. Whether each variable is set is read from the environment and
  // never printed, and no token value is logged or transmitted here.
  const names = String(arg(args, '--token-envs', 'SLACK_BOT_TOKEN'))
    .split(',').map((n) => n.trim()).filter(Boolean);
  const present = names.filter((n) => process.env[n]);
  const hasStore = args.includes('--installation-store');
  const [storage, storageDetail] = tokenStorageShape(present, hasStore, audience);
  const storageLine = `storage    ${storage.padEnd(16)} ${storageDetail}`;
  if (storage === 'env-token-but-public') console.warn(storageLine);
  else console.log(storageLine);

  if (present.length) {
    const reply = await (await fetch(`${API}auth.test`,
      { headers: { Authorization: `Bearer ${process.env[present[0]]}` } })).json();
    if (reply.ok === true) {
      console.log(`identity   ${String(reply.team_id).padEnd(16)} ${reply.team}`
        + `${reply.is_enterprise_install ? ' (enterprise install)' : ' - one workspace'}`);
    } else {
      console.warn(`identity   unavailable      ${reply.error}`);
    }
  }

  const checklist = gateChecklist(manifest, storage);
  for (const [item, itemState, itemDetail] of checklist) {
    const row = `gate       ${item.padEnd(32)} ${itemState.padEnd(9)} ${itemDetail}`;
    if (itemState === 'fail') console.warn(row); else console.log(row);
  }

  const [state, why] = distributionVerdict(transport, audience, storage, checklist);
  const findings = ['never-activated', 'no-store', 'incomplete', 'blocked-by-transport'];
  const verdictLine = `verdict    ${state.padEnd(16)} ${why}`;
  if (findings.includes(state)) console.warn(verdictLine); else console.log(verdictLine);
  if (state === 'never-activated' || state === 'no-store' || state === 'incomplete') {
    console.warn('  repair: implement an installation store first, before activation, '
      + "so each workspace's token has somewhere to live");
    console.warn('  repair: add every environment OAuth redirect URL, then complete the '
      + 'Manage Distribution checklist and activate');
    console.warn('  repair: enable org-wide installation as well if an Enterprise Grid '
      + 'customer needs one install for the whole org');
    process.exitCode = 1;
  }
}

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

Add a test

The first test is the boundary between this note and its neighbour: a Socket Mode app must come back blocked-by-transport and never reach the checklist, because a tidy list of prerequisites for an app that cannot be listed at all is a list of things that will not help. The rest pin the three audiences, the difference between a single environment token that is fine and the same token that is a live bug, and the fact that optional survives as its own state rather than being flattened into a failure.

test_slack_distribution_readiness.py
from slack_distribution_readiness import (
    blocking_transport, distribution_verdict, gate_checklist, install_audience,
    redirect_presence, token_storage_shape,
)


def app(socket=False, redirects=(), org=False, bot_scopes=("chat:write",),
        user_scopes=(), bot_user="releasebot"):
    return {
        "oauth_config": {
            "redirect_urls": list(redirects),
            "scopes": {"bot": list(bot_scopes), "user": list(user_scopes)},
        },
        "settings": {"socket_mode_enabled": socket, "org_deploy_enabled": org},
        "features": {"bot_user": {"display_name": bot_user}} if bot_user else {},
    }


def test_socket_mode_is_the_other_note_and_this_script_says_so():
    state, detail = blocking_transport(app(socket=True))
    assert state == "socket-mode"
    assert "different note" in detail


def test_an_http_app_is_the_one_this_checklist_is_for():
    assert blocking_transport(app())[0] == "http"


def test_no_manifest_is_unknown_rather_than_either():
    assert blocking_transport(None)[0] == "unknown"


def test_an_empty_redirect_list_means_the_flow_cannot_return():
    state, count, detail = redirect_presence(app())
    assert (state, count) == ("none", 0)
    assert "nowhere to be delivered" in detail


def test_blank_redirect_entries_do_not_count_as_urls():
    assert redirect_presence(app(redirects=("", "   ")))[0] == "none"


def test_one_and_several_redirect_urls_are_distinguished():
    assert redirect_presence(app(redirects=("https://a.example.com/cb",)))[0] == "one"
    assert redirect_presence(app(redirects=("https://a.example.com/cb",
                                            "https://b.example.com/cb")))[1] == 2


def test_no_redirect_url_pins_the_audience_to_one_workspace():
    audience, detail = install_audience(app())
    assert audience == "dev-workspace-only"
    assert "nowhere else" in detail


def test_a_redirect_url_opens_the_app_to_any_workspace():
    assert install_audience(app(redirects=("https://a.example.com/cb",)))[0] == \
        "any-workspace"


def test_org_deploy_is_the_third_audience_rather_than_a_flavour_of_the_second():
    assert install_audience(app(redirects=("https://a.example.com/cb",),
                                org=True))[0] == "org-wide"


def test_one_env_token_is_correct_for_one_workspace():
    state, detail = token_storage_shape(["SLACK_BOT_TOKEN"], False, "dev-workspace-only")
    assert state == "single-env-token"
    assert "until the day" in detail


def test_the_same_env_token_is_a_bug_once_the_app_is_public():
    state, detail = token_storage_shape(["SLACK_BOT_TOKEN"], False, "any-workspace")
    assert state == "env-token-but-public"
    assert "nowhere to put its own token" in detail


def test_an_installation_store_passes_whatever_the_audience():
    for audience in ("dev-workspace-only", "any-workspace", "org-wide"):
        assert token_storage_shape(["SLACK_BOT_TOKEN"], True, audience)[0] == \
            "installation-store"


def test_nothing_described_is_unknown_rather_than_a_failure():
    assert token_storage_shape([], False, "any-workspace")[0] == "unknown"


def test_the_checklist_fails_the_redirect_item_and_the_storage_item():
    rows = dict((r[0], r[1]) for r in gate_checklist(app(), "single-env-token"))
    assert rows["redirect URL configured"] == "fail"
    assert rows["per-workspace token storage"] == "fail"
    assert rows["public transport"] == "pass"


def test_org_wide_install_stays_optional_rather_than_becoming_a_failure():
    rows = dict((r[0], r[1]) for r in gate_checklist(app(), "installation-store"))
    assert rows["org-wide installation"] == "optional"
    rows = dict((r[0], r[1]) for r in gate_checklist(app(org=True),
                                                     "installation-store"))
    assert rows["org-wide installation"] == "pass"


def test_a_user_token_app_with_no_bot_user_is_a_valid_shape():
    rows = dict((r[0], r[1]) for r in gate_checklist(
        app(bot_user="", bot_scopes=(), user_scopes=("search:read",)),
        "installation-store"))
    assert rows["bot user declared"] == "optional"
    assert rows["scopes requested"] == "pass"


def test_an_app_that_declares_no_scopes_at_all_fails():
    rows = dict((r[0], r[1]) for r in gate_checklist(
        app(bot_scopes=(), user_scopes=()), "installation-store"))
    assert rows["scopes requested"] == "fail"


def test_an_unreadable_manifest_blocks_the_checklist_rather_than_failing_it():
    assert gate_checklist(None, "unknown") == [
        ("manifest readable", "blocked", "no manifest was read")]


def test_the_verdict_hands_a_socket_app_straight_over():
    state, detail = distribution_verdict("socket-mode", "dev-workspace-only",
                                         "single-env-token", [])
    assert state == "blocked-by-transport"
    assert "not the problem to solve first" in detail


def test_the_finished_app_that_was_never_let_out_is_this_note():
    checklist = gate_checklist(app(), "single-env-token")
    state, _detail = distribution_verdict("http", "dev-workspace-only",
                                          "single-env-token", checklist)
    assert state == "never-activated"


def test_distribution_on_with_one_env_token_is_named_before_the_checklist():
    state, detail = distribution_verdict("http", "any-workspace",
                                         "env-token-but-public", [])
    assert state == "no-store"
    assert "another workspace" in detail


def test_everything_met_reads_as_distributable():
    manifest = app(redirects=("https://a.example.com/cb",))
    checklist = gate_checklist(manifest, "installation-store")
    assert distribution_verdict("http", "any-workspace", "installation-store",
                                checklist)[0] == "distributable"
slack-distribution-readiness.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  blockingTransport, distributionVerdict, gateChecklist, installAudience,
  redirectPresence, tokenStorageShape,
} from './slack-distribution-readiness.mjs';

function app({
  socket = false, redirects = [], org = false, botScopes = ['chat:write'],
  userScopes = [], botUser = 'releasebot',
} = {}) {
  return {
    oauth_config: {
      redirect_urls: [...redirects],
      scopes: { bot: [...botScopes], user: [...userScopes] },
    },
    settings: { socket_mode_enabled: socket, org_deploy_enabled: org },
    features: botUser ? { bot_user: { display_name: botUser } } : {},
  };
}

function rows(checklist) {
  return Object.fromEntries(checklist.map((r) => [r[0], r[1]]));
}

test('socket mode is the other note and this script says so', () => {
  const [state, detail] = blockingTransport(app({ socket: true }));
  assert.equal(state, 'socket-mode');
  assert.match(detail, /different note/);
});

test('an http app is the one this checklist is for', () => {
  assert.equal(blockingTransport(app())[0], 'http');
});

test('no manifest is unknown rather than either', () => {
  assert.equal(blockingTransport(null)[0], 'unknown');
});

test('an empty redirect list means the flow cannot return', () => {
  const [state, count, detail] = redirectPresence(app());
  assert.equal(state, 'none');
  assert.equal(count, 0);
  assert.match(detail, /nowhere to be delivered/);
});

test('blank redirect entries do not count as urls', () => {
  assert.equal(redirectPresence(app({ redirects: ['', '   '] }))[0], 'none');
});

test('one and several redirect urls are distinguished', () => {
  assert.equal(redirectPresence(app({ redirects: ['https://a.example.com/cb'] }))[0],
    'one');
  assert.equal(redirectPresence(app({
    redirects: ['https://a.example.com/cb', 'https://b.example.com/cb'],
  }))[1], 2);
});

test('no redirect url pins the audience to one workspace', () => {
  const [audience, detail] = installAudience(app());
  assert.equal(audience, 'dev-workspace-only');
  assert.match(detail, /nowhere else/);
});

test('a redirect url opens the app to any workspace', () => {
  assert.equal(installAudience(app({ redirects: ['https://a.example.com/cb'] }))[0],
    'any-workspace');
});

test('org deploy is the third audience rather than a flavour of the second', () => {
  assert.equal(installAudience(app({
    redirects: ['https://a.example.com/cb'], org: true,
  }))[0], 'org-wide');
});

test('one env token is correct for one workspace', () => {
  const [state, detail] = tokenStorageShape(['SLACK_BOT_TOKEN'], false,
    'dev-workspace-only');
  assert.equal(state, 'single-env-token');
  assert.match(detail, /until the day/);
});

test('the same env token is a bug once the app is public', () => {
  const [state, detail] = tokenStorageShape(['SLACK_BOT_TOKEN'], false, 'any-workspace');
  assert.equal(state, 'env-token-but-public');
  assert.match(detail, /nowhere to put its own token/);
});

test('an installation store passes whatever the audience', () => {
  for (const audience of ['dev-workspace-only', 'any-workspace', 'org-wide']) {
    assert.equal(tokenStorageShape(['SLACK_BOT_TOKEN'], true, audience)[0],
      'installation-store');
  }
});

test('nothing described is unknown rather than a failure', () => {
  assert.equal(tokenStorageShape([], false, 'any-workspace')[0], 'unknown');
});

test('the checklist fails the redirect item and the storage item', () => {
  const r = rows(gateChecklist(app(), 'single-env-token'));
  assert.equal(r['redirect URL configured'], 'fail');
  assert.equal(r['per-workspace token storage'], 'fail');
  assert.equal(r['public transport'], 'pass');
});

test('org wide install stays optional rather than becoming a failure', () => {
  assert.equal(rows(gateChecklist(app(), 'installation-store'))['org-wide installation'],
    'optional');
  assert.equal(
    rows(gateChecklist(app({ org: true }), 'installation-store'))['org-wide installation'],
    'pass');
});

test('a user token app with no bot user is a valid shape', () => {
  const r = rows(gateChecklist(
    app({ botUser: '', botScopes: [], userScopes: ['search:read'] }),
    'installation-store'));
  assert.equal(r['bot user declared'], 'optional');
  assert.equal(r['scopes requested'], 'pass');
});

test('an app that declares no scopes at all fails', () => {
  const r = rows(gateChecklist(app({ botScopes: [], userScopes: [] }),
    'installation-store'));
  assert.equal(r['scopes requested'], 'fail');
});

test('an unreadable manifest blocks the checklist rather than failing it', () => {
  assert.deepEqual(gateChecklist(null, 'unknown'),
    [['manifest readable', 'blocked', 'no manifest was read']]);
});

test('the verdict hands a socket app straight over', () => {
  const [state, detail] = distributionVerdict('socket-mode', 'dev-workspace-only',
    'single-env-token', []);
  assert.equal(state, 'blocked-by-transport');
  assert.match(detail, /not the problem to solve first/);
});

test('the finished app that was never let out is this note', () => {
  const checklist = gateChecklist(app(), 'single-env-token');
  assert.equal(distributionVerdict('http', 'dev-workspace-only', 'single-env-token',
    checklist)[0], 'never-activated');
});

test('distribution on with one env token is named before the checklist', () => {
  const [state, detail] = distributionVerdict('http', 'any-workspace',
    'env-token-but-public', []);
  assert.equal(state, 'no-store');
  assert.match(detail, /another workspace/);
});

test('everything met reads as distributable', () => {
  const manifest = app({ redirects: ['https://a.example.com/cb'] });
  const checklist = gateChecklist(manifest, 'installation-store');
  assert.equal(distributionVerdict('http', 'any-workspace', 'installation-store',
    checklist)[0], 'distributable');
});

FAQ

How is this different from the note about Socket Mode blocking distribution?

That note is about an app that cannot be distributed: Socket Mode is not accepted on the Marketplace, and no amount of configuration changes that, so the repair is moving the delivery layer to a public HTTPS endpoint. This note is about an app that could be distributed and never was. Its transport is already public, nothing is blocking it, and the missing pieces are a redirect URL, an installation store and a click on Activate Public Distribution. The script checks the transport first and refuses to print a checklist for an app the checklist cannot help.

Do I actually need to activate distribution to install in a second workspace?

Yes, for anyone outside your own workspace. Until activation the app is installable only where it was created, and the install link for anyone else produces an error page rather than a consent screen. There is one adjacent case worth knowing: on Enterprise Grid, an app installed in one workspace of an org is still not installed in the others, and installing once for the whole org is a further setting on top of public distribution rather than a consequence of it.

Why implement the installation store before activating rather than after?

Because the window between the two is where the worst version of this bug lives. Once distribution is on, any workspace can install, and every installation mints its own bot token. An app still reading one token from the environment will keep using whichever token is in that variable, so the second customer's events are handled with the first customer's credentials and the app posts into the wrong company's channels. Nothing errors. Doing the store first means there is never a moment when a successful install produces a wrong-tenant app.

The manifest looks right and installs still fail with bad_redirect_uri. Same thing?

No, and this script deliberately does not try to diagnose it. bad_redirect_uri means a redirect URL is configured and the one your code sent does not match it, usually over a trailing slash, a scheme, or a staging hostname that was never added. That is a comparison between your code and your configuration. This note only asks whether the list is empty, because an empty list is a different and more fundamental state: not a mismatch, but no possible match at all.

We only ever want this app in our own workspace. Is any of this a problem?

No, and the script is written so it does not nag you about it. A single-workspace app with no redirect URLs and one bot token in an environment variable is a correct and supported shape, which is why token_storage_shape calls that combination single-env-token and not a failure. The check becomes useful the moment somebody says the word customer, and the useful thing about running it before that conversation is that the answer takes ten seconds instead of a fortnight.

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.