From 8473aaa9945dec647dc9e66722b44fdd7ee49d2f Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 27 Oct 2026 09:36:44 +0700 Subject: [PATCH] chore(ops): plain-English daily report + NIP-17 alerts to both admins - scripts/ops-report.sh builds a simple daily report (app, website, deps, security, weekly scan); --send DMs it to the two admins from src/lib/admins.ts via NIP-17 (scripts/send-ops-report.ts), sender = app identity. - Delivery discovers kind-10050 inboxes + NIP-65 write relays and always includes the community relay; OPS_DRY_RUN=1 previews without publishing. - scripts/send-ops-alert.sh sends short alerts; wired into auto-deploy (preflight/smoke), update-deps (full+fallback failure) and ops-health --notify. - skatespots-ops-report.timer daily 07:00 + installer. - Signer moved into the 0600 zsp.env; old history copies removed. --- AGENTS.md | 21 ++ scripts/auto-deploy.sh | 2 + scripts/install-ops-report-timer.sh | 12 + scripts/ops-health.sh | 1 + scripts/ops-report.sh | 214 ++++++++++++++++++ scripts/send-ops-alert.sh | 24 ++ scripts/send-ops-report.ts | 135 +++++++++++ scripts/systemd/skatespots-ops-report.service | 7 + scripts/systemd/skatespots-ops-report.timer | 10 + scripts/update-deps.sh | 1 + 10 files changed, 427 insertions(+) create mode 100755 scripts/install-ops-report-timer.sh create mode 100755 scripts/ops-report.sh create mode 100755 scripts/send-ops-alert.sh create mode 100644 scripts/send-ops-report.ts create mode 100644 scripts/systemd/skatespots-ops-report.service create mode 100644 scripts/systemd/skatespots-ops-report.timer diff --git a/AGENTS.md b/AGENTS.md index b76da37..b62fe76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2403,6 +2403,27 @@ Rules: final `npm test` + boot smoke gates the result. If both the full and fallback paths fail, it restores the original tree and schedules the ~3-day retry. +### Ops reports + admin alerts (2026-10-27) +- `scripts/ops-report.sh` prints a **plain-English** daily report (app version, + website state, dependency/security state, weekly scan). `--send` DMs it to + the two admins from `src/lib/admins.ts` (founder `npub1yrnuj…`, second admin + `npub1e2ay…`) via NIP-17 (`scripts/send-ops-report.ts`). +- Delivery mirrors the app: discover each admin's kind-10050 inbox, fall back to + NIP-65 write relays, always include `wss://relay.skate-spots.org`. One gift + wrap per recipient; the sender gets the copy. `OPS_DRY_RUN=1` prints + recipients/relays without publishing. +- `scripts/send-ops-alert.sh "…"` sends a short alert; wired into + `auto-deploy.sh` (preflight/smoke failure), `update-deps.sh` (full+fallback + failure) and `ops-health.sh --notify` (deduped by problem signature). +- `skatespots-ops-report.timer` runs daily at 07:00 (Persistent); install with + `scripts/install-ops-report-timer.sh`. A test report was DMed to both admins + on 2026-10-27 (kind 1059 on the community relay for each). +- `~/.config/skatespots/zsp.env` (mode 600) now actually holds `SIGN_WITH` (the + template was empty); `scripts/release.sh`, the alert sender and the ops report + read it and never print it. Old signer copies were removed from + `~/.bash_history`; clear any open shell's in-memory history with + `history -c; history -w` if it predates this. + ### Community relay — self-hosted Newlay (2026-10-12) The community relay moved off the hosted relay.tools instance diff --git a/scripts/auto-deploy.sh b/scripts/auto-deploy.sh index 7dbea40..876af70 100755 --- a/scripts/auto-deploy.sh +++ b/scripts/auto-deploy.sh @@ -145,6 +145,7 @@ log "preflight: typecheck + lint + i18n + tests…" if ! "$REPO/scripts/preflight.sh"; then log "PREFLIGHT FAILED — not deploying" record_skip preflight-failed "the test gate failed" + timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deploy is paused because the test gate failed. The live site is unchanged." || true exit 0 fi @@ -160,6 +161,7 @@ log "smoke-testing the built bundle…" if ! node scripts/smoke-test.mjs; then log "SMOKE TEST FAILED — aborting deploy (the built bundle did not boot)" record_skip smoke-failed "the built bundle failed the smoke test" + timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deploy is paused because the built app did not start. The live site is unchanged." || true exit 1 fi diff --git a/scripts/install-ops-report-timer.sh b/scripts/install-ops-report-timer.sh new file mode 100755 index 0000000..29c4c06 --- /dev/null +++ b/scripts/install-ops-report-timer.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Install the daily plain-English ops report timer. +set -euo pipefail +SRC="$(cd "$(dirname "$0")/systemd" && pwd)" +DEST="$HOME/.config/systemd/user" +mkdir -p "$DEST" +cp "$SRC/skatespots-ops-report.service" "$DEST/" +cp "$SRC/skatespots-ops-report.timer" "$DEST/" +systemctl --user daemon-reload +systemctl --user enable --now skatespots-ops-report.timer +systemctl --user list-timers skatespots-ops-report.timer --no-pager diff --git a/scripts/ops-health.sh b/scripts/ops-health.sh index 71dfd02..9628f97 100755 --- a/scripts/ops-health.sh +++ b/scripts/ops-health.sh @@ -268,6 +268,7 @@ if [ "$NOTIFY" = "1" ]; then if command -v notify-send >/dev/null 2>&1; then notify-send -a SkateSpots "SkateSpots ops" "$body" 2>/dev/null || true fi + timeout 45 "$REPO/scripts/send-ops-alert.sh" "SkateSpots needs attention: $body" || true printf '%s' "$signature" > "$NOTIFY_FILE" fi else diff --git a/scripts/ops-report.sh b/scripts/ops-report.sh new file mode 100755 index 0000000..91a8acd --- /dev/null +++ b/scripts/ops-report.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# +# Plain-English SkateSpots ops report. +# +# scripts/ops-report.sh # print it +# scripts/ops-report.sh --send # print it and DM it to both admins (NIP-17) +# +# The daily timer runs `--send` at 07:00. The sender is the app identity in +# ~/.config/skatespots/zsp.env (mode 600); recipients are src/lib/admins.ts. +set -euo pipefail + +REPO="/home/sovereignsk8/SkateSpots" +cd "$REPO" +if [ -d "$HOME/.nvm/versions/node" ]; then + NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)" + [ -n "$NODE_BIN" ] && export PATH="$NODE_BIN:$PATH" +fi +export PATH="$HOME/go/bin:$PATH" + +SEND=0 +[ "${1:-}" = "--send" ] && SEND=1 + +now="$(date +%s)" +now_human="$(LC_ALL=C date '+%A %-d %B %Y, %H:%M')" + +state_deploy="$HOME/.local/state/skatespots-deploy" +state_deps="$HOME/.local/state/skatespots-deps" +deployed_sha="$(cat "$state_deploy/last-deployed-sha" 2>/dev/null || echo unknown)" +origin_sha="$(git rev-parse origin/main 2>/dev/null || echo unknown)" +deploy_epoch="$(cat "$state_deploy/last-deploy-epoch" 2>/dev/null || echo 0)" +skip_file="$state_deploy/last-skip" +deps_last_file="$state_deps/last-run" +deps_fail_file="$state_deps/last-failure" + +human_left() { + local s="$1" d h m + [ "$s" -lt 0 ] && s=0 + d=$((s / 86400)); h=$(((s % 86400) / 3600)); m=$(((s % 3600) / 60)) + if [ "$d" -gt 0 ]; then + printf '%s day%s %s hour%s' "$d" "$([ "$d" -eq 1 ] || echo s)" "$h" "$([ "$h" -eq 1 ] || echo s)" + elif [ "$h" -gt 0 ]; then + printf '%s hour%s %s minute%s' "$h" "$([ "$h" -eq 1 ] || echo s)" "$m" "$([ "$m" -eq 1 ] || echo s)" + else + printf '%s minute%s' "$m" "$([ "$m" -eq 1 ] || echo s)" + fi +} + +version="$(node -p "require('./package.json').version")" +problems=() + +# ── app ───────────────────────────────────────────────────────────────────── +app_line="version $version" +if command -v nak >/dev/null 2>&1; then + if timeout 8 nak req -k 30063 -d "org.skatespots.app@$version" wss://relay.zapstore.dev 2>/dev/null \ + | grep -qE "\"version\"[[:space:]]*,[[:space:]]*\"$version\""; then + app_line="version $version is published on Zapstore" + else + app_line="version $version; the Zapstore release could not be confirmed just now" + fi +else + app_line="version $version" +fi + +# ── website ───────────────────────────────────────────────────────────────── +web_line="" +if [ "$deployed_sha" = "$origin_sha" ]; then + web_line="the website is up to date" +else + if [ -f "$skip_file" ]; then + IFS='|' read -r sk_at sk_reason sk_sha sk_detail < "$skip_file" + case "${sk_reason:-}" in + throttled) + # `sk_at` is the last CHECK time (the timer polls every 3 min); the + # 24h window is measured from the last successful DEPLOY. + remain=$((86400 - (now - ${deploy_epoch:-0}))) + if [ "$remain" -gt 0 ]; then + web_line="new changes are waiting; it will update automatically in about $(human_left "$remain")" + else + web_line="new changes are waiting; it should update at the next check" + fi ;; + non-site-only|dist-unchanged) + web_line="up to date (the latest changes did not affect the website)" ;; + dirty-tree) + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + web_line="paused because the server has uncommitted changes" + problems+=("The website deploy is paused because the server has uncommitted changes.") + else + remain=$((86400 - (now - ${deploy_epoch:-0}))) + if [ "$remain" -gt 0 ]; then + web_line="new changes are waiting; it will update automatically in about $(human_left "$remain")" + else + web_line="new changes are waiting; the next check will deploy them" + fi + fi ;; + preflight-failed) + web_line="paused because the tests failed" + problems+=("The website deploy is paused because the tests failed.") ;; + smoke-failed) + web_line="paused because the built app did not start" + problems+=("The website deploy is paused because the built app did not start.") ;; + diverged) + web_line="paused because the server code is out of sync" + problems+=("The website deploy is paused because the server code is out of sync.") ;; + fetch-failed) + web_line="waiting because the server could not reach the code host" + problems+=("The website deploy could not reach the code host.") ;; + *) + web_line="new changes are waiting (reason: ${sk_reason:-unknown})" ;; + esac + else + web_line="new changes are waiting; the next check will deploy them" + fi +fi + +deploy_timer="$(systemctl --user is-active skatespots-deploy.timer 2>/dev/null || echo unknown)" +if [ "$deploy_timer" != "active" ]; then + web_line="$web_line; the website deploy timer is stopped" + problems+=("The website deploy timer is stopped.") +fi + +# ── dependencies ──────────────────────────────────────────────────────────── +deps_line="" +deps_timer="$(systemctl --user is-active skatespots-deps.timer 2>/dev/null || echo unknown)" +scan_timer="$(systemctl --user is-active skatespots-deps-scan.timer 2>/dev/null || echo unknown)" + +if [ "$deps_timer" = "active" ]; then + if [ -f "$deps_last_file" ]; then + deps_last="$(cat "$deps_last_file" 2>/dev/null || echo 0)" + deps_due=$((deps_last + 16 * 86400)) + if [ "$deps_due" -gt "$now" ]; then + deps_line="the next automatic dependency check is in about $(human_left $((deps_due - now)))" + else + deps_line="the next automatic dependency check runs at the next daily check (around midnight)" + fi + else + deps_line="the first automatic check will run at the next daily check" + fi +else + deps_line="the dependency timer is stopped" + problems+=("The dependency update timer is stopped.") +fi + +if [ -f "$deps_fail_file" ]; then + deps_line="$deps_line. The last update failed and was rolled back; the next retry is in about 3 days" + problems+=("The last dependency update failed and was rolled back.") +fi + +if [ "$scan_timer" != "active" ]; then + problems+=("The weekly security scan timer is stopped.") +fi + +# ── security ──────────────────────────────────────────────────────────────── +security_line="no security scan report is available yet" +latest_report="$(ls -1t "$state_deps"/report-*.txt 2>/dev/null | head -1 || true)" +if [ -n "$latest_report" ]; then + osv_line="$(grep -E '^Total [0-9]+ packages affected' "$latest_report" | tail -1 || true)" + if [[ "$osv_line" =~ Total\ ([0-9]+)\ packages\ affected\ by\ ([0-9]+)\ known\ vulnerabilities\ \(([0-9]+)\ Critical ]]; then + pkgs="${BASH_REMATCH[1]}"; vulns="${BASH_REMATCH[2]}"; crit="${BASH_REMATCH[3]}" + if [ "$crit" -eq 0 ]; then + security_line="the latest security scan found $vulns known issues in $pkgs packages; none are critical. They are in build/development tools and the Excel export, which only writes files" + else + security_line="the latest security scan found $vulns known issues in $pkgs packages, including $crit critical" + problems+=("The security scan found $crit critical issue(s).") + fi + else + security_line="the latest security scan finished; check the server report for details" + fi +fi + +scan_line="every Monday at 06:15" +if [ "$scan_timer" != "active" ]; then + scan_line="the timer is stopped" +fi + +# ── compose ───────────────────────────────────────────────────────────────── +if [ "${#problems[@]}" -eq 0 ]; then + overall="Overall: all good." +else + overall="Overall: needs attention." +fi + +report="$(cat </dev/null | sort -V | tail -1)" + [ -n "$NODE_BIN" ] && export PATH="$NODE_BIN:$PATH" +fi +export PATH="$HOME/go/bin:$PATH" + +MSG="${*:-SkateSpots needs attention.}" +{ + echo "SkateSpots alert" + echo + echo "$MSG" + echo + echo "$(LC_ALL=C date '+%A %-d %B %Y, %H:%M')" + echo + echo "This message was sent automatically to the SkateSpots admins." +} | timeout 90 "$REPO/node_modules/.bin/tsx" "$REPO/scripts/send-ops-report.ts" diff --git a/scripts/send-ops-report.ts b/scripts/send-ops-report.ts new file mode 100644 index 0000000..c920e91 --- /dev/null +++ b/scripts/send-ops-report.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Send a plain-text SkateSpots ops report to the two admins as a NIP-17 DM. + * + * The message is read from stdin. The sender is the app identity in + * ~/.config/skatespots/zsp.env (mode 600); the nsec is never printed. + * + * Recipients come from src/lib/admins.ts (the single source of truth): + * npub1yrnuj… (founder) and npub1e2ay… (second admin). + * + * Delivery mirrors the app's DM logic: discover each admin's kind-10050 inbox, + * fall back to their NIP-65 write relays, and always include the community relay + * (the push service watches it). One gift wrap is published per recipient. + */ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool'; +import * as nip17 from 'nostr-tools/nip17'; +import * as nip19 from 'nostr-tools/nip19'; +import { getPublicKey } from 'nostr-tools/pure'; +import WebSocket from 'ws'; +import { ADMIN_PUBKEYS } from '../src/lib/admins'; + +useWebSocketImplementation(WebSocket as never); + +const DISCOVERY = [ + 'wss://purplepag.es', + 'wss://relay.ditto.pub', + 'wss://relay.skate-spots.org', + 'wss://nos.lol', +]; +const COMMUNITY = 'wss://relay.skate-spots.org'; + +function readSecret(): string { + const fromEnv = process.env.SIGN_WITH?.trim(); + if (fromEnv) return fromEnv; + const file = process.env.SKATES_RELEASE_ENV + || join(homedir(), '.config/skatespots/zsp.env'); + const text = readFileSync(file, 'utf8'); + const match = text.match(/^\s*(?:export\s+)?SIGN_WITH=(nsec1[0-9a-z]+)\s*$/m); + if (!match) throw new Error(`SIGN_WITH not found in ${file}`); + return match[1]; +} + +function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)), + ]); +} + +async function main(): Promise { + const text = readFileSync(0, 'utf8').trim(); + if (!text) throw new Error('empty report on stdin'); + + const secret = readSecret(); + const decoded = nip19.decode(secret); + if (decoded.type !== 'nsec') throw new Error('SIGN_WITH is not an nsec'); + const senderSecret = decoded.data; + const senderPubkey = getPublicKey(senderSecret); + + // `wrapManyEvents` adds the sender automatically; don't add the founder twice. + const recipients = ADMIN_PUBKEYS + .filter((pubkey) => pubkey !== senderPubkey) + .map((publicKey) => ({ publicKey })); + + const pool = new SimplePool(); + const authors = [...new Set([...ADMIN_PUBKEYS, senderPubkey])]; + + const [inboxes, relayLists] = await Promise.all([ + pool.querySync( + DISCOVERY, + { kinds: [10050], authors, limit: authors.length * 2 }, + { maxWait: 4000 }, + ).catch(() => []), + pool.querySync( + DISCOVERY, + { kinds: [10002], authors: ADMIN_PUBKEYS, limit: ADMIN_PUBKEYS.length }, + { maxWait: 4000 }, + ).catch(() => []), + ]); + + const relays = new Set([COMMUNITY]); + const haveInbox = new Set(); + + for (const event of inboxes) { + const urls = event.tags + .filter(([name]) => name === 'relay') + .map(([, url]) => url) + .filter(Boolean); + if (urls.length) haveInbox.add(event.pubkey); + for (const url of urls) relays.add(url); + } + + // A recipient with no inbox list falls back to their NIP-65 write relays. + for (const event of relayLists) { + if (haveInbox.has(event.pubkey)) continue; + for (const [name, url, marker] of event.tags) { + if (name === 'r' && url && (!marker || marker === 'write')) relays.add(url); + } + } + + const wraps = nip17.wrapManyEvents(senderSecret, recipients, text, 'SkateSpots ops'); + + if (process.env.OPS_DRY_RUN === '1') { + console.log(`sender: ${nip19.npubEncode(senderPubkey)}`); + console.log(`recipients: ${ADMIN_PUBKEYS.map((p) => nip19.npubEncode(p)).join(', ')}`); + console.log(`relays: ${[...relays].join(', ')}`); + console.log(`dry run: ${wraps.length} gift wrap(s) prepared, nothing published`); + pool.close([...relays]); + return; + } + + let accepted = 0; + for (const wrap of wraps) { + const results = await Promise.allSettled( + pool.publish([...relays], wrap).map((p) => withTimeout(p, 10000)), + ); + accepted += results.filter((result) => result.status === 'fulfilled').length; + } + pool.close([...relays]); + + console.log(`recipients: ${ADMIN_PUBKEYS.map((p) => nip19.npubEncode(p)).join(', ')}`); + console.log(`relays: ${[...relays].join(', ')}`); + console.log(`accepted: ${accepted} publish(es)`); + console.log(`message ids: ${wraps.map((wrap) => wrap.id).join(', ')}`); + + if (accepted === 0) process.exitCode = 1; +} + +main().catch((error) => { + console.error(`send-ops-report: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/systemd/skatespots-ops-report.service b/scripts/systemd/skatespots-ops-report.service new file mode 100644 index 0000000..cca32d2 --- /dev/null +++ b/scripts/systemd/skatespots-ops-report.service @@ -0,0 +1,7 @@ +[Unit] +Description=SkateSpots daily ops report (NIP-17 to admins) + +[Service] +Type=oneshot +TimeoutStartSec=5min +ExecStart=%h/SkateSpots/scripts/ops-report.sh --send diff --git a/scripts/systemd/skatespots-ops-report.timer b/scripts/systemd/skatespots-ops-report.timer new file mode 100644 index 0000000..87ea730 --- /dev/null +++ b/scripts/systemd/skatespots-ops-report.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily SkateSpots ops report + +[Timer] +OnCalendar=*-*-* 07:00:00 +Persistent=true +RandomizedDelaySec=10min + +[Install] +WantedBy=timers.target diff --git a/scripts/update-deps.sh b/scripts/update-deps.sh index 1997f8f..c5fcbb3 100755 --- a/scripts/update-deps.sh +++ b/scripts/update-deps.sh @@ -278,5 +278,6 @@ else RETRY_DAYS=3; [ "$INTERVAL_DAYS" -gt 4 ] || RETRY_DAYS=1 echo $(( $(date +%s) - (INTERVAL_DAYS - RETRY_DAYS) * 86400 )) > "$LAST" notify "Dependency update REVERTED — full and patch fallback failed (see $FAIL_DIR). ${OSV_SUMMARY}" + timeout 90 "$REPO/scripts/send-ops-alert.sh" "The dependency update failed and was rolled back. The app is unchanged. The next retry is in about 3 days." || true exit 1 fi