SkateSpots/push-service/server.ts
SkateSpots 4df961b015 feat(push): category-aware notifications (trades-only actually means trades-only)
- /subscribe + /native/poll/register carry the enabled categories; the server
  stores them per subscription/token and only pushes matching ones (DM wrap ->
  dms; offer/counter/accept/attestation/shipment -> trades). Absent = legacy
  (all), empty array = none.
- /pending uses a per-category activity breakdown so it only moves for the
  token's categories; the smoke test proves a DM does NOT wake a trades-only token.
- usePushNotifications/useBackgroundDelivery send categories (from device prefs)
  and expose resubscribe(); NotificationSettingsCard re-sends on toggle change.
2026-10-01 20:01:07 +07:00

462 lines
21 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;
/** Which categories this device wants. Empty/absent = all (back-compat). */
categories?: string[];
}
/**
* Event kind → notification category, mirroring the app's `NotificationCategory`
* (only `dms` and `trades` are pushed — social/crew/newsletter are in-app only).
*/
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
};
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 };
}
// ─── 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 MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND, SHIPMENT_STATUS_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 === 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 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' }, '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;
/** 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);
state.subscriptions[sub.endpoint] = {
endpoint: sub.endpoint,
pubkey: body.proof.pubkey.toLowerCase(),
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);
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(', ')}`);