mirageslack

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 28, 2026 License: MIT Imports: 25 Imported by: 0

README

mirage-slack

A thin reverse proxy / multiplexer for Slack Apps development — one shared parent Slack App, many developer endpoints.

  Slack workspace                                   developer endpoints
  ┌────────────────┐                                ┌────────────────────┐
  │ Slash commands │                                │  alice's ngrok     │
  │ Interactivity  │──▶ mirage-slack ──────────────▶│  bob's local server│
  │ Events API     │    (channel-based routing)     │  main / production │
  └────────────────┘                                └────────────────────┘

Instead of spinning up a separate Slack App per developer (dev Alice, dev Bob, staging, …), create one parent Slack App once, point it at mirage-slack, and let each channel dispatch to a different endpoint. Developers use slash commands to register their own endpoint, bind it to a channel, and stop when done.

  • Simple forwarder — Slack payloads pass through untouched. The endpoint sees the same request shape it would see when wired directly to Slack, so end-to-end behaviour observed via mirage-slack matches production.
  • Self-discovering storage — The runtime uses a bot-owned Slack List as its only backing store. No DynamoDB / Postgres to set up. mirage-slack finds or creates the list on startup.
  • Lambda / server / container ready — Single binary runs on all three via fujiwara/ridge.

Contents

Install

go install github.com/mashiike/mirage-slack/cmd/mirage-slack@latest

Requires Go 1.26+.

Quick start

1. Create the parent Slack App

Create an app at api.slack.com/apps and configure:

Slack App feature Value
Slash Command /mirage-slack Request URL: https://<host>/slack/commands
Interactivity & Shortcuts Request URL: https://<host>/slack/interactive
Event Subscriptions Request URL: https://<host>/slack/events

The URL paths shown above (/slack/commands, /slack/interactive, /slack/events) are the defaults mirage-slack listens on. If your hosting layer requires different paths, override them via server.paths.* in the config and register the matching URLs in the Slack App settings.

Minimal Bot Token scopes:

  • commands (receive slash commands)
  • lists:write, lists:read (manage the bot-owned Slack List)

Add any scope your forward targets need in production (app_mentions:read, chat:write, channels:history, …). These must be registered here because the parent App is the one actually subscribed to Slack events.

2. Generate a starter config
mirage-slack init-config > config.jsonnet
# or
mirage-slack init-config --output config.jsonnet

Edit the file if you want to override defaults. The minimum workable configuration uses only two environment variables:

local env = std.native('env');

{
  slack: {
    signing_secret: env('SLACK_SIGNING_SECRET'),
    bot_token: env('SLACK_BOT_TOKEN'),
  },
  command: { name: '/mirage-slack' },
  routing: {},
}
3. Run the server
export SLACK_SIGNING_SECRET=...
export SLACK_BOT_TOKEN=xoxb-...
mirage-slack run --config=config.jsonnet --addr=:8080

On startup, mirage-slack:

  1. Calls auth.test to resolve the bot's user ID and team URL.
  2. Searches files.list?types=lists for a bot-owned list whose title matches slack.list_name (defaults to the slash command name without the leading slash; e.g. /mirage-slackmirage-slack).
  3. If no list is found, creates one via slackLists.create with mirage-slack's fixed schema.
  4. Caches the list metadata (column IDs, permalink components) and starts serving HTTP.

No separate init / migration step is required.

AWS Lambda: match the Function URL Invoke Mode

mirage-slack relies on fujiwara/ridge to adapt the HTTP handler to the Lambda runtime. Ridge defaults to the buffered response format ({statusCode, headers, body, ...}). If the Lambda Function URL's Invoke Mode is set to RESPONSE_STREAM, set the RIDGE_STREAMING_RESPONSE=true environment variable so ridge switches to the streaming response format. When the two disagree, Function URL cannot unwrap the response and the raw Lambda proxy envelope leaks through to Slack as a JSON string.

Function URL Invoke Mode Lambda environment variable
BUFFERED (default)
RESPONSE_STREAM RIDGE_STREAMING_RESPONSE=true

Slack slash commands complete within the 3-second ACK window, so BUFFERED is the natural choice unless you already have a reason to use RESPONSE_STREAM.

4. Register a development endpoint

From any channel:

/mirage-slack register alice https://alice.ngrok.app
/mirage-slack launch alice

