Skip to content

Commit 52998fc

Browse files
committed
Code review changes & small bug fixes
1 parent 0d31555 commit 52998fc

File tree

12 files changed

+330
-84
lines changed

12 files changed

+330
-84
lines changed

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ IMPORTANT: Always run `bun run check-code` after making changes. It runs typeche
7979
- `lib/` contains shared app helpers (Nostr pool, token text parsing, topbar config)
8080
- `types/appTypes.ts` contains app-local shared types
8181
- `apps/push/src/` is split by concern: `http.ts` (Bun API), `ownership.ts` (signed challenge verification), `storage.ts` (SQLite persistence for subscriptions/pubkeys/challenges/seen outer event ids), `relayWatcher.ts` (relay subscription for outer `kind: 1059` events with catch-up vs live delivery gating), and `push.ts` (Web Push delivery + invalid subscription cleanup, including stale subscriptions tied to an old VAPID keypair)
82-
- Push service proof events use `kind: 27235` with short-lived per-pubkey challenge nonces; the server never decrypts NIP-17 payloads and only emits generic notifications for outer `kind: 1059` events tagged `["linky","push"]`, so sender self-copies / reactions / edits can sync over relays without triggering push
82+
- Push service proof events use `kind: 27235` with short-lived per-pubkey challenge nonces; `/subscribe` and `/unsubscribe` both require valid proofs per affected pubkey, full unsubscribe only happens when the last proven pubkey is removed, the server never decrypts NIP-17 payloads, and it only emits generic notifications for outer `kind: 1059` events tagged `["linky","push"]`, so sender self-copies / reactions / edits can sync over relays without triggering push
8383

8484
## Code Conventions
8585

@@ -109,15 +109,15 @@ IMPORTANT: Always run `bun run check-code` after making changes. It runs typeche
109109
- PWA service worker is built from `apps/web-app/src/sw.ts` via Vite PWA `injectManifest`; changes there affect both prod and dev SW behavior
110110
- Dev mode now keeps the registered PWA service worker alive for push testing; use `#advanced/push-debug` to inspect persistent client/SW push logs and manually reset service workers/caches when needed
111111
- Push registration now validates the live `PushSubscription.options.applicationServerKey` against the current server VAPID public key and forces a re-subscribe on mismatch; open clients also re-register when the service worker emits `pushsubscriptionchange`
112-
- Push registration persists a stable browser `installationId` plus the last server-registered endpoint in localStorage; subscribe calls also request cleanup of legacy subscriptions without an installation id for the same pubkey, the push server replaces stale endpoints for the same installation, and the client best-effort unregisters the previous endpoint when the browser rotates/replaces the current subscription, preventing duplicate generic notifications from old endpoints
112+
- Push registration persists a stable browser `installationId` plus the last server-registered endpoint in localStorage; subscribe calls also request cleanup of legacy subscriptions without an installation id for the same pubkey, the push server replaces stale endpoints for the same installation, and the client best-effort unregisters the previous endpoint with a signed unsubscribe proof when the browser rotates/replaces the current subscription, preventing duplicate generic notifications from old endpoints
113113
- Cashu payments now publish actual token chat messages to the recipient without the outer push marker and emit one separate notify-only wrapped event per payment (`kind: 24133`, `["linky","payment_notice"]`) as the sole push trigger; receiver inbox sync never stores that notice in chat history, but the service worker and open-app notification paths render it as `You received money` / `Přijali jste peníze`
114114
- The PWA service worker mirrors the active `nsec` into IndexedDB on app startup/login, clears it on logout, and for closed-app push delivery it fetches the outer `kind: 1059` event from relays, decrypts it locally in the service worker, uses `You received money` / `Přijali jste peníze` copy for Cashu token messages and notify-only payment notice events, and still shows a generic fallback notification when decrypt/validation fails; any open Linky window client still suppresses the service-worker notification in favor of in-app notification logic, while inbox sync keeps actual Cashu token chat messages silent in notification surfaces and uses the notify-only payment event for the user-visible payment alert
115115
- Chat retention is enforced in `useMessagesDomain` (latest 500 messages/contact, 3000 global; reactions capped to 5000 and orphaned reactions are pruned)
116116
- Wallet top-up receive quotes are cached in owner-scoped localStorage until claimed/expired, so dismissing the QR screen does not drop a pending receive
117117
- Push service env is documented in `apps/push/.env.example`; `PUSH_VAPID_SUBJECT`, `PUSH_VAPID_PUBLIC_KEY`, and `PUSH_VAPID_PRIVATE_KEY` must be set before `apps/push` starts
118118
- `apps/push` CORS allowlist is configured via `PUSH_CORS_ORIGIN`; it accepts `*` or a comma-separated list of allowed web app origins
119119
- `apps/push` relay watcher defaults now match the web app chat publish relays (`wss://relay.damus.io`, `wss://nos.lol`, `wss://relay.0xchat.com`, `wss://shu01.shugur.net`) unless overridden via `PUSH_DEFAULT_RELAYS`
120-
- `apps/push` relay watcher uses a 3-day catch-up `since` window to accommodate NIP-59 randomized outer `created_at`, persists seen outer event ids in SQLite, suppresses notification delivery until EOSE switches the watcher into live mode, periodically refreshes the subscription to reset reconnect `since` drift, and prunes old seen-event rows on the server cleanup interval
120+
- `apps/push` relay watcher uses a 3-day catch-up `since` window to accommodate NIP-59 randomized outer `created_at`, persists seen outer event ids in SQLite, keeps a size-bounded in-memory seen-id cache for O(1) hot-path dedupe checks, suppresses notification delivery until EOSE switches the watcher into live mode, periodically refreshes the subscription to reset reconnect `since` drift, and prunes both cache-expired ids and old SQLite seen-event rows on the server cleanup interval
121121
- Container publishing is handled by `.github/workflows/push-image.yml`, which builds `apps/push/Dockerfile` and publishes the image to GHCR
122122

