This commit is contained in:
Jason Ross
2026-03-30 21:08:20 -05:00
parent 29fea31800
commit 0f3c951fc1
71 changed files with 8453 additions and 48 deletions
+41 -29
View File
@@ -49,10 +49,10 @@ A lightweight, self-hosted admin dashboard for managing user invites, bans, and
### Services
| Service | Stack | Port | Access |
|--------------|-------------------|-------|-------------------|
| `admin-web` | Vite + React | 5180 | WireGuard only |
| `admin-api` | Express + Node 22 | 5181 | WireGuard only |
| Service | Stack | Port | Access |
| ----------- | ----------------- | ---- | -------------- |
| `admin-web` | Vite + React | 5180 | WireGuard only |
| `admin-api` | Express + Node 22 | 5181 | WireGuard only |
Both services run in their own Podman Compose stack but join the Stoat stack's Podman network (`stoat_default`) as an external network, giving them direct access to MongoDB and Redis. No new databases — `admin-api` connects to Stoat's existing MongoDB instance.
@@ -102,13 +102,16 @@ Stoat's account records. Relevant fields for admin operations:
```typescript
type Account = {
_id: string; // ULID, matches user._id
_id: string; // ULID, matches user._id
email: string;
email_normalised: string;
disabled: boolean; // ← set true to ban at account level
disabled: boolean; // ← set true to ban at account level
spam: boolean;
verification: { status: "Verified" | "Pending" | "Moving" };
deletion?: { status: "Scheduled" | "WaitingForVerification" | "Deleted"; after?: string };
deletion?: {
status: "Scheduled" | "WaitingForVerification" | "Deleted";
after?: string;
};
lockout?: { attempts: number; expiry: string };
};
```
@@ -119,10 +122,10 @@ Stoat's user profiles. Relevant fields:
```typescript
type User = {
_id: string; // ULID
_id: string; // ULID
username: string;
discriminator: string;
flags?: number; // bitmask: 1=suspended, 2=deleted, 4=banned
flags?: number; // bitmask: 1=suspended, 2=deleted, 4=banned
// ... avatar, status, etc.
};
```
@@ -144,7 +147,7 @@ Used when `invite_only = true` in `Revolt.toml`. Each document is an invite code
```typescript
type Invite = {
_id: string; // the invite code itself
_id: string; // the invite code itself
};
```
@@ -154,7 +157,7 @@ Strike/suspension/ban audit records (from the official admin panel schema).
```typescript
type Strike = {
_id: string; // ULID
_id: string; // ULID
user_id: string;
reason: string;
type?: "strike" | "suspension" | "ban";
@@ -217,19 +220,19 @@ All routes prefixed with `/api`. All require a valid session except `POST /api/a
### Auth
| Method | Path | Description |
|--------|-------------------|-------------------------------------|
| POST | `/api/auth/login` | Login with username + password |
| POST | `/api/auth/logout`| Destroy session |
| GET | `/api/auth/me` | Return current session user or 401 |
| Method | Path | Description |
| ------ | ------------------ | ---------------------------------- |
| POST | `/api/auth/login` | Login with username + password |
| POST | `/api/auth/logout` | Destroy session |
| GET | `/api/auth/me` | Return current session user or 401 |
### Invites
| Method | Path | Description |
|--------|------------------------|--------------------------------------------------------------|
| GET | `/api/invites` | List all invite records from SQLite (with status) |
| POST | `/api/invites` | Create invite: generate code → insert into Mongo + SQLite → send email via Resend |
| DELETE | `/api/invites/:code` | Revoke: delete from Mongo `revolt.invites`, set SQLite status to `revoked` |
| Method | Path | Description |
| ------ | -------------------- | --------------------------------------------------------------------------------- |
| GET | `/api/invites` | List all invite records from SQLite (with status) |
| POST | `/api/invites` | Create invite: generate code → insert into Mongo + SQLite → send email via Resend |
| DELETE | `/api/invites/:code` | Revoke: delete from Mongo `revolt.invites`, set SQLite status to `revoked` |
#### Invite creation flow
@@ -258,13 +261,13 @@ For each SQLite record where status = 'pending':
### Users
| Method | Path | Description |
|--------|------------------------------|---------------------------------------------------|
| GET | `/api/users` | List users from Mongo `revolt.users` (paginated) |
| GET | `/api/users/:id` | Get user + account details |
| POST | `/api/users/:id/ban` | Ban user (see flow below) |
| POST | `/api/users/:id/unban` | Reverse a ban |
| DELETE | `/api/users/:id` | Delete user (see flow below) |
| Method | Path | Description |
| ------ | ---------------------- | ------------------------------------------------ |
| GET | `/api/users` | List users from Mongo `revolt.users` (paginated) |
| GET | `/api/users/:id` | Get user + account details |
| POST | `/api/users/:id/ban` | Ban user (see flow below) |
| POST | `/api/users/:id/unban` | Reverse a ban |
| DELETE | `/api/users/:id` | Delete user (see flow below) |
#### Ban flow
@@ -330,7 +333,7 @@ Verify the Stoat network name with `podman network ls` while Stoat is running, a
networks:
stoat:
external: true
name: stoat_default # must match the actual Stoat stack network name
name: stoat_default # must match the actual Stoat stack network name
services:
admin-api:
@@ -394,6 +397,7 @@ systemd
### s6 Service Directories
**`/etc/s6-services/stoat/run`:**
```bash
#!/bin/bash
set -e
@@ -402,6 +406,7 @@ exec podman compose up 2>&1
```
**`/etc/s6-services/stoat/finish`:**
```bash
#!/bin/bash
cd /srv/stoat
@@ -409,6 +414,7 @@ podman compose down
```
**`/etc/s6-services/stoat-admin/run`:**
```bash
#!/bin/bash
set -e
@@ -427,6 +433,7 @@ exec podman compose up 2>&1
The admin stack's `run` script checks for the Stoat network before starting. If the network doesn't exist yet (because the Stoat stack hasn't finished initializing), the script sleeps briefly and exits. s6 restarts it automatically, effectively retrying until the network appears. Combined with the MongoDB connection retry in the admin-api code, this handles all timing dependencies without explicit dependency declarations.
**`/etc/s6-services/stoat-admin/finish`:**
```bash
#!/bin/bash
cd /srv/stoat-admin
@@ -436,12 +443,14 @@ podman compose down
**Log service (same pattern for both stacks):**
**`/etc/s6-services/stoat/log/run`:**
```bash
#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat/
```
**`/etc/s6-services/stoat-admin/log/run`:**
```bash
#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat-admin/
@@ -454,6 +463,7 @@ The `T` directive prefixes each log line with a TAI64N timestamp. Logs are writt
A single systemd unit runs the s6 scan directory. This is the only systemd unit needed for the entire chat infrastructure.
**`/etc/systemd/system/s6-services.service`:**
```ini
[Unit]
Description=s6 service supervision tree
@@ -496,6 +506,7 @@ tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal
### Dockerfiles
**admin-api:**
```dockerfile
FROM node:22-slim
WORKDIR /app
@@ -508,6 +519,7 @@ CMD ["node", "dist/index.js"]
```
**admin-web:**
```dockerfile
FROM node:22-slim AS build-app
WORKDIR /app
+37 -19
View File
@@ -114,11 +114,11 @@ Create `api/src/db/mongo.ts`. Export a function `connectMongo()` that creates a
```typescript
// Each function returns a typed Collection handle
accounts() // revolt.accounts
users() // revolt.users
sessions() // revolt.sessions
invites() // revolt.invites
safetyStrikes() // revolt.safety_strikes
accounts(); // revolt.accounts
users(); // revolt.users
sessions(); // revolt.sessions
invites(); // revolt.invites
safetyStrikes(); // revolt.safety_strikes
```
Define TypeScript interfaces for each collection's document shape matching the types in the design doc. Place these in `api/src/db/types.ts`. Only include the fields the admin dashboard reads or writes — do not attempt to type the entire Revolt schema.
@@ -205,6 +205,7 @@ Create `api/src/routes/invites.ts`. Implement an Express Router. All routes requ
`POST /api/invites`: Accept `{ email, expiresInHours?: number }` in the request body. Validate with Zod (email must be a valid email format).
Implementation steps, in order:
1. Generate a 12-character alphanumeric code using `nanoid` with a custom alphabet (`0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz`).
2. Compute `expires_at` as an ISO 8601 string if `expiresInHours` was provided, otherwise null.
3. Insert `{ _id: code }` into MongoDB `revolt.invites`.
@@ -241,21 +242,21 @@ Create `api/src/routes/users.ts`. Implement an Express Router. All routes requir
The join between `users` and `accounts` is by `_id` (they share the same ULID). Since MongoDB doesn't have native joins, perform this as a `$lookup` aggregation or two sequential queries. The aggregation approach is preferred:
```typescript
db.collection('users').aggregate([
db.collection("users").aggregate([
{ $match: matchFilter },
{ $skip: (page - 1) * limit },
{ $limit: limit },
{
$lookup: {
from: 'accounts',
localField: '_id',
foreignField: '_id',
as: 'account',
from: "accounts",
localField: "_id",
foreignField: "_id",
as: "account",
pipeline: [{ $project: { email: 1, disabled: 1, verification: 1 } }]
}
},
{ $unwind: { path: '$account', preserveNullAndEmptyArrays: true } }
])
{ $unwind: { path: "$account", preserveNullAndEmptyArrays: true } }
]);
```
`GET /api/users/:id`: Fetch a single user from `revolt.users` and their account from `revolt.accounts` by the same `_id`. Also fetch their strike history from `revolt.safety_strikes` where `user_id = id`. Return `200 { user, account, strikes }`. Return `404` if neither user nor account exists.
@@ -263,6 +264,7 @@ db.collection('users').aggregate([
`POST /api/users/:id/ban`: Accept `{ reason: string }` in the request body. Validate with Zod (reason must be a non-empty string).
Implementation steps, in order:
1. Fetch the user from `revolt.users` to confirm they exist. Return `404` if not found.
2. Check if the account is already disabled (`revolt.accounts.disabled === true`). If so, return `400 { error: "User is already banned" }`.
3. Update `revolt.accounts`: set `disabled = true` where `_id = id`.
@@ -275,6 +277,7 @@ Implementation steps, in order:
`POST /api/users/:id/unban`: No request body required.
Implementation steps:
1. Fetch the account from `revolt.accounts`. Return `404` if not found.
2. Check that `disabled === true`. If not, return `400 { error: "User is not banned" }`.
3. Update `revolt.accounts`: set `disabled = false` where `_id = id`.
@@ -285,6 +288,7 @@ Implementation steps:
`DELETE /api/users/:id`: Accept optional `{ reason?: string }` in the request body.
Implementation steps:
1. Fetch the user from `revolt.users`. Return `404` if not found.
2. Update `revolt.accounts`: set `deletion = { status: "Scheduled", after: new Date().toISOString() }` where `_id = id`.
3. Update `revolt.users`: set `flags` to `(currentFlags || 0) | 2` where `_id = id`.
@@ -299,6 +303,7 @@ Do not attempt to delete user data (messages, DMs, memberships) directly. Stoat'
Create `api/src/routes/dashboard.ts`. Implement an Express Router. Requires auth.
`GET /api/dashboard/stats`: Aggregate and return summary counts:
1. Total users: `revolt.users.countDocuments({})`.
2. Banned users: `revolt.users.countDocuments({ flags: { $bitsAllSet: 4 } })`.
3. Pending invites: SQLite query `SELECT COUNT(*) FROM invite_records WHERE status = 'pending'`.
@@ -315,6 +320,7 @@ Return `200 { totalUsers, bannedUsers, pendingInvites, recentBans }`.
Create `api/src/index.ts`. This is the main entry point.
Startup sequence:
1. Load environment variables (use a validation function with Zod to parse and validate all required env vars at startup — fail fast with a clear error message if any are missing).
2. Connect to MongoDB via `connectMongo()`.
3. Initialize SQLite (the import of `sqlite.ts` triggers table creation).
@@ -341,19 +347,22 @@ Create `web/src/lib/api.ts`. Export a configured fetch wrapper:
```typescript
const API_BASE = import.meta.env.VITE_API_URL;
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
export async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
credentials: 'include', // send session cookie
credentials: "include", // send session cookie
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
"Content-Type": "application/json",
...options?.headers
}
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(res.status, body.error || 'Request failed');
throw new ApiError(res.status, body.error || "Request failed");
}
return res.json();
@@ -537,7 +546,7 @@ Create `compose.yml` at the repo root. This is the generic, open-source-friendly
networks:
stoat:
external: true
name: stoat_default # Adjust to match your Stoat stack's network name
name: stoat_default # Adjust to match your Stoat stack's network name
services:
admin-api:
@@ -582,6 +591,7 @@ Create `compose.override.example.yml` with comments explaining common customizat
Create example s6 service directory structures in a `deploy/s6/` directory at the repo root. These are reference files that users copy to their s6 scan directory (e.g., `/etc/s6-services/`). All `run` and `finish` scripts must be executable (`chmod +x`).
**`deploy/s6/stoat-admin/run`:**
```bash
#!/bin/bash
set -e
@@ -601,6 +611,7 @@ exec podman compose up 2>&1
The network check handles the race condition where s6 starts both stacks simultaneously. If `stoat_default` doesn't exist yet, the script sleeps briefly and exits non-zero. s6 restarts it, and it tries again. Once the network exists, it falls through to `exec podman compose up` which replaces the bash process with the podman process — exactly what s6 expects as a long-lived supervised process. The `exec` is critical: without it, bash stays resident as a parent between s6 and podman, and signals from s6 would hit bash instead of podman.
**`deploy/s6/stoat-admin/finish`:**
```bash
#!/bin/bash
cd /srv/stoat-admin
@@ -610,6 +621,7 @@ podman compose down
The `finish` script runs whenever `run` exits (whether normally or via `s6-svc -d`). It ensures containers are cleaned up rather than left orphaned. No `exec` needed here — this is a short-lived cleanup script, not a long-running process.
**`deploy/s6/stoat-admin/log/run`:**
```bash
#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat-admin/
@@ -620,6 +632,7 @@ The `T` directive prefixes each line with a TAI64N timestamp. Logs for each stac
Also create the equivalent Stoat stack service directory structure (`deploy/s6/stoat/`) with the same pattern, substituting the compose project path and removing the network check (the Stoat stack creates the network, it doesn't depend on it). Include both in the repo as reference examples, with a note that paths and network names must be adjusted for each deployment.
**`deploy/s6/stoat/run`:**
```bash
#!/bin/bash
set -e
@@ -628,6 +641,7 @@ exec podman compose up 2>&1
```
**`deploy/s6/stoat/finish`:**
```bash
#!/bin/bash
cd /srv/stoat
@@ -635,6 +649,7 @@ podman compose down
```
**`deploy/s6/stoat/log/run`:**
```bash
#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat/
@@ -662,6 +677,7 @@ WantedBy=multi-user.target
```
Document the required setup steps in the README:
1. Install s6 on Ubuntu 24.04: `apt install s6`.
2. Create the scan directory: `mkdir -p /etc/s6-services`.
3. Create log output directories: `mkdir -p /var/log/s6/stoat /var/log/s6/stoat-admin`.
@@ -718,6 +734,7 @@ s6-svc -r /etc/s6-services/stoat-admin
Create `.github/workflows/build.yml`. Trigger on push to `main` and on tags matching `v*`.
Jobs:
1. **build-api**: Check out the repo, set up Node 22, run `npm ci` and `npm run build` in `api/`, then build the Docker image and push to GHCR. Tag with both `latest` and the Git SHA (or Git tag if triggered by a tag push).
2. **build-web**: Same pattern for `web/`. Pass `VITE_API_URL` as a build arg — for the CI-built image, use a placeholder value. Users will rebuild with their own URL or override at runtime.
@@ -728,6 +745,7 @@ Use `docker/login-action` for GHCR auth and `docker/build-push-action` for build
Create `.github/workflows/ci.yml`. Trigger on pull requests and pushes to `main`.
Jobs:
1. **api-check**: Run `npm ci`, `npm run build` (TypeScript type checking), and `npx eslint .` in `api/`.
2. **web-check**: Run `npm ci`, `npm run build`, and `npx eslint .` in `web/`.