Subsequent slash commands / interactivity / events in that channel are forwarded to https://alice.ngrok.app. When you are done:

/mirage-slack terminate alice       # stop forwarding, keep the registration
/mirage-slack unregister alice      # remove the registration entirely

Slash subcommands

Command Effect
/mirage-slack register <name> <url> Record a forward target (or update the URL of an existing entry).
/mirage-slack unregister <name> Remove the entry.
/mirage-slack launch <name> [--protect] Bind the entry to the current channel. With --protect, mirage-slack verifies the Slack signature before forwarding.
/mirage-slack terminate <name> Unbind the entry. The registration is kept so you can launch again later.
/mirage-slack list Grant the current channel view access to the list file and post its URL (Slack unfurls it).
/mirage-slack prune-list <file_id> Delete a bot-owned Slack List by its file_id (the F… identifier visible in files.list / Slack API tools). Useful for cleaning up orphan lists left behind after slack.list_name (or command.name) changes. The currently active list is refused to avoid foot-gunning.

Rules:

  • Registrations are workspace-wide; launch bindings are per-channel.
  • One environment per channel: if another entry is already launched in the same channel, launch errors out — terminate the other one first.
  • DMs are not supported as launch targets (public / private channels only).
  • Success responses post in the channel (in_channel). Errors and usage go to the invoking user only (ephemeral).

Configuration

Config is a Jsonnet file. Two native functions are registered:

Function Use
std.native('env')(name) Read an environment variable.
std.native('ssm')(path) Fetch an AWS SSM Parameter Store value (decrypted).
Path Type Required Default Description
slack.signing_secret string Slack App Signing Secret.
slack.bot_token string Bot User OAuth Token (xoxb-…).
slack.list_name string command.name without the leading / Title of the bot-owned Slack List. Deriving from the command name keeps multi-instance deployments collision-free with no extra config.
command.name string /mirage-slack Slash command name.
routing.default_endpoint string Forward target for requests whose channel is not bound to any entry.
routing.default_endpoint_protect bool true Verify the Slack signature before forwarding to default_endpoint.
server.paths.commands string /slack/commands URL path for the slash command entrypoint.
server.paths.interactive string /slack/interactive URL path for the interactive component entrypoint.
server.paths.events string /slack/events URL path for the Events API entrypoint.

CLI flags:

mirage-slack [--config=path] [--log-format=json|text] [--log-level=debug|info|warn|error] <command>

Commands:
  run          [--addr=:8080]                    start the HTTP server
  init-config  [--output path] [--force]         emit a starter config.jsonnet

Design & architecture

  • Transparent forwarding is the non-negotiable property. mirage-slack does not validate, enrich, or reshape payloads on the forward path by default. This is what makes it a development aid: if the endpoint works under mirage-slack, it will work when wired to Slack directly.
  • Opt-in signature verification (launch --protect, routing.default_endpoint_protect) exists for production-ish endpoints that should not be usable as an open relay.
  • 3-second Slack SLA is handled with context.WithoutCancel: the forward request is detached from the inbound context so the endpoint can keep processing past the 2.5-second ACK deadline. Late replies must use Slack's response_url (this is the standard Slack pattern anyway).

For the complete rationale, see docs/ARCHITECTURE.md.

Contributing

  • See CLAUDE.md for the developer hand-off notes (architecture invariants, non-obvious gotchas, how to reason about changes).
  • go build ./... / go vet ./... should pass without errors.
  • my-local/echo-endpoint/ is a throwaway forward target useful for manual end-to-end verification with ngrok / devtunnels.

License

MIT

Documentation

Overview

Package mirageslack provides a Slack Apps dispatcher / multiplexer for developer environments.

See docs/ARCHITECTURE.md for the design rationale and CLAUDE.md for maintenance hand-off notes.

Index

Constants

View Source
const (
	ColName            = "name"
	ColEndpoint        = "endpoint"
	ColLaunched        = "launched"
	ColLaunchedChannel = "launched_channel"
	ColProtected       = "protected"
)

Column keys for the mirage-slack Slack List schema.

Variables

View Source
var Schema = []Column{
	{Key: ColName, Name: "Name", Type: "text", IsPrimaryColumn: true},
	{Key: ColEndpoint, Name: "Endpoint", Type: "link"},
	{Key: ColLaunched, Name: "Launched", Type: "checkbox"},
	{Key: ColLaunchedChannel, Name: "Launched Channel", Type: "channel"},
	{Key: ColProtected, Name: "Protected", Type: "checkbox"},
}

