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:
parent
3d18dcf5f3
commit
5785a77211
3 changed files with 113 additions and 8 deletions
22
AGENTS.md
22
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
|
(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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue