diff --git a/AGENTS.md b/AGENTS.md index 92c1d2d..0cbc58a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -791,11 +791,23 @@ session with **no other memory of this project**. So: (commit `68c12a2`); do NOT re-add Firebase/google-services. Web push (VAPID) is live and unaffected. For the APK the only no-Google options are: **(a) UnifiedPush** (FOSS distributor the user installs — e.g. ntfy / - NextPush; the app registers with the distributor, no Google) or **(b) a - foreground service** keeping the relay WebSocket alive (no server, but a - sticky notification + battery + custom native code). Both are unbuilt; pick - one before attempting native closed-app notifications. (Approach + tradeoffs - came from the Numo/Linky/BTCPay review this session.) + NextPush) or **(b) a native background poll** of our own service. **Chosen: + (b), the Cordn pattern** — Cordn (`Cordn-msg/cordn-web`, package + `cordn-background`) does **native background polling** via a custom Capacitor + plugin (Kotlin + Rust/UniFFI) with a `standard` (WorkManager ~15 min) and a + `fast` (foreground service ~5 min) mode, polling its **ContextVM** coordinator + and posting local notifications — **no Google**. We need **less**: our + notifications are already content-free, so no Rust/decrypt is needed. + **Server side DONE + verified (2026-10-01):** `push-service` has + `/native/poll/register` (NIP-98 → poll token; the pubkey is added to the relay + watch), `GET /pending?token&since` → `{ seq, at, changed }` (a content-free + cursor), and `/native/poll/unregister`; `pushTo()` calls `recordActivity()`. + **STILL TODO:** the native `skatespots-background` Capacitor plugin (Kotlin + WorkManager + foreground service + local notifications + battery-exemption + prompt) — unbuilt, needs a device to verify. + ⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers + defer it); `fast` needs a sticky notification. Never the only path. (Approach + + tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.) - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS delivery coordinator** exposed as a **ContextVM server** over Nostr relays (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is diff --git a/push-service/README.md b/push-service/README.md index c9c6a51..3cc63a0 100644 --- a/push-service/README.md +++ b/push-service/README.md @@ -80,6 +80,29 @@ Caddy — copy `Caddyfile` to `/etc/caddy/Caddyfile` and reload once. Logs: `npm run deploy:push -- --logs`. +## Native background polling (no Google) — the Cordn pattern + +For the Android **APK** (a Capacitor WebView has **no Web Push**), the service +supports a **content-free activity cursor** the app polls from a native background +worker — no Google, no distributor app. + +| Method | Path | Body | Returns | +| --- | --- | --- | --- | +| POST | `/native/poll/register` | `{ pubkey, proof }` | `{ token, seq }` | +| GET | `/pending?token=&since=` | — | `{ seq, at, changed }` | +| POST | `/native/poll/unregister` | `{ token, proof }` | `{ ok }` | + +- `register` is guarded by the same NIP-98 challenge proof; it returns an + **unguessable poll token**. The server then **watches the relays for that + pubkey** (added to the market subscription) and bumps a per-pubkey `seq` every + time something arrives — **the same content-free signal as web push**. +- `/pending` returns only `{ seq, at, changed }` — never *what* arrived. A native + worker polls it (`standard` = WorkManager ~15 min, `fast` = foreground service + ~5 min), and when `seq > since` it shows a **local notification** and advances + its cursor. +- **No decryption, no keys, no Google** on the server. Cordn does the same thing + with an extra Rust/MLS layer because they decrypt previews; we don't need to. + ## Privacy The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and diff --git a/push-service/server.ts b/push-service/server.ts index a42b2ab..ecaa715 100644 --- a/push-service/server.ts +++ b/push-service/server.ts @@ -60,15 +60,28 @@ webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate); interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string } interface State { subscriptions: Record; // endpoint → sub + /** Poll token → the pubkey it may read activity for (native background polling). */ + pollTokens: Record; + /** + * 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; challenges: Record; } function load(): State { try { const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial; - return { subscriptions: parsed.subscriptions ?? {}, challenges: parsed.challenges ?? {} }; + return { + subscriptions: parsed.subscriptions ?? {}, + pollTokens: parsed.pollTokens ?? {}, + activity: parsed.activity ?? {}, + challenges: parsed.challenges ?? {}, + }; } catch { - return { subscriptions: {}, challenges: {} }; + return { subscriptions: {}, pollTokens: {}, activity: {}, challenges: {} }; } } const state = load(); @@ -113,7 +126,15 @@ 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): Promise { + recordActivity(pubkey); for (const s of subsFor(pubkey)) { try { await webpush.sendNotification( @@ -146,7 +167,10 @@ 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[] { - return [...new Set(Object.values(state.subscriptions).map((s) => s.pubkey))]; + // 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). */ @@ -339,6 +363,52 @@ Bun.serve({ 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); }, });