fix: deterministic relay merge and privacy hardening
This commit is contained in:
parent
5a1d849681
commit
42d25818b4
12 changed files with 327 additions and 31 deletions
20
AGENTS.md
20
AGENTS.md
|
|
@ -2415,6 +2415,26 @@ Rules:
|
||||||
- Any time the deploy output isn't verified by the smoke test, run
|
- Any time the deploy output isn't verified by the smoke test, run
|
||||||
`node scripts/smoke-test.mjs` manually before shipping.
|
`node scripts/smoke-test.mjs` manually before shipping.
|
||||||
|
|
||||||
|
### Trust, privacy and consistency invariants
|
||||||
|
- **No BFT / no relay quorum.** Nostr events are signed facts; relays are not
|
||||||
|
voters. Social feeds resolve conflicts locally, not by consensus.
|
||||||
|
- **Deterministic merge:** `src/lib/eventMerge.ts` is the single merge policy
|
||||||
|
(newest `created_at`, event-id tie-break, local tombstones). Do not add
|
||||||
|
"first relay wins" logic; relay arrival order must not change the result.
|
||||||
|
- **Trust boundaries are documented** in `docs/THREAT_MODEL.md`. Keep UI copy
|
||||||
|
honest: a confirmed trade means both parties signed; a payment proof means the
|
||||||
|
money leg was paid; escrow is time-locked, not arbitration.
|
||||||
|
- **No remote hotlinks for small brand assets:** OpenSats icons are local under
|
||||||
|
`public/images/` so rendering the footer does not leak the viewer's IP.
|
||||||
|
- **Android Auto Backup is off** (`android:allowBackup="false"` injected by
|
||||||
|
`scripts/inject-backup-policy.mjs`); identity/settings/escrow state stays on
|
||||||
|
the device and must be exported explicitly.
|
||||||
|
- **Image uploads re-encode decodable formats through canvas** to strip
|
||||||
|
EXIF/GPS before Blossom. GIF and HEIC/HEIF are passed through because they need
|
||||||
|
special handling; do not claim they are metadata-stripped.
|
||||||
|
- **No mandatory identity, deposit or global trust score.** Use warnings,
|
||||||
|
explicit consent and optional escrow at value boundaries.
|
||||||
|
|
||||||
### Distribution without GitHub
|
### Distribution without GitHub
|
||||||
> **Owner decision (2026-10-30): no external identities.** Distribution must
|
> **Owner decision (2026-10-30): no external identities.** Distribution must
|
||||||
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,
|
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,
|
||||||
|
|
|
||||||
104
docs/THREAT_MODEL.md
Normal file
104
docs/THREAT_MODEL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# SkateSpots threat model
|
||||||
|
|
||||||
|
This is the short, technical version of what SkateSpots can and cannot guarantee.
|
||||||
|
It exists so product copy, support answers and future code do not accidentally
|
||||||
|
promise consensus, arbitration or delivery verification that the system does not
|
||||||
|
have.
|
||||||
|
|
||||||
|
## The model in one sentence
|
||||||
|
|
||||||
|
**SkateSpots verifies signed facts, keeps a local view, and uses social/economic
|
||||||
|
signals at value boundaries. It does not run a consensus protocol.**
|
||||||
|
|
||||||
|
Nostr relays are not voters. A relay can withhold, censor, reorder or serve a
|
||||||
|
stale event, but it cannot forge a valid signature. Different clients can
|
||||||
|
therefore see different snapshots of the network without either side being
|
||||||
|
"wrong" in a Byzantine-consensus sense.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Nostr identity keys and local signer access.
|
||||||
|
- Local cache, drafts, pending posts and local deletion tombstones.
|
||||||
|
- Crew membership and Cordn group membership.
|
||||||
|
- Marketplace listings, offers, trade attestations and reputation.
|
||||||
|
- Cashu proofs and escrow tokens.
|
||||||
|
- Device storage and any local backup/export.
|
||||||
|
|
||||||
|
## Actors and trust boundaries
|
||||||
|
|
||||||
|
| Actor | Can do | Cannot do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Author | Sign events with their key | Forge another author's signature |
|
||||||
|
| Relay | Withhold, censor, reorder, serve stale or conflicting signed events | Forge a valid event; guarantee availability |
|
||||||
|
| Cordn coordinator | Route opaque group ciphertext; drop or delay messages | Read plaintext; forge an author proof |
|
||||||
|
| Cashu mint | Issue/redeem proofs, enforce locktime, freeze or become insolvent | Spend a correctly locked P2PK proof without a valid witness |
|
||||||
|
| Founder/admin | Modify the crew record, moderate crew content | Forge members' signatures; guarantee global agreement |
|
||||||
|
| Buyer/seller | Publish attestations and payment/receipt claims | Make a false claim true by signing it |
|
||||||
|
| WebView / host | Run the client | Be assumed trustworthy after device compromise |
|
||||||
|
|
||||||
|
## Guarantees
|
||||||
|
|
||||||
|
- **Authenticity:** a valid Schnorr signature proves which key signed an event,
|
||||||
|
not that the content is true.
|
||||||
|
- **Availability:** the client can use cached data and multiple relays; there is
|
||||||
|
no guarantee that every relay has every event.
|
||||||
|
- **Local determinism:** when two valid versions of an addressable or replaceable
|
||||||
|
event exist, the client chooses the newest `created_at`, with an event-id
|
||||||
|
tie-breaker. This is deterministic, not globally agreed.
|
||||||
|
- **Deletion:** NIP-09 is a request. Local tombstones prevent a deleted item from
|
||||||
|
flashing back on that device; relays may ignore the request.
|
||||||
|
- **Payments:** a Lightning preimage proves the invoice was paid. It does not
|
||||||
|
prove goods were delivered.
|
||||||
|
- **Escrow:** a time-locked Cashu P2PK token protects the buyer after the lock
|
||||||
|
expires and lets the seller redeem on delivery. It is not arbitration and does
|
||||||
|
not prove delivery.
|
||||||
|
- **Reputation:** reciprocal signed attestations, distinct partners and
|
||||||
|
web-of-trust weighting are evidence. Two colluding accounts can still create a
|
||||||
|
fake trade; the app must not present that as proof of goods.
|
||||||
|
|
||||||
|
## Non-guarantees
|
||||||
|
|
||||||
|
- No global ordering or consensus over relays.
|
||||||
|
- No protection from a compromised identity key, signer or device.
|
||||||
|
- No guaranteed delivery or carrier verification.
|
||||||
|
- No neutral dispute resolution.
|
||||||
|
- No guarantee that every relay honours deletion or moderation.
|
||||||
|
- No guarantee that a mint remains solvent, online or P2PK-compatible.
|
||||||
|
- No anonymity guarantee: public events, relays, image hosts and map/weather
|
||||||
|
providers may observe network metadata.
|
||||||
|
- No guarantee that a Cordn coordinator cannot censor a group. It cannot read or
|
||||||
|
forge messages, but it can drop them.
|
||||||
|
|
||||||
|
## Cordn specifically
|
||||||
|
|
||||||
|
The current Cordn group scheme is NIP-44 group delivery with a signed author
|
||||||
|
proof inside the ciphertext. It is not full MLS yet: it does not provide MLS
|
||||||
|
forward secrecy or post-compromise security. A coordinator cannot read the
|
||||||
|
message or impersonate the author, but it can refuse to relay it.
|
||||||
|
|
||||||
|
## Marketplace safety rules
|
||||||
|
|
||||||
|
- Prefer escrowed or in-person deals for high-value trades.
|
||||||
|
- Never send a plain, unlocked Cashu token in a public event.
|
||||||
|
- Treat "confirmed trade" as "both parties signed", not "goods verified".
|
||||||
|
- Treat "payment verified" as "the money leg is verified".
|
||||||
|
- A new account with no distinct confirmed partners is a warning, not a ban.
|
||||||
|
- Cash, barter and off-platform settlement have no on-chain proof; use
|
||||||
|
reputation and judgement.
|
||||||
|
|
||||||
|
## Privacy boundaries
|
||||||
|
|
||||||
|
- Public Nostr events are public forever in practice; deletion is best-effort.
|
||||||
|
- DMs and Cordn ciphertext are encrypted, but metadata (who, when, which group)
|
||||||
|
may be visible to relays/coordinators depending on the feature.
|
||||||
|
- Images are public once uploaded; strip metadata before upload and do not put
|
||||||
|
addresses in public listing images.
|
||||||
|
- Self-hosted infrastructure avoids third-party analytics, but network observers
|
||||||
|
can still see traffic to relays, image hosts, map tiles and weather services.
|
||||||
|
|
||||||
|
## What would change this model
|
||||||
|
|
||||||
|
A real consensus or arbitration layer would need a separate design with explicit
|
||||||
|
participants, quorum, liveness, slashing/appeals and privacy trade-offs. It
|
||||||
|
should not be smuggled in as "trust" copy or a global score. Until then:
|
||||||
|
**signed facts, local merge, explicit boundaries, no fake guarantees.**
|
||||||
|
|
@ -21,11 +21,11 @@
|
||||||
"check:i18n": "node scripts/check-i18n.mjs",
|
"check:i18n": "node scripts/check-i18n.mjs",
|
||||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||||
"android:open": "npx cap open android",
|
"android:open": "npx cap open android",
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"android:version": "node scripts/sync-android-version.mjs",
|
||||||
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
|
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs && cd android && ./gradlew assembleRelease",
|
||||||
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
|
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
|
||||||
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
||||||
"translation:matrix": "node scripts/translation-matrix.mjs"
|
"translation:matrix": "node scripts/translation-matrix.mjs"
|
||||||
|
|
|
||||||
BIN
public/images/opensats.ico
Normal file
BIN
public/images/opensats.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
41
scripts/inject-backup-policy.mjs
Normal file
41
scripts/inject-backup-policy.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Disable Android Auto Backup for SkateSpots.
|
||||||
|
*
|
||||||
|
* The app stores identity material, local settings, pending escrow tokens and
|
||||||
|
* other private state in its WebView storage. Android Auto Backup can copy that
|
||||||
|
* data to a cloud account without the user ever asking for a backup. Keep it
|
||||||
|
* device-local: SkateSpots has its own explicit export/backup flows.
|
||||||
|
*
|
||||||
|
* Idempotent; exits quietly when android/ has not been generated yet.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const manifestPath = join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
||||||
|
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
console.log('[inject-backup-policy] android/ not generated yet — skipping.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = readFileSync(manifestPath, 'utf8');
|
||||||
|
const before = manifest;
|
||||||
|
|
||||||
|
if (/android:allowBackup="[^"]*"/.test(manifest)) {
|
||||||
|
manifest = manifest.replace(/android:allowBackup="[^"]*"/, 'android:allowBackup="false"');
|
||||||
|
} else {
|
||||||
|
manifest = manifest.replace(
|
||||||
|
/<application\b/,
|
||||||
|
'<application android:allowBackup="false"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest === before) {
|
||||||
|
console.log('[inject-backup-policy] allowBackup already disabled.');
|
||||||
|
} else {
|
||||||
|
writeFileSync(manifestPath, manifest);
|
||||||
|
console.log('[inject-backup-policy] ✓ Disabled Android cloud backup (allowBackup=false).');
|
||||||
|
}
|
||||||
|
|
@ -1223,7 +1223,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
<span>{t('chromeUi.notSponsoredBy')}</span>
|
<span>{t('chromeUi.notSponsoredBy')}</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<img
|
<img
|
||||||
src="https://opensats.org/favicon.ico"
|
src="/images/opensats.ico"
|
||||||
alt="OpenSats"
|
alt="OpenSats"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function MapFooterStrip() {
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1.5 text-gray-500">
|
<span className="flex items-center gap-1.5 text-gray-500">
|
||||||
{t('chromeUi.notSponsoredBy')}
|
{t('chromeUi.notSponsoredBy')}
|
||||||
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" />
|
<img src="/images/opensats.ico" alt="OpenSats" className="h-3 w-3" />
|
||||||
<span className="font-medium text-gray-400">OpenSats</span>
|
<span className="font-medium text-gray-400">OpenSats</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,27 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||||
import { useRelays } from '@/lib/relays';
|
import { useRelays } from '@/lib/relays';
|
||||||
import { useCitrine } from '@/hooks/useCitrine';
|
import { useCitrine } from '@/hooks/useCitrine';
|
||||||
import { SKATE_RELAY } from '@/lib/types';
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
|
import { mergeEvents } from '@/lib/eventMerge';
|
||||||
|
import { getLocalDeletions } from '@/lib/localDeletions';
|
||||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicate a batch of events from several relays, keeping the newest per
|
* Merge relay results deterministically and drop this device's local deletion
|
||||||
* identity:
|
* tombstones. Consensus is deliberately absent: relays are not voters, and this
|
||||||
* - addressable (has `d`): `pubkey:kind:d-tag`;
|
* client must not let relay timing decide which version of an event is shown.
|
||||||
* - replaceable (0, 3, 10000–19999): `pubkey:kind` (NIP-01 keeps only the
|
|
||||||
* latest per author+kind — keying by id would surface stale profiles/relay
|
|
||||||
* lists when relays return different versions);
|
|
||||||
* - otherwise: the event id.
|
|
||||||
*/
|
*/
|
||||||
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
|
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
|
||||||
const map = new Map<string, NostrEvent>();
|
const deleted = getLocalDeletions();
|
||||||
for (const event of events) {
|
return mergeEvents(events, {
|
||||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
isDeleted: (event) => {
|
||||||
let key: string;
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`;
|
const coord = dTag !== undefined ? `${event.kind}:${event.pubkey}:${dTag}` : undefined;
|
||||||
else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
return !!(
|
||||||
key = `${event.pubkey}:${event.kind}`;
|
(coord && deleted.has(coord)) ||
|
||||||
} else key = event.id;
|
deleted.has(`e:${event.id}`)
|
||||||
const existing = map.get(key);
|
);
|
||||||
if (!existing || event.created_at > existing.created_at) map.set(key, event);
|
},
|
||||||
}
|
});
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,8 @@ async function prepareImage(file: File): Promise<File> {
|
||||||
try {
|
try {
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
||||||
// Already small enough: keep the original bytes/format.
|
// Always redraw through a canvas, even for images that are already small:
|
||||||
if (scale === 1 && file.size <= 1_200_000) {
|
// this strips EXIF/GPS and other metadata before the file leaves the device.
|
||||||
bitmap.close();
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
|
@ -74,7 +71,7 @@ async function prepareImage(file: File): Promise<File> {
|
||||||
const blob = await new Promise<Blob | null>((resolve) => {
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
canvas.toBlob(resolve, 'image/webp', 0.84);
|
canvas.toBlob(resolve, 'image/webp', 0.84);
|
||||||
});
|
});
|
||||||
if (!blob || blob.size >= file.size) return file;
|
if (!blob) return file;
|
||||||
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
|
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||||||
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
|
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
55
src/lib/eventMerge.test.ts
Normal file
55
src/lib/eventMerge.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { eventMergeKey, eventReplaces, mergeEvents } from './eventMerge';
|
||||||
|
|
||||||
|
function ev(partial: Partial<NostrEvent> & { id: string }): NostrEvent {
|
||||||
|
return {
|
||||||
|
pubkey: 'a'.repeat(64),
|
||||||
|
created_at: 100,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: '',
|
||||||
|
sig: '0'.repeat(128),
|
||||||
|
...partial,
|
||||||
|
} as NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('eventMerge', () => {
|
||||||
|
it('keys addressable, replaceable and regular events correctly', () => {
|
||||||
|
expect(eventMergeKey(ev({ id: '1', kind: 30818, tags: [['d', 'spot']] }))).toBe(
|
||||||
|
`${'a'.repeat(64)}:30818:spot`,
|
||||||
|
);
|
||||||
|
expect(eventMergeKey(ev({ id: '2', kind: 0 }))).toBe(`${'a'.repeat(64)}:0`);
|
||||||
|
expect(eventMergeKey(ev({ id: '3', kind: 1 }))).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the newest addressable version regardless of input order', () => {
|
||||||
|
const old = ev({ id: 'b', kind: 30818, tags: [['d', 'spot']], created_at: 100 });
|
||||||
|
const fresh = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']], created_at: 101 });
|
||||||
|
|
||||||
|
expect(mergeEvents([old, fresh])).toEqual([fresh]);
|
||||||
|
expect(mergeEvents([fresh, old])).toEqual([fresh]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses event id as a deterministic tie-breaker', () => {
|
||||||
|
const first = ev({ id: 'aaa', kind: 30000, tags: [['d', 'x']], created_at: 100 });
|
||||||
|
const second = ev({ id: 'zzz', kind: 30000, tags: [['d', 'x']], created_at: 100 });
|
||||||
|
|
||||||
|
expect(eventReplaces(first, second)).toBe(true);
|
||||||
|
expect(mergeEvents([first, second])).toEqual([second]);
|
||||||
|
expect(mergeEvents([second, first])).toEqual([second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps distinct regular events and deduplicates by id', () => {
|
||||||
|
const a = ev({ id: 'a' });
|
||||||
|
const b = ev({ id: 'b' });
|
||||||
|
expect(mergeEvents([a, b, a])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops local tombstones', () => {
|
||||||
|
const a = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']] });
|
||||||
|
const b = ev({ id: 'b', kind: 30818, tags: [['d', 'other']] });
|
||||||
|
|
||||||
|
expect(mergeEvents([a, b], { isDeleted: (event) => event.id === 'a' })).toEqual([b]);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/lib/eventMerge.ts
Normal file
74
src/lib/eventMerge.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic event merging.
|
||||||
|
*
|
||||||
|
* Nostr does not provide global ordering: relays can return the same logical
|
||||||
|
* event set in a different order, and two relays can serve different versions
|
||||||
|
* of an addressable event. We do not attempt consensus; we make the client's
|
||||||
|
* local answer deterministic and explainable.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - addressable events (`d` tag): newest `created_at` wins;
|
||||||
|
* - replaceable events (kind 0/3 and 10000-19999): newest `created_at` wins;
|
||||||
|
* - regular events: identity is the event id;
|
||||||
|
* - equal timestamps: lexicographically larger event id wins, so relay order
|
||||||
|
* and Promise timing cannot change the result.
|
||||||
|
*
|
||||||
|
* Local NIP-09 tombstones are applied by the caller before/while merging; a
|
||||||
|
* tombstone is a device-local safety net, not a claim that every relay erased
|
||||||
|
* the event.
|
||||||
|
*/
|
||||||
|
export type EventMergePredicate = (event: NostrEvent) => boolean;
|
||||||
|
|
||||||
|
export interface EventMergeOptions {
|
||||||
|
/** Return true for events that must not appear (local tombstones, moderation). */
|
||||||
|
isDeleted?: EventMergePredicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable identity used to compare two versions of the same logical event. */
|
||||||
|
export function eventMergeKey(event: NostrEvent): string {
|
||||||
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
if (dTag !== undefined) return `${event.pubkey}:${event.kind}:${dTag}`;
|
||||||
|
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
||||||
|
return `${event.pubkey}:${event.kind}`;
|
||||||
|
}
|
||||||
|
return event.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when `candidate` should replace `current` for the same key.
|
||||||
|
* Larger ids win ties; this is arbitrary but stable across relays and devices.
|
||||||
|
*/
|
||||||
|
export function eventReplaces(current: NostrEvent, candidate: NostrEvent): boolean {
|
||||||
|
if (candidate.created_at !== current.created_at) {
|
||||||
|
return candidate.created_at > current.created_at;
|
||||||
|
}
|
||||||
|
return candidate.id > current.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge events from one or more relays into one deterministic list.
|
||||||
|
*
|
||||||
|
* The returned list is sorted by key only for stable snapshots; callers that
|
||||||
|
* care about display order should sort for their own UI.
|
||||||
|
*/
|
||||||
|
export function mergeEvents(
|
||||||
|
events: readonly NostrEvent[],
|
||||||
|
options: EventMergeOptions = {},
|
||||||
|
): NostrEvent[] {
|
||||||
|
const { isDeleted } = options;
|
||||||
|
const byKey = new Map<string, NostrEvent>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (isDeleted?.(event)) continue;
|
||||||
|
|
||||||
|
const key = eventMergeKey(event);
|
||||||
|
const current = byKey.get(key);
|
||||||
|
if (!current || eventReplaces(current, event)) {
|
||||||
|
byKey.set(key, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byKey.values()];
|
||||||
|
}
|
||||||
|
|
@ -115,12 +115,20 @@ export default function TrustPage() {
|
||||||
<p>{t('trustUi.povDesc')}</p>
|
<p>{t('trustUi.povDesc')}</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex flex-col items-center gap-3 pt-2">
|
||||||
<Link to="/settings">
|
<Link to="/settings">
|
||||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
||||||
{t('trustUi.settingsCta')}
|
{t('trustUi.settingsCta')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://git.skate-spots.org/skatespots/SkateSpots/blob/main/docs/THREAT_MODEL.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-gray-500 underline decoration-dotted underline-offset-2 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{t('common.learnMore')}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue