Files
stoat-admin/docs/stoat-admin-tasks.md
T
2026-03-30 13:43:51 -05:00

36 KiB
Raw Blame History

Stoat Admin Dashboard — Implementation Tasks

Reference: stoat-admin-design.md (system design document)

This document describes every task required to build and deploy the Stoat Admin Dashboard from scratch. The project consists of two services — admin-api (Express/Node/TypeScript) and admin-web (Vite/React/TypeScript) — deployed as a standalone Podman Compose stack that joins the Stoat chat instance's existing Podman network to access MongoDB directly. The admin stack and the Stoat application stack are fully independent and can be started, stopped, and updated without affecting each other. The project will be open-sourced, so all configuration must be parameterized through environment variables with no hardcoded infrastructure details.


Phase 0: Project Scaffolding

0.1 — Initialize the monorepo

Create a single Git repository with two top-level packages. Use a flat structure rather than a workspace manager — this is a small project and the two packages share no runtime code.

stoat-admin/
├── api/                    # Express backend
├── web/                    # Vite + React frontend
├── deploy/
│   └── s6/                 # Reference s6 service directories and systemd unit
│       ├── stoat/          # s6 service dir for the Stoat application stack
│       ├── stoat-admin/    # s6 service dir for the admin dashboard stack
│       └── s6-services.service  # systemd unit for s6-svscan
├── compose.yml             # Production compose file (generic, ships with repo)
├── compose.override.example.yml  # Example overrides for deployment-specific config
├── .env.example            # Documents all required env vars with placeholder values
├── .gitignore
├── .dockerignore
├── LICENSE                 # AGPL-3.0 to match Stoat's licensing
└── README.md

0.2 — Initialize api/ package

Scaffold the Express backend with TypeScript. Target Node 22. Use ESM modules ("type": "module" in package.json).

Install production dependencies: express, express-session, better-sqlite3, better-sqlite3-session-store, mongodb, argon2, nanoid, node-cron, resend, cors, helmet, zod (request validation).

Install dev dependencies: typescript, tsx (for development), @types/express, @types/express-session, @types/better-sqlite3, @types/node-cron, @types/cors.

Create tsconfig.json targeting ES2022, NodeNext module resolution, strict mode enabled, outDir: "./dist".

Create npm scripts: dev (run with tsx watch), build (run tsc), start (run node dist/index.js), seed (run seed script).

0.3 — Initialize web/ package

Scaffold with npm create vite@latest using the react-ts template.

Install production dependencies: react-router-dom, @tanstack/react-query.

Install dev dependencies: tailwindcss, @tailwindcss/vite, @types/react, @types/react-dom.

Configure Tailwind via the Vite plugin. Create a minimal tailwind.css with @import "tailwindcss".

Set up a VITE_API_URL environment variable (used at build time for API base URL). Create an api.ts utility module that reads this variable and exports a configured fetch wrapper that includes credentials (cookies) on every request.

0.4 — Create .env.example

This file documents every required environment variable. It ships with the repo and contains only placeholder values.

# MongoDB connection string — must point to the same MongoDB instance used by Stoat
# The admin dashboard connects directly to Stoat's database, not through an API
MONGODB=mongodb://database:27017

# Resend (https://resend.com) for sending invite emails
RESEND_API_KEY=re_xxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com

# Secret for signing Express session cookies
# Generate with: openssl rand -base64 32
SESSION_SECRET=

# The public-facing URL of your Stoat instance (used in invite emails)
INSTANCE_URL=https://chat.yourdomain.com

# A display name for your instance (used in invite email subject/body)
INSTANCE_NAME=My Stoat Instance

# Port the admin API listens on
ADMIN_API_PORT=5181

# URL where the admin frontend is served (used for CORS origin)
ADMIN_WEB_ORIGIN=http://localhost:5180

0.5 — Create .gitignore

Must exclude all sensitive and generated files. Include at minimum:

node_modules/
dist/
*.db
*.sqlite
.env
.env.local
.env.production
secrets.env
data/

0.6 — Create .dockerignore

Must mirror .gitignore to prevent sensitive files from entering the Docker build context. Additionally exclude .git/, README.md, compose*.yml, and test directories.


Phase 1: Backend — Database Layer

1.1 — MongoDB connection module

