diff --git a/scripts/host/relay-health.sh b/scripts/host/relay-health.sh
index d292b48..9c783cd 100644
--- a/scripts/host/relay-health.sh
+++ b/scripts/host/relay-health.sh
@@ -78,6 +78,49 @@ else
ISSUES+=("could not read relay TLS certificate")
fi
+# Machine-readable snapshot for the signed admin dashboard. Aggregate numbers
+# only: no IPs, pubkeys, paths or secrets.
+local_relay=$(code -H 'Accept: application/nostr+json' http://127.0.0.1:7777/)
+public_relay=$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)
+push_status=$(code https://push.skate-spots.org/health)
+quota_status=$(code http://127.0.0.1:8790/health)
+version=$(curl -s -m 8 -H 'Accept: application/nostr+json' https://relay.skate-spots.org/ \
+ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("version",""))' 2>/dev/null || true)
+svc_on() { systemctl is-active --quiet "$1" && echo true || echo false; }
+STATUS_DISK="${disk:-0}" STATUS_AVAIL="${avail:-0}" STATUS_MEDIA="${total_bytes:-0}" \
+STATUS_CORDN_REJECTS="${cordn_rejects:-0}" STATUS_CORDN_ANNOUNCE="${cordn_announce:-0}" \
+STATUS_TLS_DAYS="${days:-0}" STATUS_LOCAL="${local_relay:-000}" STATUS_PUBLIC="${public_relay:-000}" \
+STATUS_PUSH="${push_status:-000}" STATUS_QUOTA="${quota_status:-000}" STATUS_VERSION="$version" \
+STATUS_SERVICES="$(printf 'relay=%s\nai=%s\npush=%s\ncvm=%s\nquota=%s\ncaddy=%s\n' \
+ "$(svc_on skatespots-relay.service)" "$(svc_on skatespots-ai.service)" \
+ "$(svc_on skatespots-push.service)" "$(svc_on skatespots-cvm.service)" \
+ "$(svc_on skatespots-quota.service)" "$(svc_on caddy.service)")" \
+STATUS_ISSUES="$(printf '%s\n' "${ISSUES[@]}")" \
+python3 - <<'PY'
+import datetime, json, os
+services = {}
+for line in os.environ.get('STATUS_SERVICES','').splitlines():
+ if '=' in line:
+ k,v = line.split('=',1); services[k] = v == 'true'
+issues = [x for x in os.environ.get('STATUS_ISSUES','').splitlines() if x]
+payload = {
+ 'updatedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ 'relay': {'local': os.environ.get('STATUS_LOCAL'), 'public': os.environ.get('STATUS_PUBLIC')},
+ 'version': os.environ.get('STATUS_VERSION',''),
+ 'cordn': {'announces20m': int(os.environ.get('STATUS_CORDN_ANNOUNCE','0') or 0),
+ 'rejects15m': int(os.environ.get('STATUS_CORDN_REJECTS','0') or 0)},
+ 'storage': {'diskPct': int(os.environ.get('STATUS_DISK','0') or 0),
+ 'memAvailMiB': int(os.environ.get('STATUS_AVAIL','0') or 0),
+ 'mediaBytes': int(os.environ.get('STATUS_MEDIA','0') or 0)},
+ 'tls': {'days': int(os.environ.get('STATUS_TLS_DAYS','0') or 0)},
+ 'services': services,
+ 'issues': issues,
+}
+path = '/home/ubuntu/skatespots-quota/status.json'
+with open(path,'w') as f: json.dump(payload,f)
+os.chmod(path, 0o644)
+PY
+
sig=""
[ "${#ISSUES[@]}" -gt 0 ] && sig=$(printf '%s\n' "${ISSUES[@]}" | sort | tr '\n' '|')
prev=""
diff --git a/scripts/quota-proxy.mjs b/scripts/quota-proxy.mjs
index d83b3d9..a7f008f 100644
--- a/scripts/quota-proxy.mjs
+++ b/scripts/quota-proxy.mjs
@@ -46,6 +46,7 @@ const IP_HOURLY_BYTES = Number(process.env.IP_HOURLY_BYTES || 500 * 1024 * 1024)
const IP_HOURLY_UPLOADS = Number(process.env.IP_HOURLY_UPLOADS || 100);
const TOTAL_BYTES = Number(process.env.TOTAL_BYTES || 20 * 1024 * 1024 * 1024);
const STATE_PATH = process.env.QUOTA_STATE || '/home/ubuntu/skatespots-quota/usage.json';
+const STATUS_PATH = process.env.STATUS_PATH || '/home/ubuntu/skatespots-quota/status.json';
const ADMIN_PUBKEYS = new Set(
String(process.env.ADMIN_PUBKEYS || '')
.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean),
@@ -163,8 +164,43 @@ function forward(req, res, body) {
upstream.on('error', () => json(res, 502, { error: 'Media relay unavailable.' }));
if (body) upstream.end(body); else req.pipe(upstream);
}
+function opsAuth(req) {
+ const event = decodeToken(req.headers.authorization);
+ if (!event || event.kind !== 27235 || !verifyEvent(event)) return false;
+ if (!ADMIN_PUBKEYS.has(String(event.pubkey).toLowerCase())) return false;
+ if (tag(event, 'method') !== 'GET') return false;
+ if (tag(event, 'u') !== 'https://relay.skate-spots.org/ops') return false;
+ const exp = Number(tag(event, 'expiration') || 0);
+ if (exp && exp < Math.floor(Date.now() / 1000)) return false;
+ return true;
+}
+
const server = http.createServer(async (req, res) => {
if (req.url === '/health') { json(res, 200, { ok: true }); return; }
+ if (req.url === '/ops') {
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-methods': 'GET, OPTIONS',
+ 'access-control-allow-headers': 'Authorization, Content-Type',
+ });
+ res.end();
+ return;
+ }
+ if (req.method !== 'GET' || !opsAuth(req)) { json(res, 401, { error: 'Admin authorization required.' }); return; }
+ try {
+ const raw = readFileSync(STATUS_PATH, 'utf8');
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'cache-control': 'no-store',
+ 'access-control-allow-origin': '*',
+ });
+ res.end(raw);
+ } catch {
+ json(res, 503, { error: 'Status snapshot unavailable.' });
+ }
+ return;
+ }
if (req.method !== 'PUT' || req.url !== '/upload') { forward(req, res); return; }
const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0];
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 0aacf5d..24fa98b 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -23,6 +23,7 @@ const DiyPage = lazyWithRetry(() => import("./pages/DiyPage"));
const DeckCostPage = lazyWithRetry(() => import("./pages/DeckCostPage"));
const DeckDesignerPage = lazyWithRetry(() => import("./pages/DeckDesignerPage"));
const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
+const OpsPage = lazyWithRetry(() => import("./pages/OpsPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
@@ -159,6 +160,7 @@ export function AppRouter() {
{t('settingsUi.adminReview.title')}
-- {flaggedCount > 0 - ? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' }) - : t('settingsUi.adminReview.clean')} -
+{t('settingsUi.adminReview.title')}
++ {flaggedCount > 0 + ? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' }) + : t('settingsUi.adminReview.clean')} +
+Operator status
+Read-only relay, Cordn, storage and TLS health.
+{label}
+{value}
+This page is restricted to the two relay admin npubs.
+Read-only snapshot, signed by your admin npub.
+Load the signed snapshot to inspect the relay.
+With Amber this requires one approval.
+• {issue}
)} +{t('settingsUi.settingsPage.advanced')} · read-only
+