SkateSpots/scripts/cordn-join-watcher.ts
SkateSpots 66fc47cc28 feat: restore map footer, inline crew roles, Cordn reactions/push
- Keep the map footer links permanently visible; remove the hide toggle.
- Publish inline founder promote/demote from the member row.
- Add encrypted MLS reaction/delete/read control messages and UI.
- Add the minute Cordn join watcher: NIP-17 DMs + encrypted bell notices.
- Add the browser translation-matrix runner and a low-end performance finding.
2026-10-28 11:02:47 +07:00

361 lines
11 KiB
TypeScript

/**
* Cordn join-request watcher.
*
* Coordinator join requests are RPC rows, not Nostr events. This small always-on
* job turns each new request into:
* 1. a NIP-17 DM for OS/web push (the app's push service marks `skatespots`),
* 2. an encrypted NIP-78 notification for the in-app bell.
*
* It uses a dedicated watcher key (no admin key), only reads coordinator
* metadata + public crew events, and never sees message plaintext.
*
* Run: npx tsx scripts/cordn-join-watcher.ts
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import WebSocket from 'ws';
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip19,
} from 'nostr-tools';
import * as nip44 from 'nostr-tools/nip44';
import * as nip59 from 'nostr-tools/nip59';
import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool';
import { CordnCoordinatorClient } from '../src/lib/cordn/native/coordinator';
import type { FetchManyPendingJoinRequestsOutput } from '@cordn/core';
import { SKATESPOTS_COORDINATOR } from '../src/lib/coordinator';
import {
CORDN_JOIN_D_PREFIX,
CORDN_JOIN_TAG,
CORDN_WATCHER_PUBKEY,
} from '../src/lib/cordn/watch';
(globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket;
useWebSocketImplementation(WebSocket as never);
const COMMUNITY = 'wss://relay.skate-spots.org';
const DISCOVERY = [
'wss://purplepag.es',
'wss://relay.ditto.pub',
'wss://relay.skate-spots.org',
'wss://nos.lol',
];
const CREW_KIND = 35006;
const DM_KIND = 14;
const GIFT_WRAP_KIND = 1059;
const PUSH_MARKER = 'skatespots';
interface Crew {
gid: string;
name: string;
pubkey: string;
identifier: string;
recipients: string[];
}
interface WatcherState {
seen: Record<string, number>;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function readWatcherSecret(): Uint8Array {
const file = join(homedir(), '.config/skatespots/cordn-watcher.env');
const text = readFileSync(file, 'utf8');
const nsec = text.match(/^CORDN_WATCHER_NSEC=(nsec1[0-9a-z]+)$/m)?.[1];
if (!nsec) throw new Error(`CORDN_WATCHER_NSEC not found in ${file}`);
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') throw new Error('CORDN_WATCHER_NSEC is not an nsec');
const pubkey = getPublicKey(decoded.data);
if (pubkey !== CORDN_WATCHER_PUBKEY) {
throw new Error('watcher key does not match src/lib/cordn/watch.ts');
}
return decoded.data;
}
function statePath(): string {
const dir = join(homedir(), '.local', 'state', 'skatespots-cordn-watcher');
mkdirSync(dir, { recursive: true });
return join(dir, 'state.json');
}
function loadState(): WatcherState {
const path = statePath();
if (!existsSync(path)) return { seen: {} };
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<WatcherState>;
return { seen: parsed.seen ?? {} };
} catch {
return { seen: {} };
}
}
function saveState(state: WatcherState): void {
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
for (const [key, at] of Object.entries(state.seen)) {
if (at < cutoff) delete state.seen[key];
}
writeFileSync(statePath(), JSON.stringify(state, null, 2), { mode: 0o600 });
}
function randomPastTimestamp(): number {
return Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 24 * 60 * 60);
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
function shortNpub(pubkey: string): string {
try {
const npub = nip19.npubEncode(pubkey);
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
} catch {
return pubkey.slice(0, 12);
}
}
function crewFromEvent(event: {
pubkey: string;
tags: string[][];
}): Crew | null {
const d = event.tags.find(([name]) => name === 'd')?.[1];
const name = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !name) return null;
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
const coordinatorPubkey = coordinatorTag?.[2];
if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null;
const admins = event.tags
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
.map(([, value]) => value.toLowerCase());
const recipients = [...new Set([event.pubkey.toLowerCase(), ...admins])];
return {
gid: `${CREW_KIND}:${event.pubkey}:${d}`,
name,
pubkey: event.pubkey,
identifier: d,
recipients,
};
}
async function discoverDmRelays(pool: SimplePool, recipients: string[]): Promise<string[]> {
const relays = new Set<string>([COMMUNITY]);
const [inboxes, relayLists] = await Promise.all([
pool
.querySync(
DISCOVERY,
{ kinds: [10050], authors: recipients, limit: recipients.length * 2 },
{ maxWait: 4000 },
)
.catch(() => []),
pool
.querySync(
DISCOVERY,
{ kinds: [10002], authors: recipients, limit: recipients.length * 2 },
{ maxWait: 4000 },
)
.catch(() => []),
]);
const haveInbox = new Set<string>();
const addRelay = (url: string | undefined) => {
if (!url) return;
relays.add(url.replace(/\/+$/, ''));
};
for (const event of inboxes) {
for (const [name, url] of event.tags) {
if (name === 'relay' && url) {
addRelay(url);
haveInbox.add(event.pubkey);
}
}
}
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')) addRelay(url);
}
}
return [...relays];
}
function createDmWrap(params: {
watcherSecret: Uint8Array;
recipient: string;
text: string;
}): ReturnType<typeof finalizeEvent> {
const rumor = nip59.createRumor(
{
kind: DM_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', params.recipient], ['subject', 'Cordn join request']],
content: params.text,
},
params.watcherSecret,
);
const seal = nip59.createSeal(rumor, params.watcherSecret, params.recipient);
const ephemeral = generateSecretKey();
const conversationKey = nip44.v2.utils.getConversationKey(ephemeral, params.recipient);
const content = nip44.v2.encrypt(JSON.stringify(seal), conversationKey);
return finalizeEvent(
{
kind: GIFT_WRAP_KIND,
created_at: randomPastTimestamp(),
tags: [['p', params.recipient], [PUSH_MARKER, 'push']],
content,
},
ephemeral,
);
}
function encryptedNotice(params: {
watcherSecret: Uint8Array;
recipient: string;
key: string;
payload: Record<string, unknown>;
}): ReturnType<typeof finalizeEvent> {
const conversationKey = nip44.v2.utils.getConversationKey(
params.watcherSecret,
params.recipient,
);
const content = nip44.v2.encrypt(JSON.stringify(params.payload), conversationKey);
const d = `${CORDN_JOIN_D_PREFIX}:${createHash('sha256')
.update(`${params.key}:${params.recipient}`)
.digest('hex')
.slice(0, 32)}`;
return finalizeEvent(
{
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', d],
['p', params.recipient],
['t', CORDN_JOIN_TAG],
['alt', 'Cordn join request'],
],
content,
},
params.watcherSecret,
);
}
async function main(): Promise<void> {
const watcherSecret = readWatcherSecret();
const watcherPubkey = getPublicKey(watcherSecret);
const pool = new SimplePool();
const crewEvents = await pool.querySync(
[COMMUNITY],
{ kinds: [CREW_KIND], '#t': ['skatecrew'], limit: 500 },
{ maxWait: 8000 },
);
const crews = crewEvents
.map(crewFromEvent)
.filter((crew): crew is Crew => !!crew && crew.recipients.length > 0);
if (crews.length === 0) {
pool.close([COMMUNITY]);
console.log('[cordn-watcher] no crews found');
return;
}
const coordinator = new CordnCoordinatorClient({
serverPubkey: SKATESPOTS_COORDINATOR.pubkey,
relays: SKATESPOTS_COORDINATOR.relays,
privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''),
});
let requests: FetchManyPendingJoinRequestsOutput;
try {
requests = await coordinator.fetchManyPendingJoinRequests({
groups: crews.map((crew) => ({ gid: crew.gid })),
});
} finally {
await coordinator.disconnect().catch(() => undefined);
}
if (requests.requests.length === 0) {
pool.close([COMMUNITY]);
console.log('[cordn-watcher] no pending join requests');
return;
}
const byGid = new Map(crews.map((crew) => [crew.gid, crew]));
const state = loadState();
let sent = 0;
for (const request of requests.requests) {
const crew = byGid.get(request.gid);
if (!crew) continue;
const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
if (state.seen[key]) continue;
const atSeconds = request.at > 1e12 ? Math.floor(request.at / 1000) : request.at;
const payload = {
id: key,
gid: request.gid,
crewName: crew.name,
crewPubkey: crew.pubkey,
crewIdentifier: crew.identifier,
requester: request.pk,
at: atSeconds,
};
const text = [
`New private-chat request for ${crew.name}.`,
'',
`Requester: ${shortNpub(request.pk)}`,
'',
`Review: https://skate-spots.org/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`,
].join('\n');
const relays = await discoverDmRelays(pool, crew.recipients);
let delivered = 0;
for (const recipient of crew.recipients) {
const notice = encryptedNotice({
watcherSecret,
recipient,
key,
payload,
});
const wrap = createDmWrap({ watcherSecret, recipient, text });
const published = await Promise.allSettled([
...pool.publish([COMMUNITY], notice).map((promise) => withTimeout(promise, 10_000)),
...pool.publish(relays, wrap).map((promise) => withTimeout(promise, 10_000)),
]);
if (published.some((result) => result.status === 'fulfilled')) delivered += 1;
}
if (delivered > 0) {
sent += delivered;
state.seen[key] = atSeconds || Math.floor(Date.now() / 1000);
saveState(state);
}
}
pool.close([...new Set([COMMUNITY, ...DISCOVERY])]);
console.log(
JSON.stringify(
{
watcher: nip19.npubEncode(watcherPubkey),
crews: crews.length,
pending: requests.requests.length,
newRequests: sent,
},
null,
2,
),
);
// Keep the timer's journal quiet on a normal idle run.
await sleep(10);
}
main().catch((error) => {
console.error(
`[cordn-watcher] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
});