123123
## Maintaining This File

apps/push/README.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,7 @@ Every pubkey listed in `recipientPubkeys` needs its own proof.
7979

8080
### `POST /unsubscribe`
8181

82-
Remove an entire stored subscription by endpoint:
83-
84-
```json
85-
{
86-
"endpoint": "https://example.push/service"
87-
}
88-
```
89-
90-
Or remove only selected pubkeys from a subscription with ownership proofs:
82+
Remove selected pubkeys from a subscription with ownership proofs. If you remove the subscription's last remaining pubkey, the whole subscription row is deleted:
9183

9284
```json
9385
{
@@ -114,6 +106,8 @@ Or remove only selected pubkeys from a subscription with ownership proofs:
114106
}
115107
```
116108

109+
Every pubkey listed in `recipientPubkeys` needs its own unsubscribe proof. Full subscription removal requires proving ownership for the subscription's current pubkeys.
110+
117111
### `GET /health`
118112

119113
Simple health check.

apps/push/src/guards.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -339,36 +339,22 @@ export function readUnsubscribeRequest(value: unknown): UnsubscribeRequestBody {
339339
}
340340

341341
const endpoint = readEndpointOrSubscriptionEndpoint(value);
342-
const recipientPubkeysValue = value.recipientPubkeys;
343-
const proofsValue = value.proofs;
344-
let recipientPubkeys: string[] | null = null;
345-
let proofs: OwnershipProofInput[] = [];
346-
347-
if (recipientPubkeysValue !== undefined) {
348-
recipientPubkeys = uniqueStrings(
349-
readStringArray(recipientPubkeysValue, "recipientPubkeys").map((pubkey) =>
350-
readPubkey(pubkey, "recipientPubkeys[]"),
351-
),
352-
);
353-
if (recipientPubkeys.length === 0) {
354-
throw new RequestError(
355-
400,
356-
"invalid_request",
357-
"recipientPubkeys must contain at least one pubkey when provided",
358-
);
359-
}
360-
proofs = readOwnershipProofs(proofsValue);
361-
} else if (proofsValue !== undefined) {
342+
const recipientPubkeys = uniqueStrings(
343+
readStringArray(value.recipientPubkeys, "recipientPubkeys").map((pubkey) =>
344+
readPubkey(pubkey, "recipientPubkeys[]"),
345+
),
346+
);
347+
if (recipientPubkeys.length === 0) {
362348
throw new RequestError(
363349
400,
364350
"invalid_request",
365-
"proofs may only be provided when recipientPubkeys are provided",
351+
"recipientPubkeys must contain at least one pubkey",
366352
);
367353
}
368354

369355
return {
370356
endpoint,
371357
recipientPubkeys,
372-
proofs,
358+
proofs: readOwnershipProofs(value.proofs),
373359
};
374360
}

