diff --git a/.env.example b/.env.example
index 076d653..d73e56c 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,15 @@ SLACK_SIGNING_SECRET=your-signing-secret
SLACK_APP_TOKEN=xapp-your-app-token
SLACK_BOT_TOKEN=xoxb-your-bot-token
+# Web
+SLACK_CLIENT_ID=your-slack-app-client-id
+SLACK_CLIENT_SECRET=your-slack-app-client-secret
+# openssl rand -base64 48
+BETTER_AUTH_SECRET=replace-with-at-least-32-random-characters
+# public origin the dashboard and API are served from
+DASHBOARD_BASE_URL=https://prometheus.hackclub.com
+# PORT=3000
+
# PostgreSQL
DATABASE_URL=postgresql://prometheus:password@postgres:5432/prometheus
# DATABASE_POOL_SIZE=4
diff --git a/AGENTS.md b/AGENTS.md
index 6723202..62cd78e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,7 +41,7 @@ Prometheus is a Slack bot built with `@slack/bolt` in Socket Mode. It runs via `
- `canAnchor` — `canManage` OR workspaceAdmin
- `SUPERADMINS` grants access to the `/pro admin` command; it does not automatically insert rows into `global_admins`
-**Database** (`lib/db.js`): Uses Bun's native pooled PostgreSQL client, defaulting to four connections per bot instance. The Drizzle schema is `lib/db/schema.ts`; generated migrations live in `drizzle/` and must be applied with `bun run db:migrate` before starting a new application version. Tables are `global_admins`, `appointed_managers`, `channel_bans`, `join_messages`, `embed_blocks`, `anchor_polls`, `anchor_poll_choices`, `anchor_poll_votes`, and `anchor_nps_responses`.
+**Database** (`lib/db.js`): Uses Bun's native pooled PostgreSQL client, defaulting to four connections per bot instance. The Drizzle schema is `lib/db/schema.ts`; generated migrations live in `drizzle/` and must be applied with `bun run db:migrate` before starting a new application version. Tables are `global_admins`, `appointed_managers`, `channel_bans`, `join_messages`, `embed_blocks`, `anchor_polls`, `anchor_poll_choices`, `anchor_poll_votes`, `anchor_nps_responses`, and `channel_api_keys`.
All exported database functions are asynchronous and must be awaited. Multi-row anchor creation and vote toggling use transactions and advisory locks so overlapping bot instances remain consistent during rolling deploys. Keep schema changes additive and safe for old and new application versions to run concurrently. Generate and commit migrations, do not try to manually create or edit drizzle migrations.
@@ -51,6 +51,48 @@ All exported database functions are asynchronous and must be awaited. Multi-row
**Rate limiter** (`lib/ratelimiter.js`): Handles Slack API rate limits with exponential backoff. Used by `lib/purge.js` for batch message deletion.
+**Web dashboard and API** (`lib/web/`): A Hono app served by `lib/web/server.js`, mounted by
+`index.js` alongside the bot and also runnable on its own with `bun run start:web`. Sign-in is
+Slack OAuth through Better Auth (`auth.js`), restricted to the workspace the bot is installed in.
+
+- `app.js` — routing, security headers, and same-origin checks on every session mutation
+- `api.js` — the public, bearer-authenticated `/api/v1` router
+- `apiKeys.js` — key minting, hashing, and revocation
+- `permissions.js` — maps a Slack user to the channels they can configure
+- `sections.js` — the single source of truth for dashboard navigation
+- `views.jsx` — hono/jsx server-rendered pages; `dashboard.css` is the only asset
+
+The dashboard is deliberately scoped to API keys right now. The rest of the first pass is parked,
+not deleted: nav entries live in `PARKED` in `sections.js`, page components in `views.parked.jsx`,
+and their mutation helpers in `channelSettings.js`. Bringing a section back means moving its `PARKED`
+entry into `NAV`, adding it to the `pages` map in `views.jsx`, and re-registering its POST routes in
+`app.js`. Nothing imports `views.parked.jsx` or `channelSettings.js` in the meantime.
+
+There is no JavaScript on the dashboard — the CSP forbids it — so every interaction is a plain form
+POST. Key creation renders its response directly instead of redirecting, because the plaintext key
+exists only for that one response.
+
+**API keys** (`lib/web/api.js`): `POST /api/v1/messages/delete` deletes messages programmatically.
+Keys are scoped to one channel and one owner, stored only as a SHA-256 hash, and re-checked against
+`canManage` on every request, so demoting an owner disables their keys without an explicit
+revocation. Deletions reuse `logDelete`/`publicLogDelete` and are attributed to the key's owner.
+Per-key throughput is capped at 60 requests per minute in memory, and batches at 50 messages.
+
+Two properties are load-bearing. `LOG_CHANNEL` is mandatory for the endpoint: `logDelete` treats an
+unset channel as a successful no-op, so without this guard the API would delete messages with no
+record at all. And authorization is rechecked per message rather than once per request, so revoking a
+key or demoting its owner halts a batch already in flight. Request bodies are counted in bytes as
+they stream and capped at 64 KB, because `Content-Length` is absent under chunked encoding. The
+`reason` has its angle brackets stripped — it lands in a mrkdwn block, where `` would
+otherwise fire a real broadcast ping in the audit channel.
+
+A user may hold `MAX_API_KEYS_PER_USER` (5) active keys in total, across every channel, not per
+channel. `createChannelApiKey` counts and inserts inside one transaction behind an advisory lock on
+the user id, so concurrent creations cannot both pass the check; it returns `null` at the cap and
+`createApiKey` turns that into a user-facing error. The per-key rate limit is a loose temporary
+guard, not a quota: Slack's tier 3 limit is the real ceiling and key holders already have the same
+power via the Slack shortcut.
+
## Environment Variables
| Variable | Purpose |
@@ -67,6 +109,11 @@ All exported database functions are asynchronous and must be awaited. Multi-row
| `HACKCLUB_CDN_KEY` | CDN API key for archiving deleted threads (optional) |
| `SLACK_BROWSER_TOKEN` | Browser token (xoxc) for undocumented moderation APIs (optional) |
| `SLACK_COOKIE` | Session cookie (`d=` value) paired with browser token (optional) |
+| `SLACK_CLIENT_ID` | Slack app client ID for dashboard sign-in (optional) |
+| `SLACK_CLIENT_SECRET` | Slack app client secret for dashboard sign-in (optional) |
+| `BETTER_AUTH_SECRET` | Random 32+ character secret for Better Auth sessions (optional) |
+| `DASHBOARD_BASE_URL` | Public HTTPS origin the dashboard and API are served from (optional) |
+| `PORT` | Port the web server listens on (optional, defaults to `3000`) |
## Adding a New Command
diff --git a/Dockerfile b/Dockerfile
index 5dc02de..14df0e4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,7 +9,9 @@ COPY . .
ENV NODE_ENV=production
+EXPOSE 3000
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD bun -e 'try { const response = await fetch("http://127.0.0.1:3000/health"); process.exit(response.ok ? 0 : 1); } catch { process.exit(1); }'
+ CMD bun -e 'try { const port = process.env.PORT || 3000; const response = await fetch(`http://127.0.0.1:${port}/health`); process.exit(response.ok ? 0 : 1); } catch { process.exit(1); }'
CMD ["bun", "run", "index.js"]
diff --git a/README.md b/README.md
index cb0ecbc..658255a 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,34 @@ Prometheus is a Slack bot that lets community members take responsibility for ke
- **Channel manager**: appointed per-channel; can delete, destroy, set welcome messages
- **Channel moderator**: appointed per-channel; can timeout, @here, @channel
+## Web API
+
+Prometheus offers an web API to programmatically execute moderation actions. You can use this API how you see fit, common use cases include automod bots, cleaning up certain bot messes, or anything to your hearts content.
+
+Keys are scoped to **one channel and one user**. You can hold up to five keys at once, counted across every channel; a key only ever works in the channel it was made for, and it explodes the moment its owner loses their channel manager role. Any actions done via API keys is still logged in the same way as if the user had done it themselves.
+
+```bash
+curl -X POST https://prometheus.hackclub.com/api/v1/messages/delete \
+ -H "Authorization: Bearer $PROMETHEUS_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"ts":"1699999999.123456","reason":"spam"}'
+```
+
+```json
+{ "ok": true, "channel": "C0123ABCD", "deleted": ["1699999999.123456"], "failed": [] }
+```
+
+| Field | Required | Notes |
+| -------- | -------- | ----------------------------------------------- |
+| `ts` | Yes | One message timestamp, or an array of up to 50 |
+| `reason` | Yes | Up to 500 characters, recorded in the audit log |
+
+A `200` means the request was accepted, not that every message was deleted, so check `deleted` and `failed`, where each failure carries the Slack error (`message_not_found`, `cant_delete_message`).
+
+Other statuses: `400` malformed request, `401` missing or invalid key, `403` no permission, `413` request body over 64 KB, `429` over the rate limit of 60 requests per minute.
+
+`GET /api/v1/key` just returns a key's metadata, which is handy for checking the status of a key.
+
## Setup
> **⚠️ Workspace admin perms required.** Only cloning the repo and installing the Slack app is **not** enough. Almost every moderation feature (delete message, destroy thread, timeout/kick, move members, ban enforcement, clear embeds, and similar) runs through `SLACK_USER_TOKEN` and **will not work** unless that token is a User OAuth token (`xoxp`) from a **workspace admin**. Without it, only trivial commands like `ping`, `coin`, and `help` work.
@@ -48,20 +76,25 @@ Prometheus is a Slack bot that lets community members take responsibility for ke
4. Create an app-level token with `connections:write` (for Socket Mode!).
5. Fill out your `.env`, check the `.env.example` for reference. Here's a bit more detailed rundown of what to expect
-| Variable | Required | Purpose |
-| ---------------------- | -------- | ------------------------------------------------------------------------------------------------- |
-| `SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (xoxb) for posting messages |
-| `SLACK_USER_TOKEN` | Yes | User OAuth Token (`xoxp`) from a **workspace admin** — required for all moderation actions |
-| `SLACK_APP_TOKEN` | Yes | App-Level Token (xapp) with `connections:write` for Socket Mode |
-| `SLACK_SIGNING_SECRET` | Yes | Signing secret from app settings |
-| `SUPERADMINS` | Yes | Comma-separated Slack user IDs seeded as global admins (e.g. `U12345678,U87654321`) |
-| `DATABASE_URL` | Yes | PostgreSQL connection URL |
-| `DATABASE_POOL_SIZE` | No | Maximum PostgreSQL connections per bot instance (defaults to 4) |
-| `LOG_CHANNEL` | No | Channel ID for **private** audit logs which includes full message content and CDN transcripts |
-| `PUBLIC_LOG_CHANNEL` | No | Channel ID for **public** audit logs which are redacted, shows only who did what in which channel |
-| `HACKCLUB_CDN_KEY` | No | CDN API key for archiving deleted thread archives to the HC CDN |
-| `SLACK_BROWSER_TOKEN` | No | Browser token (xoxc) for Slack's undocumented moderation APIs (eg thread hiding) |
-| `SLACK_COOKIE` | No | Session cookie (`d=` value) paired with `SLACK_BROWSER_TOKEN` |
+| Variable | Required | Purpose |
+| ---------------------- | -------- | -------------------------------------------------------------------------------------------------- |
+| `SLACK_BOT_TOKEN` | Yes | Bot User OAuth Token (xoxb) for posting messages |
+| `SLACK_USER_TOKEN` | Yes | User OAuth Token (`xoxp`) from a **workspace admin** — required for all moderation actions |
+| `SLACK_APP_TOKEN` | Yes | App-Level Token (xapp) with `connections:write` for Socket Mode |
+| `SLACK_SIGNING_SECRET` | Yes | Signing secret from app settings |
+| `SUPERADMINS` | Yes | Comma-separated Slack user IDs seeded as global admins (e.g. `U12345678,U87654321`) |
+| `DATABASE_URL` | Yes | PostgreSQL connection URL |
+| `DATABASE_POOL_SIZE` | No | Maximum PostgreSQL connections per bot instance (defaults to 4) |
+| `LOG_CHANNEL` | API | Channel ID for **private** audit logs which includes full message content and CDN transcripts |
+| `PUBLIC_LOG_CHANNEL` | No | Channel ID for **public** audit logs which are redacted, shows only who did what in which channel |
+| `HACKCLUB_CDN_KEY` | No | CDN API key for archiving deleted thread archives to the HC CDN |
+| `SLACK_BROWSER_TOKEN` | No | Browser token (xoxc) for Slack's undocumented moderation APIs (eg thread hiding) |
+| `SLACK_COOKIE` | No | Session cookie (`d=` value) paired with `SLACK_BROWSER_TOKEN` |
+| `SLACK_CLIENT_ID` | No | Slack app client ID used by Sign in with Slack |
+| `SLACK_CLIENT_SECRET` | No | Slack app client secret used by Sign in with Slack |
+| `BETTER_AUTH_SECRET` | No | Random secret of at least 32 characters used by Better Auth |
+| `DASHBOARD_BASE_URL` | No | Public HTTPS origin the dashboard and API are served from (e.g. `https://prometheus.hackclub.com`) |
+| `PORT` | No | Port the dashboard and API listen on (defaults to 3000) |
6. Apply database migrations and run it:
diff --git a/bun.lock b/bun.lock
index 0e7d5c2..8555826 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,8 +7,10 @@
"dependencies": {
"@slack/bolt": "^4.7.3",
"airtable": "^0.12.2",
+ "better-auth": "^1.6.27",
"dotenv": "^17.4.2",
"drizzle-orm": "0.45.2",
+ "hono": "^4.13.1",
"tldts": "^7.4.10",
},
"devDependencies": {
@@ -20,6 +22,24 @@
},
},
"packages": {
+ "@better-auth/core": ["@better-auth/core@1.6.27", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-A6/mQW4AT2kSHCRZDh9+k8jPUqnCUUCymzBgIroK0l60wLioMtJdcmZiTwrAn6KVUw+4XWw64MgaRn7LOie3wg=="],
+
+ "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-BDJ02ra/fji/ah3aIpxjjNOu/JQVex0UisxS4S0vGZZyiAs+DL24mn3/iovyD5dWB3u3NaGg1n8zpTOntiIXcg=="],
+
+ "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-aavv7W4+b3QVObYo166baplWvwocCk8ORDxRqQ9ytzVl/waiUpSXAOtDHdXZHFsoVHFVPtEzyEq0Tqr3Qvvfig=="],
+
+ "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2" } }, "sha512-p4NB37MdaVFkxkpLEAKjORgTPhaOAPu1M2zgQXiTJJ3F6Uq3Y1YcCgoz+VxJu0TQfxibCsJD/dZlJMVgf+CF7A=="],
+
+ "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-IOYbJMIjEC//f+JpFlmIQjh6x1R/rGAfdzlojFk6HeAJqbsELuMcI+27dIoesbfHLF/icTrSpZtK986XhJrCfA=="],
+
+ "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-v7DXVyaFbkrfLoiFDtcVF7BkEZgFa/DgEGE7XjrAXmMACa5pjDvb7lm8W5X+/qgIbQP04eThhgFQ6EWOsjr8OQ=="],
+
+ "@better-auth/telemetry": ["@better-auth/telemetry@1.6.27", "", { "peerDependencies": { "@better-auth/core": "^1.6.27", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-aYrSiVWQfua8w5YX8X1cM6ca48EdS0t5k+CvYXIsruaNIpR1DXMe1DOD0M5FWAVABnlCf9joyHXbFRSIZSNY/A=="],
+
+ "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="],
+
+ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="],
+
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -78,6 +98,12 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+ "@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="],
+
+ "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="],
+
+ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
+
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ=="],
@@ -166,6 +192,8 @@
"@slack/web-api": ["@slack/web-api@7.19.0", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA=="],
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
@@ -210,6 +238,10 @@
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
+ "better-auth": ["better-auth@1.6.27", "", { "dependencies": { "@better-auth/core": "1.6.27", "@better-auth/drizzle-adapter": "1.6.27", "@better-auth/kysely-adapter": "1.6.27", "@better-auth/memory-adapter": "1.6.27", "@better-auth/mongo-adapter": "1.6.27", "@better-auth/prisma-adapter": "1.6.27", "@better-auth/telemetry": "1.6.27", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-x3jyxpAiBSsMO/DaXMFSVwM8bTqnYZlCKsZxBV43zL3ZtsGa/CzeUuNKxPT2Lsz7ahTBY90Ku1tibN6nRpVEbw=="],
+
+ "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
+
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
@@ -236,6 +268,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
+
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
@@ -302,6 +336,8 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+ "hono": ["hono@4.13.1", "", {}, "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw=="],
+
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
@@ -318,12 +354,16 @@
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+ "jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="],
+
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+ "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
+
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -352,6 +392,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "nanostores": ["nanostores@1.4.2", "", {}, "sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g=="],
+
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
@@ -394,6 +436,8 @@
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
+ "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="],
+
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -406,6 +450,8 @@
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+ "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
+
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
@@ -452,6 +498,8 @@
"ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
+ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@slack/logger/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
@@ -476,6 +524,8 @@
"@types/ws/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
+ "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="],
+
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"bun-types/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
diff --git a/dashboard.js b/dashboard.js
new file mode 100644
index 0000000..8cfd6c2
--- /dev/null
+++ b/dashboard.js
@@ -0,0 +1,30 @@
+import "dotenv/config";
+import { WebClient } from "@slack/web-api";
+import { checkDatabaseConnection, sql } from "./lib/db.js";
+import { startWebServer } from "./lib/web/server.js";
+
+if (!process.env.SLACK_USER_TOKEN) {
+ throw new Error("SLACK_USER_TOKEN is required by the standalone dashboard");
+}
+if (!process.env.SLACK_BOT_TOKEN) {
+ throw new Error("SLACK_BOT_TOKEN is required by the standalone dashboard");
+}
+
+if (!(await checkDatabaseConnection())) {
+ throw new Error("The standalone dashboard could not connect to DATABASE_URL");
+}
+
+const client = new WebClient(process.env.SLACK_USER_TOKEN);
+const botClient = new WebClient(process.env.SLACK_BOT_TOKEN);
+const server = startWebServer({ botClient, client, isHealthy: checkDatabaseConnection });
+
+console.log(`Prometheus dashboard listening on ${server.url}`);
+
+async function shutdown() {
+ server.stop();
+ await sql.close({ timeout: 5 });
+ process.exit(0);
+}
+
+process.once("SIGINT", shutdown);
+process.once("SIGTERM", shutdown);
diff --git a/drizzle/0001_productive_christian_walker.sql b/drizzle/0001_productive_christian_walker.sql
new file mode 100644
index 0000000..1225335
--- /dev/null
+++ b/drizzle/0001_productive_christian_walker.sql
@@ -0,0 +1,14 @@
+CREATE TABLE "channel_api_keys" (
+ "id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "channel_api_keys_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
+ "channel_id" text NOT NULL,
+ "user_id" text NOT NULL,
+ "name" text NOT NULL,
+ "key_prefix" text NOT NULL,
+ "key_hash" text NOT NULL,
+ "created_at" bigint DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint NOT NULL,
+ "last_used_at" bigint,
+ "revoked_at" bigint
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX "channel_api_keys_hash" ON "channel_api_keys" USING btree ("key_hash");--> statement-breakpoint
+CREATE INDEX "channel_api_keys_owner" ON "channel_api_keys" USING btree ("channel_id","user_id") WHERE "channel_api_keys"."revoked_at" IS NULL;
\ No newline at end of file
diff --git a/drizzle/0002_colorful_lionheart.sql b/drizzle/0002_colorful_lionheart.sql
new file mode 100644
index 0000000..06f5016
--- /dev/null
+++ b/drizzle/0002_colorful_lionheart.sql
@@ -0,0 +1,2 @@
+DROP INDEX "channel_api_keys_owner";--> statement-breakpoint
+CREATE INDEX "channel_api_keys_owner" ON "channel_api_keys" USING btree ("user_id","channel_id") WHERE "channel_api_keys"."revoked_at" IS NULL;
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..f726084
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,835 @@
+{
+ "id": "597fce34-1da3-4496-b1b8-748dc5a91d59",
+ "prevId": "3b0038e2-51aa-4ec9-b0af-730d03bb3228",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.anchor_nps_responses": {
+ "name": "anchor_nps_responses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_nps_responses_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "poll_id": {
+ "name": "poll_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "score": {
+ "name": "score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment": {
+ "name": "comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_nps_responses_poll_user": {
+ "name": "anchor_nps_responses_poll_user",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anchor_nps_responses_poll_id_fkey": {
+ "name": "anchor_nps_responses_poll_id_fkey",
+ "tableFrom": "anchor_nps_responses",
+ "tableTo": "anchor_polls",
+ "columnsFrom": ["poll_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anchor_poll_choices": {
+ "name": "anchor_poll_choices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_poll_choices_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "poll_id": {
+ "name": "poll_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_user_id": {
+ "name": "creator_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_poll_choices_position": {
+ "name": "anchor_poll_choices_position",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anchor_poll_choices_poll_id_fkey": {
+ "name": "anchor_poll_choices_poll_id_fkey",
+ "tableFrom": "anchor_poll_choices",
+ "tableTo": "anchor_polls",
+ "columnsFrom": ["poll_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anchor_poll_votes": {
+ "name": "anchor_poll_votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_poll_votes_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "poll_id": {
+ "name": "poll_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "choice_id": {
+ "name": "choice_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_poll_votes_poll": {
+ "name": "anchor_poll_votes_poll",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anchor_poll_votes_unique": {
+ "name": "anchor_poll_votes_unique",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "choice_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anchor_poll_votes_poll_id_fkey": {
+ "name": "anchor_poll_votes_poll_id_fkey",
+ "tableFrom": "anchor_poll_votes",
+ "tableTo": "anchor_polls",
+ "columnsFrom": ["poll_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "anchor_poll_votes_choice_id_fkey": {
+ "name": "anchor_poll_votes_choice_id_fkey",
+ "tableFrom": "anchor_poll_votes",
+ "tableTo": "anchor_poll_choices",
+ "columnsFrom": ["choice_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anchor_polls": {
+ "name": "anchor_polls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_polls_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_user_id": {
+ "name": "creator_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'poll'"
+ },
+ "question": {
+ "name": "question",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "anonymous": {
+ "name": "anonymous",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "multi_select": {
+ "name": "multi_select",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "add_choice_setting": {
+ "name": "add_choice_setting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'no_one'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "message_ts": {
+ "name": "message_ts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closes_at": {
+ "name": "closes_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_current": {
+ "name": "is_current",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_polls_channel_current": {
+ "name": "anchor_polls_channel_current",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"anchor_polls\".\"is_current\" = 1",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anchor_polls_channel_history": {
+ "name": "anchor_polls_channel_history",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.appointed_managers": {
+ "name": "appointed_managers",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'moderator'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "appointed_managers_pkey": {
+ "name": "appointed_managers_pkey",
+ "columns": ["user_id", "channel_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_api_keys": {
+ "name": "channel_api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "channel_api_keys_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_prefix": {
+ "name": "key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "channel_api_keys_hash": {
+ "name": "channel_api_keys_hash",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "channel_api_keys_owner": {
+ "name": "channel_api_keys_owner",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"channel_api_keys\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_bans": {
+ "name": "channel_bans",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "banned_by": {
+ "name": "banned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "channel_bans_pkey": {
+ "name": "channel_bans_pkey",
+ "columns": ["user_id", "channel_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.embed_blocks": {
+ "name": "embed_blocks",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_by": {
+ "name": "blocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_at": {
+ "name": "blocked_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "embed_blocks_pkey": {
+ "name": "embed_blocks_pkey",
+ "columns": ["channel_id", "type", "target"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "embed_blocks_type_check": {
+ "name": "embed_blocks_type_check",
+ "value": "\"embed_blocks\".\"type\" IN ('domain', 'host', 'path')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.global_admins": {
+ "name": "global_admins",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.join_messages": {
+ "name": "join_messages",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ephemeral'"
+ },
+ "set_by": {
+ "name": "set_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "set_at": {
+ "name": "set_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..506ee1b
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,835 @@
+{
+ "id": "ee983e57-cb44-445f-8be9-e9f1ec193f5f",
+ "prevId": "597fce34-1da3-4496-b1b8-748dc5a91d59",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.anchor_nps_responses": {
+ "name": "anchor_nps_responses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_nps_responses_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "poll_id": {
+ "name": "poll_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "score": {
+ "name": "score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment": {
+ "name": "comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_nps_responses_poll_user": {
+ "name": "anchor_nps_responses_poll_user",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anchor_nps_responses_poll_id_fkey": {
+ "name": "anchor_nps_responses_poll_id_fkey",
+ "tableFrom": "anchor_nps_responses",
+ "tableTo": "anchor_polls",
+ "columnsFrom": ["poll_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anchor_poll_choices": {
+ "name": "anchor_poll_choices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_poll_choices_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "poll_id": {
+ "name": "poll_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_user_id": {
+ "name": "creator_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_poll_choices_position": {
+ "name": "anchor_poll_choices_position",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anchor_poll_choices_poll_id_fkey": {
+ "name": "anchor_poll_choices_poll_id_fkey",
+ "tableFrom": "anchor_poll_choices",
+ "tableTo": "anchor_polls",
+ "columnsFrom": ["poll_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anchor_poll_votes": {
+ "name": "anchor_poll_votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_poll_votes_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "poll_id": {
+ "name": "poll_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "choice_id": {
+ "name": "choice_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_poll_votes_poll": {
+ "name": "anchor_poll_votes_poll",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anchor_poll_votes_unique": {
+ "name": "anchor_poll_votes_unique",
+ "columns": [
+ {
+ "expression": "poll_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "choice_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "anchor_poll_votes_poll_id_fkey": {
+ "name": "anchor_poll_votes_poll_id_fkey",
+ "tableFrom": "anchor_poll_votes",
+ "tableTo": "anchor_polls",
+ "columnsFrom": ["poll_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "anchor_poll_votes_choice_id_fkey": {
+ "name": "anchor_poll_votes_choice_id_fkey",
+ "tableFrom": "anchor_poll_votes",
+ "tableTo": "anchor_poll_choices",
+ "columnsFrom": ["choice_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.anchor_polls": {
+ "name": "anchor_polls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "anchor_polls_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_user_id": {
+ "name": "creator_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'poll'"
+ },
+ "question": {
+ "name": "question",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "anonymous": {
+ "name": "anonymous",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "multi_select": {
+ "name": "multi_select",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "add_choice_setting": {
+ "name": "add_choice_setting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'no_one'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "message_ts": {
+ "name": "message_ts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "closes_at": {
+ "name": "closes_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_current": {
+ "name": "is_current",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {
+ "anchor_polls_channel_current": {
+ "name": "anchor_polls_channel_current",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"anchor_polls\".\"is_current\" = 1",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "anchor_polls_channel_history": {
+ "name": "anchor_polls_channel_history",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.appointed_managers": {
+ "name": "appointed_managers",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'moderator'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "appointed_managers_pkey": {
+ "name": "appointed_managers_pkey",
+ "columns": ["user_id", "channel_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_api_keys": {
+ "name": "channel_api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "byDefault",
+ "name": "channel_api_keys_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "2147483647",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_prefix": {
+ "name": "key_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "channel_api_keys_hash": {
+ "name": "channel_api_keys_hash",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "channel_api_keys_owner": {
+ "name": "channel_api_keys_owner",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"channel_api_keys\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_bans": {
+ "name": "channel_bans",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "banned_by": {
+ "name": "banned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "channel_bans_pkey": {
+ "name": "channel_bans_pkey",
+ "columns": ["user_id", "channel_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.embed_blocks": {
+ "name": "embed_blocks",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_by": {
+ "name": "blocked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_at": {
+ "name": "blocked_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "embed_blocks_pkey": {
+ "name": "embed_blocks_pkey",
+ "columns": ["channel_id", "type", "target"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "embed_blocks_type_check": {
+ "name": "embed_blocks_type_check",
+ "value": "\"embed_blocks\".\"type\" IN ('domain', 'host', 'path')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.global_admins": {
+ "name": "global_admins",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.join_messages": {
+ "name": "join_messages",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ephemeral'"
+ },
+ "set_by": {
+ "name": "set_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "set_at": {
+ "name": "set_at",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index e1f2ac2..4b4b367 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -8,6 +8,20 @@
"when": 1786541340081,
"tag": "0000_baseline",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1787159468938,
+ "tag": "0001_productive_christian_walker",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1787197687345,
+ "tag": "0002_colorful_lionheart",
+ "breakpoints": true
}
]
}
diff --git a/index.js b/index.js
index 89a3d8c..286e41a 100644
--- a/index.js
+++ b/index.js
@@ -9,23 +9,13 @@ import appHomeHandler, {
actions as homeActions,
views as homeViews,
} from "./lib/listeners/appHome.js";
+import { startWebServer } from "./lib/web/server.js";
export const userClient = new WebClient(process.env.SLACK_USER_TOKEN);
let slackConnected = false;
const receiver = new SocketModeReceiver({
appToken: process.env.SLACK_APP_TOKEN,
- customRoutes: [
- {
- path: "/health",
- method: "GET",
- handler: (_req, res) => {
- const healthy = slackConnected && receiver.client.websocket?.isActive();
- res.writeHead(healthy ? 200 : 503, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ status: healthy ? "ok" : "disconnected" }));
- },
- },
- ],
});
receiver.client.on("connected", () => {
@@ -77,6 +67,17 @@ for (const v of homeViews) {
}
(async () => {
- await app.start();
- console.log(`fire stolen, legs broken`);
+ const server = startWebServer({
+ botClient: app.client,
+ client: userClient,
+ isHealthy: () => slackConnected && receiver.client.websocket?.isActive(),
+ });
+
+ try {
+ await app.start();
+ console.log(`fire stolen, legs broken; web dashboard listening on ${server.url}`);
+ } catch (error) {
+ server.stop();
+ throw error;
+ }
})();
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..f8fc8b5
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ },
+ "include": ["lib/web/**/*.js", "lib/web/**/*.jsx"]
+}
diff --git a/lib/db.js b/lib/db.js
index 42069f7..86066cc 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -6,6 +6,15 @@ const epoch = "EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint";
const first = (rows) => rows[0];
+export async function checkDatabaseConnection() {
+ try {
+ await sql`SELECT 1`;
+ return true;
+ } catch {
+ return false;
+ }
+}
+
export async function isGlobalAdmin(userId) {
return Boolean(first(await sql`SELECT 1 FROM global_admins WHERE user_id = ${userId}`));
}
@@ -77,6 +86,15 @@ export async function listAllAppointedManagers() {
`;
}
+export async function listUserAppointedManagers(userId) {
+ return sql`
+ SELECT channel_id, role, added_by, added_at
+ FROM appointed_managers
+ WHERE user_id = ${userId}
+ ORDER BY channel_id
+ `;
+}
+
export async function hasAppointedManager(channelId) {
return Boolean(
first(await sql`SELECT 1 FROM appointed_managers WHERE channel_id = ${channelId}`),
@@ -396,5 +414,82 @@ export async function recordNpsComment(pollId, userId, comment) {
);
}
+const apiKeyColumns = "id, channel_id, user_id, name, key_prefix, created_at, last_used_at";
+
+// A user may hold this many active keys in total, across every channel.
+export const MAX_API_KEYS_PER_USER = 5;
+
+export async function countUserApiKeys(userId) {
+ const [row] = await sql`
+ SELECT count(*)::int AS count FROM channel_api_keys
+ WHERE user_id = ${userId} AND revoked_at IS NULL
+ `;
+ return row.count;
+}
+
+// Returns null when the caller is already at the cap. Counting and inserting share
+// a transaction and an advisory lock on the user, so two concurrent creations
+// cannot both read a count of four and both insert.
+export async function createChannelApiKey(channelId, userId, name, keyPrefix, keyHash) {
+ return sql.begin(async (tx) => {
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`api_keys:${userId}`}, 0))`;
+ const [row] = await tx`
+ SELECT count(*)::int AS count FROM channel_api_keys
+ WHERE user_id = ${userId} AND revoked_at IS NULL
+ `;
+ if (row.count >= MAX_API_KEYS_PER_USER) return null;
+
+ return first(
+ await tx`
+ INSERT INTO channel_api_keys (channel_id, user_id, name, key_prefix, key_hash)
+ VALUES (${channelId}, ${userId}, ${name}, ${keyPrefix}, ${keyHash})
+ RETURNING ${sql.unsafe(apiKeyColumns)}
+ `,
+ );
+ });
+}
+
+export async function listChannelApiKeys(channelId, userId) {
+ return sql`
+ SELECT ${sql.unsafe(apiKeyColumns)} FROM channel_api_keys
+ WHERE channel_id = ${channelId} AND user_id = ${userId} AND revoked_at IS NULL
+ ORDER BY created_at DESC, id DESC
+ `;
+}
+
+export async function findChannelApiKeyByHash(keyHash) {
+ return first(
+ await sql`
+ SELECT ${sql.unsafe(apiKeyColumns)} FROM channel_api_keys
+ WHERE key_hash = ${keyHash} AND revoked_at IS NULL
+ `,
+ );
+}
+
+export async function isChannelApiKeyActive(id) {
+ return Boolean(
+ first(await sql`SELECT 1 FROM channel_api_keys WHERE id = ${id} AND revoked_at IS NULL`),
+ );
+}
+
+export async function touchChannelApiKey(id) {
+ await sql`UPDATE channel_api_keys SET last_used_at = ${sql.unsafe(epoch)} WHERE id = ${id}`;
+}
+
+export async function revokeChannelApiKey(id, channelId, userId) {
+ return Boolean(
+ first(
+ await sql`
+ UPDATE channel_api_keys SET revoked_at = ${sql.unsafe(epoch)}
+ WHERE id = ${id}
+ AND channel_id = ${channelId}
+ AND user_id = ${userId}
+ AND revoked_at IS NULL
+ RETURNING id
+ `,
+ ),
+ );
+}
+
export { sql };
export default sql;
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 75adead..77967a8 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -163,3 +163,24 @@ export const anchorNpsResponses = pgTable(
uniqueIndex("anchor_nps_responses_poll_user").on(table.pollId, table.userId),
],
);
+
+export const channelApiKeys = pgTable(
+ "channel_api_keys",
+ {
+ id: integer().primaryKey().generatedByDefaultAsIdentity(),
+ channelId: text("channel_id").notNull(),
+ userId: text("user_id").notNull(),
+ name: text().notNull(),
+ keyPrefix: text("key_prefix").notNull(),
+ keyHash: text("key_hash").notNull(),
+ createdAt: bigint("created_at", { mode: "number" }).notNull().default(epoch),
+ lastUsedAt: bigint("last_used_at", { mode: "number" }),
+ revokedAt: bigint("revoked_at", { mode: "number" }),
+ },
+ (table) => [
+ uniqueIndex("channel_api_keys_hash").on(table.keyHash),
+ index("channel_api_keys_owner")
+ .on(table.userId, table.channelId)
+ .where(sql`${table.revokedAt} IS NULL`),
+ ],
+);
diff --git a/lib/web/api.js b/lib/web/api.js
new file mode 100644
index 0000000..28b243d
--- /dev/null
+++ b/lib/web/api.js
@@ -0,0 +1,267 @@
+import { Hono } from "hono";
+import { findChannelApiKeyByHash, isChannelApiKeyActive, touchChannelApiKey } from "../db.js";
+import { logDelete } from "../logger.js";
+import { canManage } from "../perms.js";
+import { publicLogDelete } from "../public-logger.js";
+import { RateLimiter } from "../ratelimiter.js";
+import { hashApiKey } from "./apiKeys.js";
+
+const rateLimiter = new RateLimiter(1000, 5);
+const logger = console;
+
+const BEARER = /^Bearer\s+(\S+)$/i;
+
+const AUDIT_CONFIGURED = Boolean(process.env.LOG_CHANNEL);
+
+// adjust as needed
+const WINDOW_MS = 60 * 1000;
+const WINDOW_MAX = 60;
+const windows = new Map();
+
+function withinBudget(keyId) {
+ const now = Date.now();
+ const window = windows.get(keyId);
+ if (!window || window.resetAt <= now) {
+ windows.set(keyId, { count: 1, resetAt: now + WINDOW_MS });
+ if (windows.size > 1000) {
+ for (const [id, entry] of windows) if (entry.resetAt <= now) windows.delete(id);
+ }
+ return { ok: true };
+ }
+ if (window.count >= WINDOW_MAX) {
+ return { ok: false, retryAfter: Math.ceil((window.resetAt - now) / 1000) };
+ }
+ window.count += 1;
+ return { ok: true };
+}
+
+const TOO_LARGE = Symbol("too large");
+
+async function readBoundedBody(c) {
+ const declared = Number(c.req.header("Content-Length"));
+ if (Number.isFinite(declared) && declared > 64 * 1024) return TOO_LARGE;
+
+ const reader = c.req.raw.body?.getReader();
+ if (!reader) return "";
+
+ const chunks = [];
+ let size = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > 64 * 1024) {
+ await reader.cancel();
+ return TOO_LARGE;
+ }
+ chunks.push(value);
+ }
+ return new TextDecoder().decode(Buffer.concat(chunks));
+}
+
+function fail(c, status, error, message) {
+ return c.json({ ok: false, error, message }, status);
+}
+
+function describe(value) {
+ if (typeof value === "string") {
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
+ }
+ const kind = Array.isArray(value) ? "array" : typeof value;
+ return `${"aeiou".includes(kind[0]) ? "An" : "A"} ${kind} value`;
+}
+
+function messageTimestamps(value) {
+ const list = Array.isArray(value) ? value : [value];
+ if (!list.length) throw new Error("Provide at least one message ts.");
+ if (list.length > 50) {
+ throw new Error(`Delete at most 50 messages per request.`);
+ }
+
+ const timestamps = [];
+ for (const entry of list) {
+ if (typeof entry !== "string" || !/^\d{10}\.\d{6}$/.test(entry)) {
+ throw new Error(`${describe(entry)} is not a Slack message ts, like 1699999999.123456.`);
+ }
+ if (!timestamps.includes(entry)) timestamps.push(entry);
+ }
+ return timestamps;
+}
+
+function deletionReason(value) {
+ if (value !== undefined && typeof value !== "string") {
+ throw new Error("Reason must be a string.");
+ }
+
+ const reason = String(value ?? "")
+ .replace(/[<>]/g, "")
+ .trim();
+ if (!reason) throw new Error("A reason is required, and is recorded in the audit log.");
+ if (reason.length > 500) {
+ throw new Error(`Reason must be 500 characters or fewer.`);
+ }
+ return reason;
+}
+
+async function readMessage(client, channel, ts) {
+ try {
+ const history = await client.conversations.history({
+ channel,
+ latest: ts,
+ oldest: ts,
+ inclusive: true,
+ limit: 1,
+ });
+ const message = history.messages?.find((entry) => entry.ts === ts);
+ return { ts, user: message?.user || null, text: message?.text || "" };
+ } catch (error) {
+ logger.warn(`[api] could not read ${channel}/${ts} for the audit log: ${error.message}`);
+ return { ts, user: null, text: "" };
+ }
+}
+
+async function deleteMessage(client, botClient, { channel, ts, deletedBy, reason }) {
+ const message = await readMessage(client, channel, ts);
+
+ await Promise.all([
+ logDelete(botClient, { channel, message, deletedBy, reason }),
+ publicLogDelete(botClient, { channel, deletedBy }),
+ ]);
+
+ await rateLimiter.exec(() => client.chat.delete({ channel, ts }));
+}
+
+async function stillAuthorized(client, apiKey) {
+ const [active, permitted] = await Promise.all([
+ isChannelApiKeyActive(apiKey.id),
+ canManage(client, apiKey.user_id, apiKey.channel_id),
+ ]);
+ return active && permitted;
+}
+
+export function createApiRouter({ client, botClient = client }) {
+ const api = new Hono();
+
+ api.use("*", async (c, next) => {
+ const match = BEARER.exec(c.req.header("Authorization") || "");
+ if (!match) {
+ c.header("WWW-Authenticate", "Bearer");
+ return fail(c, 401, "not_authed", "Pass your key as an Authorization: Bearer header.");
+ }
+
+ const apiKey = await findChannelApiKeyByHash(hashApiKey(match[1]));
+ if (!apiKey) return fail(c, 401, "invalid_auth", "That API key is not valid.");
+
+ if (!(await canManage(client, apiKey.user_id, apiKey.channel_id))) {
+ return fail(
+ c,
+ 403,
+ "owner_not_permitted",
+ "The user who created this key can no longer manage that channel.",
+ );
+ }
+
+ const budget = withinBudget(apiKey.id);
+ if (!budget.ok) {
+ c.header("Retry-After", String(budget.retryAfter));
+ return fail(c, 429, "ratelimited", `Slow down, retry in ${budget.retryAfter}s.`);
+ }
+
+ c.set("apiKey", apiKey);
+ await next();
+ });
+
+ api.get("/key", (c) => {
+ const apiKey = c.get("apiKey");
+ return c.json({
+ ok: true,
+ key: {
+ name: apiKey.name,
+ prefix: apiKey.key_prefix,
+ channel: apiKey.channel_id,
+ owner: apiKey.user_id,
+ created_at: apiKey.created_at,
+ last_used_at: apiKey.last_used_at,
+ },
+ });
+ });
+
+ api.post("/messages/delete", async (c) => {
+ const apiKey = c.get("apiKey");
+
+ if (!AUDIT_CONFIGURED) {
+ return fail(
+ c,
+ 503,
+ "audit_unavailable",
+ "Deletion is disabled because LOG_CHANNEL is not configured.",
+ );
+ }
+
+ const raw = await readBoundedBody(c);
+ if (raw === TOO_LARGE) {
+ return fail(c, 413, "body_too_large", `Keep the request body under 65,536 bytes.`);
+ }
+
+ let body;
+ try {
+ body = JSON.parse(raw);
+ } catch {
+ return fail(c, 400, "invalid_json", "Send a JSON body.");
+ }
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ return fail(c, 400, "invalid_body", "Send a JSON object.");
+ }
+
+ // The key names the channel; the caller never gets to pick one.
+ const channel = apiKey.channel_id;
+
+ let timestamps;
+ let reason;
+ try {
+ timestamps = messageTimestamps(body.ts);
+ reason = deletionReason(body.reason);
+ } catch (error) {
+ return fail(c, 400, "invalid_request", error.message);
+ }
+
+ await touchChannelApiKey(apiKey.id);
+
+ const deleted = [];
+ const failed = [];
+ for (const ts of timestamps) {
+ if (!(await stillAuthorized(client, apiKey))) {
+ failed.push({ ts, error: "authorization_revoked" });
+ continue;
+ }
+
+ try {
+ await deleteMessage(client, botClient, {
+ channel,
+ ts,
+ deletedBy: apiKey.user_id,
+ reason,
+ });
+ deleted.push(ts);
+ } catch (error) {
+ const slackError = error.data?.error || "delete_failed";
+ logger.error(`[api] delete failed for ${channel}/${ts}: ${slackError}`);
+ failed.push({ ts, error: slackError });
+ }
+ }
+
+ logger.info(
+ `[api] key ${apiKey.key_prefix} deleted ${deleted.length}/${timestamps.length} in ${channel}`,
+ );
+ return c.json({ ok: failed.length === 0, channel, deleted, failed });
+ });
+
+ api.notFound((c) => fail(c, 404, "unknown_endpoint", "No such API endpoint."));
+
+ api.onError((error, c) => {
+ logger.error("[api]", error);
+ return fail(c, 500, "internal_error", "The request could not be completed.");
+ });
+
+ return api;
+}
diff --git a/lib/web/apiKeys.js b/lib/web/apiKeys.js
new file mode 100644
index 0000000..411b446
--- /dev/null
+++ b/lib/web/apiKeys.js
@@ -0,0 +1,82 @@
+import { createHash, randomBytes } from "node:crypto";
+import {
+ MAX_API_KEYS_PER_USER,
+ countUserApiKeys,
+ createChannelApiKey,
+ listChannelApiKeys,
+ revokeChannelApiKey,
+} from "../db.js";
+import { logAdmin } from "../logger.js";
+
+const KEY_PREFIX = "prom_";
+const PREFIX_LENGTH = KEY_PREFIX.length + 8;
+
+export function hashApiKey(key) {
+ return createHash("sha256").update(key).digest("hex");
+}
+
+export function generateApiKey() {
+ const key = `${KEY_PREFIX}${randomBytes(32).toString("base64url")}`;
+ return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, PREFIX_LENGTH) };
+}
+
+export { MAX_API_KEYS_PER_USER };
+
+export async function loadApiKeys(channelId, userId) {
+ const [keys, total] = await Promise.all([
+ listChannelApiKeys(channelId, userId),
+ countUserApiKeys(userId),
+ ]);
+ return {
+ keys,
+ total,
+ max: MAX_API_KEYS_PER_USER,
+ remaining: Math.max(0, MAX_API_KEYS_PER_USER - total),
+ };
+}
+
+function keyName(value) {
+ const name = String(value || "").trim();
+ if (!name) throw new Error("Give the key a name so you can tell it apart later.");
+ if (name.length > 60) throw new Error(`Key names must be 60 characters or fewer.`);
+ return name;
+}
+
+export async function createApiKey(botClient, userId, channelId, form) {
+ const name = keyName(form.get("name"));
+ const { key, keyHash, keyPrefix } = generateApiKey();
+ const record = await createChannelApiKey(channelId, userId, name, keyPrefix, keyHash);
+ if (!record) {
+ throw new Error(
+ `You can hold ${MAX_API_KEYS_PER_USER} API keys at a time, across every channel. Revoke one first.`,
+ );
+ }
+
+ try {
+ await logAdmin(botClient, {
+ action: "created an API key",
+ adminUser: userId,
+ channel: channelId,
+ detail: `${name} (\`${keyPrefix}…\`)`,
+ });
+ } catch (error) {
+ console.error(`[web] created key ${keyPrefix} in ${channelId} but could not log it:`, error);
+ }
+
+ return { key, record };
+}
+
+export async function revokeApiKey(botClient, userId, channelId, form) {
+ const id = Number.parseInt(String(form.get("id") || ""), 10);
+ if (!Number.isInteger(id)) throw new Error("That key does not exist.");
+
+ if (!(await revokeChannelApiKey(id, channelId, userId))) {
+ throw new Error("That key does not exist, or it is not yours to revoke.");
+ }
+
+ await logAdmin(botClient, {
+ action: "revoked an API key",
+ adminUser: userId,
+ channel: channelId,
+ });
+}
diff --git a/lib/web/app.js b/lib/web/app.js
new file mode 100644
index 0000000..7543b44
--- /dev/null
+++ b/lib/web/app.js
@@ -0,0 +1,294 @@
+import { Hono } from "hono";
+import { serveStatic } from "hono/bun";
+import { jsxRenderer } from "hono/jsx-renderer";
+import { createApiRouter } from "./api.js";
+import { createApiKey, loadApiKeys, revokeApiKey } from "./apiKeys.js";
+import { createDashboardAuth, dashboardConfigured } from "./auth.js";
+import { loadPermissions } from "./permissions.js";
+import { sectionBySlug, sectionPath } from "./sections.js";
+import { DashboardPage, ErrorPage, LandingPage, NoChannelsPage } from "./views.jsx";
+
+const CHANNEL_ID = /^[CG][A-Z0-9]+$/i;
+
+const securityHeaders = {
+ "Content-Security-Policy":
+ "default-src 'none'; style-src 'self'; img-src https: data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+};
+
+function applySecurityHeaders(c) {
+ for (const [name, value] of Object.entries(securityHeaders)) c.header(name, value);
+}
+
+function redirectFromAuth(response, fallback = "/") {
+ const headers = new Headers(response.headers);
+ headers.set("Location", headers.get("Location") || fallback);
+ headers.delete("Content-Length");
+ headers.delete("Content-Type");
+ return new Response(null, { headers, status: 302 });
+}
+
+function render(c, component, status = 200) {
+ c.status(status);
+ return c.render(component);
+}
+
+function sectionRedirect(c, channelId, slug, key, value) {
+ const path = sectionPath(channelId, slug);
+ if (!key) return c.redirect(path, 303);
+ return c.redirect(`${path}?${new URLSearchParams({ [key]: value })}`, 303);
+}
+
+function publicBaseUrl(c) {
+ if (process.env.DASHBOARD_BASE_URL) {
+ return new URL(process.env.DASHBOARD_BASE_URL).origin;
+ }
+ return new URL(c.req.url).origin;
+}
+
+function requestIsSameOrigin(c) {
+ const origin = c.req.header("Origin");
+ if (!origin) return false;
+ const configuredOrigin = process.env.DASHBOARD_BASE_URL
+ ? new URL(process.env.DASHBOARD_BASE_URL).origin
+ : new URL(c.req.url).origin;
+ return origin === configuredOrigin;
+}
+
+function channelIdFromInput(input) {
+ const value = input?.trim();
+ if (!value) return null;
+ if (CHANNEL_ID.test(value)) return value.toUpperCase();
+
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "https:" || !url.hostname.endsWith(".slack.com")) return null;
+ const channelId = url.pathname.split("/").findLast((part) => CHANNEL_ID.test(part));
+ return channelId?.toUpperCase() || null;
+ } catch {
+ return null;
+ }
+}
+
+export function createWebApp({ client, botClient = client, isHealthy = () => true }) {
+ const app = new Hono();
+ const dashboardAuth = createDashboardAuth(client);
+
+ app.use("*", jsxRenderer());
+ app.use("*", async (c, next) => {
+ await next();
+ applySecurityHeaders(c);
+ if (!c.req.path.startsWith("/assets/")) c.header("Cache-Control", "no-store");
+ });
+
+ app.get("/assets/dashboard.css", serveStatic({ path: "./lib/web/dashboard.css" }));
+
+ app.get("/health", async (c) => {
+ const healthy = await isHealthy();
+ return c.json({ status: healthy ? "ok" : "disconnected" }, healthy ? 200 : 503);
+ });
+
+ app.route("/api/v1", createApiRouter({ botClient, client }));
+
+ function currentSession(c) {
+ if (!dashboardAuth) return null;
+ return dashboardAuth.auth.api.getSession({
+ request: c.req.raw,
+ headers: c.req.raw.headers,
+ asResponse: false,
+ });
+ }
+
+ function signInPage(c) {
+ const ready = dashboardConfigured();
+ return render(
+ c,
+ LandingPage({
+ error: ready
+ ? c.req.query("auth") === "failed"
+ ? "Slack could not complete sign-in for this workspace."
+ : null
+ : "Dashboard sign-in has not been configured yet.",
+ }),
+ ready ? 200 : 503,
+ );
+ }
+
+ app.get("/", async (c) => {
+ const session = await currentSession(c);
+ if (!session) return signInPage(c);
+
+ const channelInput = c.req.query("channel");
+ const requestedChannelId = channelIdFromInput(channelInput);
+ if (requestedChannelId) return sectionRedirect(c, requestedChannelId, "");
+
+ const permissions = await loadPermissions(client, session.user.slackUserId, botClient);
+ const error = channelInput
+ ? "Enter a Slack channel link or a channel ID like C0123ABCD."
+ : c.req.query("error");
+ const [firstChannel] = permissions.channels;
+ if (firstChannel) {
+ return sectionRedirect(c, firstChannel.channel_id, "", error && "error", error);
+ }
+ return render(c, NoChannelsPage({ error, permissions, user: session.user }));
+ });
+
+ async function renderSection(c, session, channelId, slug, extra = {}) {
+ const section = sectionBySlug(slug);
+ if (!section) {
+ return sectionRedirect(c, channelId, "", "error", "That settings page does not exist.");
+ }
+
+ const [permissions, teamId] = await Promise.all([
+ loadPermissions(client, session.user.slackUserId, botClient, channelId),
+ dashboardAuth.teamId(),
+ ]);
+ const channel = permissions.channels.find((item) => item.channel_id === channelId);
+ if (!channel) {
+ const error = "That channel was not found, or you cannot configure it.";
+ const [firstChannel] = permissions.channels;
+ if (firstChannel) return sectionRedirect(c, firstChannel.channel_id, "", "error", error);
+ return render(c, NoChannelsPage({ error, permissions, user: session.user }));
+ }
+
+ if (section.capability && !channel[section.capability]) {
+ return sectionRedirect(
+ c,
+ channelId,
+ "",
+ "error",
+ `Your role cannot change ${section.title.toLowerCase()} in this channel.`,
+ );
+ }
+
+ const apiKeys = await loadApiKeys(channelId, session.user.slackUserId);
+ return render(
+ c,
+ DashboardPage({
+ baseUrl: publicBaseUrl(c),
+ error: c.req.query("error"),
+ permissions,
+ section,
+ selectedChannel: channel,
+ settings: { apiKeys },
+ status: c.req.query("status"),
+ teamId,
+ user: session.user,
+ ...extra,
+ }),
+ );
+ }
+
+ app.get("/c/:channelId/:section?", async (c) => {
+ const session = await currentSession(c);
+ if (!session) return c.redirect("/");
+
+ const rawChannelId = c.req.param("channelId");
+ if (!CHANNEL_ID.test(rawChannelId)) return c.redirect("/", 303);
+ const channelId = rawChannelId.toUpperCase();
+ const slug = c.req.param("section") || "";
+ if (rawChannelId !== channelId) return sectionRedirect(c, channelId, slug);
+
+ return renderSection(c, session, channelId, slug);
+ });
+
+ async function authorize(c, capability) {
+ if (!dashboardAuth) return { response: c.redirect("/") };
+ if (!requestIsSameOrigin(c)) return { response: c.text("Forbidden", 403) };
+
+ const session = await currentSession(c);
+ if (!session) return { response: c.redirect("/") };
+
+ const rawChannelId = c.req.param("channelId");
+ if (!CHANNEL_ID.test(rawChannelId)) return { response: c.text("Forbidden", 403) };
+ const channelId = rawChannelId.toUpperCase();
+
+ const permissions = await loadPermissions(
+ client,
+ session.user.slackUserId,
+ botClient,
+ channelId,
+ );
+ const channel = permissions.channels.find((item) => item.channel_id === channelId);
+ if (!channel || !channel[capability]) return { response: c.text("Forbidden", 403) };
+
+ return { channel, channelId, session, userId: session.user.slackUserId };
+ }
+
+ app.post("/channels/:channelId/keys", async (c) => {
+ const auth = await authorize(c, "canManage");
+ if (auth.response) return auth.response;
+
+ try {
+ const form = await c.req.formData();
+ const { key } = await createApiKey(botClient, auth.userId, auth.channelId, form);
+ return renderSection(c, auth.session, auth.channelId, "", { createdKey: key });
+ } catch (error) {
+ console.error(`[web] key creation failed in ${auth.channelId}:`, error);
+ const message = error instanceof Error ? error.message : "The key could not be created.";
+ return sectionRedirect(c, auth.channelId, "", "error", message);
+ }
+ });
+
+ app.post("/channels/:channelId/keys/revoke", async (c) => {
+ const auth = await authorize(c, "canManage");
+ if (auth.response) return auth.response;
+
+ try {
+ const form = await c.req.formData();
+ await revokeApiKey(botClient, auth.userId, auth.channelId, form);
+ return sectionRedirect(c, auth.channelId, "", "status", "key-revoked");
+ } catch (error) {
+ console.error(`[web] key revocation failed in ${auth.channelId}:`, error);
+ const message = error instanceof Error ? error.message : "The key could not be revoked.";
+ return sectionRedirect(c, auth.channelId, "", "error", message);
+ }
+ });
+
+ app.get("/auth/slack", async (c) => {
+ if (!dashboardAuth) {
+ return render(
+ c,
+ LandingPage({ error: "Dashboard sign-in has not been configured yet." }),
+ 503,
+ );
+ }
+
+ const response = await dashboardAuth.auth.api.signInSocial({
+ body: {
+ callbackURL: "/",
+ errorCallbackURL: "/?auth=failed",
+ provider: "slack",
+ },
+ request: c.req.raw,
+ asResponse: true,
+ });
+ return redirectFromAuth(response);
+ });
+
+ app.on(["GET", "POST"], "/api/auth/callback/slack", async (c) => {
+ if (!dashboardAuth) return c.notFound();
+ const response = await dashboardAuth.auth.handler(c.req.raw);
+ return response.status >= 400 ? redirectFromAuth(response, "/?auth=failed") : response;
+ });
+
+ app.post("/auth/logout", async (c) => {
+ if (!dashboardAuth) return c.redirect("/");
+ const response = await dashboardAuth.auth.api.signOut({
+ request: c.req.raw,
+ asResponse: true,
+ });
+ return redirectFromAuth(response);
+ });
+
+ app.onError((error, c) => {
+ console.error("[web]", error);
+ applySecurityHeaders(c);
+ c.header("Cache-Control", "no-store");
+ return render(c, ErrorPage(), 500);
+ });
+
+ return app;
+}
diff --git a/lib/web/auth.js b/lib/web/auth.js
new file mode 100644
index 0000000..1f7aa00
--- /dev/null
+++ b/lib/web/auth.js
@@ -0,0 +1,99 @@
+import { betterAuth } from "better-auth";
+import { APIError } from "better-auth/api";
+
+const SESSION_TTL = 8 * 60 * 60;
+
+function configuredBaseUrl() {
+ if (!process.env.DASHBOARD_BASE_URL) return null;
+
+ const url = new URL(process.env.DASHBOARD_BASE_URL);
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
+ if ((!local && url.protocol !== "https:") || url.username || url.password) {
+ throw new Error("DASHBOARD_BASE_URL must be a public HTTPS origin");
+ }
+ if (url.pathname !== "/" || url.search || url.hash) {
+ throw new Error("DASHBOARD_BASE_URL must not include a path, query, or fragment");
+ }
+ return url;
+}
+
+export function dashboardConfigured() {
+ try {
+ configuredBaseUrl();
+ return Boolean(
+ process.env.SLACK_CLIENT_ID &&
+ process.env.SLACK_CLIENT_SECRET &&
+ process.env.BETTER_AUTH_SECRET?.length >= 32,
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function createDashboardAuth(client) {
+ if (!dashboardConfigured()) return null;
+
+ const configuredUrl = configuredBaseUrl();
+ const baseURL = configuredUrl?.origin || {
+ allowedHosts: ["*"],
+ protocol: "auto",
+ };
+ let teamIdPromise;
+ const teamId = () => {
+ teamIdPromise ||= client.auth.test().then((result) => result.team_id);
+ return teamIdPromise;
+ };
+
+ const auth = betterAuth({
+ appName: "Prometheus",
+ baseURL,
+ secret: process.env.BETTER_AUTH_SECRET,
+ ...(configuredUrl && { trustedOrigins: [configuredUrl.origin] }),
+ socialProviders: {
+ slack: {
+ clientId: process.env.SLACK_CLIENT_ID,
+ clientSecret: process.env.SLACK_CLIENT_SECRET,
+ async mapProfileToUser(profile) {
+ if (profile["https://slack.com/team_id"] !== (await teamId())) {
+ throw new APIError("FORBIDDEN", {
+ message: "Sign in with the Slack workspace where Prometheus is installed.",
+ });
+ }
+ return { slackUserId: profile["https://slack.com/user_id"] };
+ },
+ },
+ },
+ user: {
+ additionalFields: {
+ slackUserId: {
+ input: true,
+ required: true,
+ returned: true,
+ type: "string",
+ },
+ },
+ },
+ session: {
+ expiresIn: SESSION_TTL,
+ cookieCache: {
+ enabled: true,
+ maxAge: SESSION_TTL,
+ strategy: "jwe",
+ },
+ },
+ account: {
+ accountLinking: { enabled: false },
+ storeAccountCookie: false,
+ storeStateStrategy: "cookie",
+ },
+ advanced: {
+ defaultCookieAttributes: {
+ httpOnly: true,
+ sameSite: "lax",
+ ...(configuredUrl && { secure: configuredUrl.protocol === "https:" }),
+ },
+ },
+ });
+
+ return { auth, teamId };
+}
diff --git a/lib/web/channelSettings.js b/lib/web/channelSettings.js
new file mode 100644
index 0000000..622814a
--- /dev/null
+++ b/lib/web/channelSettings.js
@@ -0,0 +1,270 @@
+import { parse } from "tldts";
+import {
+ addEmbedBlock,
+ createAnchorMessage,
+ createAnchorNpsSurvey,
+ createAnchorPoll,
+ getAnchorNpsResponses,
+ getAnchorPoll,
+ getAnchorPollById,
+ getAnchorPollChoices,
+ getAnchorPollVotes,
+ getwelcome,
+ listEmbedBlocks,
+ removeEmbedBlock,
+ removewelcome,
+ setAnchorPollEnabled,
+ setAnchorPollMessageTs,
+ setwelcome,
+} from "../db.js";
+import { buildAnchorMessageBlocks } from "../blocks/anchorMessage.js";
+import { buildAnchorNpsBlocks } from "../blocks/anchorNps.js";
+import { buildAnchorPollBlocks } from "../blocks/anchorPoll.js";
+import { closeOldAnchorMessage, deleteAnchor, joinChannel } from "../anchorCommon.js";
+import { logAdmin } from "../logger.js";
+import { syncNpsSurvey } from "../airtable.js";
+
+const logger = console;
+
+export async function loadChannelSettings(channelId) {
+ const [anchor, welcome, embedRules] = await Promise.all([
+ getAnchorPoll(channelId),
+ getwelcome(channelId),
+ listEmbedBlocks(channelId),
+ ]);
+
+ if (!anchor) return { anchor: null, welcome, embedRules };
+
+ const [choices, votes, responses] = await Promise.all([
+ anchor.type === "poll" ? getAnchorPollChoices(anchor.id) : [],
+ anchor.type === "poll" ? getAnchorPollVotes(anchor.id) : [],
+ anchor.type === "nps" ? getAnchorNpsResponses(anchor.id) : [],
+ ]);
+ return { anchor: { ...anchor, choices, votes, responses }, welcome, embedRules };
+}
+
+function cleanText(value, name, maxLength) {
+ const text = String(value || "").trim();
+ if (!text) throw new Error(`${name} is required.`);
+ if (text.length > maxLength) throw new Error(`${name} must be ${maxLength} characters or fewer.`);
+ return text;
+}
+
+function pollChoices(value) {
+ const choices = [];
+ const seen = new Set();
+ for (const line of String(value || "").split("\n")) {
+ const choice = line.trim();
+ if (!choice || seen.has(choice)) continue;
+ if (choice.length > 200) throw new Error("Each poll choice must be 200 characters or fewer.");
+ seen.add(choice);
+ choices.push(choice);
+ }
+ if (choices.length < 2) throw new Error("Add at least two different poll choices.");
+ if (choices.length > 20) throw new Error("Polls can have up to 20 choices.");
+ return choices;
+}
+
+async function prepareAnchor(client, channelId) {
+ const joinError = await joinChannel(client, channelId);
+ if (joinError) throw new Error(joinError);
+ return getAnchorPoll(channelId);
+}
+
+async function closePrevious(botClient, userClient, channelId, previous) {
+ await closeOldAnchorMessage(botClient, { userClient }, channelId, previous, logger);
+ if (previous?.type === "nps") {
+ try {
+ await syncNpsSurvey(await getAnchorPollById(previous.id), botClient);
+ } catch (error) {
+ logger.warn(`dashboard NPS close sync failed in ${channelId}: ${error.message}`);
+ }
+ }
+}
+
+async function postAnchor(client, poll, blocks) {
+ const message = await client.chat.postMessage({
+ channel: poll.channel_id,
+ text: poll.question,
+ blocks,
+ unfurl_links: false,
+ unfurl_media: false,
+ metadata: { event_type: "anchor_poll", event_payload: { channel: poll.channel_id } },
+ });
+ await setAnchorPollMessageTs(poll.id, message.ts);
+ await client.pins.add({ channel: poll.channel_id, timestamp: message.ts });
+}
+
+export async function saveAnchorMessage(botClient, userClient, userId, channelId, form) {
+ const message = cleanText(form.get("message"), "Message", 3000);
+ const previous = await prepareAnchor(botClient, channelId);
+ const poll = await createAnchorMessage(channelId, {
+ creator: userId,
+ question: message,
+ content: null,
+ });
+ await closePrevious(botClient, userClient, channelId, previous);
+ await postAnchor(botClient, poll, buildAnchorMessageBlocks(poll));
+ await logAdmin(botClient, {
+ action: previous ? "replaced the anchor with a message" : "created an anchor message",
+ adminUser: userId,
+ channel: channelId,
+ detail: message,
+ });
+}
+
+export async function saveAnchorPoll(botClient, userClient, userId, channelId, form) {
+ const question = cleanText(form.get("question"), "Question", 250);
+ const choices = pollChoices(form.get("choices"));
+ const addChoiceSetting = String(form.get("addChoiceSetting") || "no_one");
+ if (!["no_one", "creator", "anyone"].includes(addChoiceSetting)) {
+ throw new Error("Choose who can add poll options.");
+ }
+
+ const previous = await prepareAnchor(botClient, channelId);
+ const poll = await createAnchorPoll(channelId, {
+ creator: userId,
+ question,
+ choices,
+ anonymous: form.has("anonymous"),
+ multiSelect: form.has("multiSelect"),
+ addChoiceSetting,
+ });
+ await closePrevious(botClient, userClient, channelId, previous);
+ await postAnchor(
+ botClient,
+ poll,
+ await buildAnchorPollBlocks(userClient, poll, poll.choices, []),
+ );
+ await logAdmin(botClient, {
+ action: previous ? "replaced the anchor poll" : "created an anchor poll",
+ adminUser: userId,
+ channel: channelId,
+ detail: question,
+ });
+}
+
+export async function saveAnchorNps(botClient, userClient, userId, channelId, form) {
+ const question = cleanText(form.get("question"), "Question", 250);
+ const days = Number(form.get("days"));
+ if (!Number.isInteger(days) || days < 1 || days > 365) {
+ throw new Error("Survey length must be between 1 and 365 days.");
+ }
+
+ const previous = await prepareAnchor(botClient, channelId);
+ const poll = await createAnchorNpsSurvey(channelId, { creator: userId, question, days });
+ await closePrevious(botClient, userClient, channelId, previous);
+ await postAnchor(botClient, poll, buildAnchorNpsBlocks(poll, []));
+ try {
+ await syncNpsSurvey(poll, botClient);
+ } catch (error) {
+ logger.warn(`dashboard NPS Airtable sync failed in ${channelId}: ${error.message}`);
+ }
+ await logAdmin(botClient, {
+ action: previous ? "replaced the anchor with an NPS survey" : "created an anchor NPS survey",
+ adminUser: userId,
+ channel: channelId,
+ detail: `${question} (${days} days)`,
+ });
+}
+
+export async function toggleAnchor(botClient, userId, channelId, enabled) {
+ const anchor = await getAnchorPoll(channelId);
+ if (!anchor) throw new Error("This channel does not have an anchor yet.");
+ await setAnchorPollEnabled(channelId, enabled);
+
+ if (anchor.type === "message" && anchor.message_ts) {
+ try {
+ if (enabled) {
+ await botClient.pins.add({ channel: channelId, timestamp: anchor.message_ts });
+ } else {
+ await botClient.pins.remove({ channel: channelId, timestamp: anchor.message_ts });
+ }
+ } catch (error) {
+ logger.warn(`dashboard anchor pin toggle failed in ${channelId}: ${error.message}`);
+ }
+ }
+ if (anchor.type === "nps") {
+ try {
+ await syncNpsSurvey(await getAnchorPollById(anchor.id), botClient);
+ } catch (error) {
+ logger.warn(`dashboard NPS toggle sync failed in ${channelId}: ${error.message}`);
+ }
+ }
+ await logAdmin(botClient, {
+ action: `${enabled ? "enabled" : "disabled"} the anchor`,
+ adminUser: userId,
+ channel: channelId,
+ });
+}
+
+export async function removeAnchor(botClient, userId, channelId) {
+ const anchor = await getAnchorPoll(channelId);
+ if (!anchor) throw new Error("This channel does not have an anchor yet.");
+ await deleteAnchor(botClient, anchor, userId, logger);
+}
+
+export async function saveWelcome(botClient, userId, channelId, form) {
+ const message = cleanText(form.get("message"), "Welcome message", 3000);
+ const mode = String(form.get("mode"));
+ if (!["ephemeral", "dm"].includes(mode)) throw new Error("Choose a delivery method.");
+ await setwelcome(channelId, message, mode, userId);
+ try {
+ await botClient.conversations.join({ channel: channelId });
+ } catch (error) {
+ logger.warn(`dashboard welcome join failed in ${channelId}: ${error.message}`);
+ }
+ await logAdmin(botClient, {
+ action: `set ${mode} welcome message`,
+ adminUser: userId,
+ channel: channelId,
+ detail: message,
+ });
+}
+
+export async function removeWelcome(botClient, userId, channelId) {
+ if (!(await getwelcome(channelId))) throw new Error("This channel has no welcome message.");
+ await removewelcome(channelId);
+ await logAdmin(botClient, {
+ action: "removed welcome message",
+ adminUser: userId,
+ channel: channelId,
+ });
+}
+
+function embedTarget(rawUrl, type) {
+ let url;
+ try {
+ url = new URL(String(rawUrl));
+ } catch {
+ throw new Error("Enter a complete URL, including https://.");
+ }
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
+ throw new Error("Enter a public http or https URL.");
+ }
+ if (type === "domain") {
+ const domain = parse(url.hostname).domain;
+ if (!domain) throw new Error("That URL does not have a blockable domain.");
+ return domain;
+ }
+ if (type === "host") return url.host.toLowerCase();
+ if (type === "path") {
+ const path = url.pathname.split("/").filter(Boolean).join("/");
+ if (!path) throw new Error("A path rule needs a URL with a path.");
+ return `${url.host.toLowerCase()}/${path}`;
+ }
+ throw new Error("Choose a rule scope.");
+}
+
+export async function saveEmbedRule(userId, channelId, form) {
+ const type = String(form.get("type"));
+ const target = embedTarget(form.get("url"), type);
+ await addEmbedBlock(channelId, type, target, userId);
+}
+
+export async function deleteEmbedRule(channelId, form) {
+ const type = String(form.get("type"));
+ const target = String(form.get("target") || "");
+ if (!["domain", "host", "path"].includes(type) || !target) throw new Error("Invalid rule.");
+ await removeEmbedBlock(channelId, type, target);
+}
diff --git a/lib/web/dashboard.css b/lib/web/dashboard.css
new file mode 100644
index 0000000..e9604ba
--- /dev/null
+++ b/lib/web/dashboard.css
@@ -0,0 +1,1060 @@
+:root {
+ --canvas: #0e0d0c;
+ --panel: #151412;
+ --panel-raised: #1b1917;
+ --line: #262421;
+ --line-strong: #383430;
+ --text: #f1ece5;
+ --muted: #a09a92;
+ --faint: #6d6660;
+ --ember: #c8452a;
+ --ember-lit: #ff8256;
+
+ --s1: 0.25rem;
+ --s2: 0.5rem;
+ --s3: 0.75rem;
+ --s4: 1rem;
+ --s5: 1.5rem;
+ --s6: 2rem;
+ --s7: 3rem;
+
+ --radius: 2px;
+ --topbar: 3.5rem;
+ --serif:
+ "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", "Hoefler Text", Georgia, serif;
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ background: var(--canvas);
+ color: var(--text);
+ color-scheme: dark;
+ font-family: var(--sans);
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+}
+
+a {
+ color: inherit;
+}
+
+/* Type roles
+ ------------------------------------------------------------------------- */
+
+h1,
+h2 {
+ font-family: var(--serif);
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin: 0;
+}
+
+.label {
+ color: var(--muted);
+ font-family: var(--serif);
+ font-size: 0.74rem;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ margin: 0;
+ text-transform: uppercase;
+}
+
+.value {
+ font-family: var(--mono);
+ font-size: 0.76rem;
+}
+
+.ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.glyph {
+ flex: 0 0 auto;
+ height: 0.9rem;
+ width: 0.9rem;
+}
+
+/* Controls
+ ------------------------------------------------------------------------- */
+
+button,
+.button,
+input,
+textarea,
+select {
+ font-family: inherit;
+ font-size: 0.86rem;
+}
+
+button,
+.button {
+ align-items: center;
+ background: var(--ember);
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ color: #fff;
+ cursor: pointer;
+ display: inline-flex;
+ font-weight: 600;
+ gap: var(--s2);
+ justify-content: center;
+ min-height: 2.25rem;
+ padding: var(--s2) var(--s4);
+ text-decoration: none;
+ transition:
+ background 120ms ease,
+ border-color 120ms ease,
+ color 120ms ease;
+}
+
+button:hover,
+.button:hover {
+ background: #d75130;
+}
+
+.button-quiet {
+ background: none;
+ border-color: var(--line-strong);
+ color: var(--text);
+}
+
+.button-quiet:hover {
+ background: var(--panel-raised);
+ border-color: var(--faint);
+}
+
+.button-remove {
+ background: none;
+ border-color: var(--line-strong);
+ color: var(--ember-lit);
+}
+
+.button-remove:hover {
+ background: none;
+ border-color: var(--ember);
+}
+
+.button-small {
+ font-size: 0.78rem;
+ min-height: 1.85rem;
+ padding: var(--s1) var(--s3);
+}
+
+:focus-visible {
+ outline: 1px solid var(--ember-lit);
+ outline-offset: 2px;
+}
+
+input,
+textarea,
+select {
+ background: var(--canvas);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius);
+ color: var(--text);
+ line-height: 1.5;
+ min-width: 0;
+ padding: var(--s2) var(--s3);
+ width: 100%;
+}
+
+textarea {
+ resize: vertical;
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: var(--faint);
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+ border-color: var(--ember-lit);
+}
+
+summary {
+ cursor: pointer;
+ list-style: none;
+}
+
+summary::-webkit-details-marker {
+ display: none;
+}
+
+/* Topbar
+ ------------------------------------------------------------------------- */
+
+.console {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.topbar {
+ align-items: center;
+ background: var(--canvas);
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ gap: var(--s4);
+ height: var(--topbar);
+ padding: 0 clamp(var(--s3), 2vw, var(--s5));
+ position: sticky;
+ top: 0;
+ z-index: 30;
+}
+
+.brand {
+ align-items: center;
+ display: flex;
+ flex: 0 0 auto;
+ font-family: var(--serif);
+ font-size: 1rem;
+ gap: var(--s2);
+ letter-spacing: 0.1em;
+ text-decoration: none;
+ text-transform: uppercase;
+}
+
+.brand-mark {
+ height: 1.5rem;
+ width: 1.5rem;
+}
+
+.topbar-actions {
+ align-items: center;
+ display: flex;
+ gap: var(--s4);
+ margin-left: auto;
+ min-width: 0;
+}
+
+.slack-link {
+ color: var(--muted);
+ font-size: 0.8rem;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.slack-link:hover {
+ color: var(--text);
+}
+
+/* Topbar menus: channel switcher and profile
+ ------------------------------------------------------------------------- */
+
+.menu {
+ position: relative;
+}
+
+.menu > summary {
+ align-items: center;
+ display: flex;
+ gap: var(--s2);
+ max-width: 15rem;
+ padding: var(--s1) 0;
+}
+
+.menu > summary:hover .menu-name,
+.menu[open] > summary .menu-name {
+ color: var(--text);
+}
+
+.menu-name {
+ color: var(--muted);
+ font-size: 0.86rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.menu-name b {
+ color: var(--text);
+ font-weight: 600;
+}
+
+.menu-caret {
+ color: var(--faint);
+ display: flex;
+ transition: transform 140ms ease;
+}
+
+.menu[open] > summary .menu-caret,
+.nav-group[open] > summary .menu-caret {
+ transform: rotate(-180deg);
+}
+
+.avatar {
+ border-radius: 50%;
+ height: 1.6rem;
+ object-fit: cover;
+ width: 1.6rem;
+}
+
+.avatar-blank {
+ background: var(--line-strong);
+ display: block;
+}
+
+.menu-panel {
+ background: var(--panel-raised);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius);
+ max-height: min(28rem, 75vh);
+ overflow-y: auto;
+ padding: var(--s3);
+ position: absolute;
+ right: 0;
+ top: calc(100% + var(--s3));
+ width: 19rem;
+ z-index: 40;
+}
+
+.menu-panel-narrow {
+ width: 14rem;
+}
+
+.menu-panel .label {
+ color: var(--faint);
+ font-size: 0.68rem;
+ margin-bottom: var(--s3);
+}
+
+.menu-list {
+ display: grid;
+}
+
+.menu-item {
+ align-items: baseline;
+ color: var(--muted);
+ display: grid;
+ gap: var(--s3);
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding: var(--s2) 0;
+ text-decoration: none;
+}
+
+.menu-item + .menu-item {
+ border-top: 1px solid var(--line);
+}
+
+.menu-item:hover {
+ color: var(--text);
+}
+
+.menu-item span {
+ font-size: 0.88rem;
+}
+
+.menu-item.active span {
+ color: var(--text);
+}
+
+.menu-item.active span::before {
+ color: var(--ember-lit);
+ content: "•";
+ margin-right: 0.4em;
+}
+
+.menu-item em {
+ color: var(--faint);
+ font-size: 0.7rem;
+ font-style: normal;
+}
+
+.menu-note {
+ color: var(--muted);
+ font-size: 0.82rem;
+ line-height: 1.5;
+ margin: 0;
+}
+
+.menu-identity {
+ color: var(--muted);
+ font-size: 0.82rem;
+ line-height: 1.6;
+ margin: 0 0 var(--s3);
+}
+
+.menu-identity b {
+ color: var(--text);
+ display: block;
+ font-weight: 600;
+}
+
+.menu-form {
+ border-top: 1px solid var(--line);
+ padding-top: var(--s3);
+}
+
+.menu-form button {
+ width: 100%;
+}
+
+.picker {
+ border-top: 1px solid var(--line);
+ margin-top: var(--s3);
+ padding-top: var(--s3);
+}
+
+.picker .label {
+ font-size: 0.68rem;
+ margin-bottom: var(--s2);
+}
+
+.picker div {
+ display: flex;
+ gap: var(--s2);
+}
+
+.picker input {
+ font-size: 0.82rem;
+}
+
+.picker button {
+ flex: 0 0 auto;
+}
+
+/* Sidebar
+ ------------------------------------------------------------------------- */
+
+.workspace {
+ display: grid;
+ flex: 1;
+ grid-template-columns: 14rem minmax(0, 1fr);
+}
+
+/* One section: the sidebar would only link to the page you are already on. */
+.workspace.solo {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.sidebar {
+ align-self: start;
+ border-right: 1px solid var(--line);
+ height: calc(100vh - var(--topbar));
+ overflow-y: auto;
+ padding: var(--s5) 0 var(--s6);
+ position: sticky;
+ top: var(--topbar);
+}
+
+.nav-group > summary {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ padding: var(--s2) var(--s5);
+}
+
+.nav-group > summary:hover .label {
+ color: var(--text);
+}
+
+.nav-group nav {
+ display: grid;
+ margin: var(--s1) 0 var(--s5);
+}
+
+.nav-item {
+ color: var(--muted);
+ font-size: 0.88rem;
+ padding: var(--s2) var(--s5);
+ text-decoration: none;
+}
+
+.nav-item:hover {
+ color: var(--text);
+}
+
+.nav-item.active {
+ color: var(--text);
+ font-weight: 600;
+}
+
+.nav-item.active::before {
+ color: var(--ember-lit);
+ content: "—";
+ font-weight: 400;
+ margin-left: -1.15rem;
+ padding-right: 0.4rem;
+}
+
+/* Canvas
+ ------------------------------------------------------------------------- */
+
+.canvas {
+ max-width: 64rem;
+ min-width: 0;
+ padding: var(--s6) clamp(var(--s4), 4vw, var(--s7)) var(--s7);
+ width: 100%;
+}
+
+.page-head {
+ margin-bottom: var(--s6);
+ max-width: 42rem;
+}
+
+.page-head h1 {
+ font-size: clamp(1.6rem, 3.5vw, 2.1rem);
+ line-height: 1.2;
+ margin: var(--s3) 0 0;
+}
+
+.page-blurb {
+ color: var(--muted);
+ font-size: 0.92rem;
+ line-height: 1.6;
+ margin: var(--s2) 0 0;
+}
+
+.crumb {
+ color: var(--faint);
+ font-size: 0.78rem;
+ margin: 0;
+}
+
+.crumb b {
+ color: var(--muted);
+ font-weight: 400;
+}
+
+.notice {
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius);
+ font-size: 0.86rem;
+ line-height: 1.5;
+ margin: 0 0 var(--s5);
+ padding: var(--s3) var(--s4);
+}
+
+.notice.failure {
+ border-color: var(--ember);
+ color: var(--ember-lit);
+}
+
+.notice.info,
+.notice.success {
+ color: var(--muted);
+}
+
+/* Live anchor: the one place ember is allowed to signal state
+ ------------------------------------------------------------------------- */
+
+.live-band {
+ border-top: 1px solid var(--line);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s4);
+ justify-content: space-between;
+ margin-bottom: var(--s6);
+ padding-top: var(--s4);
+}
+
+.live-copy {
+ max-width: 38rem;
+ min-width: 0;
+}
+
+.live-copy h2 {
+ font-size: 1.15rem;
+ line-height: 1.4;
+ margin: var(--s2) 0 0;
+ overflow-wrap: anywhere;
+}
+
+.live-copy .label {
+ align-items: baseline;
+ display: flex;
+ gap: 0.45em;
+}
+
+.live-copy .label::before {
+ color: var(--ember-lit);
+ content: "•";
+}
+
+.live-band.idle .label::before {
+ color: var(--faint);
+}
+
+.live-meta {
+ color: var(--muted);
+ font-size: 0.84rem;
+ margin: var(--s2) 0 0;
+}
+
+.live-actions {
+ align-items: start;
+ display: flex;
+ gap: var(--s2);
+}
+
+/* Groups and cards
+ ------------------------------------------------------------------------- */
+
+.group + .group {
+ margin-top: var(--s6);
+}
+
+.group > .label {
+ border-bottom: 1px solid var(--line);
+ padding-bottom: var(--s3);
+}
+
+.card-grid {
+ display: grid;
+ gap: var(--s5);
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
+ margin-top: var(--s5);
+}
+
+.card {
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ display: flex;
+ flex-direction: column;
+ /* Caps the reading measure on section pages; grid columns are already narrower than this. */
+ max-width: 46rem;
+ min-width: 0;
+ padding: var(--s4);
+}
+
+.card + .card {
+ margin-top: var(--s5);
+}
+
+.card-grid .card + .card {
+ margin-top: 0;
+}
+
+.card h2 {
+ font-size: 1.05rem;
+}
+
+.card-state {
+ color: var(--faint);
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ margin: var(--s1) 0 0;
+}
+
+.card-state.on {
+ color: var(--ember-lit);
+}
+
+.card-note {
+ color: var(--muted);
+ font-size: 0.85rem;
+ line-height: 1.55;
+ margin: var(--s3) 0 0;
+}
+
+.card-link {
+ color: var(--text);
+ font-size: 0.82rem;
+ margin-top: auto;
+ padding-top: var(--s4);
+ text-decoration: none;
+}
+
+.card-link:hover {
+ color: var(--ember-lit);
+}
+
+.stats {
+ border-top: 1px solid var(--line);
+ flex: 1;
+ list-style: none;
+ margin: var(--s4) 0 0;
+ padding: 0;
+}
+
+.stats li {
+ align-items: baseline;
+ color: var(--muted);
+ display: flex;
+ font-size: 0.82rem;
+ gap: var(--s3);
+ justify-content: space-between;
+ padding: var(--s2) 0;
+}
+
+.stats li + li {
+ border-top: 1px solid var(--line);
+}
+
+.stats .value {
+ color: var(--text);
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.stats .value.dim {
+ color: var(--faint);
+}
+
+.notes {
+ color: var(--muted);
+ font-size: 0.85rem;
+ line-height: 1.7;
+ list-style: none;
+ margin: var(--s3) 0 0;
+ padding: 0;
+}
+
+.notes li + li {
+ border-top: 1px solid var(--line);
+ margin-top: var(--s2);
+ padding-top: var(--s2);
+}
+
+.quiet {
+ color: var(--faint);
+ font-size: 0.85rem;
+ line-height: 1.6;
+ margin: var(--s3) 0 0;
+}
+
+/* Forms
+ ------------------------------------------------------------------------- */
+
+.card form {
+ display: grid;
+ gap: var(--s4);
+ margin-top: var(--s5);
+}
+
+.field {
+ display: grid;
+ gap: var(--s2);
+ min-width: 0;
+}
+
+.field > span {
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+
+.field small {
+ color: var(--faint);
+ font-size: 0.76rem;
+ line-height: 1.5;
+}
+
+.field-row {
+ align-items: start;
+ display: grid;
+ gap: var(--s4);
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.field-row-rule {
+ grid-template-columns: minmax(9rem, 0.4fr) minmax(0, 1fr);
+}
+
+.check-stack {
+ display: grid;
+ gap: var(--s2);
+ padding-top: 1.6rem;
+}
+
+.check-stack label {
+ align-items: center;
+ color: var(--muted);
+ cursor: pointer;
+ display: flex;
+ font-size: 0.84rem;
+ gap: var(--s2);
+}
+
+.check-stack input {
+ accent-color: var(--ember);
+ height: 0.95rem;
+ padding: 0;
+ width: 0.95rem;
+}
+
+.input-suffix {
+ align-items: center;
+ display: flex;
+ position: relative;
+}
+
+.input-suffix input {
+ padding-right: 3.5rem;
+}
+
+.input-suffix span {
+ color: var(--faint);
+ font-size: 0.8rem;
+ pointer-events: none;
+ position: absolute;
+ right: var(--s3);
+}
+
+.form-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s2);
+ justify-self: start;
+}
+
+.rules {
+ border-top: 1px solid var(--line);
+ margin-top: var(--s4);
+}
+
+.rule {
+ align-items: baseline;
+ border-bottom: 1px solid var(--line);
+ display: grid;
+ gap: var(--s4);
+ grid-template-columns: minmax(0, 1fr) auto;
+ padding: var(--s3) 0;
+}
+
+.rule code {
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.8rem;
+ overflow-wrap: anywhere;
+}
+
+.rule small {
+ color: var(--faint);
+ display: block;
+ font-size: 0.72rem;
+ margin-top: var(--s1);
+}
+
+.rule form {
+ display: block;
+ margin: 0;
+}
+
+/* API keys
+ ------------------------------------------------------------------------- */
+
+.secret {
+ background: var(--panel-raised);
+ border: 1px solid var(--ember);
+ border-radius: var(--radius);
+ margin: var(--s4) 0 0;
+ padding: var(--s3) var(--s4);
+}
+
+.secret code {
+ color: var(--ember-lit);
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ overflow-wrap: anywhere;
+ user-select: all;
+}
+
+.snippet {
+ background: var(--canvas);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ margin: var(--s4) 0 0;
+ overflow-x: auto;
+ padding: var(--s3) var(--s4);
+}
+
+.snippet code {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ line-height: 1.7;
+ white-space: pre;
+}
+
+.notes code {
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+}
+
+/* Blank slate and sign-in
+ ------------------------------------------------------------------------- */
+
+.blank {
+ margin: 0 auto;
+ max-width: 34rem;
+ padding: clamp(var(--s7), 12vh, 7rem) var(--s4) var(--s7);
+ width: 100%;
+}
+
+.blank h1 {
+ font-size: clamp(1.6rem, 4vw, 2.1rem);
+ margin: var(--s3) 0 0;
+}
+
+.blank-note {
+ color: var(--muted);
+ line-height: 1.6;
+ margin: var(--s3) 0 var(--s5);
+}
+
+.blank .picker {
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+
+.auth-page {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ padding: var(--s6) var(--s4);
+}
+
+.auth-shell {
+ width: min(100%, 22rem);
+}
+
+.auth-shell > .brand {
+ display: inline-flex;
+}
+
+.auth-panel {
+ margin-top: var(--s7);
+}
+
+.auth-panel h1 {
+ font-size: 2rem;
+ line-height: 1.15;
+}
+
+.auth-panel > p:not(.error) {
+ color: var(--muted);
+ line-height: 1.65;
+ margin: var(--s3) 0 var(--s5);
+}
+
+.auth-panel .button {
+ width: 100%;
+}
+
+.auth-panel .error {
+ border: 1px solid var(--ember);
+ border-radius: var(--radius);
+ color: var(--ember-lit);
+ font-size: 0.85rem;
+ margin: 0 0 var(--s4);
+ padding: var(--s3);
+}
+
+.signin {
+ background: none;
+ border-color: var(--line-strong);
+ color: var(--text);
+}
+
+.signin:hover {
+ background: var(--panel);
+ border-color: var(--faint);
+}
+
+.slack-mark {
+ flex: 0 0 auto;
+ height: 0.95rem;
+ width: 0.95rem;
+}
+
+/* Responsive
+ ------------------------------------------------------------------------- */
+
+@media (max-width: 900px) {
+ .workspace {
+ /* Without an explicit row list the nav strip stretches to share leftover height. */
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .sidebar {
+ border-bottom: 1px solid var(--line);
+ border-right: 0;
+ display: flex;
+ gap: var(--s4);
+ height: auto;
+ overflow-x: auto;
+ padding: var(--s3) var(--s4);
+ position: static;
+ }
+
+ .nav-group {
+ display: contents;
+ }
+
+ .nav-group > summary {
+ display: none;
+ }
+
+ .nav-group nav {
+ display: flex;
+ gap: var(--s4);
+ margin: 0;
+ }
+
+ .nav-item {
+ padding: var(--s1) 0;
+ white-space: nowrap;
+ }
+
+ .nav-item.active::before {
+ display: none;
+ }
+}
+
+@media (max-width: 620px) {
+ .menu-hint,
+ .profile .menu-name,
+ .slack-link {
+ display: none;
+ }
+
+ .topbar-actions {
+ gap: var(--s3);
+ }
+
+ .menu-panel {
+ width: min(19rem, calc(100vw - var(--s5)));
+ }
+
+ .live-actions {
+ width: 100%;
+ }
+
+ .live-actions form,
+ .live-actions button {
+ flex: 1;
+ }
+
+ .field-row,
+ .field-row-rule {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .check-stack {
+ padding-top: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition: none !important;
+ }
+}
diff --git a/lib/web/permissions.js b/lib/web/permissions.js
new file mode 100644
index 0000000..abf313b
--- /dev/null
+++ b/lib/web/permissions.js
@@ -0,0 +1,138 @@
+import { listUserAppointedManagers } from "../db.js";
+import { isGlobalAdmin, isWorkspaceAdmin } from "../perms.js";
+
+const SUPERADMINS = (process.env.SUPERADMINS || "").split(",").filter(Boolean);
+const CHANNEL_CACHE_TTL_MS = 5 * 60 * 1000;
+const channelInfoCache = new WeakMap();
+
+export const roleAbilities = {
+ manager: [
+ "Delete messages",
+ "Destroy threads",
+ "Manage embeds",
+ "Welcome messages",
+ "Timeout members",
+ "Use @here and @channel",
+ "Manage anchor polls",
+ ],
+ moderator: ["Timeout members", "Use @here and @channel"],
+};
+
+function primaryRole({ globalAdmin, workspaceAdmin, superAdmin, roles }) {
+ if (globalAdmin) return "Global admin";
+ if (workspaceAdmin) return "Workspace admin";
+ if (roles.some(({ role }) => role === "manager")) return "Channel manager";
+ if (roles.length) return "Channel moderator";
+ if (superAdmin) return "Registry admin";
+ return "Member";
+}
+
+function inheritedAbilities(globalAdmin, workspaceAdmin, superAdmin) {
+ const abilities = [];
+ if (globalAdmin) {
+ abilities.push(
+ "Delete messages in every channel",
+ "Destroy threads in every channel",
+ "Manage embeds in every channel",
+ "Manage welcome messages in every channel",
+ "Timeout members in every channel",
+ "Use @here and @channel in every channel",
+ "Manage anchor polls in every channel",
+ );
+ } else if (workspaceAdmin) {
+ abilities.push("Manage anchor polls in every channel");
+ }
+ if (globalAdmin || superAdmin) abilities.push("Manage the global admin registry");
+ return abilities;
+}
+
+function cachedChannelInfo(client, channelId) {
+ let clientCache = channelInfoCache.get(client);
+ if (!clientCache) {
+ clientCache = new Map();
+ channelInfoCache.set(client, clientCache);
+ }
+
+ const cached = clientCache.get(channelId);
+ if (cached?.expiresAt > Date.now()) return cached.promise;
+
+ const promise = client.conversations
+ .info({ channel: channelId })
+ .then((response) => response.channel || null);
+ clientCache.set(channelId, {
+ expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
+ promise,
+ });
+ promise.catch(() => {
+ if (clientCache.get(channelId)?.promise === promise) clientCache.delete(channelId);
+ });
+ return promise;
+}
+
+async function channelInfo(channelId, clients) {
+ for (const client of new Set(clients)) {
+ try {
+ const channel = await cachedChannelInfo(client, channelId);
+ if (channel) return channel;
+ } catch {
+ // Try the next Slack client. Private channels may only be visible to one token.
+ }
+ }
+ return null;
+}
+
+export async function loadPermissions(
+ client,
+ userId,
+ discoveryClient = client,
+ requestedChannelId,
+) {
+ const [globalAdmin, workspaceAdmin, roles] = await Promise.all([
+ isGlobalAdmin(userId),
+ isWorkspaceAdmin(client, userId),
+ listUserAppointedManagers(userId),
+ ]);
+ const superAdmin = SUPERADMINS.includes(userId);
+ const grants = new Map(roles.map((grant) => [grant.channel_id, grant]));
+ const channelIds = new Set(grants.keys());
+ if ((globalAdmin || workspaceAdmin) && requestedChannelId) channelIds.add(requestedChannelId);
+ const visibleChannels = (
+ await Promise.all(
+ [...channelIds].map(async (channelId) => {
+ const grant = grants.get(channelId);
+ const channel = await channelInfo(channelId, [client, discoveryClient]);
+ if (!channel && !grant) return null;
+ if (channel?.is_archived && !grant) return null;
+ return {
+ ...grant,
+ is_private: Boolean(channel?.is_private),
+ channel_id: channelId,
+ name: channel?.name || channelId,
+ };
+ }),
+ )
+ ).filter(Boolean);
+
+ const channels = visibleChannels
+ .map((channel) => {
+ const role = channel.role || grants.get(channel.channel_id)?.role;
+ const manager = role === "manager";
+ return {
+ ...channel,
+ role: role || (globalAdmin ? "global" : "workspace"),
+ canAnchor: globalAdmin || workspaceAdmin || manager,
+ canManage: globalAdmin || manager,
+ canModerate: globalAdmin || Boolean(role),
+ };
+ })
+ .filter((channel) => channel.canManage)
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return {
+ channels,
+ globalAdmin,
+ inherited: inheritedAbilities(globalAdmin, workspaceAdmin, superAdmin),
+ primaryRole: primaryRole({ globalAdmin, workspaceAdmin, superAdmin, roles }),
+ workspaceAdmin,
+ };
+}
diff --git a/lib/web/sections.js b/lib/web/sections.js
new file mode 100644
index 0000000..c26b3d5
--- /dev/null
+++ b/lib/web/sections.js
@@ -0,0 +1,103 @@
+// Single source of truth for dashboard navigation. `app.js` uses it to route and
+// authorize section pages; `views.jsx` uses it to render the sidebar.
+//
+// The dashboard is deliberately narrow for now: API keys only. The rest of the
+// first pass lives in PARKED below and in `views.parked.jsx`, ready to move back
+// into NAV once those sections ship.
+
+export const NAV = [
+ {
+ label: "API",
+ items: [
+ {
+ slug: "",
+ label: "API keys",
+ title: "API keys",
+ blurb: "Delete messages in this channel from your own scripts.",
+ },
+ ],
+ },
+];
+
+export const PARKED = [
+ {
+ label: "Core",
+ items: [
+ {
+ slug: "access",
+ label: "Access",
+ title: "Access",
+ blurb: "Who can change what in this channel.",
+ // Read-only, and the page header already names your role, so it gets no overview card.
+ summarize: false,
+ },
+ ],
+ },
+ {
+ label: "Anchors",
+ items: [
+ {
+ slug: "anchor-message",
+ label: "Anchored message",
+ capability: "canAnchor",
+ title: "Anchored message",
+ blurb: "Keep the context this channel needs at the bottom of the conversation.",
+ },
+ {
+ slug: "anchor-poll",
+ label: "Poll",
+ capability: "canAnchor",
+ title: "Poll",
+ blurb: "Run a vote that stays visible as the channel moves.",
+ },
+ {
+ slug: "anchor-nps",
+ label: "NPS pulse",
+ capability: "canAnchor",
+ title: "NPS pulse",
+ blurb: "Collect a 1–10 score and optional written feedback.",
+ },
+ ],
+ },
+ {
+ label: "Channel",
+ items: [
+ {
+ slug: "welcome",
+ label: "Welcome message",
+ capability: "canManage",
+ title: "Welcome message",
+ blurb: "Greet people the first time they join.",
+ },
+ {
+ slug: "embeds",
+ label: "Blocked embeds",
+ capability: "canManage",
+ title: "Blocked embeds",
+ blurb: "Stop chosen links from expanding into previews.",
+ },
+ ],
+ },
+];
+
+const bySlug = new Map(NAV.flatMap(({ items }) => items.map((item) => [item.slug, item])));
+
+export function sectionBySlug(slug) {
+ return bySlug.get(slug || "") || null;
+}
+
+export function sectionPath(channelId, slug) {
+ const base = `/c/${encodeURIComponent(channelId)}`;
+ return slug ? `${base}/${slug}` : base;
+}
+
+export function visibleGroups(channel) {
+ return NAV.map(({ label, items }) => ({
+ label,
+ items: items.filter(({ capability }) => !capability || channel[capability]),
+ })).filter(({ items }) => items.length > 0);
+}
+
+export function sectionCount(channel) {
+ return visibleGroups(channel).reduce((total, { items }) => total + items.length, 0);
+}
diff --git a/lib/web/server.js b/lib/web/server.js
new file mode 100644
index 0000000..07e02a3
--- /dev/null
+++ b/lib/web/server.js
@@ -0,0 +1,17 @@
+import { createWebApp } from "./app.js";
+
+function webPort() {
+ const port = Number(process.env.PORT || 3000);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid");
+ return port;
+}
+
+export function startWebServer(options) {
+ const web = createWebApp(options);
+ return Bun.serve({
+ fetch: web.fetch,
+ hostname: "0.0.0.0",
+ idleTimeout: 60,
+ port: webPort(),
+ });
+}
diff --git a/lib/web/views.jsx b/lib/web/views.jsx
new file mode 100644
index 0000000..f3294f3
--- /dev/null
+++ b/lib/web/views.jsx
@@ -0,0 +1,584 @@
+/** @jsxImportSource hono/jsx */
+
+import { sectionCount, sectionPath, visibleGroups } from "./sections.js";
+
+export const roleLabels = {
+ global: "Global admin",
+ manager: "Channel manager",
+ moderator: "Channel moderator",
+ workspace: "Workspace admin",
+};
+
+const statusMessages = {
+ "key-revoked": "API key revoked.",
+};
+
+function Lock() {
+ return (
+
+ );
+}
+
+function Caret() {
+ return (
+
+ );
+}
+
+function SlackMark() {
+ return (
+
+ );
+}
+
+function ChannelName({ channel }) {
+ if (!channel) return No channel;
+ return (
+ <>
+ {channel.is_private ?
+ Prometheus
+
+ );
+}
+
+function Document({ title, bodyClass, children }) {
+ return (
+
+
+ {statusMessages[status]} +
+ )} + {error && ( ++ {error} +
+ )} + > + ); +} + +function AuthLayout({ title, children }) { + return ( ++ API keys for the channels you manage. Mint one, and your scripts can delete messages through + Prometheus with the same audit trail as the Slack shortcut. +
+ {error &&{error}
} + ++ Try again. If it keeps failing, ask a Prometheus admin to check the service. +
+ + Back to the dashboard + +{permissions.primaryRole}
++ {canSelectAny + ? "Paste a Slack channel link or ID and Prometheus will open its settings." + : "Channel settings appear here once you are a channel manager, a global admin, or a workspace admin."} +
+ {error && ( ++ {error} +
+ )} + {canSelectAny &&{state}
} + {description &&{description}
} + > + ); +} + +export function Stats({ rows }) { + return ( +
+
{section.blurb}
+
+ {apiKey}
+
{`${key.key_prefix}…`}
+
+ {`${key.name} · created ${keyDate(key.created_at)} · last used ${keyDate(key.last_used_at)}`}
+
+ No keys yet. Nothing can delete messages here over the API.
+ )} +
+ {example}
+
+ ts takes one message timestamp or an array of up to 50.
+ reason is required, up to 500 characters, and is recorded in the audit log.
+ deleted and failed{" "}
+ for what actually happened to each message.
+ GET {`${baseUrl}/api/v1/key`} checks a key without deleting anything.
+ + You are holding all {max} of your keys. Revoke one — here or in another channel — to + make room. +
+ )} +Nothing anchored
+{anchor.enabled ? `Live · ${anchorKind(anchor)}` : "Paused"}
+{label}
+A {kind} is anchored here right now. Publishing below replaces it.
+ ); +} + +export function AnchorMessagePage({ channel, settings }) { + const anchor = settings.anchor; + const current = anchor?.type === "message"; + return ( + <> +{ruleLabel(rule)}
+ {ruleScopes[rule.type]}
+ No rules yet. Every link expands normally.
+ )} +You are here through an admin role rather than a channel appointment.
+ )} +Nothing beyond the channels you are appointed to.
+ )} +