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.
This commit is contained in:
SkateSpots 2026-10-01 19:47:21 +07:00
parent 3d18dcf5f3
commit 5785a77211
3 changed files with 113 additions and 8 deletions

View file

@ -791,11 +791,23 @@ session with **no other memory of this project**. So:
(commit `68c12a2`); do NOT re-add Firebase/google-services. Web push (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: (VAPID) is live and unaffected. For the APK the only no-Google options are:
**(a) UnifiedPush** (FOSS distributor the user installs — e.g. ntfy / **(a) UnifiedPush** (FOSS distributor the user installs — e.g. ntfy /
NextPush; the app registers with the distributor, no Google) or **(b) a NextPush) or **(b) a native background poll** of our own service. **Chosen:
foreground service** keeping the relay WebSocket alive (no server, but a (b), the Cordn pattern** — Cordn (`Cordn-msg/cordn-web`, package
sticky notification + battery + custom native code). Both are unbuilt; pick `cordn-background`) does **native background polling** via a custom Capacitor
one before attempting native closed-app notifications. (Approach + tradeoffs plugin (Kotlin + Rust/UniFFI) with a `standard` (WorkManager ~15 min) and a
came from the Numo/Linky/BTCPay review this session.) `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 - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
delivery coordinator** exposed as a **ContextVM server** over Nostr relays delivery coordinator** exposed as a **ContextVM server** over Nostr relays
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is

View file

@ -80,6 +80,29 @@ Caddy — copy `Caddyfile` to `/etc/caddy/Caddyfile` and reload once.
Logs: `npm run deploy:push -- --logs`. 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 ## Privacy
The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and The service is **opt-in per device**. It stores a pubkey ↔ push-endpoint map and

View file

@ -60,15 +60,28 @@ webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string } interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string }
interface State { interface State {
subscriptions: Record<string, StoredSubscription>; // endpoint → sub 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 }>; challenges: Record<string, { pubkey: string; expiresAt: number }>;
} }
function load(): State { function load(): State {
try { try {
const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>; const parsed = JSON.parse(readFileSync(STORAGE_PATH, 'utf8')) as Partial<State>;
return { subscriptions: parsed.subscriptions ?? {}, challenges: parsed.challenges ?? {} }; return {
subscriptions: parsed.subscriptions ?? {},
pollTokens: parsed.pollTokens ?? {},
activity: parsed.activity ?? {},
challenges: parsed.challenges ?? {},
};
} catch { } catch {
return { subscriptions: {}, challenges: {} }; return { subscriptions: {}, pollTokens: {}, activity: {}, challenges: {} };
} }
} }
const state = load(); const state = load();
@ -113,7 +126,15 @@ function subsFor(pubkey: string): StoredSubscription[] {
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey); 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> { async function pushTo(pubkey: string, payload: Record<string, unknown>): Promise<void> {
recordActivity(pubkey);
for (const s of subsFor(pubkey)) { for (const s of subsFor(pubkey)) {
try { try {
await webpush.sendNotification( 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]; const MARKET_KINDS = [OFFER_KIND, TRADE_CONFIRM_KIND, TRADE_REQUEST_KIND, SHIPMENT_STATUS_KIND];
function subscribedPubkeys(): string[] { 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). */ /** 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); 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); return json({ error: 'not found' }, 404, headers);
}, },
}); });