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.
546 lines
24 KiB
TypeScript
546 lines
24 KiB
TypeScript
/**
|
||
* 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(', ')}`);
|