diff --git a/AGENTS.md b/AGENTS.md index b62fe76..2a8e681 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2415,6 +2415,14 @@ Rules: - `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). +- **Push:** every admin copy (including the sender's) carries the app's + `["skatespots","push"]` marker, so the push service wakes the phone with the + app closed. A push test was sent 2026-10-27 (wraps `39fbbd4e…`/`c88c9f55…`). +- **Scan noise:** the weekly scan-only DM is sent only when the OSV summary + **changes**; unchanged scans are logged, not messaged. +- **Deploy alerts:** `auto-deploy.sh` alerts on a Netlify failure and on a + post-deploy `https://skate-spots.org/` health-check failure + (`deploy-failed` / `health-failed` show in ops-health and the daily report). - `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). diff --git a/scripts/auto-deploy.sh b/scripts/auto-deploy.sh index 876af70..2bcf5ad 100755 --- a/scripts/auto-deploy.sh +++ b/scripts/auto-deploy.sh @@ -187,10 +187,34 @@ fi # 7b. Deploy to Netlify production. log "deploying to Netlify…" -netlify deploy --prod --dir=dist +if ! netlify deploy --prod --dir=dist; then + log "NETLIFY DEPLOY FAILED" + record_skip deploy-failed "netlify deploy failed" + timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deploy failed at Netlify. The live site is unchanged or incomplete. Please check the Netlify dashboard." || true + exit 1 +fi echo "$REMOTE_SHA" > "$SHA_FILE" echo "$DIST_HASH" > "$DIST_HASH_FILE" date +%s > "$INTERVAL_FILE" + +# 7c. Production health check. The CDN can take a few seconds, so retry +# briefly. If it never answers, leave a health-failed marker (the next tick +# sees LAST_SHA == REMOTE_SHA and won't loop-redeploy) and alert. +health_ok=0 +for _ in 1 2 3 4; do + if curl -fsSL --max-time 20 https://skate-spots.org/ >/dev/null 2>&1; then + health_ok=1 + break + fi + sleep 5 +done +if [ "$health_ok" -ne 1 ]; then + log "POST-DEPLOY HEALTH CHECK FAILED" + record_skip health-failed "the production URL did not answer" + timeout 60 "$REPO/scripts/send-ops-alert.sh" "The website deployed but did not answer its health check. Please check it." || true + exit 1 +fi + rm -f "$SKIP_FILE" log "deployed ${REMOTE_SHA:0:9} ✓" diff --git a/scripts/ops-health.sh b/scripts/ops-health.sh index 9628f97..2239321 100755 --- a/scripts/ops-health.sh +++ b/scripts/ops-health.sh @@ -152,6 +152,12 @@ else preflight-failed) p " ${R}→ the test gate failed — fix typecheck/lint/i18n/tests before it can deploy.${N}" problems+=("deploy blocked: preflight failed"); signature+="preflight;" ;; + deploy-failed) + p " ${R}→ Netlify rejected the deploy — check the Netlify dashboard.${N}" + problems+=("Netlify deploy failed"); signature+="deploy-failed;" ;; + health-failed) + p " ${R}→ the production URL did not answer after the deploy.${N}" + problems+=("production health check failed"); signature+="health-failed;" ;; diverged) p " ${R}→ local main diverged from origin/main — a manual merge is needed.${N}" problems+=("local main diverged from origin/main"); signature+="diverged;" ;; diff --git a/scripts/ops-report.sh b/scripts/ops-report.sh index d0f404c..110c793 100755 --- a/scripts/ops-report.sh +++ b/scripts/ops-report.sh @@ -63,53 +63,61 @@ fi # ── website ───────────────────────────────────────────────────────────────── web_line="" -if [ "$deployed_sha" = "$origin_sha" ]; then +skip_reason=""; sk_at=""; sk_sha=""; sk_detail="" +if [ -f "$skip_file" ]; then + IFS='|' read -r sk_at skip_reason sk_sha sk_detail < "$skip_file" +fi + +if [ "$skip_reason" = "health-failed" ]; then + web_line="deployed but did not answer its health check" + problems+=("The website deployed but did not answer its health check.") +elif [ "$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. + case "${skip_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; 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 + 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.") ;; + deploy-failed) + web_line="the deploy failed at Netlify" + problems+=("The website deploy failed at Netlify.") ;; + 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; the next check will deploy them" ;; + *) + web_line="new changes are waiting (reason: ${skip_reason})" ;; + esac fi deploy_timer="$(systemctl --user is-active skatespots-deploy.timer 2>/dev/null || echo unknown)" diff --git a/scripts/send-ops-report.ts b/scripts/send-ops-report.ts index c920e91..fabf0e5 100644 --- a/scripts/send-ops-report.ts +++ b/scripts/send-ops-report.ts @@ -16,9 +16,10 @@ 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 nip44 from 'nostr-tools/nip44'; +import * as nip59 from 'nostr-tools/nip59'; import * as nip19 from 'nostr-tools/nip19'; -import { getPublicKey } from 'nostr-tools/pure'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import WebSocket from 'ws'; import { ADMIN_PUBKEYS } from '../src/lib/admins'; @@ -50,6 +51,55 @@ function withTimeout(p: Promise, ms: number): Promise { ]); } +const DM_KIND = 14; +const GIFT_WRAP_KIND = 1059; +/** Same plaintext marker the app's DM sender and push-service use. */ +const PUSH_MARKER = 'skatespots'; + +function randomPastTimestamp(): number { + return Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 24 * 60 * 60); +} + +/** + * NIP-17 gift wraps, mirroring `useDirectMessages.ts` but adding the push + * marker to every admin copy (including the sender's, so the founder's phone + * also wakes for the daily report). The push service only reads the recipient + * `p` tag + marker; it never decrypts. + */ +function createWraps( + senderSecret: Uint8Array, + senderPubkey: string, + admins: string[], + text: string, +) { + const rumor = nip59.createRumor( + { + kind: DM_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [...admins.map((pubkey) => ['p', pubkey]), ['subject', 'SkateSpots ops']], + content: text, + }, + senderSecret, + ); + + const targets = [...new Set([senderPubkey, ...admins])]; + return targets.map((target) => { + const seal = nip59.createSeal(rumor, senderSecret, target); + const ephemeral = generateSecretKey(); + const conversationKey = nip44.v2.utils.getConversationKey(ephemeral, target); + const content = nip44.v2.encrypt(JSON.stringify(seal), conversationKey); + return finalizeEvent( + { + kind: GIFT_WRAP_KIND, + created_at: randomPastTimestamp(), + tags: [['p', target], [PUSH_MARKER, 'push']], + content, + }, + ephemeral, + ); + }); +} + async function main(): Promise { const text = readFileSync(0, 'utf8').trim(); if (!text) throw new Error('empty report on stdin'); @@ -60,11 +110,6 @@ async function main(): Promise { 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])]; @@ -101,7 +146,7 @@ async function main(): Promise { } } - const wraps = nip17.wrapManyEvents(senderSecret, recipients, text, 'SkateSpots ops'); + const wraps = createWraps(senderSecret, senderPubkey, ADMIN_PUBKEYS, text); if (process.env.OPS_DRY_RUN === '1') { console.log(`sender: ${nip19.npubEncode(senderPubkey)}`); diff --git a/scripts/update-deps.sh b/scripts/update-deps.sh index c5fcbb3..9de8eea 100755 --- a/scripts/update-deps.sh +++ b/scripts/update-deps.sh @@ -163,7 +163,17 @@ ls -1dt "$FAILURE_DIR_BASE"/* 2>/dev/null | tail -n +9 | xargs -r rm -rf if [ "$SCAN_ONLY" -eq 1 ]; then log "scan-only; report: $REPORT" echo "$OSV_SUMMARY" - notify "Vulnerability scan: $OSV_SUMMARY (report: $REPORT)" + PREV_REPORT="$(ls -1t "$STATE_DIR"/report-*.txt 2>/dev/null | sed -n '2p' | head -1 || true)" + PREV_OSV="" + if [ -n "$PREV_REPORT" ]; then + PREV_OSV="$(grep -E '^Total [0-9]+ packages affected' "$PREV_REPORT" | tail -1 || true)" + fi + if [ "$OSV_SUMMARY" != "$PREV_OSV" ]; then + notify "Vulnerability scan changed: $OSV_SUMMARY (report: $REPORT)" + timeout 90 "$REPO/scripts/send-ops-alert.sh" "Security scan changed. $OSV_SUMMARY. Check the server report for details." || true + else + log "scan unchanged since the previous report — not alerting" + fi exit 0 fi