SkateSpots/scripts/send-ops-report.ts
SkateSpots bf28519e9e feat(ops): push-marked admin alerts, scan-change DMs, deploy/health alerts
- NIP-17 wraps now carry the app's ['skatespots','push'] marker on every admin
  copy (including the sender), so the push service wakes the phone with the app
  closed. Push test wraps 39fbbd4e…/c88c9f55… verified on the community relay.
- The weekly scan-only job DMs only when the OSV summary changes; unchanged
  scans are logged, not messaged.
- auto-deploy alerts on Netlify failure and on a post-deploy production URL
  health-check failure; deploy-failed/health-failed appear in ops-health and
  the daily report.
2026-10-27 09:52:37 +07:00

180 lines
6 KiB
JavaScript

#!/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 nip44 from 'nostr-tools/nip44';
import * as nip59 from 'nostr-tools/nip59';
import * as nip19 from 'nostr-tools/nip19';
import { finalizeEvent, generateSecretKey, 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)),
]);
}
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<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);
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 = createWraps(senderSecret, senderPubkey, ADMIN_PUBKEYS, text);
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);
});