- 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.
135 lines
4.7 KiB
JavaScript
135 lines
4.7 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 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);
|
|
});
|