- 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.
361 lines
11 KiB
TypeScript
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);
|
|
});
|