Schema is the fixed Slack List schema that mirage-slack owns.

View Source
var Version = "0.2.1"

Version is the release version reported by the binary.

Managed by tagpr (https://github.com/Songmu/tagpr) — the value here is bumped automatically when tagpr cuts a release. Do not hand-edit outside of that flow.

Functions

func Run

func Run(ctx context.Context, args []string) error

Run is the entry point used by cmd/mirage-slack/main.go.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App bundles the runtime state needed to serve mirage-slack.

func NewApp

func NewApp(ctx context.Context, cfg *Config) (*App, error)

NewApp builds an App and ensures the Slack List is ready (discovered or created) before returning.

func (*App) HandleEvent

func (a *App) HandleEvent(w http.ResponseWriter, r *http.Request)

HandleEvent is the Events API entrypoint. url_verification challenges are answered here; everything else is forwarded using the event's channel_id.

func (*App) HandleInteractive

func (a *App) HandleInteractive(w http.ResponseWriter, r *http.Request)

HandleInteractive is the forward-only entrypoint for block actions, modal submissions, and shortcuts.

func (*App) HandleSlashCommand

func (a *App) HandleSlashCommand(w http.ResponseWriter, r *http.Request)

HandleSlashCommand dispatches slash command requests to either the internal subcommand pipeline or the forward pipeline.

func (*App) Handler

func (a *App) Handler() http.Handler

Handler builds the HTTP handler tree.

func (*App) Serve

func (a *App) Serve(ctx context.Context, addr string) error

Serve starts the HTTP server. On AWS Lambda, ridge runs the Lambda runtime transparently; otherwise it listens on addr.

type CLI

type CLI struct {
	Config    string           `help:"path to config file (jsonnet)" short:"c" default:"config.jsonnet"`
	LogFormat string           `help:"log output format (json or text)" enum:"json,text" default:"json"`
	LogLevel  string           `help:"log level (debug, info, warn, error)" enum:"debug,info,warn,error" default:"info"`
	Version   kong.VersionFlag `help:"show version and exit" short:"V"`

	Run        runCmd        `cmd:"" default:"withargs" help:"run the mirage-slack HTTP server"`
	InitConfig initConfigCmd `cmd:"init-config" help:"emit a starter config.jsonnet"`
}

CLI is the top-level kong grammar for the `mirage-slack` binary.

type Column

type Column struct {
	Key             string `json:"key"`
	Name            string `json:"name"`
	Type            string `json:"type"`
	IsPrimaryColumn bool   `json:"is_primary_column,omitempty"`
}

Column describes one column in a Slack List schema.

type CommandConfig

type CommandConfig struct {
	Name string `json:"name"`
}

type Config

type Config struct {
	Slack   SlackConfig   `json:"slack"`
	Command CommandConfig `json:"command"`
	Routing RoutingConfig `json:"routing"`
	Server  ServerConfig  `json:"server"`
}

Config is the top-level configuration loaded from a jsonnet file.

func LoadConfig

func LoadConfig(ctx context.Context, path string) (*Config, error)

LoadConfig evaluates the jsonnet file at path and decodes it into a Config. Native functions (ssm, env) are registered against the provided context.

func (*Config) DefaultEndpointProtectEnabled

func (c *Config) DefaultEndpointProtectEnabled() bool

DefaultEndpointProtectEnabled returns the effective value with default applied.

func (*Config) Validate

func (c *Config) Validate() error

Validate checks required fields.

type Entry

type Entry struct {
	ItemID          string
	Name            string
	Endpoint        string
	Launched        bool
	LaunchedChannel string
	Protected       bool
}

Entry is one mirage-slack environment row.

type RoutingConfig

type RoutingConfig struct {
	DefaultEndpoint        string `json:"default_endpoint"`
	DefaultEndpointProtect *bool  `json:"default_endpoint_protect"`
}

type ServerConfig added in v0.1.1

type ServerConfig struct {
	Paths ServerPaths `json:"paths"`
}

ServerConfig overrides the HTTP mount points exposed by mirage-slack.

type ServerPaths added in v0.1.1

type ServerPaths struct {
	Commands    string `json:"commands"`
	Interactive string `json:"interactive"`
	Events      string `json:"events"`
}

ServerPaths maps each Slack entrypoint to its URL path. Empty fields fall back to the defaults shown in DefaultServerPaths.

func DefaultServerPaths added in v0.1.1

func DefaultServerPaths() ServerPaths

DefaultServerPaths returns the default URL paths used when the config leaves the corresponding fields empty.

type SlackConfig

type SlackConfig struct {
	SigningSecret string `json:"signing_secret"`
	BotToken      string `json:"bot_token"`
	// ListName identifies the bot-owned Slack List. At run startup mirage-slack
	// looks up a bot-owned list with this title; if none exists, it creates one.
	// Defaults to the slash command name without the leading slash (e.g. command
	// "/mirage-slack" yields "mirage-slack"), so running multiple instances with
	// distinct command names yields distinct list titles with zero extra config.
	ListName string `json:"list_name"`
}

type SlackListClient

type SlackListClient struct {
	// contains filtered or unexported fields
}

SlackListClient is a thin wrapper around the slackLists.* Web API methods plus a handful of files.* calls that we use to discover the bot-owned list.

slack-go/slack does not expose the Lists API yet (2026-04), so we call the raw endpoints with a bot token.

func NewSlackListClient

func NewSlackListClient(token, listName string) *SlackListClient

NewSlackListClient constructs a client. Call Ensure before any read/write operation to populate list_id / column IDs. listName must be non-empty; Config.applyDefaults derives it from the configured slash command name.

func (*SlackListClient) DeleteList added in v0.2.0

func (c *SlackListClient) DeleteList(ctx context.Context, fileID string) error

DeleteList removes a bot-owned Slack List via files.delete. Refuses to delete the currently active list (file_id equal to ListID()) so the caller cannot accidentally wipe the live entries. slackLists.delete returns unknown_method, but files.delete works against list-type files.

func (*SlackListClient) Ensure

func (c *SlackListClient) Ensure(ctx context.Context) (string, error)

Ensure makes sure mirage-slack has a usable list:

  1. auth.test → bot user_id + team_id + team url
  2. files.list?types=lists → find bot-owned list whose title matches
  3. if not found, slackLists.create with the mirage-slack schema
  4. cache list_id + column_id map + permalink components on the client

Emits INFO logs at every step so a slow startup (typically caused by files.list scanning many lists in a large workspace) is visible.

func (*SlackListClient) GrantViewAccessToChannel

func (c *SlackListClient) GrantViewAccessToChannel(ctx context.Context, channelID string) error

GrantViewAccessToChannel shares the bot-owned list with the given channel as "Can view" (read-only). Idempotent: Slack treats the call as upsert. Needed so Slack's automatic unfurl of the permalink works for channel members who don't otherwise have access to the bot-owned list.

func (*SlackListClient) Launch

func (c *SlackListClient) Launch(ctx context.Context, name, channel string, protect bool) error

Launch binds an already-registered entry to the given channel and flips its launched flag on. Rejects the operation if the channel is already bound to a different entry (v1 rule: one launched entry per channel).

func (*SlackListClient) ListEntries

func (c *SlackListClient) ListEntries(ctx context.Context) ([]Entry, error)

ListEntries returns every mirage-slack environment row.

func (*SlackListClient) ListID

func (c *SlackListClient) ListID() string

ListID returns the resolved list_id (available after Ensure).

func (*SlackListClient) ListName

func (c *SlackListClient) ListName() string

ListName returns the configured list name (primary identifier).

func (c *SlackListClient) ListPermalink() string

ListPermalink returns the Slack URL for the bot-owned list. Posting this URL in a message triggers Slack's automatic unfurl.

func (*SlackListClient) Register

func (c *SlackListClient) Register(ctx context.Context, name, endpoint string) error

Register creates a new entry with the given endpoint, or updates an existing entry's endpoint while preserving its launch state.

func (*SlackListClient) Terminate

func (c *SlackListClient) Terminate(ctx context.Context, name string) error

Terminate clears the launch state of a registered entry.

func (*SlackListClient) Unregister

func (c *SlackListClient) Unregister(ctx context.Context, name string) error

Unregister deletes the entry by name.

type SlashCommand

type SlashCommand struct {
	Command     string
	Text        string
	ChannelID   string
	UserID      string
	ResponseURL string
	TriggerID   string
}

SlashCommand is the subset of Slack slash command payload fields that mirage-slack inspects.

Directories

Path Synopsis
cmd
mirage-slack command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL