SkateSpots/push-service/server.ts
SkateSpots baa770f1b8 feat(push): push marketplace events (offers/requests/confirms), not just DMs
The service now also subscribes to marketplace events addressed to subscribers
(kinds 30403/31872/31871) via #p filters built from the live subscription set,
re-subscribed on every subscribe/unsubscribe. Payloads stay generic ('New offer
on your listing', 'Your offer was accepted') - never the amount or item. Verified
locally: subscribe -> offer to #p -> push attempt.
2026-10-01 16:07:21 +07:00

338 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SkateSpots push service
* ─────────────────────────────────────────────────────────────────────────────
* A keyless Bun service that delivers **closed-app notifications** for NIP-17
* gift wraps. It watches relays for kind-1059 wraps carrying the plaintext push
* marker `["<PUSH_MARKER>", "push"]`, extracts ONLY the recipient pubkey and the
* wrap id, and sends a Web Push. It **never decrypts** anything — the client
* fetches + decrypts the named wrap itself.
*
* Protocol (ours — independent of, though inspired by, Linky):
* GET /health → { ok, subscriptions, relays }
* GET /vapid-public-key → { key }
* POST /auth/challenge {pubkey} → { challenge, expiresAt }
* POST /subscribe { subscription, proof } → { ok }
* POST /unsubscribe { endpoint, proof } → { ok }
*
* `proof` is a NIP-98 kind-27235 event signed by the subscribing pubkey,
* carrying ["challenge", <value>] from /auth/challenge — it proves ownership
* of that pubkey. A challenge is one-time (consumed on use).
*
* State is a small JSON file (subscriptions + challenges). Env + deploy: README.md.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import webpush from 'web-push';
import { verifyEvent, type NostrEvent } from 'nostr-tools';
// Bun provides this at runtime; declare it so the standalone tsc check passes
// without pulling all of @types/bun into the app's toolchain.
declare const Bun: {
serve(options: { port: number; fetch: (req: Request) => Response | Promise<Response> }): unknown;
};
// ─── Config ──────────────────────────────────────────────────────────────────
const env = process.env;
const PORT = Number(env.PORT ?? 8788);
const MARKER = (env.PUSH_MARKER ?? 'skatespots').trim();
const STORAGE_PATH = env.PUSH_STORAGE_PATH ?? './push-state.json';
const RELAYS = (env.PUSH_RELAYS ?? 'wss://goskatespots.nostr1.com')
.split(',').map((s) => s.trim()).filter(Boolean);
const LOOKBACK_SECONDS = Number(env.PUSH_LOOKBACK_SECONDS ?? 3 * 24 * 60 * 60);
const CHALLENGE_TTL_SECONDS = Number(env.PUSH_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const ORIGINS = (env.PUSH_ALLOWED_ORIGINS ?? 'https://skate-spots.org,http://localhost:8080')
.split(',').map((s) => s.trim()).filter(Boolean);
let vapidPublic = env.PUSH_VAPID_PUBLIC_KEY ?? '';
let vapidPrivate = env.PUSH_VAPID_PRIVATE_KEY ?? '';
const vapidSubject = env.PUSH_VAPID_SUBJECT ?? 'mailto:owner@skate-spots.org';
if (!vapidPublic || !vapidPrivate) {
const generated = webpush.generateVAPIDKeys();
vapidPublic = generated.publicKey;
vapidPrivate = generated.privateKey;
console.warn('[push] ⚠️ PUSH_VAPID_* not set — generated EPHEMERAL keys (subscriptions break on restart).');
}
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
// ─── State (JSON file) ───────────────────────────────────────────────────────
interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string }
interface State {
subscriptions: Record<string, StoredSubscription>; // endpoint → sub
challenges: Record<string, { pubkey: string; expiresAt: number }>;
}
function load(): State {
try {
const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>;
return { subscriptions: parsed.subscriptions ?? {}, challenges: parsed.challenges ?? {} };
} catch {
return { subscriptions: {}, challenges: {} };
}
}
const state = load();
function save(): void {
try { writeFileSync(STORAGE_PATH, JSON.stringify(state)); }
catch (err) { console.warn('[push] could not persist state:', (err as Error).message); }
}
function sweepChallenges(): void {
const now = Math.floor(Date.now() / 1000);
for (const [id, c] of Object.entries(state.challenges)) if (c.expiresAt < now) delete state.challenges[id];
}
// ─── NIP-98 proof ────────────────────────────────────────────────────────────
function tagValue(ev: NostrEvent, name: string): string | undefined {
return ev.tags.find(([n]) => n === name)?.[1];
}
/** Verify a kind-27235 ownership proof for `expectedPubkey`, consuming its challenge. */
function verifyProof(proof: unknown, expectedPubkey: string): { ok: boolean; error?: string } {
if (!proof || typeof proof !== 'object') return { ok: false, error: 'missing proof' };
const ev = proof as NostrEvent;
if (ev.kind !== 27235) return { ok: false, error: 'proof must be kind 27235' };
if (ev.pubkey !== expectedPubkey) return { ok: false, error: 'proof pubkey mismatch' };
const challenge = tagValue(ev, 'challenge');
if (!challenge) return { ok: false, error: 'proof missing challenge' };
if (!verifyEvent(ev)) return { ok: false, error: 'bad proof signature' };
const row = state.challenges[challenge];
delete state.challenges[challenge]; // one-time, even on failure
save();
if (!row) return { ok: false, error: 'unknown challenge' };
if (row.pubkey !== expectedPubkey) return { ok: false, error: 'challenge/pubkey mismatch' };
if (row.expiresAt < Math.floor(Date.now() / 1000)) return { ok: false, error: 'challenge expired' };
return { ok: true };
}
// ─── Web Push ────────────────────────────────────────────────────────────────
function subsFor(pubkey: string): StoredSubscription[] {
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey);
}
async function pushTo(pubkey: string, payload: Record<string, unknown>): Promise<void> {
for (const s of subsFor(pubkey)) {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
JSON.stringify(payload),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
console.log(`[push] dropping dead subscription for ${pubkey.slice(0, 12)}…`);
delete state.subscriptions[s.endpoint];
save();
} else {
console.warn(`[push] send failed (${status ?? 'error'}) for ${pubkey.slice(0, 12)}…`);
}
}
}
}
// ─── Relay watcher (no keys, no decryption) ──────────────────────────────────
const relayStatus: Record<string, string> = {};
const relaySockets = new Map<string, WebSocket>();
/** Marketplace kinds we push for. Offers carry the whole negotiation state. */
const OFFER_KIND = 30403; // offer / counter / accepted / declined / withdrawn
const TRADE_CONFIRM_KIND = 31871; // counterparty recorded the trade
const TRADE_REQUEST_KIND = 31872; // "please confirm our trade"
const MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND];
function subscribedPubkeys(): string[] {
return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))];
}
/** Content-free body — no amounts, no item text (that stays out of the push). */
function marketBody(ev: NostrEvent): string {
if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade';
if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade';
const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'open';
if (status === 'accepted') return 'Your offer was accepted';
if (status === 'declined') return 'Your offer was declined';
if (status === 'withdrawn') return 'An offer was withdrawn';
const role = ev.tags.find(([n]) => n === 'role')?.[1];
return role === 'offer' ? 'New offer on your listing' : 'You got a counter-offer';
}
let marketSubSeq = 0;
let currentMarketSubId: string | null = null;
/** Ask ONE relay for marketplace events addressed to the CURRENT subscribers. */
function sendMarketSubscription(ws: WebSocket, since: number): void {
if (currentMarketSubId) {
try { ws.send(JSON.stringify(['CLOSE', currentMarketSubId])); } catch { /* socket gone */ }
}
const pubkeys = subscribedPubkeys();
if (pubkeys.length === 0) return;
const id = `skatespots-push-mkt-${++marketSubSeq}`;
// `#p` filters so we only receive marketplace events addressed to subscribers,
// chunked (a REQ carries multiple filters).
const filters: unknown[] = [];
for (let i = 0; i < pubkeys.length; i += 50) {
filters.push({ kinds: MARKET_KINDS, '#p': pubkeys.slice(i, i + 50), since });
}
ws.send(JSON.stringify(['REQ', id, ...filters]));
currentMarketSubId = id;
}
let marketRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** Rebuild the marketplace subscription after a subscribe/unsubscribe (debounced). */
function refreshMarketSubscription(): void {
if (marketRefreshTimer) clearTimeout(marketRefreshTimer);
marketRefreshTimer = setTimeout(() => {
const since = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS;
for (const ws of relaySockets.values()) {
if (ws.readyState === 1) sendMarketSubscription(ws, since);
}
}, 1500);
}
function handleEvent(ev: NostrEvent): void {
if (!verifyEvent(ev)) return;
// 1) NIP-17 gift wraps: push-marked, exactly one recipient, never decrypted.
if (ev.kind === 1059) {
const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))];
if (recipients.length !== 1) return; // fan-out wraps aren't pushable
if (!ev.tags.some(([n, v]) => n === MARKER && v === 'push')) return;
console.log(`[push] 🔔 marked wrap ${ev.id.slice(0, 12)}… → ${recipients[0].slice(0, 12)}…`);
void pushTo(recipients[0], { title: 'SkateSpots', body: 'New message', wrapId: ev.id, url: '/#/messages' });
return;
}
// 2) Marketplace events addressed to a subscriber (public — metadata only;
// we only ever send a generic body, never the amount or item).
if (MARKET_KINDS.includes(ev.kind)) {
const recipients = [...new Set(ev.tags.filter(([n]) => n === 'p').map(([, v]) => v).filter(Boolean))];
for (const r of recipients) {
if (r === ev.pubkey) continue; // never notify the author about their own message
console.log(`[push] 🔔 market ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`);
void pushTo(r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: '/#/trades' });
}
}
}
function watchRelay(url: string): void {
relayStatus[url] = 'connecting';
const ws = new WebSocket(url);
const since = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS;
ws.addEventListener('open', () => {
relayStatus[url] = 'live';
relaySockets.set(url, ws);
ws.send(JSON.stringify(['REQ', 'skatespots-push', { kinds: [1059], since }]));
sendMarketSubscription(ws, since);
console.log(`[push] watching ${url}`);
});
ws.addEventListener('message', (msg: MessageEvent) => {
let parsed: unknown;
try { parsed = JSON.parse(String(msg.data)); } catch { return; }
if (!Array.isArray(parsed) || parsed[0] !== 'EVENT') return;
handleEvent(parsed[2] as NostrEvent);
});
const retry = () => { relayStatus[url] = 'down'; relaySockets.delete(url); setTimeout(() => watchRelay(url), 5000); };
ws.addEventListener('close', retry);
ws.addEventListener('error', () => { try { ws.close(); } catch { /* noop */ } });
}
for (const url of RELAYS) watchRelay(url);
// ─── HTTP ────────────────────────────────────────────────────────────────────
function cors(origin: string | null): Record<string, string> {
if (origin && ORIGINS.includes(origin)) return { 'access-control-allow-origin': origin, vary: 'Origin' };
return {};
}
function json(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...extra } });
}
Bun.serve({
port: PORT,
async fetch(req: Request) {
const url = new URL(req.url);
const headers = cors(req.headers.get('origin'));
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...headers,
'access-control-allow-methods': 'GET,POST,OPTIONS',
'access-control-allow-headers': 'content-type',
'access-control-max-age': '86400',
},
});
}
if (url.pathname === '/health') {
return json({
ok: true,
subscriptions: Object.keys(state.subscriptions).length,
relays: relayStatus,
}, 200, headers);
}
if (url.pathname === '/vapid-public-key') return json({ key: vapidPublic }, 200, headers);
if (req.method === 'POST' && url.pathname === '/auth/challenge') {
const body = (await req.json().catch(() => null)) as { pubkey?: string } | null;
if (!body?.pubkey || !/^[0-9a-f]{64}$/i.test(body.pubkey)) {
return json({ error: 'valid hex pubkey required' }, 400, headers);
}
sweepChallenges();
const id = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + CHALLENGE_TTL_SECONDS;
state.challenges[id] = { pubkey: body.pubkey.toLowerCase(), expiresAt };
save();
return json({ challenge: id, expiresAt }, 200, headers);
}
if (req.method === 'POST' && url.pathname === '/subscribe') {
const body = (await req.json().catch(() => null)) as {
subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
proof?: NostrEvent;
} | null;
const sub = body?.subscription;
if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
return json({ error: 'subscription { endpoint, keys{ p256dh, auth } } required' }, 400, headers);
}
if (!body?.proof) return json({ error: 'proof required' }, 400, headers);
const check = verifyProof(body.proof, body.proof.pubkey);
if (!check.ok) return json({ error: check.error }, 401, headers);
state.subscriptions[sub.endpoint] = {
endpoint: sub.endpoint,
pubkey: body.proof.pubkey.toLowerCase(),
p256dh: sub.keys.p256dh,
auth: sub.keys.auth,
};
save();
console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`);
refreshMarketSubscription();
return json({ ok: true }, 200, headers);
}
if (req.method === 'POST' && url.pathname === '/unsubscribe') {
const body = (await req.json().catch(() => null)) as { endpoint?: string; proof?: NostrEvent } | null;
if (!body?.endpoint || !body?.proof) return json({ error: 'endpoint and proof required' }, 400, headers);
const check = verifyProof(body.proof, body.proof.pubkey);
if (!check.ok) return json({ error: check.error }, 401, headers);
delete state.subscriptions[body.endpoint];
save();
refreshMarketSubscription();
return json({ ok: true }, 200, headers);
}
return json({ error: 'not found' }, 404, headers);
},
});
console.log(`[push] listening on :${PORT} — marker "${MARKER}", relays: ${RELAYS.join(', ')}`);