apps/push/src/http.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,6 @@ export function createHttpHandler({
256256
);
257257

258258
const body = readUnsubscribeRequest(await readJsonBody(request));
259-
if (body.recipientPubkeys === null) {
260-
const removed = storage.unregisterSubscription(body.endpoint);
261-
console.info(
262-
`[push] unsubscribe endpoint=${hashEndpoint(body.endpoint)} removed=${removed} ip=${ip}`,
263-
);
264-
return jsonResponse(config, request, 200, {
265-
ok: true,
266-
removed,
267-
endpoint: body.endpoint,
268-
});
269-
}
270-
271259
const consumedChallengeNonces = ownershipVerifier.verifyProofs(
272260
"unsubscribe",
273261
body.recipientPubkeys,

apps/push/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ const relayWatcher = new RelayWatcher({
3131

3232
relayWatcher.start();
3333
const cleanupTimer = setInterval(() => {
34-
storage.pruneChallenges(Date.now());
35-
storage.pruneSeenEvents(Date.now(), RelayWatcher.SEEN_EVENT_RETENTION_MS);
36-
rateLimiter.prune(Date.now());
34+
const nowMs = Date.now();
35+
storage.pruneChallenges(nowMs);
36+
storage.pruneSeenEvents(nowMs, RelayWatcher.SEEN_EVENT_RETENTION_MS);
37+
relayWatcher.pruneSeen(nowMs);
38+
rateLimiter.prune(nowMs);
3739
}, 60 * 1000);
3840

3941
const server = Bun.serve({

apps/push/src/relayWatcher.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { SeenEventIdCache } from "./relayWatcher";
4+
5+
describe("SeenEventIdCache", () => {
6+
it("drops expired entries on direct lookup", () => {
7+
const cache = new SeenEventIdCache({
8+
ttlMs: 10,
9+
maxEntries: 10,
10+
});
11+
12+
cache.markSeen("event-1", 100);
13+
14+
expect(cache.has("event-1", 109)).toBe(true);
15+
expect(cache.has("event-1", 110)).toBe(false);
16+
expect(cache.size).toBe(0);
17+
});
18+
19+
it("prunes only expired prefixes during periodic cleanup", () => {
20+
const cache = new SeenEventIdCache({
21+
ttlMs: 10,
22+
maxEntries: 10,
23+
});
24+
25+
cache.markSeen("event-1", 100);
26+
cache.markSeen("event-2", 105);
27+
cache.markSeen("event-3", 120);
28+
29+
cache.pruneExpired(115);
30+
31+
expect(cache.size).toBe(1);
32+
expect(cache.has("event-1", 115)).toBe(false);
33+
expect(cache.has("event-2", 115)).toBe(false);
34+
expect(cache.has("event-3", 115)).toBe(true);
35+
});
36+
37+
it("evicts the oldest cached ids when the cache reaches capacity", () => {
38+
const cache = new SeenEventIdCache({
39+
ttlMs: 100,
40+
maxEntries: 2,
41+
});
42+
43+
cache.markSeen("event-1", 100);
44+
cache.markSeen("event-2", 101);
45+
cache.markSeen("event-3", 102);
46+
47+
expect(cache.size).toBe(2);
48+
expect(cache.has("event-1", 150)).toBe(false);
49+
expect(cache.has("event-2", 150)).toBe(true);
50+
expect(cache.has("event-3", 150)).toBe(true);
51+
});
52+
});

apps/push/src/relayWatcher.ts

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ interface RelayWatcherOptions {
1313
eventDedupeTtlMs: number;
1414
}
1515

16+
interface SeenEventIdCacheOptions {
17+
ttlMs: number;
18+
maxEntries: number;
19+
}
20+
1621
interface ClosableSubscription {
1722
close: (reason?: string) => void;
1823
}
@@ -109,17 +114,73 @@ function describeEventRecipients(event: NostrEvent): string {
109114
return recipients.length > 0 ? recipients.join(",") : "none";
110115
}
111116

117+
export class SeenEventIdCache {
118+
private readonly entries = new Map<string, number>();
119+
private readonly ttlMs: number;
120+
private readonly maxEntries: number;
121+
122+
constructor(options: SeenEventIdCacheOptions) {
123+
this.ttlMs = options.ttlMs;
124+
this.maxEntries = options.maxEntries;
125+
}
126+
127+
has(eventId: string, nowMs: number): boolean {
128+
const expiresAt = this.entries.get(eventId);
129+
if (expiresAt === undefined) {
130+
return false;
131+
}
132+
133+
if (expiresAt <= nowMs) {
134+
this.entries.delete(eventId);
135+
return false;
136+
}
137+
138+
return true;
139+
}
140+
141+
markSeen(eventId: string, nowMs: number): void {
142+
this.entries.delete(eventId);
143+
this.entries.set(eventId, nowMs + this.ttlMs);
144+
this.pruneOverflow();
145+
}
146+
147+
pruneExpired(nowMs: number): void {
148+
// Entries are inserted in first-seen order, so with a fixed TTL the oldest
149+
// ids also expire first and cleanup can stop at the first live entry.
150+
for (const [eventId, expiresAt] of this.entries) {
151+
if (expiresAt > nowMs) {
152+
break;
153+
}
154+
this.entries.delete(eventId);
155+
}
156+
}
157+
158+
get size(): number {
159+
return this.entries.size;
160+
}
161+
162+
private pruneOverflow(): void {
163+
while (this.entries.size > this.maxEntries) {
164+
const oldestEventId = this.entries.keys().next().value;
165+
if (typeof oldestEventId !== "string") {
166+
return;
167+
}
168+
this.entries.delete(oldestEventId);
169+
}
170+
}
171+
}
172+
112173
export class RelayWatcher {
113174
private static readonly CATCH_UP_LOOKBACK_SECONDS = 3 * 24 * 60 * 60;
114175
private static readonly LIVE_SUBSCRIPTION_REFRESH_MS = 10 * 60 * 1000;
176+
private static readonly SEEN_EVENT_CACHE_MAX_ENTRIES = 50_000;
115177
static readonly SEEN_EVENT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
116178

117179
private readonly relayUrls: string[];
118180
private readonly storage: PushStorage;
119181
private readonly pushDelivery: PushDeliveryService;
120-
private readonly seenEventIds = new Map<string, number>();
182+
private readonly seenEventIds: SeenEventIdCache;
121183
private readonly pool = new SimplePool({ enableReconnect: true });
122-
private readonly eventDedupeTtlMs: number;
123184
private liveDeliveryEnabled = false;
124185
private refreshIntervalHandle: ReturnType<typeof setInterval> | null = null;
125186
private subscription: ClosableSubscription | null = null;
@@ -128,7 +189,10 @@ export class RelayWatcher {
128189
this.relayUrls = options.relayUrls;
129190
this.storage = options.storage;
130191
this.pushDelivery = options.pushDelivery;
131-
this.eventDedupeTtlMs = options.eventDedupeTtlMs;
192+
this.seenEventIds = new SeenEventIdCache({
193+
ttlMs: options.eventDedupeTtlMs,
194+
maxEntries: RelayWatcher.SEEN_EVENT_CACHE_MAX_ENTRIES,
195+
});
132196
}
133197

134198
start(): void {
@@ -190,27 +254,21 @@ export class RelayWatcher {
190254
}
191255

192256
private markSeen(eventId: string, nowMs: number): boolean {
193-
this.pruneSeen(nowMs);
194-
195-
if (this.seenEventIds.has(eventId)) {
257+
if (this.seenEventIds.has(eventId, nowMs)) {
196258
return false;
197259
}
198260

199261
if (!this.storage.recordSeenEvent(eventId, nowMs)) {
200-
this.seenEventIds.set(eventId, nowMs + this.eventDedupeTtlMs);
262+
this.seenEventIds.markSeen(eventId, nowMs);
201263
return false;
202264
}
203265

204-
this.seenEventIds.set(eventId, nowMs + this.eventDedupeTtlMs);
266+
this.seenEventIds.markSeen(eventId, nowMs);
205267
return true;
206268
}
207269

208-
private pruneSeen(nowMs: number): void {
209-
for (const [eventId, expiresAt] of this.seenEventIds.entries()) {
210-
if (expiresAt <= nowMs) {
211-
this.seenEventIds.delete(eventId);
212-
}
213-
}
270+
pruneSeen(nowMs: number): void {
271+
this.seenEventIds.pruneExpired(nowMs);
214272
}
215273

216274
private async handleEvent(event: NostrEvent): Promise<void> {

0 commit comments

Comments
 (0)