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
|
||||
`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
|
||||
> **Owner decision (2026-10-30): no external identities.** Distribution must
|
||||
> 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",
|
||||
"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",
|
||||
"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: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: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-backup-policy.mjs && node scripts/inject-signing-config.mjs && node scripts/make-android-splash.mjs",
|
||||
"android:open": "npx cap open android",
|
||||
"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",
|
||||
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
||||
"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 className="inline-flex items-center gap-1.5">
|
||||
<img
|
||||
src="https://opensats.org/favicon.ico"
|
||||
src="/images/opensats.ico"
|
||||
alt="OpenSats"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function MapFooterStrip() {
|
|||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1.5 text-gray-500">
|
||||
{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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,30 +4,27 @@ import { useAppContext } from '@/hooks/useAppContext';
|
|||
import { useRelays } from '@/lib/relays';
|
||||
import { useCitrine } from '@/hooks/useCitrine';
|
||||
import { SKATE_RELAY } from '@/lib/types';
|
||||
import { mergeEvents } from '@/lib/eventMerge';
|
||||
import { getLocalDeletions } from '@/lib/localDeletions';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Deduplicate a batch of events from several relays, keeping the newest per
|
||||
* identity:
|
||||
* - addressable (has `d`): `pubkey:kind:d-tag`;
|
||||
* - 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.
|
||||
* Merge relay results deterministically and drop this device's local deletion
|
||||
* tombstones. Consensus is deliberately absent: relays are not voters, and this
|
||||
* client must not let relay timing decide which version of an event is shown.
|
||||
*/
|
||||
function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
|
||||
const map = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
let key: string;
|
||||
if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`;
|
||||
else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
||||
key = `${event.pubkey}:${event.kind}`;
|
||||
} else key = event.id;
|
||||
const existing = map.get(key);
|
||||
if (!existing || event.created_at > existing.created_at) map.set(key, event);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
const deleted = getLocalDeletions();
|
||||
return mergeEvents(events, {
|
||||
isDeleted: (event) => {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
const coord = dTag !== undefined ? `${event.kind}:${event.pubkey}:${dTag}` : undefined;
|
||||
return !!(
|
||||
(coord && deleted.has(coord)) ||
|
||||
deleted.has(`e:${event.id}`)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -59,11 +59,8 @@ async function prepareImage(file: File): Promise<File> {
|
|||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
||||
// Already small enough: keep the original bytes/format.
|
||||
if (scale === 1 && file.size <= 1_200_000) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
// Always redraw through a canvas, even for images that are already small:
|
||||
// this strips EXIF/GPS and other metadata before the file leaves the device.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * 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) => {
|
||||
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';
|
||||
return new File([blob], `${base}.webp`, { type: 'image/webp', lastModified: file.lastModified });
|
||||
} 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>
|
||||
</Section>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<div className="flex flex-col items-center gap-3 pt-2">
|
||||
<Link to="/settings">
|
||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
||||
{t('trustUi.settingsCta')}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue