/apps/deviant-admin/errors/<id> was resolving ./app.css against the full URL (→ /apps/deviant-admin/errors/app.css → 404), landing on unstyled text. Inject a dynamic <base href> at the top of <head> that rewinds the path to the directory above /errors/<id> or the current dir — base stays correct regardless of mount or deep-link path. |
||
|---|---|---|
| .. | ||
| public | ||
| BRIEFING.md | ||
| deviant-admin.service | ||
| nginx-snippet.conf | ||
| README.md | ||
| server.js | ||
admin.deviant.x — error inbox
Receiver for mobile-wallet error reports + a small dashboard to browse them.
Layout
admin/
├── server.js # Node service — ingest + admin API + static dashboard
├── deviant-admin.service # systemd unit
├── nginx-snippet.conf # two location blocks for silentmode-st
├── README.md # this file
└── public/ # dashboard shell (vanilla HTML + CSS + JS)
├── index.html
├── app.css
└── app.js
No build step. No npm deps. Node ≥ 20 (uses global fetch + URL).
Endpoints
| URL on silentmode.st | Behind nginx | Auth |
|---|---|---|
POST /apps/deviant/errors |
→ Node /ingest |
none |
GET /apps/deviant-admin/ |
→ Node / (static dashboard) |
HTTP basic |
GET /apps/deviant-admin/api/errors?… |
→ Node /api/errors |
HTTP basic |
GET /apps/deviant-admin/api/errors/<id> |
→ Node /api/errors/<id> |
HTTP basic |
GET /apps/deviant-admin/errors/<id> |
→ Node /errors/<id> (SPA deep link) |
HTTP basic |
Storage
One JSON file per report:
/opt/silent-mode/data/deviant/errors/<id>.json
Where <id> matches [A-Za-z0-9_-]{1,64} (sanitised server-side). The mobile
client sends IDs shaped DEV-YYYYMMDD-HHMMSS-<4hex> which already pass.
The receiver atomically writes <id>.json.tmp-<uuid> → rename() to avoid
half-written files from racing scans in the dashboard.
Rate limit
In-memory sliding window: 60 req / 60 s per X-Forwarded-For IP. Over → 429
with Retry-After: 60. Mobile client retries from its offline queue on the
next online event — no need to be generous.
Install (first time)
# 1. Create data + install dirs on the VPS.
ssh silentmode '
mkdir -p /opt/silent-mode/apps/deviant-admin/public
mkdir -p /opt/silent-mode/data/deviant/errors
chown -R www-data:www-data /opt/silent-mode/data/deviant
'
# 2. Ship the service + dashboard.
scp admin/server.js silentmode:/opt/silent-mode/apps/deviant-admin/server.js
scp admin/public/* silentmode:/opt/silent-mode/apps/deviant-admin/public/
# 3. Install the systemd unit + start it.
scp admin/deviant-admin.service silentmode:/etc/systemd/system/deviant-admin.service
ssh silentmode '
systemctl daemon-reload
systemctl enable --now deviant-admin
systemctl status deviant-admin --no-pager | head -20
'
# 4. Add nginx blocks (merge into /etc/nginx/sites-enabled/silentmode-st).
# See admin/nginx-snippet.conf for the two location blocks.
ssh silentmode 'nginx -t && systemctl reload nginx'
# 5. Set up basic auth.
ssh silentmode '
apt-get install -y apache2-utils # once, if htpasswd not installed
htpasswd -c /etc/nginx/.htpasswd-deviant-admin admin
chown root:www-data /etc/nginx/.htpasswd-deviant-admin
chmod 640 /etc/nginx/.htpasswd-deviant-admin
'
Deploy (subsequent)
scp admin/server.js silentmode:/opt/silent-mode/apps/deviant-admin/server.js
scp admin/public/* silentmode:/opt/silent-mode/apps/deviant-admin/public/
ssh silentmode 'systemctl restart deviant-admin'
Dashboard changes (public/* only) don't need a restart; the service reads them on each request.
Smoke test
# Receiver — expect 201.
curl -sS -X POST https://silentmode.st/apps/deviant/errors \
-H "Content-Type: application/json" \
-d '{"id":"DEV-20261103-000000-test","ts":"2026-11-03T00:00:00.000Z","version":"0.0.0","scope":"test","message":"smoke","name":"Error","stack":"at smoke","ua":"curl","extra":{}}'
# Admin API — expect {ok:true, total: ≥ 1, rows: [...]}
curl -sS -u admin:<pw> https://silentmode.st/apps/deviant-admin/api/errors
# Dashboard in a browser:
# https://silentmode.st/apps/deviant-admin/
# → prompts for basic auth → list of reports → click a row → drawer with payload
BNS
admin.deviant.x resolves via a u-redirect BNS record:
{"u":"silentmode.st/apps/deviant-admin/"}
Published with the Argus update.js tool once the receiver + dashboard verify.
Field policy
Enforced client-side (shells/app/src/errorReport.ts writes extra explicitly
— no auto-scraped globals). Reinforced here by the dashboard: raw payload is
only shown under click-to-expand sections. No private keys, mnemonics, PINs,
cookies or session fingerprints. Addresses, categories, amounts, URLs are
fair game; sourceIp is kept for abuse debugging only.