SkateSpots/push-service/server.ts
SkateSpots 6efd6b8deb feat(relay): retire the legacy hosted relay from the app + servers
Verified the legacy relay held the same 115 events as the new one (0 diff) before removing it, so no data is lost. Drops wss://goskatespots.nostr1.com from the default pools, the DM/CVM/zap relay arrays, and the cvm-server + push-service defaults.
2026-10-12 13:06:02 +07:00

546 lines
24 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://relay.skate-spots.org')
.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;
/** Which categories this device wants. Empty/absent = all (back-compat). */
categories?: string[];
}
/**
* Event kind → notification category, mirroring the app's `NotificationCategory`.
* `newsletter` has no push channel (readers poll it); reactions/reposts are
* deliberately excluded (too noisy).
*/
const CATEGORY_BY_KIND: Record<number, string> = {
1059: 'dms', // NIP-17 gift wrap
30403: 'trades', // marketplace offer / counter / accept / decline / withdraw
31871: 'trades', // trade attestation (sold / bought)
31872: 'trades', // confirm-request
34967: 'trades', // shipment status
36229: 'crew', // crew invitation
31969: 'crew', // crew join request
9735: 'social', // zap receipt
1: 'social', // reply / mention
1111: 'social', // comment
};
/** Where a tap should land, per category. */
const URL_BY_CATEGORY: Record<string, string> = {
trades: '/#/trades',
crew: '/#/crews',
social: '/#/',
dms: '/#/messages',
};
function allows(categories: string[] | undefined, category: string): boolean {
// ABSENT = legacy subscriber → allow everything. An EMPTY array = the user
// explicitly wants no push categories.
if (categories === undefined) return true;
return categories.includes(category);
}
interface State {
subscriptions: Record<string, StoredSubscription>; // endpoint → sub
/** Poll token → the pubkey + categories it may read activity for (native polling). */
pollTokens: Record<string, { pubkey: string; categories?: string[]; createdAt: number }>;
/**
* Per-pubkey activity cursor with a per-category breakdown, so a native poller
* can ask "anything new since N?" for ONLY the categories the user wants —
* WITHOUT the server ever revealing *what* (content-free, like web push).
*/
activity: Record<string, { seq: number; at: number; byCategory: Record<string, number> }>;
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 ?? {},
pollTokens: parsed.pollTokens ?? {},
activity: parsed.activity ?? {},
challenges: parsed.challenges ?? {},
};
} catch {
return { subscriptions: {}, pollTokens: {}, activity: {}, 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 };
}
/**
* Reject endpoints that could make the server POST to internal infrastructure
* (SSRF via a forged Web Push endpoint). Real push services are public HTTPS
* hosts; anything loopback/private/link-local is refused.
*/
function isSafePushEndpoint(endpoint: string): boolean {
let url: URL;
try {
url = new URL(endpoint);
} catch {
return false;
}
if (url.protocol !== 'https:') return false;
const host = url.hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
return false;
}
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
if (a === 0 || a === 10 || a === 127) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
if (a === 169 && b === 254) return false;
if (a === 100 && b >= 64 && b <= 127) return false;
}
if (host === '[::1]' || host === '::1') return false;
if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return false;
return true;
}
// ─── Web Push ────────────────────────────────────────────────────────────────
function subsFor(pubkey: string): StoredSubscription[] {
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey);
}
/** Bump the content-free activity cursor for a pubkey + category. */
function recordActivity(pubkey: string, category: string): void {
const cur = state.activity[pubkey] ?? { seq: 0, at: 0, byCategory: {} };
const seq = cur.seq + 1;
state.activity[pubkey] = {
seq,
at: Math.floor(Date.now() / 1000),
byCategory: { ...cur.byCategory, [category]: seq },
};
save();
}
async function pushTo(pubkey: string, payload: Record<string, unknown>, category: string): Promise<void> {
recordActivity(pubkey, category);
for (const s of subsFor(pubkey)) {
// Respect the device's category choices.
if (!allows(s.categories, category)) continue;
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 SHIPMENT_STATUS_KIND = 34967; // shipped / received / issue (self-reported)
const CREW_INVITE_KIND = 36229; // crew invitation
const CREW_JOIN_REQUEST_KIND = 31969; // crew join request
const ZAP_RECEIPT_KIND = 9735; // zap
const TEXT_NOTE_KIND = 1; // reply / mention
const COMMENT_KIND = 1111; // comment
const MARKET_KINDS = [
OFFER_KIND,
TRADE_CONFIRM_KIND,
TRADE_REQUEST_KIND,
SHIPMENT_STATUS_KIND,
CREW_INVITE_KIND,
CREW_JOIN_REQUEST_KIND,
ZAP_RECEIPT_KIND,
TEXT_NOTE_KIND,
COMMENT_KIND,
];
function subscribedPubkeys(): string[] {
// Web subscribers AND native poll-token holders — we watch for both.
const web = Object.values(state.subscriptions).map((s) => s.pubkey);
const poll = Object.values(state.pollTokens).map((t) => t.pubkey);
return [...new Set([...web, ...poll])];
}
/** Content-free body — no amounts, no item text (that stays out of the push). */
function marketBody(ev: NostrEvent): string {
if (ev.kind === CREW_INVITE_KIND) return 'invited you to a crew';
if (ev.kind === CREW_JOIN_REQUEST_KIND) return 'asked to join your crew';
if (ev.kind === ZAP_RECEIPT_KIND) return 'You got a zap ⚡';
if (ev.kind === TEXT_NOTE_KIND || ev.kind === COMMENT_KIND) return 'New reply or mention';
if (ev.kind === TRADE_REQUEST_KIND) return 'asked you to confirm a trade';
if (ev.kind === TRADE_CONFIRM_KIND) return 'confirmed your trade';
if (ev.kind === SHIPMENT_STATUS_KIND) {
const status = ev.tags.find(([n]) => n === 'status')?.[1];
if (status === 'shipped') return 'Your item shipped';
if (status === 'received') return 'Item marked received';
if (status === 'issue') return 'There’s an issue with a shipment';
return 'Shipment update';
}
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' }, 'dms');
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 category = CATEGORY_BY_KIND[ev.kind] ?? 'trades';
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] 🔔 ${category} ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`);
void pushTo(
r,
{ title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: URL_BY_CATEGORY[category] ?? '/#/' },
category,
);
}
}
}
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;
/** The device's chosen categories (only dms/trades are pushed). */
categories?: string[];
} | 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);
if (!isSafePushEndpoint(sub.endpoint)) {
return json({ error: 'invalid push endpoint' }, 400, headers);
}
const ownerPubkey = body.proof.pubkey.toLowerCase();
const existing = state.subscriptions[sub.endpoint];
if (existing && existing.pubkey !== ownerPubkey) {
return json({ error: 'endpoint already registered to another account' }, 409, headers);
}
state.subscriptions[sub.endpoint] = {
endpoint: sub.endpoint,
pubkey: ownerPubkey,
p256dh: sub.keys.p256dh,
auth: sub.keys.auth,
categories: Array.isArray(body.categories) ? body.categories : undefined,
};
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);
const owned = state.subscriptions[body.endpoint];
// Only the owner may remove a subscription. A non-owner gets an idempotent
// OK without deleting anything, so this can't be used to disable another
// account's notifications or probe which endpoints exist.
if (owned && owned.pubkey !== body.proof.pubkey.toLowerCase()) {
return json({ ok: true }, 200, headers);
}
delete state.subscriptions[body.endpoint];
save();
refreshMarketSubscription();
return json({ ok: true }, 200, headers);
}
// ── Native background polling (no Google) ─────────────────────────────
// The device registers once (NIP-98 proof) and receives an unguessable poll
// token; it then polls `/pending` and shows a LOCAL notification when the
// content-free activity cursor moves. The server never reveals what arrived.
if (req.method === 'POST' && url.pathname === '/native/poll/register') {
const body = (await req.json().catch(() => null)) as
| { pubkey?: string; proof?: NostrEvent; categories?: string[] }
| null;
if (!body?.pubkey || !body?.proof) return json({ error: 'pubkey and proof required' }, 400, headers);
const check = verifyProof(body.proof, body.pubkey);
if (!check.ok) return json({ error: check.error }, 401, headers);
// One token per pubkey: re-registering (e.g. after a settings change)
// replaces the old one so nothing is orphaned.
for (const [t, e] of Object.entries(state.pollTokens)) {
if (e.pubkey === body.pubkey.toLowerCase()) delete state.pollTokens[t];
}
const token = crypto.randomUUID();
state.pollTokens[token] = {
pubkey: body.pubkey.toLowerCase(),
categories: Array.isArray(body.categories) ? body.categories : undefined,
createdAt: Math.floor(Date.now() / 1000),
};
save();
refreshMarketSubscription();
console.log(`[push] 🔁 poll token for ${body.pubkey.slice(0, 12)}…`);
return json({ token, seq: state.activity[body.pubkey.toLowerCase()]?.seq ?? 0 }, 200, headers);
}
if (req.method === 'POST' && url.pathname === '/native/poll/unregister') {
const body = (await req.json().catch(() => null)) as { token?: string; proof?: NostrEvent } | null;
if (!body?.token || !body?.proof) return json({ error: 'token and proof required' }, 400, headers);
const entry = state.pollTokens[body.token];
if (!entry) return json({ ok: true }, 200, headers);
const check = verifyProof(body.proof, entry.pubkey);
if (!check.ok) return json({ error: check.error }, 401, headers);
delete state.pollTokens[body.token];
save();
refreshMarketSubscription();
return json({ ok: true }, 200, headers);
}
if (req.method === 'GET' && url.pathname === '/pending') {
const token = url.searchParams.get('token') ?? '';
const entry = state.pollTokens[token];
if (!entry) return json({ error: 'unknown token' }, 401, headers);
const since = Number(url.searchParams.get('since') ?? 0);
const activity = state.activity[entry.pubkey] ?? { seq: 0, at: 0, byCategory: {} };
// Only the categories THIS token asked for can move `changed`.
const allowed = Array.isArray(entry.categories)
? entry.categories
: Object.keys(activity.byCategory);
const cutoff = Number.isFinite(since) ? since : 0;
const changed = allowed.some((c) => (activity.byCategory[c] ?? 0) > cutoff);
return json({ seq: activity.seq, at: activity.at, changed }, 200, headers);
}
return json({ error: 'not found' }, 404, headers);
},
});
console.log(`[push] listening on :${PORT} — marker "${MARKER}", relays: ${RELAYS.join(', ')}`);