SkateSpots/push-service/server.ts
SkateSpots 5785a77211 feat(push): server-side native background polling (no Google) - Cordn pattern
Adds the content-free activity cursor the Android APK can poll from a native
worker: POST /native/poll/register (NIP-98 -> poll token; the pubkey joins the
relay watch), GET /pending?token&since -> { seq, at, changed }, and
/native/poll/unregister. pushTo() bumps recordActivity(). Verified locally:
register -> offer addressed to the pubkey -> seq 0 -> 1, changed=true. No Google,
no decryption; the native plugin is the remaining piece.
2026-10-01 19:47:21 +07:00

416 lines
19 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
/** Poll token → the pubkey it may read activity for (native background polling). */
pollTokens: Record<string, { pubkey: string; createdAt: number }>;
/**
* Per-pubkey activity cursor, bumped whenever something arrives for a pubkey,
* so a native poller can ask "anything new since N?" WITHOUT the server ever
* revealing *what* (content-free — the same guarantee as web push).
*/
activity: Record<string, { seq: number; at: 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 (native pollers read it). */
function recordActivity(pubkey: string): void {
const cur = state.activity[pubkey];
state.activity[pubkey] = { seq: (cur?.seq ?? 0) + 1, at: Math.floor(Date.now() / 1000) };
save();
}
async function pushTo(pubkey: string, payload: Record<string, unknown>): Promise<void> {
recordActivity(pubkey);
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 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' });
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);
}
// ── 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 } | 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);
const token = crypto.randomUUID();
state.pollTokens[token] = {
pubkey: body.pubkey.toLowerCase(),
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 };
return json(
{ seq: activity.seq, at: activity.at, changed: activity.seq > (Number.isFinite(since) ? since : 0) },
200,
headers,
);
}
return json({ error: 'not found' }, 404, headers);
},
});
console.log(`[push] listening on :${PORT} — marker "${MARKER}", relays: ${RELAYS.join(', ')}`);