Merge pull request #1 from JMR-dev/feat-mvp

Feat mvp
This commit was merged in pull request #1.
This commit is contained in:
Jason Ross
2026-04-27 19:37:34 -05:00
committed by GitHub
87 changed files with 7829 additions and 48 deletions
View File
+17
View File
@@ -0,0 +1,17 @@
node_modules/
dist/
.turbo/
.husky/_/
*.db
*.sqlite
.env
.env.local
.env.production
secrets.env
data/
.git/
README.md
compose*.yml
test/
tests/
coverage/
+29
View File
@@ -0,0 +1,29 @@
# MongoDB connection string for the Stoat database.
MONGODB=mongodb://database:27017
# Resend credentials for invite email delivery.
RESEND_API_KEY=re_xxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com
# Express session signing secret. Generate with: openssl rand -base64 32
SESSION_SECRET=
# Stoat instance metadata used in invite emails.
INSTANCE_URL=https://chat.yourdomain.com
INSTANCE_NAME=My Stoat Instance
# Admin API listen port and the HTTPS browser origin allowed by CORS.
ADMIN_API_PORT=5181
ADMIN_WEB_ORIGIN=https://localhost:9443
# Admin hostname terminated by the dedicated Caddy proxy. Use localhost for
# local compose usage and replace it with a real name for deployment.
ADMIN_HOSTNAME=localhost
# Compose-level convenience variables for the dedicated admin proxy. These
# defaults avoid privileged ports locally; set 80/443 in deployment if needed.
ADMIN_BIND_IP=127.0.0.1
ADMIN_HTTP_PORT=9080
ADMIN_HTTPS_PORT=9443
# Optional frontend API base URL override for standalone web builds.
ADMIN_WEB_API_URL=
+51
View File
@@ -0,0 +1,51 @@
name: Build And Push
on:
push:
branches:
- main
tags:
- "v*"
jobs:
build-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./api
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/stoat-admin-api:latest
ghcr.io/${{ github.repository_owner }}/stoat-admin-api:${{ github.sha }}
build-web:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./web
push: true
build-args: |
VITE_API_URL=http://127.0.0.1:5181
tags: |
ghcr.io/${{ github.repository_owner }}/stoat-admin-web:latest
ghcr.io/${{ github.repository_owner }}/stoat-admin-web:${{ github.sha }}
+23
View File
@@ -0,0 +1,23 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --no-frozen-lockfile
- run: pnpm lint
- run: pnpm build
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.turbo/
.husky/_/
*.db
*.sqlite
.env
.env.local
.env.production
secrets.env
data/
coverage/
.DS_Store
+1
View File
@@ -0,0 +1 @@
pnpm exec lint-staged
+8
View File
@@ -0,0 +1,8 @@
.git
.turbo
node_modules
dist
data
*.db
*.sqlite
+5
View File
@@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "none"
}
+6
View File
@@ -0,0 +1,6 @@
SPDX-License-Identifier: AGPL-3.0-only
This repository is intended to be distributed under the GNU Affero General Public
License v3.0 only. Replace this placeholder with the full AGPL-3.0 license text
before release.
+157
View File
@@ -0,0 +1,157 @@
# Stoat Admin
Stoat Admin is a lightweight self-hosted moderation and invite dashboard for a Stoat chat instance. It runs as a separate Podman Compose stack, joins the Stoat network to talk to MongoDB directly, and now ships with a dedicated `admin-proxy` Caddy service that terminates HTTPS for the admin stack with Caddy's internal CA.
## Features
- Single-admin login with Argon2id password hashing and SQLite-backed sessions
- Invite creation, revocation, and acceptance tracking
- User listing, lookup, ban, unban, and scheduled deletion actions
- Dashboard stats for users, pending invites, and recent bans
- `pnpm` workspace with Turborepo coordinating cross-package tasks
- Example Podman Compose, s6 service directories, and GitHub Actions workflows
## Project Layout
```text
.
├── api/ # Express + TypeScript backend
├── proxy/ # Caddy + Coraza reverse proxy image
├── web/ # Vite + React frontend and static image build
├── deploy/s6/ # Example s6 service directories and systemd unit
├── compose.yml # Production-oriented compose file
├── compose.override.example.yml
├── docs/ # Design and task references
└── .env.example
```
## Admin Setup
To create the admin user, run this command inside the admin container
`node dist/seed.js --username admin --password <your-password>`
## Prerequisites
- Node 22+
- `pnpm` via Corepack
- Turborepo is installed through the workspace dependencies
- A Stoat deployment with MongoDB reachable on the shared container network
- `invite_only = true` in Stoat's `Revolt.toml`
- Podman or Docker-compatible compose support
- A hostname for the admin dashboard that resolves on your WireGuard/private network
- A way to trust Caddy's internal root CA on the admin devices that will access the dashboard
## Quick Start
1. Enable `pnpm`:
```sh
corepack enable
```
2. Install dependencies:
```sh
pnpm install
```
3. Copy the environment template and fill in the real values:
```sh
cp .env.example .env
```
4. Seed the admin account from the root workspace:
```sh
pnpm seed -- --username admin --password '<strong-password>'
```
5. Run both packages together through Turborepo:
```sh
pnpm dev
```
Useful targeted variants:
```sh
pnpm dev:api
pnpm dev:web
```
## Configuration
| Variable | Description |
| ------------------- | ------------------------------------------------ |
| `MONGODB` | MongoDB connection string for the Stoat database |
| `RESEND_API_KEY` | Resend API key for invite delivery |
| `RESEND_FROM_EMAIL` | Sender address for invite messages |
| `SESSION_SECRET` | Express session signing secret |
| `INSTANCE_URL` | Public Stoat URL used in invite links |
| `INSTANCE_NAME` | Human-readable instance name used in copy |
| `ADMIN_API_PORT` | Listen port for `admin-api` |
| `ADMIN_WEB_ORIGIN` | Exact HTTPS browser origin allowed by CORS |
| `ADMIN_HOSTNAME` | Hostname served by the dedicated Caddy proxy |
| `ADMIN_BIND_IP` | Compose bind IP for the proxy's published ports |
| `ADMIN_HTTP_PORT` | Published HTTP port for redirect handling |
| `ADMIN_HTTPS_PORT` | Published HTTPS port for the admin dashboard |
| `ADMIN_WEB_API_URL` | Optional frontend API override outside compose |
## Deployment
The repo ships with a standalone `compose.yml` that expects an external `stoat_default` network. Update the network name if your Stoat stack uses a different one.
For local compose use, the proxy defaults to `https://localhost:9443` and `http://localhost:9080`. For deployed hosts, set `ADMIN_HOSTNAME` to the real admin name and switch `ADMIN_HTTP_PORT`/`ADMIN_HTTPS_PORT` to `80`/`443` or use [compose.override.example.yml](/home/jasonross/workspace/stoat-admin/compose.override.example.yml) as a starting point.
The deployment topology is:
- `admin-proxy` is built from [proxy/Dockerfile](/home/jasonross/workspace/stoat-admin/proxy/Dockerfile), publishes the configured HTTP and HTTPS ports, issues a private certificate from Caddy's internal CA, applies Coraza, and reverse-proxies `/api/*` to `admin-api` and everything else to `admin-web`.
- `admin-web` and `admin-api` are no longer published directly on the host.
- `admin-web` serves the built Vite bundle privately on the admin network.
- `admin-api` stays attached to the shared Stoat network for MongoDB access and also joins a private admin network used by the proxy.
Before starting the stack, point `ADMIN_HOSTNAME` at the host running `admin-proxy` on your WireGuard/private network and set `ADMIN_WEB_ORIGIN` to `https://<that-hostname>`.
After the proxy has started once, install Caddy's root CA on each admin device before browsing to the dashboard. One way to export it is:
```sh
docker compose exec admin-proxy sh -c 'cat /data/caddy/pki/authorities/local/root.crt' > admin-proxy-root.crt
```
Then import `admin-proxy-root.crt` into the OS/browser trust store for the devices that should access the dashboard.
For supervised deployments:
1. Install `s6`
2. Copy `deploy/s6/stoat` and `deploy/s6/stoat-admin` into `/etc/s6-services`
3. Adjust service paths and network names
4. Copy `deploy/s6/s6-services.service` into `/etc/systemd/system/`
5. Enable the unit:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now s6-services
```
Common operations:
```sh
s6-svc -r /etc/s6-services/stoat-admin
s6-svc -d /etc/s6-services/stoat-admin
s6-svc -u /etc/s6-services/stoat-admin
s6-svstat /etc/s6-services/stoat-admin
tail -f /var/log/s6/stoat-admin/current | s6-tai64nlocal
```
## Development Notes
- The backend uses SQLite for admin credentials, audit logs, and invite metadata, and MongoDB for Stoat state.
- In the composed deployment, the frontend always uses same-origin `/api` requests through `admin-proxy`; `VITE_API_URL` is only useful outside compose.
- Root task orchestration is handled by Turborepo through [turbo.json](/home/jasonross/workspace/stoat-admin/turbo.json).
- The current repo state is a first implementation slice based on the design docs in [docs/stoat-admin-design.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-design.md) and [docs/stoat-admin-tasks.md](/home/jasonross/workspace/stoat-admin/docs/stoat-admin-tasks.md).
## Contributing
Keep infrastructure-specific values out of committed files. Prefer changes that preserve the split between the standalone admin stack and the main Stoat stack.
+31
View File
@@ -0,0 +1,31 @@
# Security Policy
## Security Model
Stoat Admin is designed to keep the application containers private even when the admin entrypoint is fronted by its own HTTPS proxy. The intended deployment model is:
- `admin-proxy` is the only published service and terminates HTTPS for the admin stack with Caddy's internal CA
- `admin-web` and `admin-api` are reachable only on the private admin container network
- `admin-api` joins the Stoat network only so it can reach MongoDB
- the dashboard is not exposed through the public Stoat reverse proxy
- the API still requires session-based authentication with an Argon2id-hashed admin credential
This means the reverse proxy limits what is exposed, the shared Stoat network is used only where needed, and the application session still limits user access.
## Reporting
If you discover a security issue, avoid opening a public issue with exploit details. Share the report privately with the maintainer and include:
- affected version or commit
- reproduction steps
- impact
- any suggested mitigation
## Deployment Notes
- Keep `SESSION_SECRET` and `RESEND_API_KEY` out of the repository.
- Restrict permissions on the SQLite database file mounted at `/data/admin.db`.
- Set `ADMIN_WEB_ORIGIN` precisely. Do not use `*`.
- Verify the compose port bindings expose only `admin-proxy`, not `admin-web` or `admin-api`.
- Trust Caddy's internal root CA only on the admin devices that should access the dashboard.
- Protect the `admin_proxy_data` volume. It contains the private CA material used to issue the dashboard certificate.
+69
View File
@@ -0,0 +1,69 @@
# Admin Stack
This repository contains the deployment configuration for the Admin interface.
## Prerequisites
1. Same host as Stoat, rootless user with linger.
2. Ansible + podman + WireGuard userspace tools installed.
3. GCP credentials for Secret Manager.
4. Public DNS record for `admin.${DOMAIN}` in Google Cloud DNS pointing to the WG server IP (or no record at all if using `tls internal`).
5. Cloud DNS service account provisioned with `roles/dns.admin` and stored in Secret Manager.
## First Deploy
Run the bootstrap script:
```bash
./scripts/bootstrap.sh
```
## Adding a new WG client
1. Edit `wg_clients` in `ansible/inventory.yml` (or your overriding group_vars).
2. Re-run the wireguard playbook:
```bash
ansible-playbook -i ansible/inventory.yml ansible/wireguard.yml
```
3. Distribute the new client config from `./generated/clients/<name>.conf`.
## Removing a WG client
1. Remove the client from `wg_clients`.
2. Re-run the playbook.
3. Verify in `wg show wg0` that the peer is gone.
## Rotating the WG server key
Rotating the server key is disruptive — every client config must be regenerated and redistributed.
1. Remove the old key from Secret Manager or create a new version.
2. Re-run the wireguard playbook.
## Rotating the Caddy DNS service account key
1. Generate a new key with `gcloud iam service-accounts keys create`.
2. Push to Secret Manager as a new version.
3. Re-run bootstrap step 6 to materialize the key.
4. Restart Caddy (`podman compose restart caddy`).
5. Disable the old key with `gcloud iam service-accounts keys disable` and finally delete after a grace period.
## Redeploy Procedure
1. `podman compose pull`
2. `podman compose up -d`
3. `./scripts/verify.sh`
## Secret Rotation Procedure
1. Update the secret in GCP Secret Manager (e.g. `admin-env`).
2. Materialize the `.env` file again.
3. `podman compose up -d` to recreate containers with the new environment.
## SQLite Backup and Recovery Procedure
Backups are handled by `scripts/sqlite-backup.sh`.
1. To restore, stop the `admin-api` container.
2. Replace the live `admin.db` in the `admin-sqlite` named volume with the snapshot file.
3. Restart the `admin-api` container.
+17
View File
@@ -0,0 +1,17 @@
all:
children:
admin_host:
hosts:
localhost:
ansible_connection: local
vars:
host_public_ip: "192.0.2.1"
wg_subnet: "10.42.0.0/24"
wg_server_ip: "10.42.0.1"
wg_listen_port: 51820
wg_clients:
- name: jason-laptop
ip: "10.42.0.10"
- name: jason-phone
ip: "10.42.0.11"
podman_user: "stoat"
+25
View File
@@ -0,0 +1,25 @@
---
- name: Podman Networks Setup
hosts: admin_host
become: true
become_user: "{{ podman_user }}"
tasks:
- name: Verify linger is enabled
ansible.builtin.command: loginctl show-user {{ podman_user }}
register: linger_check
changed_when: false
failed_when: "'Linger=yes' not in linger_check.stdout"
- name: Admin edge network
containers.podman.podman_network:
name: admin-edge
driver: bridge
subnet: 10.89.20.0/24
internal: false
state: present
- name: Assert stoat-shared exists (Stoat's Ansible owns it)
ansible.builtin.command: podman network inspect stoat-shared
register: shared_check
changed_when: false
failed_when: shared_check.rc != 0
@@ -0,0 +1,11 @@
[Interface]
PrivateKey = {{ client.private_key }}
Address = {{ client.ip }}/24
DNS = 1.1.1.1
[Peer]
PublicKey = {{ wg_server_public_key }}
PresharedKey = {{ client.psk }}
Endpoint = {{ host_public_ip }}:{{ wg_listen_port }}
AllowedIPs = {{ wg_subnet }}
PersistentKeepalive = 25
+13
View File
@@ -0,0 +1,13 @@
[Interface]
PrivateKey = {{ wg_server_private_key }}
Address = {{ wg_server_ip }}/24
ListenPort = {{ wg_listen_port }}
SaveConfig = false
{% for client in wg_clients_enriched %}
[Peer]
# {{ client.name }}
PublicKey = {{ client.public_key }}
PresharedKey = {{ client.psk }}
AllowedIPs = {{ client.ip }}/32
{% endfor %}
+153
View File
@@ -0,0 +1,153 @@
---
- name: WireGuard Host Setup
hosts: admin_host
become: true
tasks:
- name: Ensure wireguard and tools are installed
ansible.builtin.package:
name:
- wireguard-tools
- firewalld
state: present
- name: Ensure firewalld is running and enabled
ansible.builtin.systemd:
name: firewalld
state: started
enabled: true
- name: Open WG UDP port on public zone
ansible.posix.firewalld:
zone: public
port: "{{ wg_listen_port }}/udp"
permanent: true
state: enabled
notify: Reload firewalld
- name: Check if WG server private key exists in Secret Manager
delegate_to: localhost
become: false
ansible.builtin.command: >
gcloud secrets versions access latest --secret=admin-wg-server-key --project={{ lookup('env', 'GCP_PROJECT_ID') }}
register: wg_sm_check
failed_when: false
changed_when: false
- name: Generate WG server private key locally if not in Secret Manager
delegate_to: localhost
become: false
ansible.builtin.command: wg genkey
register: wg_local_gen
when: wg_sm_check.rc != 0
changed_when: true
- name: Create Secret in Secret Manager if missing
delegate_to: localhost
become: false
ansible.builtin.command: >
gcloud secrets create admin-wg-server-key --project={{ lookup('env', 'GCP_PROJECT_ID') }} --replication-policy="automatic"
when: wg_sm_check.rc != 0
failed_when: false
changed_when: false
- name: Push new WG server private key to Secret Manager
delegate_to: localhost
become: false
ansible.builtin.command: >
gcloud secrets versions add admin-wg-server-key
--data-file=-
--project={{ lookup('env', 'GCP_PROJECT_ID') }}
args:
stdin: "{{ wg_local_gen.stdout }}"
when: wg_sm_check.rc != 0
- name: Set server private key variable
ansible.builtin.set_fact:
wg_server_private_key: "{{ wg_sm_check.stdout if wg_sm_check.rc == 0 else wg_local_gen.stdout }}"
no_log: true
- name: Generate server public key
delegate_to: localhost
become: false
ansible.builtin.command: wg pubkey
args:
stdin: "{{ wg_server_private_key }}"
register: wg_server_pub_gen
changed_when: false
- name: Set server public key variable
ansible.builtin.set_fact:
wg_server_public_key: "{{ wg_server_pub_gen.stdout }}"
- name: Ensure /etc/wireguard directory exists
ansible.builtin.file:
path: /etc/wireguard
state: directory
mode: "0700"
- name: Generate client keys
delegate_to: localhost
become: false
ansible.builtin.shell: |
priv=$(wg genkey)
pub=$(echo "$priv" | wg pubkey)
psk=$(wg genpsk)
echo '{"private_key": "'$priv'", "public_key": "'$pub'", "psk": "'$psk'"}'
register: wg_client_keys_gen
with_items: "{{ wg_clients }}"
changed_when: true
no_log: true
- name: Enrich wg_clients with keys
ansible.builtin.set_fact:
wg_clients_enriched: >-
{{
wg_clients_enriched | default([]) +
[item.0 | combine(item.1.stdout | from_json)]
}}
loop: "{{ wg_clients | zip(wg_client_keys_gen.results) | list }}"
no_log: true
- name: Render server wg0.conf
ansible.builtin.template:
src: templates/wg0.conf.j2
dest: /etc/wireguard/wg0.conf
mode: "0600"
notify: Restart wg-quick
- name: Enable and start wg-quick@wg0
ansible.builtin.systemd:
name: wg-quick@wg0
state: started
enabled: true
- name: Ensure client config directory exists on control machine
delegate_to: localhost
become: false
ansible.builtin.file:
path: "{{ playbook_dir }}/../generated/clients"
state: directory
mode: "0700"
- name: Render client configs on control machine
delegate_to: localhost
become: false
ansible.builtin.template:
src: templates/client.conf.j2
dest: "{{ playbook_dir }}/../generated/clients/{{ item.name }}.conf"
mode: "0600"
loop: "{{ wg_clients_enriched }}"
vars:
client: "{{ item }}"
no_log: true
handlers:
- name: Reload firewalld
ansible.builtin.systemd:
name: firewalld
state: reloaded
- name: Restart wg-quick
ansible.builtin.systemd:
name: wg-quick@wg0
state: restarted
+20
View File
@@ -0,0 +1,20 @@
{
email {$ACME_EMAIL}
}
admin.{$DOMAIN} {
tls {
dns googleclouddns {
gcp_project {$GCP_PROJECT_ID}
gcp_application_default /etc/caddy/credentials/sa.json
}
}
handle /api/* {
reverse_proxy admin-api:3000
}
handle {
reverse_proxy admin-frontend:3000
}
}
+6
View File
@@ -0,0 +1,6 @@
FROM caddy:2.8.4-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/googleclouddns
FROM caddy:2.8.4
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+59
View File
@@ -0,0 +1,59 @@
networks:
admin-edge:
external: true
stoat-shared:
external: true
volumes:
admin-sqlite:
caddy-data:
caddy-config:
services:
caddy:
build: ./caddy
ports:
- "${WG_SERVER_IP}:80:80"
- "${WG_SERVER_IP}:443:443"
volumes:
- caddy-data:/data
- caddy-config:/config
- ./secrets/caddy-dns-sa.json:/etc/caddy/credentials/sa.json:ro
environment:
GCP_PROJECT_ID: ${GCP_PROJECT_ID}
ACME_EMAIL: ${ACME_EMAIL}
DOMAIN: ${DOMAIN}
networks:
- admin-edge
restart: unless-stopped
admin-frontend:
image: ${ADMIN_FRONTEND_IMAGE}
environment:
- API_URL=http://admin-api:3000
networks:
- admin-edge
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
admin-api:
image: ${ADMIN_API_IMAGE}
volumes:
- admin-sqlite:/data/db
environment:
- SQLITE_DB_PATH=/data/db/admin.db
- MONGO_URL=mongodb://admin_stack_ro:${ADMIN_STACK_DB_PASSWORD}@mongodb:27017/revolt
- SESSION_SECRET=${SESSION_SECRET}
networks:
- admin-edge
- stoat-shared
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== 1. Verifying rootless podman ==="
PODMAN_USER=$(whoami)
if ! loginctl show-user ${PODMAN_USER} | grep -q "Linger=yes"; then
echo "Error: Linger is not enabled for user ${PODMAN_USER}"
exit 1
fi
echo "=== 2. Ansible: WireGuard ==="
ansible-playbook -i ansible/inventory.yml ansible/wireguard.yml
echo "Client configs generated at: $(realpath ./ansible/../generated/clients)"
echo "Please securely copy these to your client devices."
echo "=== 3. Verify wg0 ==="
if ! wg show wg0 >/dev/null 2>&1; then
echo "Error: wg0 interface is not up"
exit 1
fi
echo "=== 4. Ansible: Networks ==="
ansible-playbook -i ansible/inventory.yml ansible/networks.yml
echo "=== 5. Materialize .env ==="
gcloud secrets versions access latest --secret=admin-env > .env
chmod 600 .env
echo "=== 6. Materialize Caddy SA Key ==="
mkdir -p ./secrets
gcloud secrets versions access latest --secret=admin-caddy-dns-sa-key > ./secrets/caddy-dns-sa.json
chmod 600 ./secrets/caddy-dns-sa.json
echo "=== 7. Podman Compose Build ==="
podman compose build
echo "=== 8. Podman Compose Pull ==="
podman compose pull
echo "=== 9. Podman Compose Up ==="
podman compose up -d
echo "=== 10. Wait for services ==="
echo "Waiting up to 120s for services to become healthy..."
sleep 10 # Let them start
echo "=== 11. Verify ==="
./scripts/verify.sh
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SRC_VOLUME="admin-sqlite"
DEST_DIR="/var/backups/admin-sqlite"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST_FILE="${DEST_DIR}/admin-${TIMESTAMP}.sqlite"
mkdir -p "${DEST_DIR}"
# Use sqlite3 .backup for an atomic snapshot
podman run --rm \
-v "${SRC_VOLUME}:/data:ro" \
-v "${DEST_DIR}:/out" \
docker.io/keinos/sqlite3:3.42.0 \
sqlite3 /data/admin.db ".backup '/out/admin-${TIMESTAMP}.sqlite'"
# Retain last 7 snapshots locally
ls -1t "${DEST_DIR}"/admin-*.sqlite | tail -n +8 | xargs -r rm
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
WG_SERVER_IP="${WG_SERVER_IP:-10.42.0.1}"
HOST_PUBLIC_IP=$(curl -s ifconfig.me || echo "127.0.0.1")
echo "Running verification checks..."
# 1. WG interface up
if wg show wg0 >/dev/null 2>&1 && wg show wg0 peers | grep -q .; then
echo "[ok] WG interface wg0 is up and has peers"
else
echo "[fail] WG interface wg0 is down or has no peers"
exit 1
fi
# 2. WG IP bound
if ip -o addr show wg0 | grep -q "${WG_SERVER_IP}"; then
echo "[ok] WG interface wg0 bound to ${WG_SERVER_IP}"
else
echo "[fail] WG interface wg0 is not bound to ${WG_SERVER_IP}"
exit 1
fi
# 3. Caddy listening on WG IP, NOT public IP
if ss -tlnp | grep -E ':443\b' | grep -q "${WG_SERVER_IP}"; then
if ss -tlnp | grep -E ':443\b' | grep -q -E "0\.0\.0\.0|::|\*"; then
echo "[fail] Caddy is bound to public IP"
exit 1
else
echo "[ok] Caddy is bound only to WG IP"
fi
else
echo "[fail] Caddy is not bound to ${WG_SERVER_IP}:443"
exit 1
fi
# 4. Admin endpoint NOT reachable from public
if curl --max-time 3 -k https://${HOST_PUBLIC_IP}/ >/dev/null 2>&1; then
echo "[fail] Admin endpoint is reachable from public IP"
exit 1
else
echo "[ok] Admin endpoint is not reachable from public IP"
fi
# 5. Networks present
if podman network inspect admin-edge >/dev/null 2>&1 && podman network inspect stoat-shared >/dev/null 2>&1; then
echo "[ok] Podman networks admin-edge and stoat-shared exist"
else
echo "[fail] Required podman networks are missing"
exit 1
fi
# 6. All expected services healthy
SERVICES=("admin-stack-caddy-1" "admin-stack-admin-frontend-1" "admin-stack-admin-api-1")
for service in "${SERVICES[@]}"; do
if podman ps --format "{{.Names}}" | grep -q "${service}"; then
echo "[ok] Service ${service} is running"
else
echo "[fail] Service ${service} is not running"
exit 1
fi
done
# 7. admin-api can reach MongoDB
API_CONTAINER=$(podman ps -q -f name=admin-stack-admin-api-1)
if podman exec "${API_CONTAINER}" curl -s http://localhost:3000/health >/dev/null 2>&1; then
echo "[ok] admin-api healthcheck passed"
else
echo "[fail] admin-api healthcheck failed"
exit 1
fi
# 8. No unexpected host ports bound
if podman ps --format '{{.Ports}}' | grep -v "${WG_SERVER_IP}" | grep -q ":"; then
echo "[fail] Unexpected ports bound"
podman ps --format '{{.Names}}: {{.Ports}}'
exit 1
else
echo "[ok] No unexpected host ports bound"
fi
echo "All checks passed!"
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.turbo/
.env
.env.*
secrets.env
*.db
*.db-*
*.sqlite
*.sqlite*
data/
coverage/
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
FROM node:24-slim AS build
WORKDIR /app
COPY package.json ./
RUN corepack enable && pnpm install --frozen-lockfile=false
COPY tsconfig.json ./
COPY src/ ./src/
RUN pnpm build
FROM node:24-slim
WORKDIR /app
COPY package.json ./
RUN corepack enable && pnpm install --prod --frozen-lockfile=false
COPY --from=build /app/dist ./dist
RUN mkdir -p /data
EXPOSE 5181
CMD ["node", "dist/index.js"]
+37
View File
@@ -0,0 +1,37 @@
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
export default [
{
ignores: ["dist/**", "node_modules/**"]
},
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
globals: {
...globals.node
}
},
plugins: {
"@typescript-eslint": tsPlugin
},
rules: {
...tsPlugin.configs.recommended.rules,
"no-undef": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_"
}
]
}
}
];
+58
View File
@@ -0,0 +1,58 @@
{
"name": "stoat-admin-api",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.6.3",
"pnpm": {
"onlyBuiltDependencies": [
"argon2",
"better-sqlite3",
"esbuild"
]
},
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.json",
"check": "pnpm lint && pnpm typecheck",
"start": "node dist/index.js",
"seed": "tsx src/seed.ts",
"lint": "eslint ."
},
"dependencies": {
"argon2": "^0.44.0",
"better-sqlite3": "^12.8.0",
"better-sqlite3-session-store": "^0.1.0",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-session": "^1.18.1",
"helmet": "^8.0.0",
"mongodb": "^7.1.1",
"nanoid": "^5.1.5",
"node-cron": "^4.0.7",
"resend": "^6.10.0",
"ulid": "^3.0.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/node": "24.12.0",
"@types/node-cron": "^3.0.11",
"eslint": "^10.1.0",
"globals": "^17.4.0",
"tsx": "^4.19.2",
"typescript": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { sqlite } from "./sqlite.js";
const insertAuditLog = sqlite.prepare<[string, string, string | null]>(
"INSERT INTO audit_log (action, target, details) VALUES (?, ?, ?)"
);
export function logAction(
action: string,
target: string,
details?: Record<string, unknown>
): void {
insertAuditLog.run(action, target, details ? JSON.stringify(details) : null);
}
+80
View File
@@ -0,0 +1,80 @@
import { MongoClient, type Collection, type Db } from "mongodb";
import { env } from "../lib/env.js";
import type {
AccountDocument,
InviteDocument,
SessionDocument,
StrikeDocument,
UserDocument
} from "./types.js";
let client: MongoClient | null = null;
let db: Db | null = null;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function connectMongo(): Promise<Db> {
if (db) {
return db;
}
let attempt = 0;
for (;;) {
try {
client = new MongoClient(env.MONGODB);
await client.connect();
db = client.db("revolt");
return db;
} catch (error) {
const waitMs = Math.min(1000 * 2 ** attempt, 30_000);
attempt += 1;
console.error(
`MongoDB connection failed. Retrying in ${waitMs}ms.`,
error
);
await delay(waitMs);
}
}
}
export function getDb(): Db {
if (!db) {
throw new Error("MongoDB has not been connected yet");
}
return db;
}
export function accounts(): Collection<AccountDocument> {
return getDb().collection<AccountDocument>("accounts");
}
export function users(): Collection<UserDocument> {
return getDb().collection<UserDocument>("users");
}
export function sessions(): Collection<SessionDocument> {
return getDb().collection<SessionDocument>("sessions");
}
export function invites(): Collection<InviteDocument> {
return getDb().collection<InviteDocument>("invites");
}
export function safetyStrikes(): Collection<StrikeDocument> {
return getDb().collection<StrikeDocument>("safety_strikes");
}
export async function closeMongo(): Promise<void> {
if (client) {
await client.close();
client = null;
db = null;
}
}
+114
View File
@@ -0,0 +1,114 @@
import { existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import type { AdminUserRecord, InviteRecord } from "./types.js";
// Keep local development aligned with the compose-mounted ./data directory.
const workspaceSqlitePath = resolve(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
"data",
"admin.db"
);
function resolveSqlitePath(): string {
if (existsSync("/data")) {
return "/data/admin.db";
}
return workspaceSqlitePath;
}
export const sqlitePath = resolveSqlitePath();
mkdirSync(dirname(sqlitePath), { recursive: true });
export const sqlite = new Database(sqlitePath);
sqlite.pragma("journal_mode = WAL");
sqlite.exec(`
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 const statements = {
getAdminUserByUsername: sqlite.prepare<[string], AdminUserRecord>(
"SELECT id, username, password_hash FROM admin_user WHERE username = ?"
),
getFirstAdminUser: sqlite.prepare<[], AdminUserRecord>(
"SELECT id, username, password_hash FROM admin_user ORDER BY id ASC LIMIT 1"
),
insertAdminUser: sqlite.prepare<[string, string]>(
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)"
),
updateAdminPasswordByUsername: sqlite.prepare<[string, string]>(
"UPDATE admin_user SET password_hash = ? WHERE username = ?"
),
listInviteRecords: sqlite.prepare<[], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
ORDER BY created_at DESC`
),
countInviteRecords: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM invite_records"
),
countPendingInvites: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM invite_records WHERE status = 'pending'"
),
insertInviteRecord: sqlite.prepare<[string, string, string | null]>(
"INSERT INTO invite_records (code, email, status, expires_at) VALUES (?, ?, 'pending', ?)"
),
getInviteRecordByCode: sqlite.prepare<[string], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
WHERE code = ?`
),
updateInviteResendMessage: sqlite.prepare<[string | null, string]>(
"UPDATE invite_records SET resend_message_id = ? WHERE code = ?"
),
markInviteRevoked: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'revoked' WHERE code = ?"
),
selectPendingInvites: sqlite.prepare<[], InviteRecord>(
`SELECT id, code, email, status, created_at, expires_at, accepted_at, resend_message_id
FROM invite_records
WHERE status = 'pending'
ORDER BY created_at DESC`
),
markInviteAccepted: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'accepted', accepted_at = datetime('now') WHERE code = ?"
),
markInviteExpired: sqlite.prepare<[string]>(
"UPDATE invite_records SET status = 'expired' WHERE code = ?"
),
countRecentBans: sqlite.prepare<[], { count: number }>(
"SELECT COUNT(*) AS count FROM audit_log WHERE action = 'user_banned' AND created_at > datetime('now', '-30 days')"
)
};
+70
View File
@@ -0,0 +1,70 @@
export interface AccountDocument {
_id: string;
email: string;
email_normalised?: string;
disabled: boolean;
spam?: boolean;
verification?: {
status: "Verified" | "Pending" | "Moving";
};
deletion?: {
status: "Scheduled" | "WaitingForVerification" | "Deleted";
after?: string;
};
lockout?: {
attempts: number;
expiry: string;
};
}
export interface UserDocument {
_id: string;
username: string;
discriminator: string;
flags?: number;
avatar?: unknown;
}
export interface SessionDocument {
_id: string;
user_id: string;
}
export interface InviteDocument {
_id: string;
}
export interface StrikeDocument {
_id: string;
user_id: string;
reason: string;
type?: "strike" | "suspension" | "ban";
case_id?: string;
}
export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired";
export interface InviteRecord {
id: number;
code: string;
email: string;
status: InviteRecordStatus;
created_at: string;
expires_at: string | null;
accepted_at: string | null;
resend_message_id: string | null;
}
export interface AuditLogRecord {
id: number;
action: string;
target: string;
details: string | null;
created_at: string;
}
export interface AdminUserRecord {
id: number;
username: string;
password_hash: string;
}
+66
View File
@@ -0,0 +1,66 @@
import "dotenv/config";
import cors from "cors";
import express from "express";
import helmet from "helmet";
import cron from "node-cron";
import "./db/sqlite.js";
import { connectMongo } from "./db/mongo.js";
import { syncInviteStatuses } from "./jobs/inviteSync.js";
import { env } from "./lib/env.js";
import { errorHandler, notFound } from "./middleware/errors.js";
import { requireAuth, sessionMiddleware } from "./middleware/auth.js";
import { authRouter } from "./routes/auth.js";
import { dashboardRouter } from "./routes/dashboard.js";
import { invitesRouter } from "./routes/invites.js";
import { usersRouter } from "./routes/users.js";
async function main(): Promise<void> {
await connectMongo();
const app = express();
app.set("trust proxy", 1);
app.use(
helmet({
crossOriginResourcePolicy: false
})
);
app.use(
cors({
origin: env.ADMIN_WEB_ORIGIN,
credentials: true
})
);
app.use(express.json());
app.use(sessionMiddleware);
app.get("/api/health", (_req, res) => {
res.status(200).json({ ok: true });
});
app.use("/api/auth", authRouter);
app.use("/api/invites", requireAuth, invitesRouter);
app.use("/api/users", requireAuth, usersRouter);
app.use("/api/dashboard", requireAuth, dashboardRouter);
app.use(notFound);
app.use(errorHandler);
await syncInviteStatuses();
cron.schedule("*/5 * * * *", () => {
void syncInviteStatuses().catch((error) => {
console.error("Invite sync failed", error);
});
});
app.listen(env.ADMIN_API_PORT, () => {
console.log(`admin-api listening on :${env.ADMIN_API_PORT}`);
});
}
void main().catch((error) => {
console.error("Failed to start admin-api", error);
process.exit(1);
});
+23
View File
@@ -0,0 +1,23 @@
import { invites } from "../db/mongo.js";
import { statements } from "../db/sqlite.js";
export async function syncInviteStatuses(): Promise<void> {
const pendingInvites = statements.selectPendingInvites.all();
for (const record of pendingInvites) {
const inviteExists = await invites().findOne({ _id: record.code });
if (!inviteExists) {
statements.markInviteAccepted.run(record.code);
continue;
}
if (
record.expires_at &&
new Date(record.expires_at).getTime() < Date.now()
) {
statements.markInviteExpired.run(record.code);
await invites().deleteOne({ _id: record.code });
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";
type AsyncRouteHandler = (
req: Request,
res: Response,
next: NextFunction
) => Promise<void>;
export function asyncHandler(handler: AsyncRouteHandler): RequestHandler {
return (req, res, next) => {
void handler(req, res, next).catch(next);
};
}
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
MONGODB: z.string().min(1, "MONGODB is required"),
RESEND_API_KEY: z.string().min(1, "RESEND_API_KEY is required"),
RESEND_FROM_EMAIL: z
.string()
.email("RESEND_FROM_EMAIL must be a valid email"),
SESSION_SECRET: z
.string()
.min(32, "SESSION_SECRET must be at least 32 characters"),
INSTANCE_URL: z.string().url("INSTANCE_URL must be a valid URL"),
INSTANCE_NAME: z.string().min(1, "INSTANCE_NAME is required"),
ADMIN_API_PORT: z.coerce.number().int().positive().default(5181),
ADMIN_WEB_ORIGIN: z.string().url("ADMIN_WEB_ORIGIN must be a valid URL")
});
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
console.error("Invalid environment configuration.");
console.error(JSON.stringify(parsedEnv.error.flatten().fieldErrors, null, 2));
throw new Error("Environment validation failed");
}
export const env = parsedEnv.data;
+10
View File
@@ -0,0 +1,10 @@
export const USER_FLAG_SUSPENDED = 1;
export const USER_FLAG_DELETED = 2;
export const USER_FLAG_BANNED = 4;
export function hasFlag(
flags: number | null | undefined,
mask: number
): boolean {
return ((flags ?? 0) & mask) === mask;
}
+44
View File
@@ -0,0 +1,44 @@
import session from "express-session";
import connectSqlite3 from "better-sqlite3-session-store";
import type { RequestHandler } from "express";
import { env } from "../lib/env.js";
import { sqlite } from "../db/sqlite.js";
const SQLiteStore = connectSqlite3(session);
export const SESSION_COOKIE_NAME = "stoat-admin.sid";
const SESSION_COOKIE_SECURE =
new URL(env.ADMIN_WEB_ORIGIN).protocol === "https:";
export const SESSION_COOKIE_OPTIONS = {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: SESSION_COOKIE_SECURE
} as const;
export const sessionMiddleware = session({
name: SESSION_COOKIE_NAME,
secret: env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new SQLiteStore({
client: sqlite,
expired: {
clear: true,
intervalMs: 15 * 60 * 1000
}
}),
cookie: {
...SESSION_COOKIE_OPTIONS,
maxAge: 2 * 60 * 60 * 1000
}
});
export const requireAuth: RequestHandler = (req, res, next) => {
if (!req.session.userId) {
res.status(401).json({ error: "Not authenticated" });
return;
}
next();
};
+10
View File
@@ -0,0 +1,10 @@
import type { ErrorRequestHandler, RequestHandler } from "express";
export const notFound: RequestHandler = (_req, res) => {
res.status(404).json({ error: "Not found" });
};
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
console.error(error);
res.status(500).json({ error: "Internal server error" });
};
+74
View File
@@ -0,0 +1,74 @@
import argon2 from "argon2";
import { Router } from "express";
import { z } from "zod";
import { statements } from "../db/sqlite.js";
import { asyncHandler } from "../lib/async-handler.js";
import {
requireAuth,
SESSION_COOKIE_NAME,
SESSION_COOKIE_OPTIONS
} from "../middleware/auth.js";
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1)
});
export const authRouter = Router();
authRouter.post(
"/login",
asyncHandler(async (req, res) => {
const credentials = loginSchema.parse(req.body);
const user = statements.getAdminUserByUsername.get(credentials.username);
if (!user) {
res.status(401).json({ error: "Invalid username or password" });
return;
}
const isValid = await argon2.verify(
user.password_hash,
credentials.password
);
if (!isValid) {
res.status(401).json({ error: "Invalid username or password" });
return;
}
req.session.userId = user.id;
req.session.username = user.username;
res.status(200).json({ username: user.username });
})
);
authRouter.post(
"/logout",
requireAuth,
asyncHandler(async (req, res) => {
await new Promise<void>((resolve, reject) => {
req.session.destroy((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
res.clearCookie(SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS);
res.status(200).json({ success: true });
})
);
authRouter.get(
"/me",
requireAuth,
asyncHandler(async (req, res) => {
res.status(200).json({ username: req.session.username });
})
);
+34
View File
@@ -0,0 +1,34 @@
import { Router } from "express";
import { users } from "../db/mongo.js";
import { statements } from "../db/sqlite.js";
import { asyncHandler } from "../lib/async-handler.js";
import { USER_FLAG_BANNED } from "../lib/flags.js";
export const dashboardRouter = Router();
dashboardRouter.get(
"/stats",
asyncHandler(async (_req, res) => {
const [totalUsers, bannedUserAggregate] = await Promise.all([
users().countDocuments({}),
users()
.aggregate([
{ $match: { flags: { $bitsAllSet: USER_FLAG_BANNED } } },
{ $count: "count" }
])
.toArray()
]);
const pendingInvites = statements.countPendingInvites.get()?.count ?? 0;
const recentBans = statements.countRecentBans.get()?.count ?? 0;
const bannedUsers = bannedUserAggregate[0]?.count ?? 0;
res.status(200).json({
totalUsers,
bannedUsers,
pendingInvites,
recentBans
});
})
);
+116
View File
@@ -0,0 +1,116 @@
import { Router } from "express";
import { customAlphabet } from "nanoid";
import { Resend } from "resend";
import { z } from "zod";
import { logAction } from "../db/audit.js";
import { invites } from "../db/mongo.js";
import { statements } from "../db/sqlite.js";
import type { InviteRecord } from "../db/types.js";
import { asyncHandler } from "../lib/async-handler.js";
import { env } from "../lib/env.js";
const inviteAlphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const generateCode = customAlphabet(inviteAlphabet, 12);
const resend = new Resend(env.RESEND_API_KEY);
const createInviteSchema = z.object({
email: z.string().email(),
expiresInHours: z.coerce
.number()
.int()
.positive()
.max(24 * 365)
.optional()
});
function getInviteRecordOrThrow(code: string): InviteRecord {
const record = statements.getInviteRecordByCode.get(code);
if (!record) {
throw new Error(`Invite record ${code} not found after insert`);
}
return record;
}
export const invitesRouter = Router();
invitesRouter.get(
"/",
asyncHandler(async (_req, res) => {
const inviteRecords = statements.listInviteRecords.all();
const count =
statements.countInviteRecords.get()?.count ?? inviteRecords.length;
res.status(200).json({ invites: inviteRecords, count });
})
);
invitesRouter.post(
"/",
asyncHandler(async (req, res) => {
const payload = createInviteSchema.parse(req.body);
const code = generateCode();
const expiresAt = payload.expiresInHours
? new Date(
Date.now() + payload.expiresInHours * 60 * 60 * 1000
).toISOString()
: null;
await invites().insertOne({ _id: code });
statements.insertInviteRecord.run(code, payload.email, expiresAt);
let warning: string | undefined;
try {
const response = await resend.emails.send({
from: env.RESEND_FROM_EMAIL,
to: payload.email,
subject: `You've been invited to ${env.INSTANCE_NAME}`,
text: `You've been invited to ${env.INSTANCE_NAME}.\n\nUse this invite link to register:\n${env.INSTANCE_URL}?invite=${code}`
});
const messageId = response.data?.id ?? null;
statements.updateInviteResendMessage.run(messageId, code);
} catch (error) {
console.error("Invite email delivery failed", error);
warning = "Invite created but email delivery failed";
}
logAction("invite_created", payload.email, {
code,
expires_at: expiresAt
});
const record = getInviteRecordOrThrow(code);
res.status(201).json({
invite: record,
...(warning ? { warning } : {})
});
})
);
invitesRouter.delete(
"/:code",
asyncHandler(async (req, res) => {
const { code } = z.object({ code: z.string().min(1) }).parse(req.params);
const record = statements.getInviteRecordByCode.get(code);
if (!record) {
res.status(404).json({ error: "Invite not found" });
return;
}
if (record.status !== "pending") {
res.status(400).json({ error: "Only pending invites can be revoked" });
return;
}
await invites().deleteOne({ _id: code });
statements.markInviteRevoked.run(code);
logAction("invite_revoked", record.email, { code });
res.status(200).json({ success: true });
})
);
+241
View File
@@ -0,0 +1,241 @@
import { Router } from "express";
import { ulid } from "ulid";
import { z } from "zod";
import { logAction } from "../db/audit.js";
import { accounts, safetyStrikes, sessions, users } from "../db/mongo.js";
import { asyncHandler } from "../lib/async-handler.js";
import { USER_FLAG_BANNED, USER_FLAG_DELETED } from "../lib/flags.js";
const listUsersSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
search: z.string().trim().optional()
});
const userIdParamsSchema = z.object({
id: z.string().min(1)
});
const banSchema = z.object({
reason: z.string().trim().min(1)
});
const deleteSchema = z.object({
reason: z.string().trim().optional()
});
export const usersRouter = Router();
usersRouter.get(
"/",
asyncHandler(async (req, res) => {
const { page, limit, search } = listUsersSchema.parse(req.query);
const basePipeline = [
{
$lookup: {
from: "accounts",
localField: "_id",
foreignField: "_id",
as: "account",
pipeline: [
{
$project: {
email: 1,
disabled: 1,
verification: 1,
deletion: 1
}
}
]
}
},
{
$unwind: {
path: "$account",
preserveNullAndEmptyArrays: true
}
}
];
const searchStage = search
? [
{
$match: {
"account.email": {
$regex: search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
$options: "i"
}
}
}
]
: [];
const recordsPipeline = [
...basePipeline,
...searchStage,
{ $sort: { username: 1, discriminator: 1, _id: 1 } },
{ $skip: (page - 1) * limit },
{ $limit: limit },
{
$project: {
_id: 1,
username: 1,
discriminator: 1,
flags: 1,
avatar: 1,
account: 1
}
}
];
const totalPipeline = [
...basePipeline,
...searchStage,
{ $count: "total" }
];
const [userRecords, totalResult] = await Promise.all([
users().aggregate(recordsPipeline).toArray(),
users().aggregate(totalPipeline).toArray()
]);
res.status(200).json({
users: userRecords,
total: totalResult[0]?.total ?? 0,
page,
limit
});
})
);
usersRouter.get(
"/:id",
asyncHandler(async (req, res) => {
const { id } = userIdParamsSchema.parse(req.params);
const [user, account, strikes] = await Promise.all([
users().findOne({ _id: id }),
accounts().findOne({ _id: id }),
safetyStrikes().find({ user_id: id }).sort({ _id: -1 }).toArray()
]);
if (!user && !account) {
res.status(404).json({ error: "User not found" });
return;
}
res.status(200).json({ user, account, strikes });
})
);
usersRouter.post(
"/:id/ban",
asyncHandler(async (req, res) => {
const { id } = userIdParamsSchema.parse(req.params);
const { reason } = banSchema.parse(req.body);
const [user, account] = await Promise.all([
users().findOne({ _id: id }),
accounts().findOne({ _id: id })
]);
if (!user || !account) {
res.status(404).json({ error: "User not found" });
return;
}
if (account.disabled) {
res.status(400).json({ error: "User is already banned" });
return;
}
await Promise.all([
accounts().updateOne({ _id: id }, { $set: { disabled: true } }),
users().updateOne(
{ _id: id },
{ $set: { flags: (user.flags ?? 0) | USER_FLAG_BANNED } }
),
sessions().deleteMany({ user_id: id }),
safetyStrikes().insertOne({
_id: ulid(),
user_id: id,
reason,
type: "ban"
})
]);
logAction("user_banned", id, { reason });
res.status(200).json({ success: true });
})
);
usersRouter.post(
"/:id/unban",
asyncHandler(async (req, res) => {
const { id } = userIdParamsSchema.parse(req.params);
const [user, account] = await Promise.all([
users().findOne({ _id: id }),
accounts().findOne({ _id: id })
]);
if (!account || !user) {
res.status(404).json({ error: "User not found" });
return;
}
if (!account.disabled) {
res.status(400).json({ error: "User is not banned" });
return;
}
await Promise.all([
accounts().updateOne({ _id: id }, { $set: { disabled: false } }),
users().updateOne(
{ _id: id },
{ $set: { flags: (user.flags ?? 0) & ~USER_FLAG_BANNED } }
)
]);
logAction("user_unbanned", id);
res.status(200).json({ success: true });
})
);
usersRouter.delete(
"/:id",
asyncHandler(async (req, res) => {
const { id } = userIdParamsSchema.parse(req.params);
const { reason } = deleteSchema.parse(req.body ?? {});
const user = await users().findOne({ _id: id });
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
await Promise.all([
accounts().updateOne(
{ _id: id },
{
$set: {
deletion: {
status: "Scheduled",
after: new Date().toISOString()
}
}
}
),
users().updateOne(
{ _id: id },
{ $set: { flags: (user.flags ?? 0) | USER_FLAG_DELETED } }
),
sessions().deleteMany({ user_id: id })
]);
logAction("user_deleted", id, reason ? { reason } : undefined);
res.status(200).json({ success: true });
})
);
+100
View File
@@ -0,0 +1,100 @@
import "dotenv/config";
import argon2 from "argon2";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { parseArgs } from "node:util";
import { statements } from "./db/sqlite.js";
type SeedArgs = {
username?: string;
password?: string;
"reset-password"?: boolean;
};
async function promptForMissing(
args: SeedArgs
): Promise<{ username: string; password: string }> {
const readline = createInterface({ input, output });
try {
const username = args.username ?? (await readline.question("Username: "));
const password = args.password ?? (await readline.question("Password: "));
return {
username: username.trim(),
password: password.trim()
};
} finally {
readline.close();
}
}
async function main(): Promise<void> {
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
const parsed = parseArgs({
args: normalizedArgs,
options: {
username: {
type: "string"
},
password: {
type: "string"
},
"reset-password": {
type: "boolean",
default: false
}
}
});
const args = parsed.values as SeedArgs;
const existingUser = statements.getFirstAdminUser.get();
if (existingUser && !args["reset-password"]) {
console.error(
"An admin user already exists. Use --reset-password to update it."
);
process.exit(1);
}
const { username, password } = await promptForMissing({
username: args.username ?? existingUser?.username,
password: args.password,
"reset-password": args["reset-password"]
});
if (!username || !password) {
console.error("Username and password are required.");
process.exit(1);
}
const passwordHash = await argon2.hash(password, {
type: argon2.argon2id
});
if (args["reset-password"]) {
if (!existingUser) {
console.error(
"No admin user exists yet. Run the seed script without --reset-password first."
);
process.exit(1);
}
const targetUsername = existingUser?.username ?? username;
statements.updateAdminPasswordByUsername.run(passwordHash, targetUsername);
console.log(`Password updated for ${targetUsername}.`);
return;
}
statements.insertAdminUser.run(username, passwordHash);
console.log(`Admin user ${username} created.`);
}
void main().catch((error) => {
console.error(error);
process.exit(1);
});
+19
View File
@@ -0,0 +1,19 @@
declare module "better-sqlite3-session-store" {
import type session from "express-session";
interface SqliteStoreOptions {
client: unknown;
expired?: {
clear?: boolean;
intervalMs?: number;
};
}
interface SqliteStoreConstructor {
new (options: SqliteStoreOptions): session.Store;
}
export default function connectSqlite3(
sessionModule: typeof session
): SqliteStoreConstructor;
}
+10
View File
@@ -0,0 +1,10 @@
import "express-session";
declare module "express-session" {
interface SessionData {
userId?: number;
username?: string;
}
}
export {};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*"]
}
+21
View File
@@ -0,0 +1,21 @@
services:
admin-proxy:
environment:
ADMIN_HOSTNAME: admin.example.com
ADMIN_HTTP_PORT: 80
ADMIN_HTTPS_PORT: 443
ports:
- "10.0.0.1:80:80"
- "10.0.0.1:443:443"
admin-api:
environment:
ADMIN_API_PORT: 5181
ADMIN_WEB_ORIGIN: https://admin.example.com
# Common customizations:
# - Change the external network name in compose.yml if your Stoat stack uses a different name.
# - Bind admin-proxy to the interface IP that should answer ports 80/443.
# - Use ADMIN_HTTP_PORT/ADMIN_HTTPS_PORT=9080/9443 for local compose HTTPS without privileged ports.
# - Ensure ADMIN_HOSTNAME resolves to that IP on your WireGuard/private network.
# - Add resource limits or alternate image tags per deployment.
+56
View File
@@ -0,0 +1,56 @@
volumes:
admin_proxy_data:
admin_proxy_config:
networks:
stoat:
external: true
name: stoat_default
admin:
services:
admin-api:
build:
context: ./api
image: ghcr.io/owner/stoat-admin-api:latest
restart: unless-stopped
expose:
- "${ADMIN_API_PORT:-5181}"
volumes:
- ./data:/data
- ./.env:/app/.env:ro
networks:
- admin
- stoat
admin-web:
build:
context: ./web
image: ghcr.io/owner/stoat-admin-web:latest
restart: unless-stopped
expose:
- "80"
networks:
- admin
admin-proxy:
build:
context: ./proxy
image: ghcr.io/owner/stoat-admin-proxy:latest
restart: unless-stopped
depends_on:
- admin-api
- admin-web
environment:
ADMIN_API_PORT: ${ADMIN_API_PORT:-5181}
ADMIN_HOSTNAME: ${ADMIN_HOSTNAME:-localhost}
ADMIN_HTTP_PORT: ${ADMIN_HTTP_PORT:-9080}
ADMIN_HTTPS_PORT: ${ADMIN_HTTPS_PORT:-9443}
ports:
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTP_PORT:-9080}:${ADMIN_HTTP_PORT:-9080}"
- "${ADMIN_BIND_IP:-127.0.0.1}:${ADMIN_HTTPS_PORT:-9443}:${ADMIN_HTTPS_PORT:-9443}"
volumes:
- admin_proxy_data:/data
- admin_proxy_config:/config
networks:
- admin
+15
View File
@@ -0,0 +1,15 @@
[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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd /srv/stoat-admin
podman compose down
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat-admin/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
set -e
if ! podman network exists stoat_default; then
sleep 5
exit 1
fi
cd /srv/stoat-admin
exec podman compose up 2>&1
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd /srv/stoat
podman compose down
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exec s6-log -b -- T /var/log/s6/stoat/
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -e
cd /srv/stoat
exec podman compose up 2>&1
+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/`.
+51
View File
@@ -0,0 +1,51 @@
import path from "node:path";
const repoRoot = process.cwd();
const quote = (value) => JSON.stringify(value);
const inWorkspace = (workspace, file) => {
const relativePath = path.relative(repoRoot, file);
return (
relativePath === workspace ||
relativePath.startsWith(`${workspace}${path.sep}`)
);
};
const commandForWorkspace = (workspaceName, workspaceDir, files) => {
const workspaceFiles = files.filter(
(file) => inWorkspace(workspaceDir, file) && /\.(ts|tsx)$/.test(file)
);
if (workspaceFiles.length === 0) {
return null;
}
return `pnpm --filter ${workspaceName} exec eslint --fix ${workspaceFiles
.map(quote)
.join(" ")}`;
};
export default {
"**/*": (files) => {
const commands = [];
if (files.length > 0) {
commands.push(
`prettier --write --ignore-unknown ${files.map(quote).join(" ")}`
);
}
const apiCommand = commandForWorkspace("stoat-admin-api", "api", files);
if (apiCommand) {
commands.push(apiCommand);
}
const webCommand = commandForWorkspace("stoat-admin-web", "web", files);
if (webCommand) {
commands.push(webCommand);
}
return commands;
}
};
+29
View File
@@ -0,0 +1,29 @@
{
"name": "stoat-admin",
"private": true,
"type": "module",
"packageManager": "pnpm@10.6.3",
"license": "AGPL-3.0-only",
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.1",
"turbo": "^2.5.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"argon2",
"better-sqlite3",
"esbuild"
]
},
"scripts": {
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"lint": "turbo run lint",
"check": "turbo run lint build",
"dev:api": "turbo run dev --filter=stoat-admin-api",
"dev:web": "turbo run dev --filter=stoat-admin-web",
"seed": "pnpm --filter stoat-admin-api seed"
}
}
+3977
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- api
- web
+28
View File
@@ -0,0 +1,28 @@
{
http_port {$ADMIN_HTTP_PORT:9080}
https_port {$ADMIN_HTTPS_PORT:9443}
order coraza_waf first
}
{$ADMIN_HOSTNAME:localhost} {
tls internal
encode zstd gzip
coraza_waf {
load_owasp_crs
directives `
Include /etc/caddy/coraza.conf
SecRuleEngine On
`
}
@api path /api/*
handle @api {
reverse_proxy admin-api:{$ADMIN_API_PORT:5181}
}
handle {
reverse_proxy admin-web:80 admin-web:443 admin-web:9080 admin-web:9443
}
}
+9
View File
@@ -0,0 +1,9 @@
FROM caddy:2-builder-alpine AS build-caddy
RUN xcaddy build \
--with github.com/corazawaf/coraza-caddy/v2
FROM caddy:2-alpine
COPY --from=build-caddy /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
COPY coraza.conf /etc/caddy/coraza.conf
EXPOSE 80 443 9080 9443
+4
View File
@@ -0,0 +1,4 @@
# Coraza configuration overrides
# Tune false positives here as they arise.
SecAction "id:900000, phase:1, pass, t:none, nolog, setvar:tx.blocking_paranoia_level=1"
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
},
"seed": {
"cache": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.turbo/
.env*
.env
+8
View File
@@ -0,0 +1,8 @@
FROM node:24-slim AS build-app
WORKDIR /app
COPY package.json ./
RUN corepack enable && pnpm install --frozen-lockfile=false
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
EXPOSE 9080 9443
+42
View File
@@ -0,0 +1,42 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
export default [
{
ignores: ["dist/**", "node_modules/**"]
},
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
globals: {
...globals.browser
}
},
plugins: {
"@typescript-eslint": tsPlugin,
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...tsPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"no-undef": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_"
}
]
}
}
];
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stoat Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{
"name": "stoat-admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.6.3",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "vite",
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.json && vite build",
"check": "pnpm lint && pnpm typecheck",
"start": "vite preview",
"preview": "pnpm start",
"lint": "eslint ."
},
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"ulid": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.12.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.1.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"vite": "^8.0.3"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../lib/auth";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/invites", label: "Invites" },
{ to: "/users", label: "Users" }
];
export function Layout() {
const { logout, user } = useAuth();
return (
<div className="min-h-screen px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto grid min-h-[calc(100vh-3rem)] max-w-7xl gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="rounded-[28px] border border-[color:var(--line)] bg-[color:var(--bg-panel-strong)] p-6 text-stone-100 shadow-[var(--shadow)]">
<div className="mb-10 space-y-2">
<p className="text-xs uppercase tracking-[0.3em] text-stone-400">
Stoat Admin
</p>
<h1 className="text-3xl font-semibold tracking-tight">
Operations
</h1>
<p className="text-sm text-stone-300">
Invite, moderate, and review account state on your instance.
</p>
</div>
<nav className="space-y-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`block rounded-2xl px-4 py-3 text-sm transition ${
isActive
? "bg-[color:var(--accent)] text-white"
: "bg-white/6 text-stone-200 hover:bg-white/10"
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="mt-10 rounded-2xl border border-white/10 bg-white/6 p-4">
<p className="text-xs uppercase tracking-[0.24em] text-stone-400">
Signed in
</p>
<p className="mt-2 text-lg font-medium text-white">
{user?.username}
</p>
<button
type="button"
onClick={() => void logout()}
className="mt-4 w-full rounded-xl border border-white/10 bg-white/6 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/12"
>
Log out
</button>
</div>
</aside>
<main className="rounded-[28px] border border-[color:var(--line)] bg-[color:var(--bg-panel)] p-5 shadow-[var(--shadow)] sm:p-8">
<Outlet />
</main>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
@import "tailwindcss";
:root {
color-scheme: light;
--bg: #efe8db;
--bg-panel: rgba(255, 250, 240, 0.88);
--bg-panel-strong: rgba(31, 24, 17, 0.9);
--ink: #241d17;
--ink-muted: #675a4c;
--line: rgba(59, 43, 24, 0.12);
--accent: #b64926;
--accent-soft: rgba(182, 73, 38, 0.12);
--positive: #246d4f;
--warning: #8d6112;
--danger: #872f2f;
--shadow: 0 24px 80px rgba(53, 35, 16, 0.12);
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(
circle at top left,
rgba(182, 73, 38, 0.18),
transparent 34%
),
radial-gradient(
circle at bottom right,
rgba(36, 109, 79, 0.12),
transparent 28%
),
linear-gradient(180deg, #f7f1e7 0%, var(--bg) 100%);
color: var(--ink);
}
#root {
min-height: 100vh;
}
::selection {
background: rgba(182, 73, 38, 0.18);
}
+33
View File
@@ -0,0 +1,33 @@
const API_BASE = import.meta.env.VITE_API_URL ?? "";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export async function apiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...options?.headers
}
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as {
error?: string;
};
throw new ApiError(response.status, body.error ?? "Request failed");
}
return (await response.json()) as T;
}
+93
View File
@@ -0,0 +1,93 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren
} from "react";
import { apiFetch, ApiError } from "./api";
import type { SessionUser } from "./types";
interface AuthContextValue {
user: SessionUser | null;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState<SessionUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let isMounted = true;
void apiFetch<SessionUser>("/api/auth/me")
.then((sessionUser) => {
if (isMounted) {
setUser(sessionUser);
}
})
.catch((error: unknown) => {
if (!isMounted) {
return;
}
if (!(error instanceof ApiError) || error.status !== 401) {
console.error(error);
}
setUser(null);
})
.finally(() => {
if (isMounted) {
setIsLoading(false);
}
});
return () => {
isMounted = false;
};
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
async login(username: string, password: string) {
const nextUser = await apiFetch<SessionUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password })
});
setUser(nextUser);
},
async logout() {
try {
await apiFetch("/api/auth/logout", {
method: "POST"
});
} finally {
setUser(null);
}
}
}),
[isLoading, user]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
+15
View File
@@ -0,0 +1,15 @@
export function formatDateTime(value: string | null | undefined): string {
if (!value) {
return "Never";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
+48
View File
@@ -0,0 +1,48 @@
const USER_FLAG_DELETED = 2;
const USER_FLAG_BANNED = 4;
export function hasFlag(flags: number | undefined, mask: number): boolean {
return ((flags ?? 0) & mask) === mask;
}
export function getUserStatus(
flags: number | undefined,
disabled?: boolean
): {
label: "active" | "banned" | "deleted";
tone: string;
} {
if (hasFlag(flags, USER_FLAG_DELETED)) {
return { label: "deleted", tone: "text-red-800 bg-red-100 border-red-200" };
}
if (disabled || hasFlag(flags, USER_FLAG_BANNED)) {
return {
label: "banned",
tone: "text-amber-900 bg-amber-100 border-amber-200"
};
}
return {
label: "active",
tone: "text-emerald-900 bg-emerald-100 border-emerald-200"
};
}
export function getFlagLabels(flags: number | undefined): string[] {
const labels: string[] = [];
if (hasFlag(flags, USER_FLAG_BANNED)) {
labels.push("banned");
}
if (hasFlag(flags, USER_FLAG_DELETED)) {
labels.push("deleted");
}
if (labels.length === 0) {
labels.push("none");
}
return labels;
}
+75
View File
@@ -0,0 +1,75 @@
export interface SessionUser {
username: string;
}
export interface DashboardStats {
totalUsers: number;
bannedUsers: number;
pendingInvites: number;
recentBans: number;
}
export type InviteRecordStatus = "pending" | "accepted" | "revoked" | "expired";
export interface InviteRecord {
id: number;
code: string;
email: string;
status: InviteRecordStatus;
created_at: string;
expires_at: string | null;
accepted_at: string | null;
resend_message_id: string | null;
}
export interface InviteListResponse {
invites: InviteRecord[];
count: number;
}
export interface CreateInviteResponse {
invite: InviteRecord;
warning?: string;
}
export interface AccountRecord {
_id: string;
email: string;
disabled: boolean;
verification?: {
status: "Verified" | "Pending" | "Moving";
};
deletion?: {
status: "Scheduled" | "WaitingForVerification" | "Deleted";
after?: string;
};
}
export interface UserRecord {
_id: string;
username: string;
discriminator: string;
flags?: number;
avatar?: unknown;
account?: AccountRecord;
}
export interface StrikeRecord {
_id: string;
user_id: string;
reason: string;
type?: "strike" | "suspension" | "ban";
}
export interface UsersResponse {
users: UserRecord[];
total: number;
page: number;
limit: number;
}
export interface UserDetailResponse {
user: UserRecord | null;
account: AccountRecord | null;
strikes: StrikeRecord[];
}
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./lib/auth";
import { router } from "./router";
import "./index.css";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</StrictMode>
);
+58
View File
@@ -0,0 +1,58 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { Layout } from "./components/Layout";
import { useAuth } from "./lib/auth";
import { DashboardView } from "./views/Dashboard";
import { InvitesView } from "./views/Invites";
import { LoginView } from "./views/Login";
import { UserDetailView } from "./views/UserDetail";
import { UsersView } from "./views/Users";
function ProtectedLayout() {
const { isLoading, user } = useAuth();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="rounded-3xl border border-[color:var(--line)] bg-[color:var(--bg-panel)] px-6 py-5 shadow-[var(--shadow)]">
Loading admin session...
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <Layout />;
}
export const router = createBrowserRouter([
{
path: "/login",
element: <LoginView />
},
{
path: "/",
element: <ProtectedLayout />,
children: [
{
index: true,
element: <DashboardView />
},
{
path: "invites",
element: <InvitesView />
},
{
path: "users",
element: <UsersView />
},
{
path: "users/:id",
element: <UserDetailView />
}
]
}
]);
+85
View File
@@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";
import type { DashboardStats } from "../lib/types";
const statCards: Array<{
key: keyof DashboardStats;
label: string;
note: string;
}> = [
{
key: "totalUsers",
label: "Total Users",
note: "Accounts currently indexed in Stoat."
},
{
key: "bannedUsers",
label: "Banned Users",
note: "Users with the banned flag or disabled account state."
},
{
key: "pendingInvites",
label: "Pending Invites",
note: "Invites issued but not yet consumed."
},
{
key: "recentBans",
label: "Recent Bans",
note: "Audit entries created in the last 30 days."
}
];
export function DashboardView() {
const statsQuery = useQuery({
queryKey: ["dashboard-stats"],
queryFn: () => apiFetch<DashboardStats>("/api/dashboard/stats")
});
const stats = statsQuery.data;
return (
<div className="space-y-8">
<section className="rounded-[28px] bg-[color:var(--bg-panel-strong)] px-6 py-8 text-white">
<p className="text-xs uppercase tracking-[0.28em] text-stone-400">
Overview
</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight">
Instance control room
</h2>
<p className="mt-4 max-w-2xl text-sm text-stone-300">
Fast access to invite state, moderation activity, and account volume
without depending on the public Stoat UI.
</p>
</section>
{statsQuery.isLoading ? (
<div className="rounded-3xl border border-[color:var(--line)] bg-white/50 px-5 py-4">
Loading dashboard stats...
</div>
) : statsQuery.isError ? (
<div className="rounded-3xl border border-red-200 bg-red-50 px-5 py-4 text-red-700">
Failed to load dashboard stats.
</div>
) : stats ? (
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{statCards.map((card) => (
<article
key={card.key}
className="rounded-3xl border border-[color:var(--line)] bg-white/70 p-5 shadow-sm backdrop-blur"
>
<p className="text-xs uppercase tracking-[0.26em] text-[color:var(--ink-muted)]">
{card.label}
</p>
<p className="mt-4 text-4xl font-semibold tracking-tight">
{stats[card.key]}
</p>
<p className="mt-3 text-sm text-[color:var(--ink-muted)]">
{card.note}
</p>
</article>
))}
</section>
) : null}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { apiFetch, ApiError } from "../lib/api";
import { formatDateTime } from "../lib/format";
import type {
CreateInviteResponse,
InviteListResponse,
InviteRecord
} from "../lib/types";
const expiryOptions = [
{ label: "No expiry", value: "" },
{ label: "24 hours", value: "24" },
{ label: "48 hours", value: "48" },
{ label: "7 days", value: "168" },
{ label: "30 days", value: "720" }
];
const badgeStyles: Record<InviteRecord["status"], string> = {
accepted: "border-emerald-200 bg-emerald-100 text-emerald-900",
pending: "border-amber-200 bg-amber-100 text-amber-900",
revoked: "border-red-200 bg-red-100 text-red-900",
expired: "border-stone-200 bg-stone-100 text-stone-700"
};
export function InvitesView() {
const queryClient = useQueryClient();
const [email, setEmail] = useState("");
const [expiresInHours, setExpiresInHours] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const invitesQuery = useQuery({
queryKey: ["invites"],
queryFn: () => apiFetch<InviteListResponse>("/api/invites")
});
const inviteList = invitesQuery.data?.invites ?? [];
const createInviteMutation = useMutation({
mutationFn: () =>
apiFetch<CreateInviteResponse>("/api/invites", {
method: "POST",
body: JSON.stringify({
email,
...(expiresInHours ? { expiresInHours: Number(expiresInHours) } : {})
})
}),
onSuccess: (result) => {
setEmail("");
setExpiresInHours("");
setError(null);
setFeedback(
result.warning
? `${result.warning}. Invite code: ${result.invite.code}`
: `Invite created for ${result.invite.email}. Code: ${result.invite.code}`
);
void queryClient.invalidateQueries({ queryKey: ["invites"] });
},
onError: (mutationError) => {
setFeedback(null);
setError(
mutationError instanceof ApiError
? mutationError.message
: "Failed to create invite"
);
}
});
const revokeInviteMutation = useMutation({
mutationFn: (code: string) =>
apiFetch<{ success: true }>(`/api/invites/${code}`, {
method: "DELETE"
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["invites"] });
}
});
return (
<div className="space-y-8">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-[color:var(--ink-muted)]">
Invites
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
Issue and track access
</h2>
</div>
<p className="text-sm text-[color:var(--ink-muted)]">
Pending invites stay valid in Stoat even if email delivery fails.
</p>
</header>
<section className="grid gap-4 rounded-[28px] border border-[color:var(--line)] bg-white/70 p-6 lg:grid-cols-[minmax(0,1fr)_220px_180px]">
<label className="space-y-2">
<span className="text-sm font-medium">Recipient email</span>
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-2xl border border-[color:var(--line)] bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
type="email"
placeholder="user@example.com"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium">Expiry</span>
<select
value={expiresInHours}
onChange={(event) => setExpiresInHours(event.target.value)}
className="w-full rounded-2xl border border-[color:var(--line)] bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
>
{expiryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => createInviteMutation.mutate()}
disabled={!email || createInviteMutation.isPending}
className="self-end rounded-2xl bg-[color:var(--accent)] px-4 py-3 text-sm font-semibold text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
>
{createInviteMutation.isPending ? "Sending..." : "Send Invite"}
</button>
{feedback ? (
<div className="lg:col-span-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{feedback}
</div>
) : null}
{error ? (
<div className="lg:col-span-3 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
</section>
<section className="overflow-hidden rounded-[28px] border border-[color:var(--line)] bg-white/72">
<div className="border-b border-[color:var(--line)] px-6 py-4">
<h3 className="text-lg font-semibold">Invite history</h3>
</div>
{invitesQuery.isLoading ? (
<div className="px-6 py-6 text-sm text-[color:var(--ink-muted)]">
Loading invites...
</div>
) : invitesQuery.isError ? (
<div className="px-6 py-6 text-sm text-red-700">
Failed to load invites.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-stone-900/4 text-[color:var(--ink-muted)]">
<tr>
<th className="px-6 py-3 font-medium">Email</th>
<th className="px-6 py-3 font-medium">Code</th>
<th className="px-6 py-3 font-medium">Status</th>
<th className="px-6 py-3 font-medium">Created</th>
<th className="px-6 py-3 font-medium">Expires</th>
<th className="px-6 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody>
{inviteList.map((invite) => (
<tr
key={invite.code}
className="border-t border-[color:var(--line)]"
>
<td className="px-6 py-4">{invite.email}</td>
<td className="px-6 py-4 font-mono text-xs">
{invite.code}
</td>
<td className="px-6 py-4">
<span
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${badgeStyles[invite.status]}`}
>
{invite.status}
</span>
</td>
<td className="px-6 py-4">
{formatDateTime(invite.created_at)}
</td>
<td className="px-6 py-4">
{formatDateTime(invite.expires_at)}
</td>
<td className="px-6 py-4">
{invite.status === "pending" ? (
<button
type="button"
onClick={() =>
revokeInviteMutation.mutate(invite.code)
}
disabled={revokeInviteMutation.isPending}
className="rounded-xl border border-red-200 px-3 py-2 text-xs font-semibold text-red-700 transition hover:bg-red-50"
>
Revoke
</button>
) : (
<span className="text-xs text-[color:var(--ink-muted)]">
No action
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useState, type FormEvent } from "react";
import { Navigate } from "react-router-dom";
import { ApiError } from "../lib/api";
import { useAuth } from "../lib/auth";
export function LoginView() {
const { login, user, isLoading } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (!isLoading && user) {
return <Navigate to="/" replace />;
}
async function handleSubmit(
event: FormEvent<HTMLFormElement>
): Promise<void> {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(username, password);
} catch (submitError) {
if (submitError instanceof ApiError) {
setError(submitError.message);
} else {
setError("Unable to sign in");
}
} finally {
setIsSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full max-w-md rounded-[32px] border border-[color:var(--line)] bg-[color:var(--bg-panel)] p-8 shadow-[var(--shadow)]">
<p className="text-xs uppercase tracking-[0.28em] text-[color:var(--ink-muted)]">
Stoat Admin
</p>
<h1 className="mt-3 text-4xl font-semibold tracking-tight">Sign in</h1>
<p className="mt-3 text-sm text-[color:var(--ink-muted)]">
This dashboard is intended for WireGuard-restricted admin access only.
</p>
<form className="mt-8 space-y-4" onSubmit={handleSubmit}>
<label className="block space-y-2">
<span className="text-sm font-medium">Username</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
className="w-full rounded-2xl border border-[color:var(--line)] bg-white/80 px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
autoComplete="username"
required
/>
</label>
<label className="block space-y-2">
<span className="text-sm font-medium">Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-2xl border border-[color:var(--line)] bg-white/80 px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
autoComplete="current-password"
required
/>
</label>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-[color:var(--accent)] px-4 py-3 text-sm font-semibold text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { decodeTime } from "ulid";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { apiFetch } from "../lib/api";
import { formatDateTime } from "../lib/format";
import { getFlagLabels, getUserStatus } from "../lib/status";
import type { UserDetailResponse } from "../lib/types";
function strikeDate(ulidValue: string): string {
try {
return formatDateTime(new Date(decodeTime(ulidValue)).toISOString());
} catch {
return ulidValue;
}
}
export function UserDetailView() {
const { id } = useParams();
const queryClient = useQueryClient();
const [banReason, setBanReason] = useState("");
const [deleteReason, setDeleteReason] = useState("");
const userQuery = useQuery({
queryKey: ["user", id],
enabled: Boolean(id),
queryFn: () => apiFetch<UserDetailResponse>(`/api/users/${id}`)
});
const refresh = async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["user", id] }),
queryClient.invalidateQueries({ queryKey: ["users"] }),
queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] })
]);
};
const banMutation = useMutation({
mutationFn: () =>
apiFetch<{ success: true }>(`/api/users/${id}/ban`, {
method: "POST",
body: JSON.stringify({ reason: banReason })
}),
onSuccess: async () => {
setBanReason("");
await refresh();
}
});
const unbanMutation = useMutation({
mutationFn: () =>
apiFetch<{ success: true }>(`/api/users/${id}/unban`, {
method: "POST"
}),
onSuccess: refresh
});
const deleteMutation = useMutation({
mutationFn: () =>
apiFetch<{ success: true }>(`/api/users/${id}`, {
method: "DELETE",
body: JSON.stringify({ reason: deleteReason || undefined })
}),
onSuccess: async () => {
setDeleteReason("");
await refresh();
}
});
if (!id) {
return (
<div className="rounded-3xl border border-red-200 bg-red-50 px-5 py-4 text-red-700">
Missing user id.
</div>
);
}
if (userQuery.isLoading) {
return (
<div className="rounded-3xl border border-[color:var(--line)] bg-white/60 px-5 py-4">
Loading user…
</div>
);
}
const detail = userQuery.data;
if (userQuery.isError || !detail?.user) {
return (
<div className="rounded-3xl border border-red-200 bg-red-50 px-5 py-4 text-red-700">
Failed to load user.
</div>
);
}
const { user, account, strikes } = detail;
const status = getUserStatus(user.flags, account?.disabled);
const scheduledDeletion = account?.deletion?.status === "Scheduled";
return (
<div className="space-y-6">
<section className="rounded-[28px] bg-[color:var(--bg-panel-strong)] px-6 py-7 text-white">
<p className="text-xs uppercase tracking-[0.28em] text-stone-400">
User Detail
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight">
{user.username}#{user.discriminator}
</h2>
<div className="mt-4 flex flex-wrap gap-2">
<span
className={`rounded-full border px-3 py-1 text-xs font-medium ${status.tone}`}
>
{status.label}
</span>
{getFlagLabels(user.flags).map((label) => (
<span
key={label}
className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-white"
>
{label}
</span>
))}
</div>
</section>
<section className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<article className="rounded-[28px] border border-[color:var(--line)] bg-white/72 p-6">
<h3 className="text-lg font-semibold">Account info</h3>
<dl className="mt-5 grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
Email
</dt>
<dd className="mt-2 text-base font-medium">
{account?.email ?? "Unknown"}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
Verification
</dt>
<dd className="mt-2 text-base font-medium">
{account?.verification?.status ?? "Unknown"}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
User ID
</dt>
<dd className="mt-2 font-mono text-sm">{user._id}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.22em] text-[color:var(--ink-muted)]">
Deletion state
</dt>
<dd className="mt-2 text-base font-medium">
{account?.deletion?.status ?? "Not scheduled"}
{account?.deletion?.after
? ` · ${formatDateTime(account.deletion.after)}`
: ""}
</dd>
</div>
</dl>
</article>
<article className="rounded-[28px] border border-[color:var(--line)] bg-white/72 p-6">
<h3 className="text-lg font-semibold">Actions</h3>
{scheduledDeletion ? (
<p className="mt-4 text-sm text-[color:var(--ink-muted)]">
Deletion is already scheduled. Stoat&apos;s `crond` daemon will
handle the remaining cleanup.
</p>
) : status.label === "banned" ? (
<div className="mt-4 space-y-4">
<p className="text-sm text-[color:var(--ink-muted)]">
The user is currently banned. You can clear the disabled state
and banned flag.
</p>
<button
type="button"
onClick={() => unbanMutation.mutate()}
disabled={unbanMutation.isPending}
className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-800"
>
{unbanMutation.isPending ? "Unbanning..." : "Unban user"}
</button>
</div>
) : (
<div className="mt-4 space-y-6">
<div className="space-y-3 rounded-3xl border border-amber-200 bg-amber-50 p-4">
<h4 className="font-semibold text-amber-950">Ban user</h4>
<textarea
value={banReason}
onChange={(event) => setBanReason(event.target.value)}
rows={3}
className="w-full rounded-2xl border border-amber-200 bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
placeholder="Required reason"
/>
<button
type="button"
onClick={() => banMutation.mutate()}
disabled={!banReason.trim() || banMutation.isPending}
className="rounded-2xl bg-amber-600 px-4 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{banMutation.isPending ? "Applying ban..." : "Ban user"}
</button>
</div>
<div className="space-y-3 rounded-3xl border border-red-200 bg-red-50 p-4">
<h4 className="font-semibold text-red-950">
Schedule deletion
</h4>
<textarea
value={deleteReason}
onChange={(event) => setDeleteReason(event.target.value)}
rows={3}
className="w-full rounded-2xl border border-red-200 bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
placeholder="Optional reason"
/>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="rounded-2xl bg-red-700 px-4 py-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{deleteMutation.isPending
? "Scheduling..."
: "Schedule deletion"}
</button>
</div>
</div>
)}
</article>
</section>
<section className="overflow-hidden rounded-[28px] border border-[color:var(--line)] bg-white/72">
<div className="border-b border-[color:var(--line)] px-6 py-4">
<h3 className="text-lg font-semibold">Strike history</h3>
</div>
{strikes.length === 0 ? (
<div className="px-6 py-6 text-sm text-[color:var(--ink-muted)]">
No strike records found.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-stone-900/4 text-[color:var(--ink-muted)]">
<tr>
<th className="px-6 py-3 font-medium">Reason</th>
<th className="px-6 py-3 font-medium">Type</th>
<th className="px-6 py-3 font-medium">Date</th>
</tr>
</thead>
<tbody>
{strikes.map(
(strike: UserDetailResponse["strikes"][number]) => (
<tr
key={strike._id}
className="border-t border-[color:var(--line)]"
>
<td className="px-6 py-4">{strike.reason}</td>
<td className="px-6 py-4">
<span className="rounded-full border border-stone-200 bg-stone-100 px-2.5 py-1 text-xs font-medium text-stone-700">
{strike.type ?? "strike"}
</span>
</td>
<td className="px-6 py-4">{strikeDate(strike._id)}</td>
</tr>
)
)}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useQuery } from "@tanstack/react-query";
import { useDeferredValue, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiFetch } from "../lib/api";
import { getUserStatus } from "../lib/status";
import type { UsersResponse } from "../lib/types";
function buildUsersPath(page: number, search: string): string {
const params = new URLSearchParams({
page: String(page),
limit: "25"
});
if (search) {
params.set("search", search);
}
return `/api/users?${params.toString()}`;
}
export function UsersView() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim());
const usersQuery = useQuery({
queryKey: ["users", page, deferredSearch],
queryFn: () => apiFetch<UsersResponse>(buildUsersPath(page, deferredSearch))
});
const userData = usersQuery.data;
const totalPages = userData
? Math.max(1, Math.ceil(userData.total / userData.limit))
: 1;
return (
<div className="space-y-8">
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-[color:var(--ink-muted)]">
Users
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
Moderation view
</h2>
</div>
<label className="w-full max-w-md space-y-2">
<span className="text-sm font-medium">Search by email</span>
<input
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
className="w-full rounded-2xl border border-[color:var(--line)] bg-white px-4 py-3 outline-none transition focus:border-[color:var(--accent)]"
placeholder="name@example.com"
/>
</label>
</header>
<section className="overflow-hidden rounded-[28px] border border-[color:var(--line)] bg-white/72">
{usersQuery.isLoading ? (
<div className="px-6 py-6 text-sm text-[color:var(--ink-muted)]">
Loading users...
</div>
) : usersQuery.isError ? (
<div className="px-6 py-6 text-sm text-red-700">
Failed to load users.
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="bg-stone-900/4 text-[color:var(--ink-muted)]">
<tr>
<th className="px-6 py-3 font-medium">User</th>
<th className="px-6 py-3 font-medium">Email</th>
<th className="px-6 py-3 font-medium">Status</th>
<th className="px-6 py-3 font-medium">Verified</th>
</tr>
</thead>
<tbody>
{userData?.users.map((user) => {
const status = getUserStatus(
user.flags,
user.account?.disabled
);
return (
<tr
key={user._id}
className="cursor-pointer border-t border-[color:var(--line)] transition hover:bg-black/[0.03]"
onClick={() => navigate(`/users/${user._id}`)}
>
<td className="px-6 py-4 font-medium">
{user.username}#{user.discriminator}
</td>
<td className="px-6 py-4">
{user.account?.email ?? "Unknown"}
</td>
<td className="px-6 py-4">
<span
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${status.tone}`}
>
{status.label}
</span>
</td>
<td className="px-6 py-4">
{user.account?.verification?.status === "Verified"
? "Yes"
: "No"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-col gap-4 border-t border-[color:var(--line)] px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[color:var(--ink-muted)]">
Page {userData?.page ?? 1} of {totalPages} ·{" "}
{userData?.total ?? 0} results
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() =>
setPage((currentPage) => Math.max(1, currentPage - 1))
}
disabled={page <= 1}
className="rounded-xl border border-[color:var(--line)] px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
Previous
</button>
<button
type="button"
onClick={() =>
setPage((currentPage) =>
Math.min(totalPages, currentPage + 1)
)
}
disabled={page >= totalPages}
className="rounded-xl border border-[color:var(--line)] px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</>
)}
</section>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()]
});