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.
This commit is contained in:
SkateSpots 2026-10-27 09:36:44 +07:00
parent 39c388a6d7
commit 8473aaa994
10 changed files with 427 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

214
scripts/ops-report.sh Executable file
View file

@ -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 <<EOF
SkateSpots daily report
$now_human
$overall
App: $app_line.
Website: $web_line.
Dependencies: $deps_line.
Security: $security_line.
Weekly scan: $scan_line.
EOF
)"
if [ "${#problems[@]}" -gt 0 ]; then
report="$report
What needs attention:"
for problem in "${problems[@]}"; do
report="$report
- $problem"
done
fi
report="$report
This message was sent automatically to the SkateSpots admins."
printf '%s\n' "$report"
if [ "$SEND" -eq 1 ]; then
printf '%s\n' "$report" | timeout 90 "$REPO/node_modules/.bin/tsx" "$REPO/scripts/send-ops-report.ts"
fi

24
scripts/send-ops-alert.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
#
# Send a short plain-English alert to the SkateSpots admins as a NIP-17 DM.
# Usage: scripts/send-ops-alert.sh "message"
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"
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"

135
scripts/send-ops-report.ts Normal file
View file

@ -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<T>(p: Promise<T>, ms: number): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
async function main(): Promise<void> {
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<string>([COMMUNITY]);
const haveInbox = new Set<string>();
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);
});

View file

@ -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

View file

@ -0,0 +1,10 @@
[Unit]
Description=Daily SkateSpots ops report
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target

View file

@ -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