Create api/src/db/mongo.ts. Export a function connectMongo() that creates a MongoClient from the MONGODB environment variable and connects. The connection must retry with exponential backoff on failure (start at 1s, max 30s, retry indefinitely) because the admin stack runs as a separate Podman Compose stack from Stoat — MongoDB may not be available yet if both stacks start simultaneously. Export a getDb() function that returns the revolt database handle. Export typed collection accessors:

// Each function returns a typed Collection handle
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.

1.2 — SQLite initialization module

Create api/src/db/sqlite.ts. On import, open (or create) /data/admin.db using better-sqlite3. The /data path is where the Docker volume mounts; for local development, fall back to ./data/admin.db (create the directory if it doesn't exist).

Run the following DDL on startup (idempotent via IF NOT EXISTS):

CREATE TABLE IF NOT EXISTS admin_user (
  id INTEGER PRIMARY KEY,
  username TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS invite_records (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  code TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  expires_at TEXT,
  accepted_at TEXT,
  resend_message_id TEXT
);

CREATE TABLE IF NOT EXISTS audit_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  action TEXT NOT NULL,
  target TEXT NOT NULL,
  details TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

Export the better-sqlite3 database instance and prepared statement helpers for common queries.

1.3 — Audit log helper

Create api/src/db/audit.ts. Export a function logAction(action: string, target: string, details?: Record<string, unknown>) that inserts into the audit_log table. The details parameter is serialized to JSON. This function is called by every mutating route handler.


Phase 2: Backend — Authentication

2.1 — Seed script

Create api/src/seed.ts. This is a standalone CLI script (not part of the Express server) that creates the admin user.

Parse --username and --password from command-line arguments (or prompt interactively if not provided). Hash the password with argon2.hash() using the argon2id variant. Insert into the admin_user table. If a user already exists, print an error and exit (do not overwrite). The script should also support a --reset-password flag that updates the existing user's password hash.

Add an npm script: "seed": "tsx src/seed.ts".

2.2 — Session middleware

Create api/src/middleware/auth.ts.

Configure express-session with: the SESSION_SECRET env var as the secret, a better-sqlite3-backed session store writing to the same /data/admin.db file, cookie.maxAge set to 2 hours (7200000ms), cookie.httpOnly set to true, cookie.sameSite set to 'strict', cookie.secure set to false (this runs over plain HTTP on the WireGuard subnet; no TLS between browser and admin services).

Export an requireAuth middleware function that checks req.session.userId. If not present, respond with 401 { error: "Not authenticated" }. Attach this middleware to all routes except POST /api/auth/login.

2.3 — Auth routes

Create api/src/routes/auth.ts. Implement an Express Router with three routes.

POST /api/auth/login: Accept { username, password } in the request body. Validate with Zod. Look up the user in the admin_user SQLite table by username. If not found, return 401. Verify the password against the stored hash with argon2.verify(). If invalid, return 401. On success, set req.session.userId and req.session.username, return 200 { username }.

POST /api/auth/logout: Call req.session.destroy(), clear the session cookie, return 200.

GET /api/auth/me: Return 200 { username: req.session.username } if authenticated (the requireAuth middleware handles the 401 case).


Phase 3: Backend — Invite Routes

3.1 — Invite routes

Create api/src/routes/invites.ts. Implement an Express Router. All routes require auth.

GET /api/invites: Query all rows from invite_records in SQLite, ordered by created_at DESC. Return 200 with the array. Include a count field with total records for future pagination.

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.
  4. Insert a record into SQLite invite_records with code, email, status='pending', expires_at.
  5. Send the invite email via the Resend SDK. The email body should contain a link: ${INSTANCE_URL}?invite=${code}. Store the returned message_id in SQLite by updating the invite_records row.
  6. Call logAction('invite_created', email, { code, expires_at }).
  7. Return 201 with the invite record.

If the Resend API call fails, still return the invite record but include a warning field indicating the email failed to send. The invite code is still valid in MongoDB — the user just needs to receive the code through another channel.

DELETE /api/invites/:code: Look up the code in SQLite invite_records. If not found, return 404. If status is not pending, return 400 (can only revoke pending invites). Delete the document from MongoDB revolt.invites (it may already be gone if used). Update the SQLite record's status to revoked. Call logAction('invite_revoked', record.email, { code }). Return 200.

3.2 — Invite acceptance cron job

Create api/src/jobs/inviteSync.ts. Export a function syncInviteStatuses() that runs the following logic:

  1. Query all rows from SQLite invite_records where status = 'pending'.
  2. For each record, check if the corresponding document exists in MongoDB revolt.invites (query by { _id: record.code }).
  3. If the document does not exist in MongoDB, the invite was consumed by Stoat during registration. Update the SQLite record: set status = 'accepted' and accepted_at = datetime('now').
  4. If the record has an expires_at value and it is in the past, update the SQLite record: set status = 'expired'. Also attempt to delete the document from MongoDB revolt.invites (it may already be gone).

In the main Express server setup (api/src/index.ts), schedule this function with node-cron to run every 5 minutes: cron.schedule('*/5 * * * *', syncInviteStatuses).


Phase 4: Backend — User Routes

4.1 — User routes

Create api/src/routes/users.ts. Implement an Express Router. All routes require auth.

GET /api/users: Query MongoDB revolt.users. Project only the fields the frontend needs: _id, username, discriminator, flags, avatar. Join with revolt.accounts to include email, disabled, and verification.status for each user. Support query parameters: page (default 1), limit (default 50, max 100), search (optional, filters by email match against revolt.accounts). Return 200 { users: [...], total: number, page: number, limit: number }.

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:

db.collection('users').aggregate([
  { $match: matchFilter },
  { $skip: (page - 1) * limit },
  { $limit: limit },
  {
    $lookup: {
      from: 'accounts',
      localField: '_id',
      foreignField: '_id',
      as: 'account',
      pipeline: [{ $project: { email: 1, disabled: 1, verification: 1 } }]
    }
  },
  { $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.

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.
  4. Update revolt.users: set flags to (currentFlags || 0) | 4 where _id = id.
  5. Delete all documents from revolt.sessions where user_id = id.
  6. Insert a strike record into revolt.safety_strikes: { _id: generateULID(), user_id: id, reason: reason, type: "ban" }. Use the ulid npm package to generate the ULID.
  7. Call logAction('user_banned', id, { reason }).
  8. Return 200 { success: true }.

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.
  4. Update revolt.users: set flags to (currentFlags || 0) & ~4 where _id = id.
  5. Call logAction('user_unbanned', id).
  6. Return 200 { success: true }.

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.
  4. Delete all documents from revolt.sessions where user_id = id.
  5. Call logAction('user_deleted', id, { reason }).
  6. Return 200 { success: true }.

Do not attempt to delete user data (messages, DMs, memberships) directly. Stoat's crond daemon processes scheduled deletions and handles all cascading data cleanup.

4.2 — Dashboard stats route

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'.
  4. Recent bans (last 30 days): SQLite query SELECT COUNT(*) FROM audit_log WHERE action = 'user_banned' AND created_at > datetime('now', '-30 days').

Return 200 { totalUsers, bannedUsers, pendingInvites, recentBans }.


Phase 5: Backend — Server Setup

5.1 — Express server entry point

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).
  4. Configure Express with: helmet() for security headers, cors({ origin: ADMIN_WEB_ORIGIN, credentials: true }), express.json(), the session middleware from Phase 2.
  5. Mount route handlers: auth routes at /api/auth, invite routes at /api/invites, user routes at /api/users, dashboard routes at /api/dashboard.
  6. Apply requireAuth middleware to all routes except /api/auth/login.
  7. Start the cron job from Phase 3.
  8. Listen on the port from ADMIN_API_PORT.

5.2 — Error handling middleware

Create api/src/middleware/errors.ts. Add a global Express error handler that catches unhandled errors, logs the stack trace to stderr, and returns 500 { error: "Internal server error" }. Never leak stack traces or internal details in the response body.

Also create a notFound middleware mounted after all routes that returns 404 { error: "Not found" }.


Phase 6: Frontend — Core Setup

6.1 — API client module

Create web/src/lib/api.ts. Export a configured fetch wrapper:

const API_BASE = import.meta.env.VITE_API_URL;

export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${API_BASE}${path}`, {
    ...options,
    credentials: 'include',  // send session cookie
    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');
  }

  return res.json();
}

Define an ApiError class with status and message fields.

6.2 — Auth context

Create web/src/lib/auth.tsx. Implement a React context provider that manages authentication state.

On mount, call GET /api/auth/me. If it returns 200, the user is authenticated. If 401, the user is not authenticated. Expose user, login(username, password), logout(), and isLoading through the context.

Wrap the entire app in this provider in main.tsx.

6.3 — Router setup

Create web/src/router.tsx. Configure React Router with the following structure:

  • /login — Login view (public)
  • / — Dashboard view (protected)
  • /invites — Invites list view (protected)
  • /users — Users list view (protected)
  • /users/:id — User detail view (protected)

Create a ProtectedRoute wrapper component that checks the auth context and redirects to /login if not authenticated.

6.4 — Layout component

Create web/src/components/Layout.tsx. A simple shell with: a sidebar or top nav with links to Dashboard, Invites, Users, and a logout button. The main content area renders the <Outlet />. The layout should display the current user's username in the nav.


Phase 7: Frontend — Views

7.1 — Login view

Create web/src/views/Login.tsx. A centered card with username and password inputs and a submit button. On submit, call the login() function from the auth context. Display an error message on failure. Redirect to / on success.

7.2 — Dashboard view

Create web/src/views/Dashboard.tsx. Fetch stats from GET /api/dashboard/stats using TanStack Query. Display four stat cards: total users, banned users, pending invites, recent bans (last 30 days). Nothing interactive — this is a read-only overview.

7.3 — Invites view

Create web/src/views/Invites.tsx. Two sections:

A creation form at the top: email input, optional expiry dropdown (24h / 48h / 7 days / 30 days / no expiry), and a "Send Invite" button. On submit, call POST /api/invites. Show a success message with the invite code, or an error on failure. Use a TanStack Query mutation.

A table below showing all invites fetched from GET /api/invites. Columns: email, code, status (with color-coded badges: green for accepted, yellow for pending, red for revoked, gray for expired), created date, expires date. Each pending invite row has a "Revoke" button that calls DELETE /api/invites/:code and invalidates the query cache.

7.4 — Users view

Create web/src/views/Users.tsx. A search bar at the top that filters by email (debounced, passed as a search query parameter). A paginated table below showing users fetched from GET /api/users. Columns: username#discriminator, email, status (active / banned / deleted, derived from flags and account.disabled), verified (yes/no from account.verification.status). Clicking a row navigates to /users/:id.

Pagination controls (previous/next) at the bottom, driven by the total, page, and limit fields in the API response.

7.5 — User detail view

Create web/src/views/UserDetail.tsx. Fetch user data from GET /api/users/:id using TanStack Query.

Display three sections in a stacked layout:

User info card: Username#discriminator, email, account status (active/banned/disabled), email verification status, user ID (copyable). Show the user's flag state as human-readable badges.

Actions: Contextual action buttons based on the user's current state. If the user is active: show a "Ban" button that opens a confirmation dialog with a reason input, and a "Delete" button with a confirmation dialog. If the user is banned: show an "Unban" button with a confirmation dialog. If the user is scheduled for deletion: show no actions (deletion is irreversible once scheduled). Each action calls the corresponding API endpoint using a TanStack Query mutation and invalidates the user query on success.

Strike history: A table of strike records from the strikes array in the API response. Columns: reason, type (badge: strike/suspension/ban), date (decoded from the ULID _id using the ulid package's decodeTime function).


Phase 8: Dockerfiles

8.1 — API Dockerfile

Create api/Dockerfile:

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
RUN mkdir -p /data
EXPOSE 5181
CMD ["node", "dist/index.js"]

Note: argon2 is a native module that requires a build step. The node:22-slim base image includes the necessary build tools. If build issues arise, switch to node:22 (non-slim) for the build stage.

8.2 — Web Dockerfile (Caddy + Coraza WAF)

The web container uses a custom Caddy build with the Coraza WAF plugin compiled in via xcaddy. Coraza provides OWASP Core Rule Set (CRS) protection at the edge, even though this service is only reachable via WireGuard — defense in depth.

Create web/Dockerfile:

FROM node:22-slim AS build-app
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build

FROM caddy:builder AS build-caddy
RUN xcaddy build \
    --with github.com/corazawaf/coraza-caddy/v2

FROM caddy:latest
COPY --from=build-caddy /usr/bin/caddy /usr/bin/caddy
COPY --from=build-app /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
COPY coraza.conf /etc/caddy/coraza.conf
EXPOSE 80

Create web/Caddyfile:

:80 {
	# Coraza WAF — load OWASP CRS rules
	coraza_waf {
		load_owasp_crs
		directives `
			Include /etc/caddy/coraza.conf
			SecRuleEngine On
		`
	}

	# Serve the static SPA
	root * /srv
	file_server

	# SPA fallback — rewrite any path that doesn't match a file to index.html
	# Required for client-side routing (React Router)
	try_files {path} /index.html
}

Create web/coraza.conf for Coraza rule customization:

# Coraza configuration overrides
# Tune false positives here as they arise

# Set paranoia level (1 = low, 4 = max)
SecAction "id:900000, phase:1, pass, t:none, nolog, setvar:tx.blocking_paranoia_level=1"

# Suppress noisy rules that fire on legitimate SPA requests if needed
# SecRuleRemoveById <rule_id>

The try_files {path} /index.html directive in Caddy is the equivalent of nginx's try_files $uri $uri/ /index.html — it ensures that client-side routes like /users/abc123 serve the SPA's index.html instead of returning a 404.

8.3 — Web .dockerignore

Create web/.dockerignore to prevent node_modules/, dist/, and .env* from entering the build context.


Phase 9: Docker Compose & s6 Process Supervision

The admin dashboard runs as its own Podman Compose stack, fully independent from the Stoat application stack. Both stacks are supervised by s6 using a hybrid model: s6 manages stack-level lifecycle (start/stop/restart of entire compose stacks), Podman's restart: unless-stopped handles individual container crashes within a stack, and ad-hoc individual container operations use podman restart <container>. A single systemd unit runs s6-svscan as a persistent service — systemd's only job is keeping s6 alive.

9.1 — Production compose file

Create compose.yml at the repo root. This is the generic, open-source-friendly version. The stoat network is declared as external, meaning Podman expects it to already exist (created by the Stoat stack). Users must verify the actual network name with podman network ls and adjust accordingly.

networks:
  stoat:
    external: true
    name: stoat_default  # Adjust to match your Stoat stack's network name

services:
  admin-api:
    build:
      context: ./api
    image: ghcr.io/OWNER/stoat-admin-api:latest
    restart: unless-stopped
    ports:
      - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_API_PORT:-5181}:5181"
    env_file:
      - .env
    volumes:
      - ./data:/data
    networks:
      - stoat

  admin-web:
    build:
      context: ./web
      args:
        VITE_API_URL: ${ADMIN_WEB_API_URL:-http://127.0.0.1:5181}
    image: ghcr.io/OWNER/stoat-admin-web:latest
    restart: unless-stopped
    ports:
      - "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_WEB_PORT:-5180}:80"
    networks:
      - stoat

The ADMIN_BIND_IP variable defaults to 127.0.0.1 (localhost only). Users deploying with WireGuard set this to their WireGuard interface IP.

The restart: unless-stopped directive in the compose file is intentional and does not conflict with s6. The two supervisors operate at different granularities: Podman restarts individual containers that crash within a running stack, while s6 restarts the podman compose up process if the entire compose session dies. There is no overlap.

Because this is a separate stack from Stoat, depends_on cannot reference Stoat's MongoDB or Redis containers. Startup ordering between stacks is handled by s6 (see 9.3). The admin-api MongoDB connection module (Phase 1.1) must handle connection failures gracefully with exponential backoff retry, since MongoDB may not be ready yet when the admin stack starts.

9.2 — Override example

Create compose.override.example.yml with comments explaining common customizations: changing the network name, binding to a WireGuard IP, setting resource limits.

9.3 — s6 service directories

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:

#!/bin/bash
set -e

# Wait for the Stoat network to exist before starting.
# If it doesn't exist yet, this script exits and s6 restarts it,
# effectively polling until the Stoat stack has initialized the network.
if ! podman network exists stoat_default; then
  sleep 5
  exit 1
fi

cd /srv/stoat-admin
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:

#!/bin/bash
cd /srv/stoat-admin
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:

#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat-admin/

The T directive prefixes each line with a TAI64N timestamp. Logs for each stack are written to their own directory, cleanly separated. Read logs with tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal to get human-readable timestamps. The exec replaces bash with the s6-log process so s6 supervises the logger directly.

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:

#!/bin/bash
set -e
cd /srv/stoat
exec podman compose up 2>&1

deploy/s6/stoat/finish:

#!/bin/bash
cd /srv/stoat
podman compose down

deploy/s6/stoat/log/run:

#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat/

9.4 — systemd unit for s6-svscan

Create deploy/s6/s6-services.service as a reference systemd unit file. This is the single systemd unit that runs the entire s6 supervision tree. All stack lifecycle management happens through s6 tooling, not systemd.

[Unit]
Description=s6 service supervision tree
After=network-online.target podman.socket
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/s6-svscan /etc/s6-services
ExecStop=/usr/bin/s6-svscanctl -t /etc/s6-services
Restart=on-failure
RestartSec=5

[Install]
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.
  4. Copy service directories from deploy/s6/ to /etc/s6-services/, adjust paths and network names.
  5. Ensure all run and finish scripts are executable.
  6. Install the systemd unit: cp deploy/s6/s6-services.service /etc/systemd/system/, then systemctl daemon-reload && systemctl enable --now s6-services.

9.5 — Document common operations

Include a quick-reference section in the README (or a separate OPERATIONS.md) covering the day-to-day s6 commands:

# --- Stack-level operations (via s6) ---

# Restart the entire admin stack
s6-svc -r /etc/s6-services/stoat-admin

# Stop the admin stack (bring down all its containers)
s6-svc -d /etc/s6-services/stoat-admin

# Start the admin stack back up
s6-svc -u /etc/s6-services/stoat-admin

# Check if a stack is running
s6-svstat /etc/s6-services/stoat-admin

# --- Individual container operations (via podman) ---

# Restart just the admin API container without bouncing the web container
podman restart stoat-admin-admin-api-1

# View logs for a specific container
podman logs -f stoat-admin-admin-api-1

# --- Log access ---

# View live s6 logs for the admin stack (with human-readable timestamps)
tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal

# --- Updating images ---

# Pull new images and restart the admin stack
cd /srv/stoat-admin
podman compose pull
s6-svc -r /etc/s6-services/stoat-admin

Phase 10: GitHub Actions CI/CD

10.1 — Build and push workflow

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.

Use docker/login-action for GHCR auth and docker/build-push-action for building and pushing.

10.2 — Lint and type-check workflow

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/.

Phase 11: Documentation

11.1 — README.md

Write a README covering: project description (one paragraph), features list, prerequisites (Stoat instance with invite_only = true, Docker/Podman, s6 for process supervision, WireGuard recommended), quick start guide (clone, copy .env.example, fill in values, run seed script, podman compose up), s6 setup guide (installing s6, creating scan directory, copying service directories, enabling the systemd unit), configuration reference (table of all env vars with descriptions), architecture overview (link to design doc), operations quick-reference (s6 and podman commands for common tasks), development setup (how to run both services locally without Docker or s6), and contributing guidelines.

11.2 — SECURITY.md

Document the security model: WireGuard-only access, session-based auth as defense in depth, no public exposure by design. Include instructions for reporting security issues.


Phase 12: Pre-Release Audit

12.1 — Secrets scan

Before making the repository public, perform a full scan for leaked secrets:

  1. grep -r across the entire repo for: any real domain names, email addresses, IP addresses in the 10.x.x.x range, ULIDs that aren't obviously placeholders, API key prefixes (re_), anything resembling a base64-encoded secret.
  2. Check Git history with: git log --all --diff-filter=A -- '*.env' '*.env.local' 'secrets*' '*.db' '*.sqlite' to verify no sensitive files were ever committed.
  3. Verify that docker inspect on built images does not reveal any secrets baked into layers.

12.2 — License compliance

The Stoat backend is AGPL-3.0. This dashboard connects to Stoat's MongoDB but does not link against or include any Stoat code. AGPL-3.0 for the admin dashboard is a reasonable licensing choice to match the ecosystem, but confirm this is the intent. Add the LICENSE file and SPDX identifiers in package.json.


Task Dependency Graph

The phases can be partially parallelized. Here is the critical path:

Phase 0 (scaffolding)
  ├─→ Phase 1 (DB layer) → Phase 2 (auth) → Phase 3 (invites) ─┐
  │                                         → Phase 4 (users)   ─┤
  │                                         → Phase 5 (server)  ─┤
  └─→ Phase 6 (frontend core) → Phase 7 (views)                 ─┤
                                                                   ↓
                                                     Phase 8 (Docker)
                                                         ↓
                                                     Phase 9 (Compose)
                                                         ↓
                                                     Phase 10 (CI/CD)
                                                         ↓
                                                     Phase 11 (Docs)
                                                         ↓
                                                     Phase 12 (Audit)

Phases 1–5 (backend) and Phase 6–7 (frontend) can be developed in parallel. Phases 8+ are sequential and depend on both tracks being complete.