Compare commits

...

41 commits

Author SHA1 Message Date
SkateSpots
56d318811f docs: publish 1.7.70 Amber privacy fix
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 17:25:26 +07:00
SkateSpots
0859327464 release: 1.7.70 2026-10-31 17:21:43 +07:00
SkateSpots
53cad29c7e fix: gate Amber inbox decryption and expire redirect ops 2026-10-31 17:17:08 +07:00
SkateSpots
5acaa3b2b4 docs: publish 1.7.69 status polish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 17:06:03 +07:00
SkateSpots
226d0c3f95 release: 1.7.69 2026-10-31 17:00:24 +07:00
SkateSpots
f646d5aa14 ui: quiet and compact desktop status control 2026-10-31 16:57:42 +07:00
SkateSpots
0fe82c28d8 release: 1.7.68 2026-10-31 16:52:48 +07:00
SkateSpots
20de6ba8e1 feat: verify coordinator RPC and operator identity 2026-10-31 16:49:38 +07:00
SkateSpots
bcc277a054 docs: publish 1.7.67 coordinator kit
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 16:34:53 +07:00
SkateSpots
c8f471b290 release: 1.7.67 2026-10-31 16:29:05 +07:00
SkateSpots
c2750eae85 feat: production crew-owned Cordn coordinator kit 2026-10-31 16:25:52 +07:00
SkateSpots
609b15be08 docs: publish 1.7.66 safety and escrow primitive
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 15:24:07 +07:00
SkateSpots
cc0545cc5c release: 1.7.66 2026-10-31 15:16:15 +07:00
SkateSpots
31d1630c50 feat: high-value safety, reputation decay, escrow 2-of-3 primitive 2026-10-31 15:13:02 +07:00
SkateSpots
1d810ea964 docs: publish 1.7.65 diagnostics release
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 12:18:26 +07:00
SkateSpots
a813e1e7f7 release: 1.7.65 2026-10-31 12:14:45 +07:00
SkateSpots
618d961f72 feat: relay consistency diagnostic and recent reputation 2026-10-31 12:11:40 +07:00
SkateSpots
e1a011045a docs: publish 1.7.64 safety note
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 10:44:36 +07:00
SkateSpots
a44665f05f release: 1.7.64 2026-10-31 10:40:21 +07:00
SkateSpots
4392df3202 feat(market): add low-friction new-seller safety note 2026-10-31 10:37:23 +07:00
SkateSpots
99f04edf5d docs: publish 1.7.63 privacy release
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 10:01:22 +07:00
SkateSpots
3edef9cbdf release: 1.7.63 2026-10-31 09:57:30 +07:00
SkateSpots
42d25818b4 fix: deterministic relay merge and privacy hardening 2026-10-31 09:54:36 +07:00
SkateSpots
5a1d849681 docs: add 1.7.62 F-Droid changelog
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 09:36:46 +07:00
SkateSpots
b4fbae693b docs: publish 1.7.62 download page
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-31 09:31:41 +07:00
SkateSpots
c08d5d4baa release: 1.7.62 2026-10-31 09:27:52 +07:00
SkateSpots
2c8bd594a4 fix: harden badge links and polish small flows 2026-10-31 09:24:32 +07:00
SkateSpots
9ce57bedf9 docs: remove external submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:18:58 +07:00
SkateSpots
bfc777c18b docs: make distribution Nostr-only, no external identities 2026-10-30 23:17:54 +07:00
SkateSpots
a2b732317e dist: point F-Droid recipe at splash-tracked source
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:11:15 +07:00
SkateSpots
5ebf741427 build: track Android splash source for clean checkouts 2026-10-30 23:09:23 +07:00
SkateSpots
4ae30ee8e6 dist: prepare unsigned F-Droid build output 2026-10-30 23:07:42 +07:00
SkateSpots
72e5f89b49 docs: add external store submission drafts
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:04:13 +07:00
SkateSpots
d003748bb1 dist: use valid F-Droid category
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 23:02:17 +07:00
SkateSpots
6048eafc0d docs: record future distribution and external submission limits
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:59:06 +07:00
SkateSpots
562a7de7c0 docs: record Forgejo release helper
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:53:00 +07:00
SkateSpots
8b12001fa6 docs: document self-hosted F-Droid republish
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:51:09 +07:00
SkateSpots
383ff8a5cf dist: add Forgejo release helper and F-Droid instructions
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:49:23 +07:00
SkateSpots
65914144d1 dist: document self-hosted Forgejo and F-Droid repo
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:43:47 +07:00
SkateSpots
a8780d5236 dist: self-hosted Forgejo release page and F-Droid metadata 2026-10-30 22:33:53 +07:00
SkateSpots
2dcf5a2aad docs: record 1.7.61 Cordn setup timeline
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run
2026-10-30 22:25:18 +07:00
71 changed files with 1954 additions and 94 deletions

3
.gitignore vendored
View file

@ -52,7 +52,8 @@ stats.json
# Local/generated assets & incidental lockfiles (do not commit) # Local/generated assets & incidental lockfiles (do not commit)
/assets/ /assets/
/resources/ /resources/*
!/resources/splash.png
/bun.lock /bun.lock
cvm-server/bun.lock cvm-server/bun.lock
push-service/bun.lock push-service/bun.lock

137
AGENTS.md
View file

@ -1418,6 +1418,15 @@ session with **no other memory of this project**. So:
it in when known. it in when known.
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0 **Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
**`cordn`** field (app convention, documented in NIP.md — not a new kind). **`cordn`** field (app convention, documented in NIP.md — not a new kind).
**Crew-owned coordinator kit (2026-10-31):** `deploy/cordn/` is the
production hand-off for crews: Docker Compose, SQLite volume, `.env` template,
backup/restore scripts and a runbook. Cordn connects **outbound** to ContextVM
relays; no inbound port, domain or TLS terminator is required for the
coordinator itself. The app still does **not** embed a coordinator. Settings →
Coordinator links to the kit; the crew's `coordinator` tag/operator remains
the adoption path. Keep the coordinator key separate from crew/admin keys.
Do not claim multi-coordinator failover; this is one trusted delivery service
with backups, not BFT.
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex / `EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
http URL), **normalises a bare pointer to Cordn's canonical add-link** before http URL), **normalises a bare pointer to Cordn's canonical add-link** before
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page` saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
@ -2228,7 +2237,7 @@ session with **no other memory of this project**. So:
instead of showing the error boundary. instead of showing the error boundary.
- **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**, - **Baselines** (verified 2026-10-29): `npm test` → **0 errors, 0 warnings**,
**156 tests**, build ok. The gate also type-checks the CVM server **156 tests**, build ok. The gate also type-checks the CVM server
(`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.60**. (`tsc -p cvm-server/tsconfig.json`). `package.json` version **1.7.70**.
- ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0 - ✅ Lint is **clean**: `no-explicit-any` = 0 · `react-hooks/exhaustive-deps` = 0
· `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were · `no-unused-vars` = 0 · `react-refresh` = 0. Non-component exports were
**split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`, **split into their own modules** (`contexts/language.ts`, `lib/sortOptions.ts`,
@ -2415,6 +2424,95 @@ 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
> **Owner decision (2026-10-30): no external identities.** Distribution must
> stay self-hosted and Nostr-based. Do **not** create or ask for GitLab,
> Codeberg, GitHub, email or F-Droid-main accounts. The local Forgejo admin
> account and the self-hosted F-Droid repo key are infrastructure credentials,
> not third-party identities. F-Droid main and IzzyOnDroid are therefore
> **out of scope**.
- **Self-hosted Forgejo:** `https://git.skate-spots.org/skatespots/SkateSpots`
(public source, registration disabled, admin credentials only on the relay
host). It runs on `185.18.221.2` behind Caddy; do not commit its credentials.
- **Latest release (Obtanium HTML source):**
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`.
The page exposes one signed `.apk`; Obtanium may need **Override Source →
HTML** because the host is self-hosted, not Codeberg.
- **Static download page:** `public/download/index.html` → `https://skate-spots.org/download/`.
- **Forgejo release helper:** `scripts/publish-forgejo.sh` (uses `FORGEJO_TOKEN`
or `FORGEJO_PASSWORD`; `scripts/release.sh` calls it automatically when either
is set). It uploads the APK and checksum, but does not push git history.
- **Self-hosted F-Droid repo:** `https://fdroid.skate-spots.org/repo`
(`org.skatespots.app`; currently 1.7.70). It is a **third-party repo**, not
F-Droid main. Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`.
The developer APK signature is unchanged; the repo key only signs indexes.
A backup of the repo keystore/config is in
`~/.config/skatespots/fdroid-repo/` (mode 600).
- **F-Droid main/IzzyOnDroid:** **out of scope by owner decision.** Both require
external GitLab/Codeberg identities and review; do not submit, create accounts,
or present them as live. The self-hosted F-Droid repo above is the supported
F-Droid-family channel.
- **No GitHub required.** Keep Zapstore, Forgejo, the self-hosted F-Droid repo,
and the static page in sync for each release.
### Future release / distribution checklist
1. Bump `package.json`; update the top of `release-notes.txt`; run
`scripts/release.sh patch --yes --deploy`.
2. Set `FORGEJO_TOKEN` (or `FORGEJO_PASSWORD`) to upload the APK/checksum to the
latest Forgejo release; the helper is `scripts/publish-forgejo.sh`.
3. **Push source/tags to Forgejo.** The local machine's DNS for
`git.skate-spots.org` is intermittent, so the reliable path is: create a git
bundle locally, `scp` it to the relay, clone there, and push to
`http://127.0.0.1:3000/skatespots/SkateSpots.git` with the host admin
credentials. Do not put the password in a committed remote URL.
4. Add the APK to `/home/ubuntu/fdroid/repo/`, update the F-Droid metadata
changelog, run `fdroid update` in `/home/ubuntu/fdroid`, then rsync `repo/`
to `/var/www/fdroid/repo/`.
5. Update `public/download/index.html` with the new version/checksum and deploy
the web page.
6. Verify: latest Forgejo release exposes one `.apk`; static page 200;
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`; Obtanium still resolves the latest release.
7. Keep Forgejo/F-Droid credentials and the F-Droid repo keystore out of git.
Local backups: `~/.config/skatespots/forgejo-admin.txt` and
`~/.config/skatespots/fdroid-repo/` (mode 600).
### Nostr-only distribution (no external identities)
- **Zapstore** is the Nostr-native app store/update channel; keep publishing
release events there.
- **Obtanium** is URL-based, not an account: users add the latest Forgejo
release URL (HTML source if needed) or the self-hosted F-Droid repo.
- **F-Droid main and IzzyOnDroid do not support Nostr identities.** They are
intentionally not pursued. Do not create or request GitLab/Codeberg/email
accounts, and do not claim those stores are live.
- The local Forgejo admin account, the relay, and the F-Droid repo signing key
are self-hosted infrastructure, not third-party identities.
- The F-Droid recipe remains in `distribution/fdroiddata/` only as an unused
technical reference. It was locally test-built at `5ebf741`, but must not be
submitted.
### Deploy ### Deploy
- **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`. - **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. **Doc/scripts-only commits are skipped** (matches `AGENTS.md`/`*.md`/`scripts/`/`cvm-server/`/`skills/`) — Netlify bills **15 credits per production deploy**, so don't spend them on non-site changes. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`.
- ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately. - ⚠️ **Deploy throttle (2026-09-28):** production deploys are capped to one per **24h** by default (after a week of 68 deploys ≈ **1,020 credits** — a third of the monthly allowance). Batching a day's commits into one 15-credit deploy is the single biggest credit lever. Override with `SKATESPOTS_DEPLOY_MIN_INTERVAL=<seconds>` (`0` disables); deploy on demand with **`scripts/auto-deploy.sh --force`**. A throttled tick deliberately does **not** record the deployed SHA, so the pending change still deploys on a later tick; a **failed** deploy doesn't set the clock, so it retries immediately.
@ -2799,6 +2897,43 @@ Rules:
1600px WebP when smaller (GIF/undecodable formats untouched), cutting 1600px WebP when smaller (GIF/undecodable formats untouched), cutting
quota/storage/upload time. APK sha256 `8f11c229…`, versionCode 10760. quota/storage/upload time. APK sha256 `8f11c229…`, versionCode 10760.
Web live at `6c9b6fa`. Web live at `6c9b6fa`.
- **1.7.61 (2026-10-30):** Cordn setup timeline now checks prepared device
/sent request and spins on the approval step while waiting. APK sha256
`db0279f7…`, versionCode 10761. Web live at `50c95d0`.
- **1.7.62 (2026-10-31):** 404 uses an in-app link; profile badge URLs are
sanitized; marketplace readiness only counts a usable lud16; duplicate
Korean picker entry hidden; beta source link points self-hosted. APK
sha256 `e746b18e…`, versionCode 10762. Web live at `c08d5d4`.
- **1.7.63 (2026-10-31):** deterministic event merge with event-id tie-break
and local deletion tombstones; EXIF/GPS stripping for decodable uploads;
Android Auto Backup disabled; local OpenSats icon; threat-model document.
APK sha256 `70e1dd1a…`, versionCode 10763. Web live at `3edef9c`.
- **1.7.64 (2026-10-31):** low-friction new-seller safety note beside trade
actions, using existing marketplace guidance; no new gate. APK sha256
`32d5cd35…`, versionCode 10764. Web live at `a44665f`.
- **1.7.65 (2026-10-31):** Settings relay-consistency diagnostic (profiles,
on demand, no merge); recent 90-day confirmed-trade count beside lifetime
reputation. APK sha256 `73684085…`, versionCode 10765. Web live at
`a813e1e`.
- **1.7.66 (2026-10-31):** high-value safety copy (no arbitration, escrow),
180-day half-life reputation weighting, and the unit-tested 2-of-3 escrow
policy primitive (not exposed in UI pending arbiter policy). APK sha256
`0ed775ed…`, versionCode 10766. Web live at `cc0545c`.
- **1.7.67 (2026-10-31):** crew-owned Cordn coordinator deployment kit
(`deploy/cordn/`: Compose, SQLite volume, env template, backup/restore,
runbook); persistent Docker command and links from Settings/Crew edit.
No embedded coordinator, no BFT claim. APK sha256 `ac7c9589…`, versionCode
10767. Web live at `c8f471b`.
- **1.7.68 (2026-10-31):** real ContextVM RPC coordinator health probe with
throwaway key, and optional crew coordinator operator field. APK sha256
`d8222789…`, versionCode 10768. Web live at `0fe82c2`.
- **1.7.69 (2026-10-31):** desktop status control is a compact status
emoji/label chip (no large outlined pill or permanent ping). APK sha256
`45add6a3…`, versionCode 10769. Web live at `226d0c3`.
- **1.7.70 (2026-10-31):** Amber inbox decrypt gate (explicit Unlock card),
10-minute expiry for redirect pending ops, and no plaintext/crypto results
left in browser storage. APK sha256 `be521c16…`, versionCode 10770. Web
live at `0859327`.
### Relay archive (dataset backup) ### Relay archive (dataset backup)
- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):** - **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):**

View file

@ -77,11 +77,26 @@ token in a public event is spendable by anyone who sees it.
promptly, and lock windows should be generous. promptly, and lock windows should be generous.
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too. - **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too.
## Optional 2-of-3 primitive
`escrowP2PKOptions` now accepts an optional `arbiterPubkey` and can express the
NUT-11 policy used by a future 2-of-3 escrow:
- normal path: seller + arbiter, `n_sigs=2`;
- refund path: buyer + arbiter, `n_sigs_refund=2`;
- no arbiter: the current 1-of-1 seller/buyer policy.
The primitive is unit-tested, but **no UI or default flow uses it yet**. There is
no arbiter policy, bond, dispute process or appeal path, so exposing it would add
friction and false confidence. Do not enable it for real money until those are
defined and the flow is verified on-device.
## Status ## Status
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation, - ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
`escrowP2PKOptions`, offline token inspection/`parseEscrowToken`, `escrowP2PKOptions` (1-of-1, optional 2-of-3), offline token
locktime/refund policy, and the injectable `createEscrowToken` mint flow. inspection/`parseEscrowToken`, locktime/refund policy, and the injectable
`createEscrowToken` mint flow.
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test - 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
checklist** below before treating as production. Do not use real money until checklist** below before treating as production. Do not use real money until
every box passes. every box passes.

19
deploy/cordn/.env.example Normal file
View file

@ -0,0 +1,19 @@
# Copy this file to .env, then generate a key:
# openssl rand -hex 32
# Keep .env mode 600 and back it up with the coordinator data.
CORDN_SERVER_PRIVATE_KEY=
# ContextVM relays the coordinator connects to. Add a second one if you want
# more reachability; the coordinator does not need an inbound port.
CORDN_RELAY_URLS=wss://relay.contextvm.org,wss://relay2.contextvm.org
# Publicly announce the coordinator on its relays.
CORDN_ANNOUNCED=true
CORDN_SERVER_NAME=my-crew-coordinator
# Per-identity abuse protection. Raise for larger crews; keep it bounded.
CORDN_RATE_LIMIT_ENABLED=true
CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250
CORDN_RATE_LIMIT_BURST=80
CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50
CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1

127
deploy/cordn/README.md Normal file
View file

@ -0,0 +1,127 @@
# Crew-owned Cordn coordinator
This kit runs a **Cordn MLS delivery-service coordinator** that a crew owns. It
is transport only: the coordinator sees ciphertext and routing metadata (who,
when, which group), never message plaintext. It can withhold or delay messages;
it cannot forge members' messages.
This is a **server** kit. SkateSpots remains a Cordn *client*; it does not embed a
coordinator in the APK.
## What you need
- A small Linux server or always-on home machine with Docker + Compose.
- Outbound HTTPS/WSS access. **No inbound port is required** — the coordinator
connects out to its ContextVM relay(s).
- A domain is **not** required for the coordinator itself.
- A crew willing to trust the operator with availability and metadata.
## 1. Install
```bash
cp .env.example .env
chmod 600 .env
openssl rand -hex 32
```
Put the generated value into `CORDN_SERVER_PRIVATE_KEY`. Set
`CORDN_RELAY_URLS` to the relay(s) you want; the default is ContextVM’s public
relay. Set a useful `CORDN_SERVER_NAME`.
```bash
docker compose up -d
docker compose logs -f cordn
```
The server prints its coordinator public identity/add link. It may show an
`nprofile` or a `cordn.net/chat/coordinators?c=…` link.
If your build does not print an add link, derive the public key from the private
key using the Cordn CLI or `nostr-tools`, then encode an `nprofile` with the same
relay hints. Keep the private key on the server.
## 2. Adopt it in SkateSpots
Either:
- Settings → **Coordinator** → paste the `nprofile`/add-link → **Add**; or
- Crew edit → **Coordinator** → paste it and save; or
- scan the coordinator QR.
Use exactly the relay hints from the server. A mismatch produces an honest
“Not seen” health state even when the server is actually running.
The coordinator should show **Online** (or “Stale” if its heartbeat is old).
“Offline” means presence was not found, not necessarily that the service is
broken.
## 3. What the crew should know
- The coordinator can **censor or drop messages**.
- It cannot **read** MLS plaintext.
- It can see **who, when and which group**.
- It can disappear; local messages remain on members' devices, but delivery
pauses until the coordinator returns or the crew switches.
- The operator should **not** also become a crew admin automatically.
- A loss of the database is a loss of delivery history; back it up.
## 4. Backup
```bash
./backup.sh
```
Backups land in `./backups/` as a compressed copy of the coordinator volume.
Store them off the server, encrypted.
## 5. Restore
Stop the coordinator, restore the archive into the `cordn-data` volume, start it
again:
```bash
docker compose stop cordn
./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
docker compose start cordn
```
## 6. Upgrade
```bash
docker compose pull
docker compose up -d
```
The image is pinned only by the `latest` tag. If you need reproducible upgrades,
pin a digest and record it in your crew runbook.
## 7. Move to a new coordinator
MLS delivery state is tied to the coordinator’s database and identity. Before
switching:
1. back up the current coordinator;
2. export/retain the crew’s local chat state on members’ devices;
3. start the new coordinator;
4. paste its `nprofile` into the crew record;
5. have members reopen the private chat and re-establish delivery;
6. update the crew operator/notice.
Do not claim the switch is seamless unless the old and new coordinator state
have actually been migrated.
## Security notes
- Never commit `.env`; it contains the coordinator signing key.
- The coordinator key is **not** a crew admin key and must not be reused as one.
- Run it behind a trusted host/network; outbound relay traffic is metadata.
- Keep Docker and the image current.
- Do not expose an OpenCode or coordinator admin port publicly.
- This is **not** a BFT or multi-coordinator system. One coordinator is a
delivery service under the crew's social trust.
## Files
- `docker-compose.yml` — persistent SQLite deployment.
- `.env.example` — required environment.
- `backup.sh` / `restore.sh` — volume snapshots.

14
deploy/cordn/backup.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/sh
# Snapshot the Cordn coordinator data volume. Run from this directory.
set -eu
cd "$(dirname "$0")"
mkdir -p backups
stamp="$(date +%Y%m%d-%H%M%S)"
out="cordn-$stamp.tgz"
docker run --rm \
-v cordn_cordn-data:/data:ro \
-v "$PWD/backups":/backup \
alpine:3.20 \
sh -c "tar czf /backup/$out -C /data ."
echo "Backup: $PWD/backups/$out"
echo "Store it off this server and keep it encrypted."

View file

@ -0,0 +1,19 @@
name: cordn
services:
cordn:
image: ghcr.io/cordn-msg/cordn:latest
container_name: cordn
restart: unless-stopped
env_file:
- .env
environment:
CORDN_STORAGE_BACKEND: sqlite
CORDN_SQLITE_PATH: /data/cordn.sqlite
volumes:
- cordn-data:/data
# No inbound port is required: the coordinator connects *out* to the
# ContextVM relay(s) configured in CORDN_RELAY_URLS.
volumes:
cordn-data:

17
deploy/cordn/restore.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/sh
# Restore a Cordn coordinator backup into the cordn-data volume.
# Usage: ./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz
set -eu
cd "$(dirname "$0")"
[ "$#" -eq 1 ] || { echo "usage: $0 backups/cordn-YYYYMMDD-HHMMSS.tgz" >&2; exit 1; }
archive="$1"
[ -f "$archive" ] || { echo "no such backup: $archive" >&2; exit 1; }
printf 'This replaces the current coordinator volume. Type "restore" to continue: '
read -r answer
[ "$answer" = restore ] || { echo "aborted" >&2; exit 1; }
docker run --rm \
-v cordn_cordn-data:/data \
-v "$(cd "$(dirname "$archive")" && pwd)":/backup:ro \
alpine:3.20 \
sh -c "rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true; tar xzf /backup/$(basename "$archive") -C /data"
echo "Restored $(basename "$archive")."

97
distribution/README.md Normal file
View file

@ -0,0 +1,97 @@
# Distribution without GitHub
SkateSpots is published from a self-hosted **Forgejo** instance. No GitHub
account is required.
## Current release source
- Forge: <https://git.skate-spots.org/skatespots/SkateSpots>
- Latest release: <https://git.skate-spots.org/skatespots/SkateSpots/releases/latest>
- Signed APK (1.7.70):
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.70/SkateSpots-1.7.70.apk>
- SHA-256:
`be521c167814a78dc68b98bc7ac5e3c73a096f9f690fcac20ecf294bcf875767`
The APK is signed with the same key as the Zapstore build. Installing an
F-Droid-signed build over it would require uninstalling SkateSpots and losing
the local key/settings. Obtanium and direct APK updates keep the install.
## Obtanium
Obtanium cannot use a Zapstore URL as a source. Use the self-hosted Forgejo
release page instead:
1. Open Obtanium → **Add App**.
2. Paste:
`https://git.skate-spots.org/skatespots/SkateSpots/releases/latest`
3. If Obtanium does not recognise the self-hosted host, select **Override
Source → HTML**.
4. Set version extraction if desired:
`SkateSpots-([0-9.]+)\.apk`, match group `1`.
The release page contains exactly one `.apk`, so Obtanium can follow the latest
release without a custom store listing.
## F-Droid family
### Self-hosted F-Droid repo (live now)
A signed third-party F-Droid repository is published here:
- Repo: <https://fdroid.skate-spots.org/repo>
- App ID: `org.skatespots.app`
- Repo signing fingerprint:
`88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75`
Users can add this URL in F-Droid, or add it in Obtanium as an **F-Droid Third
Party Repo**. This is not F-Droid main: it is a self-hosted, signed repository
of the developer-signed APK. It keeps the existing install and signing key.
The repository metadata and APK are served from the relay host. The repo index
is signed with a repository key stored on that host; the APK itself keeps the
SkateSpots developer signature.
### No external store identities
**Owner decision:** distribution stays self-hosted and Nostr-based. No GitLab,
Codeberg, GitHub, email or F-Droid-main accounts are created or requested.
F-Droid main and IzzyOnDroid both require external identities and review, so
they are intentionally out of scope. The self-hosted repo above is the supported
F-Droid-family channel.
`distribution/fdroiddata/` and `fastlane/` are kept as an unused technical
reference only. A fresh local clone at `5ebf741` built the unsigned APK through
the recipe's `npm ci → android:init → android:sync → assembleRelease` path, but
the recipe must not be submitted.
## Release checklist
1. `npm test`
2. `scripts/release.sh patch --yes --deploy` (Zapstore + web)
3. Push the release tag and `main` to Forgejo.
4. Upload the APK through the helper:
`FORGEJO_TOKEN=... scripts/publish-forgejo.sh <version>`
(or set `FORGEJO_TOKEN` before `release.sh`; the APK upload is then automatic)
5. Copy the signed APK into the F-Droid repo on the relay host, add a
`metadata/org.skatespots.app.yml` changelog entry, run `fdroid update`, and
sync `repo/` to `/var/www/fdroid/repo/`:
```bash
cd /home/ubuntu/fdroid
cp /path/to/SkateSpots-<version>.apk repo/
. /home/ubuntu/fdroid-venv/bin/activate
fdroid update
sudo rsync -a --delete repo/ /var/www/fdroid/repo/
sudo chown -R caddy:caddy /var/www/fdroid
```
6. Verify the public latest-release page contains the new `.apk`, and that
`https://fdroid.skate-spots.org/repo/index-v2.json` lists the new
`versionCode`.
7. Update the static download page if the displayed version/checksum changes:
`public/download/index.html`.
The Forgejo instance runs on the same host as the relay. It exposes public
read access and HTTPS through Caddy; registration is disabled and administrative
credentials are kept on the host, not in this repository.

View file

@ -0,0 +1,51 @@
Categories:
- Sports & Health
License: AGPL-3.0-only
AuthorName: SkateSpots
WebSite: https://skate-spots.org
SourceCode: https://git.skate-spots.org/skatespots/SkateSpots
IssueTracker: https://git.skate-spots.org/skatespots/SkateSpots/issues
Changelog: https://git.skate-spots.org/skatespots/SkateSpots/releases
AutoName: SkateSpots
Summary: Local-first skate map, crews, events and marketplace
Description: |-
SkateSpots is a local-first skating map for finding spots, crews, events and
gear. Your keys stay on your device, your messages stay in encrypted crew
chats and the app does not use analytics or sell your data.
Find skate spots on the map, add new ones, organise sessions with your crew,
follow events and browse the community marketplace. The Android app bundles
the same web app for fast shell startup and native camera, location, push and
NFC support where available.
SkateSpots is self-hosted and built on open protocols. You can run your own
relay and Blossom storage instead of relying on a central service.
RepoType: git
Repo: https://git.skate-spots.org/skatespots/SkateSpots.git
Builds:
- versionName: 1.7.61
versionCode: 10761
commit: 5ebf741427303be0b0feeff5ce1d8420e2c0ddea
subdir: .
prebuild:
- npm ci
- npm run android:init
- npm run android:sync
# F-Droid signs the APK itself. Remove the developer signing config so
# Gradle emits an unsigned release APK instead of requiring the keystore.
- sed -i '/signingConfig signingConfigs.release/d' android/app/build.gradle
build:
- cd android && ./gradlew assembleRelease --no-daemon
output: android/app/build/outputs/apk/release/app-release-unsigned.apk
# The generated Android project is intentionally not committed. This
# recipe recreates it with Capacitor before Gradle runs. F-Droid will sign
# the resulting APK with its own key. The F-Droid buildserver supplies the
# JDK/Android SDK; do not install packages with sudo in a recipe.
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 1.7.61
CurrentVersionCode: 10761

104
docs/THREAT_MODEL.md Normal file
View 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.**

View file

@ -0,0 +1 @@
Cordn setup now shows visible progress: the device-key step is checked once prepared, and the approval step shows a spinner while waiting. The APK uses the same signing key as the Zapstore release.

View file

@ -0,0 +1 @@
Small correctness pass: 404 navigation stays in-app, profile badge URLs are sanitized, marketplace readiness only counts a usable Lightning address, and the duplicate Korean picker entry is hidden.

View file

@ -0,0 +1 @@
Deterministic relay merging with a stable event-id tie-break and local deletion tombstones; EXIF/GPS stripping on upload; Android cloud backup disabled; local OpenSats icon; added the trust-model document.

View file

@ -0,0 +1 @@
Listings from a new seller with no confirmed trading partner now show a compact safety note beside the trade actions: meet in public, prefer escrow for shipped deals, and never pay outside the app's rails.

View file

@ -0,0 +1 @@
Settings now has an on-demand relay consistency check for profile snapshots; it reports differences without merging data. Trade reputation also shows how many mutually-confirmed trades happened in the last 90 days, alongside lifetime counts.

View file

@ -0,0 +1 @@
High-value listings now show the safety note including the no-arbitration reminder; reputation uses a 180-day half-life and recent 90-day count; the 2-of-3 escrow policy primitive is unit-tested but not exposed until an arbiter policy exists.

View file

@ -0,0 +1 @@
Added a production crew-owned Cordn coordinator kit: Docker Compose with SQLite persistence, an env template, backup/restore scripts, and a runbook. The coordinator needs only outbound relay access; Settings and crew edit link to the kit. The app still does not embed a coordinator.

View file

@ -0,0 +1 @@
The desktop header status control is now a compact chip showing your current status emoji and label, or a neutral Status affordance when none is set. No permanent ping or large outlined pill.

View file

@ -0,0 +1 @@
Amber users now unlock encrypted conversations explicitly instead of triggering a decrypt storm on Messages. Amber redirect requests expire after 10 minutes, and plaintext/crypto results are no longer persisted in browser storage.

View file

@ -0,0 +1,15 @@
SkateSpots is a local-first skating map for finding spots, crews, events and
gear. Your keys stay on your device, your messages stay in encrypted crew chats
and the app does not use analytics or sell your data.
Find skate spots on the map, add new ones, organise sessions with your crew,
follow events and browse the community marketplace. The Android app bundles the
same web app for fast offline shell startup and native camera, location, push
and NFC support where available.
SkateSpots is self-hosted and built on open protocols. You can run your own
relay and Blossom storage instead of relying on a central service.
Lightning payments are optional and self-custodial. Transport and driver
profiles are self-attested; SkateSpots does not verify a licence, insurance or
vehicle roadworthiness.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1 @@
Local-first skate map, crews, events and marketplace.

View file

@ -0,0 +1 @@
SkateSpots

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.61", "version": "1.7.70",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "skate-spots", "name": "skate-spots",
"version": "1.7.61", "version": "1.7.70",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0", "@bitcoinerlab/secp256k1": "^1.2.0",

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.7.61", "version": "1.7.70",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"homepage": "https://skate-spots.org", "homepage": "https://skate-spots.org",
@ -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"

127
public/download/index.html Normal file
View file

@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>SkateSpots APK</title>
<meta
name="description"
content="Download the signed SkateSpots Android APK. No account, no store, no tracking."
/>
<style>
:root {
color-scheme: dark;
--bg: #090b0c;
--panel: #121719;
--line: #273033;
--text: #f2f5f4;
--muted: #9aa7a5;
--teal: #56e0c0;
--amber: #f5b83d;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 0%, rgba(86, 224, 192, 0.12), transparent 42%),
var(--bg);
color: var(--text);
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
main {
width: min(100%, 560px);
padding: 28px;
border: 1px solid var(--line);
border-radius: 18px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
}
.eyebrow {
margin: 0 0 10px;
color: var(--teal);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(28px, 7vw, 38px); line-height: 1.02; }
p { margin: 14px 0 0; color: var(--muted); }
a.download {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
padding: 16px 18px;
border-radius: 12px;
background: var(--teal);
color: #06201b;
font-weight: 800;
text-decoration: none;
}
a.download:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; }
a.download span:last-child { font-size: 20px; }
.meta {
display: grid;
gap: 8px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
code {
overflow-wrap: anywhere;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.links { display: flex; flex-wrap: wrap; gap: 10px 18px; margin-top: 16px; }
.links a { color: var(--amber); font-weight: 700; text-decoration: none; }
.links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main>
<p class="eyebrow">SkateSpots · Android</p>
<h1>Get the signed APK.</h1>
<p>
Local-first skating map, crews, events and marketplace. No store account
required. This is the same signing key as the Zapstore and Obtanium
releases, so it updates over an existing install.
</p>
<a
class="download"
href="https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.70/SkateSpots-1.7.70.apk"
rel="noopener"
>
<span>Download SkateSpots-1.7.70.apk · 56 MB</span>
<span aria-hidden="true">↓</span>
</a>
<div class="meta">
<span>Requires Android 7.0 or newer.</span>
<span>SHA-256: <code>be521c167814a78dc68b98bc7ac5e3c73a096f9f690fcac20ecf294bcf875767</code></span>
<span>Only install APKs from a source you trust. Android will ask you to allow installs from your browser.</span>
</div>
<div class="links">
<a href="https://git.skate-spots.org/skatespots/SkateSpots/releases">All releases</a>
<a href="https://git.skate-spots.org/skatespots/SkateSpots">Source code</a>
<a href="https://skate-spots.org">Open the web app</a>
</div>
<div class="meta">
<span><strong>Obtanium:</strong> add the latest release page above as the app source, or select <em>Override Source → HTML</em> if it is not recognised.</span>
<span><strong>F-Droid repo:</strong> <a href="https://fdroid.skate-spots.org/repo">https://fdroid.skate-spots.org/repo</a> (third-party self-hosted repo; app ID <code>org.skatespots.app</code>).</span>
<span>Repo signing fingerprint: <code>88:B1:71:1C:24:02:89:FB:B2:A7:FC:C1:B3:1F:BB:9D:60:F5:7F:19:18:E0:3D:62:2D:35:F2:3D:E1:84:46:75</code></span>
</div>
</main>
</body>
</html>

BIN
public/images/opensats.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -1,3 +1,118 @@
🛡️ Amber is quieter and safer
- Encrypted-message inboxes no longer auto-decrypt on arrival for Amber users.
Messages now show a deliberate “Unlock messages” card; opening Messages itself
does not fire a decrypt storm. The gate is Amber-only; silent signers keep the
automatic inbox.
- Amber redirect operations now expire after 10 minutes; stale callbacks are
rejected and cleared, so an old request cannot be replayed later.
- Redirect encryption operations no longer persist plaintext in localStorage,
sessionStorage or IndexedDB. Crypto results are no longer left in browser
storage.
- Added regression tests for stale Amber callback handling.
---
🎯 Quiet status control
- The desktop header no longer shows a large green outlined “Status” pill with a
permanent ping. It now shows a compact chip with your current status emoji and
label, or a neutral “Status” affordance when none is set.
- The control stays a single tap to open the status dialog; the existing avatar
status dot and mobile status flow are unchanged.
---
🧭 Coordinator setup that actually verifies itself
- Settings → Coordinator now runs a real ContextVM RPC probe, not just a
heartbeat presence check. It shows RPC latency when reachable and clearly
distinguishes “visible but RPC unavailable”.
- Crew edit now has an optional coordinator operator field (npub/hex), published
in the existing coordinator tag. The operator is a label, not an admin key.
- The RPC check uses a throwaway local key, so it never prompts Amber or touches
your identity.
- Added a live coordinator RPC test (skipped by default; `CORDN_LIVE=1`).
---
🏠 A real crew-owned coordinator kit
- Added `deploy/cordn/`: Docker Compose with SQLite persistence, `.env` template,
backup/restore scripts, and a runbook for a crew operator.
- The coordinator needs only outbound access to ContextVM relays — no inbound
port, domain or TLS terminator is required for the coordinator itself.
- Settings → Coordinator and the crew edit form now link to the production kit;
the displayed Docker command is persistent instead of the old `--rm` demo.
- The app still does not embed a coordinator. One crew-trusted coordinator is
the model; no multi-coordinator/BFT claims.
---
🎯 Value-aware safety and fresher reputation
- High-value fixed-price listings (100,000 sats or 100 USD/EUR/GBP) now get the
same compact safety note as new sellers: meet in public, prefer escrow for
shipped deals, never pay outside the app, and remember escrow is not
arbitration.
- Trade reputation now applies a 180-day half-life to the trust/activity weight,
while keeping lifetime counts and the existing distinct-partner/ring warnings.
The profile card also shows how many mutually-confirmed trades happened in the
last 90 days.
- Added the unit-tested NUT-11 2-of-3 escrow policy primitive (seller+arbiter /
buyer+arbiter). It is not exposed in the UI until an arbiter policy, bond and
appeal process exist.
---
🔎 Relay consistency and recent reputation
- Settings → Relays has an on-demand consistency check: it compares a bounded
profile snapshot from the community relay with your read relays and reports
newer/different signed profiles. It never merges data or treats relays as
voters.
- Trade reputation now shows how many mutually-confirmed trades happened in the
last 90 days, alongside the lifetime count and existing distinct-partner
warnings. Older reputation remains visible; this is a recency signal, not a
reset.
---
🛡️ A clearer warning before a new seller deal
- Listings from a seller with no confirmed trading partner, or an account under
30 days old, now show a compact safety note with the marketplace guidance:
meet in public, prefer escrow for shipped deals, and never pay outside the
app’s rails.
- The note sits beside the trade actions only — browsing stays frictionless.
---
🔒 Privacy, privacy, consistent merging
- Relay results are now merged deterministically with a newest-event rule and a
stable event-id tie-break instead of “first relay wins”. Local deletion
tombstones are applied during the same merge, so a deleted item cannot flash
back because a different relay answered first.
- Photo uploads are redrawn through a canvas before Blossom, stripping EXIF/GPS
metadata from decodable images. GIF and HEIC/HEIF still pass through as-is
because they need special handling.
- Android cloud backup is disabled for the app: local keys, settings and pending
escrow state stay on the device and must be exported explicitly.
- The OpenSats footer icon is now served locally, so opening the app does not
contact opensats.org just to render the footer.
- Added a technical threat-model document and linked it from the trust page:
signed facts, no relay consensus, no delivery proof, no arbitration.
---
🧹 Small correctness pass
- Unknown routes now return home with a real in-app link, so the native app no
longer does a full page reload on a 404.
- Profile badge links are sanitized before rendering, blocking `javascript:` and
other unsafe URL schemes from user metadata.
- Marketplace payment readiness now only says “ready” when checkout has a
Lightning address it can actually pay; an LNURL-only profile is no longer
advertised as ready.
- Removed the duplicate North Korean option from the language picker while
keeping stored preferences valid.
- The beta screen’s “View the code” link now points at the self-hosted source
instead of a GitHub search.
---
🧩 Cordn setup is easier to follow 🧩 Cordn setup is easier to follow
- The setup steps now show progress: preparing this device is checked once the - The setup steps now show progress: preparing this device is checked once the
key package is ready, and the approval step shows a spinner while waiting. key package is ready, and the approval step shows a spinner while waiting.

BIN
resources/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View 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).');
}

108
scripts/publish-forgejo.sh Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# Publish a built APK to the self-hosted Forgejo release page.
#
# Usage:
# FORGEJO_TOKEN=... scripts/publish-forgejo.sh 1.7.62 [path/to.apk]
# FORGEJO_PASSWORD=... FORGEJO_USER=skatespots scripts/publish-forgejo.sh 1.7.62
#
# This uploads the APK and its .sha256 file. It does not push the git tag or
# source; do that separately (the Forgejo repo is a mirror, not an auto-push
# remote). Keep credentials in your shell/environment, never in this repo.
set -euo pipefail
VERSION="${1:?usage: publish-forgejo.sh <version> [apk]}"
APK="${2:-android/app/build/outputs/apk/release/app-release.apk}"
TAG="${FORGEJO_TAG:-v$VERSION}"
BASE="${FORGEJO_URL:-https://git.skate-spots.org}"
OWNER="${FORGEJO_OWNER:-skatespots}"
REPO="${FORGEJO_REPO:-SkateSpots}"
API="$BASE/api/v1/repos/$OWNER/$REPO"
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; }
[ -f "$APK" ] || { echo "APK not found: $APK" >&2; exit 1; }
AUTH=()
if [ -n "${FORGEJO_TOKEN:-}" ]; then
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
elif [ -n "${FORGEJO_PASSWORD:-}" ]; then
AUTH=(-u "${FORGEJO_USER:-skatespots}:$FORGEJO_PASSWORD")
else
echo "set FORGEJO_TOKEN (preferred) or FORGEJO_PASSWORD" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
sha="$(sha256sum "$APK" | awk '{print $1}')"
body="$tmpdir/release.md"
cat >"$body" <<EOF
## SkateSpots $VERSION
Signed Android release. SHA-256: \`$sha\`
- Source: $BASE/$OWNER/$REPO
- APK signing key: same as the Zapstore release.
EOF
python3 - "$TAG" "$VERSION" "$body" >"$tmpdir/payload.json" <<'PY'
import json
import sys
from pathlib import Path
tag, version, body_path = sys.argv[1:]
print(json.dumps({
"tag_name": tag,
"name": version,
"body": Path(body_path).read_text(),
"draft": False,
"prerelease": False,
}))
PY
create_code="$(curl -sS -o "$tmpdir/release.json" -w '%{http_code}' \
"${AUTH[@]}" -H 'Content-Type: application/json' \
-X POST "$API/releases" --data-binary @"$tmpdir/payload.json" || true)"
if [ "$create_code" = "201" ]; then
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
elif [ "$create_code" = "409" ] || [ "$create_code" = "422" ]; then
curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" -o "$tmpdir/release.json"
release_id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmpdir/release.json")"
else
echo "Forgejo release creation failed (HTTP $create_code)" >&2
cat "$tmpdir/release.json" >&2
exit 1
fi
sha_file="$tmpdir/SkateSpots-$VERSION.apk.sha256"
printf '%s SkateSpots-%s.apk\n' "$sha" "$VERSION" >"$sha_file"
existing_assets="$(python3 - "$tmpdir/release.json" <<'PY'
import json
import sys
data = json.load(open(sys.argv[1]))
print("\n".join(asset["name"] for asset in data.get("assets", [])))
PY
)"
for file in "$APK" "$sha_file"; do
name="$(basename "$file")"
if printf '%s\n' "$existing_assets" | grep -Fqx "$name"; then
echo "already present: $name"
continue
fi
code="$(curl -sS -o "$tmpdir/asset.json" -w '%{http_code}' \
"${AUTH[@]}" -X POST "$API/releases/$release_id/assets?name=$name" \
-F "attachment=@$file" || true)"
case "$code" in
201|202) echo "uploaded $name" ;;
*) echo "warning: upload of $name returned HTTP $code" >&2 ;;
esac
done
echo "release: $BASE/$OWNER/$REPO/releases/tag/$TAG"
echo "latest: $BASE/$OWNER/$REPO/releases/latest"

View file

@ -126,6 +126,14 @@ for _ in 1 2 3 4 5 6; do
done done
[ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)" [ "$FOUND" -eq 1 ] || log "warning: the release event did not appear on the relay yet (it usually propagates shortly)"
if [ -n "${FORGEJO_TOKEN:-}" ] || [ -n "${FORGEJO_PASSWORD:-}" ]; then
log "publishing the APK to the self-hosted Forgejo release page…"
scripts/publish-forgejo.sh "$VERSION" "$APK" || \
log "warning: Forgejo upload failed — the Zapstore release is still valid"
else
log "Forgejo: skipped (set FORGEJO_TOKEN or FORGEJO_PASSWORD to publish there)"
fi
log "committing the release…" log "committing the release…"
git add package.json package-lock.json release-notes.txt git add package.json package-lock.json release-notes.txt
git commit -m "release: $VERSION" >/dev/null git commit -m "release: $VERSION" >/dev/null

View file

@ -19,8 +19,7 @@ import { useTranslation } from '@/contexts/language';
const DISCLAIMER_VERSION = 1; const DISCLAIMER_VERSION = 1;
const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`; const STORAGE_KEY = `skatespots_disclaimer_accepted_v${DISCLAIMER_VERSION}`;
const REPO_URL = const REPO_URL = 'https://git.skate-spots.org/skatespots/SkateSpots';
'https://github.com/search?q=SkateSpots';
export function BetaDisclaimerDialog() { export function BetaDisclaimerDialog() {
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false); const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);

View file

@ -21,6 +21,7 @@ import { toast } from '@/hooks/useToast';
import { import {
CORDN_APP_URL, CORDN_APP_URL,
CORDN_DOCKER_COMMAND, CORDN_DOCKER_COMMAND,
CORDN_DEPLOY_KIT_URL,
CORDN_RELAY_URL, CORDN_RELAY_URL,
coordinatorAddUrl, coordinatorAddUrl,
parseCoordinatorInput, parseCoordinatorInput,
@ -94,7 +95,7 @@ function RelayHealthPill({ url }: { url: string }) {
export function CoordinatorSettingsCard() { export function CoordinatorSettingsCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator(); const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator); const { status, lastSeen, eventCount, name, error, rpcStatus, rpcLatencyMs, check } = useCoordinatorHealth(coordinator);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showQr, setShowQr] = useState(false); const [showQr, setShowQr] = useState(false);
@ -175,6 +176,17 @@ export function CoordinatorSettingsCard() {
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)} : ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
</p> </p>
{rpcStatus === 'ok' && (
<p className="text-[11px] font-medium text-green-400">
{t('socialUi.coordinator.rpcOk', { ms: rpcLatencyMs ?? 0 })}
</p>
)}
{rpcStatus === 'error' && (
<p className="text-[11px] text-amber-400">
{t('socialUi.coordinator.rpcError')}
</p>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}> <Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} /> <RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
@ -314,6 +326,14 @@ export function CoordinatorSettingsCard() {
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<a href={CORDN_DEPLOY_KIT_URL} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
{t('common.learnMore')}
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Button>
</a>
</div>
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed"> <p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t('socialUi.coordinator.androidSleep')} {t('socialUi.coordinator.androidSleep')}

View file

@ -29,7 +29,7 @@ import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator'; import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR } from '@/lib/coordinator'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, parseOperatorPubkey, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
@ -336,6 +336,7 @@ export function CrewForm({ existing }: CrewFormProps) {
crewCoordinatorInputValue(existing?.coordinator) || crewCoordinatorInputValue(existing?.coordinator) ||
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })), (isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
); );
const [coordinatorOperator, setCoordinatorOperator] = useState(existing?.coordinator?.operator ?? '');
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill. // The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
const { coordinator: myCoordinator } = useCoordinator(); const { coordinator: myCoordinator } = useCoordinator();
// Live parse so the founder gets feedback (and the publish path reuses it). // Live parse so the founder gets feedback (and the publish path reuses it).
@ -494,6 +495,11 @@ export function CrewForm({ existing }: CrewFormProps) {
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' }); toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
return; return;
} }
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
if (coordinatorOperator.trim() && !operatorPubkey) {
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
return;
}
try { try {
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`; const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
@ -614,6 +620,10 @@ export function CrewForm({ existing }: CrewFormProps) {
if (parsedCoordinator) { if (parsedCoordinator) {
const coordinatorTag = ['coordinator', parsedCoordinator.url]; const coordinatorTag = ['coordinator', parsedCoordinator.url];
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey); if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
if (operatorPubkey) {
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
coordinatorTag.push(operatorPubkey);
}
tags.push(coordinatorTag); tags.push(coordinatorTag);
} }
@ -1184,6 +1194,15 @@ export function CrewForm({ existing }: CrewFormProps) {
> >
{t('socialUi.coordinator.cordnCoordinator')} {t('socialUi.coordinator.cordnCoordinator')}
</a> </a>
{' · '}
<a
href={CORDN_DEPLOY_KIT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
>
{t('common.learnMore')}
</a>
. .
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@ -1233,6 +1252,21 @@ export function CrewForm({ existing }: CrewFormProps) {
</Button> </Button>
)} )}
<div className="space-y-1.5">
<Label htmlFor="crew-coordinator-operator" className="text-xs text-gray-400">
{t('socialUi.coordinator.operatorLabel')}
</Label>
<Input
id="crew-coordinator-operator"
placeholder={t('socialUi.coordinator.operatorPlaceholder')}
value={coordinatorOperator}
onChange={(e) => setCoordinatorOperator(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
spellCheck={false}
/>
<p className="text-[10px] text-gray-600">{t('socialUi.coordinator.operatorHint')}</p>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label> <Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
<Input <Input

View file

@ -11,6 +11,8 @@ import { useTranslation } from '@/contexts/language';
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions'; import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const VISIBLE_LANGUAGE_OPTIONS = LANGUAGE_OPTIONS.filter((language) => !language.hidden);
interface LanguageToggleProps { interface LanguageToggleProps {
/** Show only the flag icon (compact mode) */ /** Show only the flag icon (compact mode) */
compact?: boolean; compact?: boolean;
@ -65,13 +67,13 @@ export function LanguageToggle({ compact = false, inline = false, className, var
const safeQuery = query.trim().toLowerCase(); const safeQuery = query.trim().toLowerCase();
const filteredLanguages = safeQuery const filteredLanguages = safeQuery
? LANGUAGE_OPTIONS.filter( ? VISIBLE_LANGUAGE_OPTIONS.filter(
(l) => (l) =>
l.name.toLowerCase().includes(safeQuery) || l.name.toLowerCase().includes(safeQuery) ||
l.nativeName.toLowerCase().includes(safeQuery) || l.nativeName.toLowerCase().includes(safeQuery) ||
l.code.toLowerCase().includes(safeQuery) l.code.toLowerCase().includes(safeQuery)
) )
: LANGUAGE_OPTIONS; : VISIBLE_LANGUAGE_OPTIONS;
const currentLanguage = const currentLanguage =
LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0]; LANGUAGE_OPTIONS.find((l) => l.code === language) || LANGUAGE_OPTIONS[0];
@ -196,7 +198,7 @@ export function LanguageSelector() {
<div className="space-y-3"> <div className="space-y-3">
<label className="text-sm font-medium">{t('settings.selectLanguage')}</label> <label className="text-sm font-medium">{t('settings.selectLanguage')}</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{LANGUAGE_OPTIONS.map((lang) => ( {VISIBLE_LANGUAGE_OPTIONS.map((lang) => (
<button <button
key={lang.code} key={lang.code}
onClick={() => setLanguage(lang.code)} onClick={() => setLanguage(lang.code)}

View file

@ -54,9 +54,11 @@ import {
ChevronDown, ChevronDown,
ArrowLeft, ArrowLeft,
Search, Search,
SmilePlus,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { SKATING_STATUSES, useUserStatus } from '@/hooks/useUserStatus';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
@ -474,6 +476,45 @@ function MobileNav({
); );
} }
/**
* Compact desktop status control. The old outlined pill + permanent ping read
* like an alert; this shows the current status as a quiet chip and falls back
* to a neutral "Status" affordance when none is set.
*/
function DesktopStatusButton({ onOpen }: { onOpen: () => void }) {
const { user } = useCurrentUser();
const { t } = useTranslation();
const { data: status } = useUserStatus(user?.pubkey);
const preset = status?.statusId
? SKATING_STATUSES.find((item) => item.id === status.statusId)
: null;
const label = preset ? t(preset.labelKey) : status?.status;
const emoji = status?.emoji || preset?.emoji;
return (
<Button
variant="ghost"
onClick={onOpen}
title={t('chromeUi.setYourStatus')}
aria-label={t('chromeUi.setYourStatus')}
className={cn(
'h-9 gap-1.5 rounded-full border border-white/10 px-2.5 text-gray-300',
'hover:border-green-500/30 hover:bg-green-500/10 hover:text-green-300',
status && 'border-green-500/20 bg-green-500/5 text-green-200',
)}
>
{emoji ? (
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
) : (
<SmilePlus className="h-4 w-4 text-green-400/80" />
)}
<span className="hidden max-w-[7rem] truncate text-xs font-semibold lg:inline">
{label || t('chromeUi.status')}
</span>
</Button>
);
}
export function Layout({ children, noPadding }: LayoutProps) { export function Layout({ children, noPadding }: LayoutProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -752,20 +793,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Link> </Link>
)} )}
{/* Status Quick Access - Desktop */} {/* Status Quick Access - Desktop */}
{user && ( {user && <DesktopStatusButton onOpen={() => setShowStatusDialog(true)} />}
<Button
variant="outline"
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
onClick={() => setShowStatusDialog(true)}
title={t('chromeUi.setYourStatus')}
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold">{t('chromeUi.status')}</span>
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -1223,7 +1251,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"
/> />

View file

@ -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>

View file

@ -21,7 +21,10 @@ export function PaymentReadinessNote() {
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const lud16 = metadata?.lud16; const lud16 = metadata?.lud16;
const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06; // PayWithSats falls back to the profile's lud16. A lud06/LNURL alone is
// not enough to settle a marketplace offer, so do not advertise readiness
// for a payment path checkout cannot actually use.
const hasLightning = isValidLightningAddress(lud16);
// Silent payment lives in a custom kind-0 field the app reads elsewhere. // Silent payment lives in a custom kind-0 field the app reads elsewhere.
const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment; const rawSp = (metadata as Record<string, unknown> | undefined)?.bitcoin_silent_payment;
const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp); const hasSilentPayment = typeof rawSp === 'string' && isValidSilentPayment(rawSp);

View file

@ -1,6 +1,7 @@
import { BadgeCheck, ExternalLink } from 'lucide-react'; import { BadgeCheck, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges'; import { SKATESPOTS_SITE, type ProfileBadge } from '@/lib/badges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** The app logo is already served with the site. */ /** The app logo is already served with the site. */
const SKATESPOTS_LOGO = '/images/logo-icon.png'; const SKATESPOTS_LOGO = '/images/logo-icon.png';
@ -26,6 +27,7 @@ export function ProfileBadges({
<div className={cn('flex flex-wrap items-center gap-2', className)}> <div className={cn('flex flex-wrap items-center gap-2', className)}>
{badges.map((b, i) => { {badges.map((b, i) => {
const app = isApp(b); const app = isApp(b);
const safeUrl = b.url ? sanitizeUrl(b.url) : null;
const cls = cn( const cls = cn(
'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors', 'inline-flex items-center gap-1.5 rounded-full border font-bold transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm', size === 'sm' ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-sm',
@ -41,17 +43,17 @@ export function ProfileBadges({
<BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} /> <BadgeCheck className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
)} )}
<span className="max-w-[12rem] truncate">{b.label}</span> <span className="max-w-[12rem] truncate">{b.label}</span>
{b.url && !app && <ExternalLink className="h-3 w-3 opacity-60" />} {safeUrl && !app && <ExternalLink className="h-3 w-3 opacity-60" />}
</> </>
); );
return b.url ? ( return safeUrl ? (
<a <a
key={`${b.label}-${i}`} key={`${b.label}-${i}`}
href={b.url} href={safeUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={b.url} title={safeUrl}
className={cls} className={cls}
> >
{inner} {inner}

View file

@ -0,0 +1,69 @@
import { Activity, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/contexts/language';
import { useRelayDivergence } from '@/hooks/useRelayDivergence';
/**
* Settings diagnostic: compare the community relay with the user's read relays.
* No merge, no vote, no data mutation.
*/
export function RelayDivergenceCard() {
const { t } = useTranslation();
const { result, checking, error, check } = useRelayDivergence();
const differences = result?.differences.length ?? 0;
return (
<Card className="bg-black/90 border-white/10">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-bold text-white">
<Activity className="h-4 w-4 text-[#ccff00]" />
{t('settingsUi.relayList.relayDiagTitle')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs leading-relaxed text-gray-400">
{t('settingsUi.relayList.relayDiagDesc')}
</p>
<Button
size="sm"
variant="outline"
onClick={() => void check()}
disabled={checking}
className="gap-1.5"
>
<RefreshCw className={checking ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
{checking
? t('settingsUi.relayList.relayDiagChecking')
: t('settingsUi.relayList.relayDiagCheck')}
</Button>
{error && (
<p className="text-xs text-red-400">
{t('settingsUi.relayList.relayDiagFailed')}
</p>
)}
{result && !error && (
<div className="space-y-1 text-xs">
<p className={differences > 0 ? 'font-bold text-amber-300' : 'text-green-400'}>
{differences > 0
? t('settingsUi.relayList.relayDiagDiff', { count: differences })
: t('settingsUi.relayList.relayDiagAgree')}
</p>
{differences > 0 && (
<p className="text-gray-500">{t('settingsUi.relayList.relayDiagDetails')}</p>
)}
{result.errors.length > 0 && (
<p className="text-gray-500">
{result.errors.length} {t('settingsUi.relayList.offline')}
</p>
)}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -48,9 +48,20 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
// Activity factor: a fresh account with no published content contributes ~0, // Activity factor: a fresh account with no published content contributes ~0,
// so a ring of empty accounts can't move the score even if cross-confirmed. // so a ring of empty accounts can't move the score even if cross-confirmed.
const activityFactor = (pk: string) => Math.min(1, (activity?.get(pk) ?? 0) / 3); const activityFactor = (pk: string) => Math.min(1, (activity?.get(pk) ?? 0) / 3);
const now = Math.floor(Date.now() / 1000);
const decayFactor = (v: { lastAt: number; recentCount: number }) => {
// 180-day half-life, floored so a good historical record is still visible.
// This changes the ranking weight only; lifetime counts stay unchanged.
const ageDays = Math.max(0, (now - v.lastAt) / 86_400);
return Math.max(0.25, Math.exp(-ageDays / 180));
};
const weighted = verifiedWith.reduce( const weighted = verifiedWith.reduce(
(sum, v) => (sum, v) =>
sum + Math.min(v.count, 3) * ((scores?.get(v.pubkey) ?? 0) / 100) * activityFactor(v.pubkey), sum +
Math.min(v.count, 3) *
((scores?.get(v.pubkey) ?? 0) / 100) *
activityFactor(v.pubkey) *
decayFactor(v),
0, 0,
); );
const trustLabel = const trustLabel =
@ -79,6 +90,13 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
? `${data.verified} ${data.verified === 1 ? t('marketUi.reputation.tradeConfirmedOne') : t('marketUi.reputation.tradeConfirmedMany')} ${t('marketUi.reputation.with')} ${people} ${people === 1 ? t('marketUi.reputation.personOne') : t('marketUi.reputation.personMany')}` ? `${data.verified} ${data.verified === 1 ? t('marketUi.reputation.tradeConfirmedOne') : t('marketUi.reputation.tradeConfirmedMany')} ${t('marketUi.reputation.with')} ${people} ${people === 1 ? t('marketUi.reputation.personOne') : t('marketUi.reputation.personMany')}`
: t('marketUi.reputation.awaiting')} : t('marketUi.reputation.awaiting')}
</p> </p>
{data.recentVerified > 0 && (
<p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.recentVerified}</span>{' '}
{t('marketUi.reputation.recentConfirmed')}
</p>
)}
{data.withPaymentProof > 0 && ( {data.withPaymentProof > 0 && (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span> <span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>

View file

@ -180,7 +180,8 @@ export function AmberCallbackHandler() {
case 'nip04_decrypt': case 'nip04_decrypt':
case 'nip44_decrypt': { case 'nip44_decrypt': {
// Store encryption/decryption results // Store encryption/decryption results
sessionStorage.setItem('amber_crypto_result', result); // The result is delivered through the callback promise/flow; do not
// leave private plaintext in browser storage.
clearAmberPendingOperation(); clearAmberPendingOperation();
toast({ toast({
title: t('settingsUi.amberCallback.operationCompleteTitle'), title: t('settingsUi.amberCallback.operationCompleteTitle'),

View file

@ -50,6 +50,10 @@ export interface CoordinatorHealth {
name: string | null; name: string | null;
error: string | null; error: string | null;
checkedAt: number | null; checkedAt: number | null;
/** Actual ContextVM RPC probe, not just presence. */
rpcStatus: 'idle' | 'checking' | 'ok' | 'error';
rpcLatencyMs: number | null;
rpcError: string | null;
} }
/** /**
@ -65,8 +69,52 @@ const INITIAL: CoordinatorHealth = {
name: null, name: null,
error: null, error: null,
checkedAt: null, checkedAt: null,
rpcStatus: 'idle',
rpcLatencyMs: null,
rpcError: null,
}; };
function randomKeyHex(): string {
const bytes = new Uint8Array(32);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function probeCoordinatorRpc(
coordinator: CoordinatorConfig,
relays: string[],
): Promise<Pick<CoordinatorHealth, 'rpcStatus' | 'rpcLatencyMs' | 'rpcError'>> {
let client: { listAvailableKeyPackages: () => Promise<unknown>; disconnect: () => Promise<void> } | null = null;
const started = Date.now();
try {
const { CordnCoordinatorClient } = await import('@/lib/cordn/native/coordinator');
client = new CordnCoordinatorClient({
serverPubkey: coordinator.pubkey,
relays,
privateKeyHex: randomKeyHex(),
});
await Promise.race([
client.listAvailableKeyPackages(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Coordinator RPC timed out.')), 7_000),
),
]);
return { rpcStatus: 'ok', rpcLatencyMs: Date.now() - started, rpcError: null };
} catch (e) {
return {
rpcStatus: 'error',
rpcLatencyMs: null,
rpcError: e instanceof Error ? e.message : 'Coordinator RPC failed.',
};
} finally {
if (client) await client.disconnect().catch(() => undefined);
}
}
/** /**
* Presence probe for a coordinator. * Presence probe for a coordinator.
* *
@ -86,7 +134,16 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
const check = useCallback(async () => { const check = useCallback(async () => {
if (!coordinator) return; if (!coordinator) return;
setResult((r) => ({ ...r, status: 'checking', error: null })); setResult((r) => ({
...r,
status: 'checking',
error: null,
rpcStatus: 'checking',
rpcLatencyMs: null,
rpcError: null,
}));
const rpc = await probeCoordinatorRpc(coordinator, relays);
try { try {
const signal = AbortSignal.timeout(8000); const signal = AbortSignal.timeout(8000);
@ -113,6 +170,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
name: null, name: null,
error: null, error: null,
checkedAt: Date.now(), checkedAt: Date.now(),
...rpc,
}); });
return; return;
} }
@ -127,6 +185,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
name: coordinatorNameFromEvent(newest), name: coordinatorNameFromEvent(newest),
error: null, error: null,
checkedAt: Date.now(), checkedAt: Date.now(),
...rpc,
}); });
} catch (err) { } catch (err) {
setResult({ setResult({
@ -136,6 +195,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
name: null, name: null,
error: err instanceof Error ? err.message : 'Could not reach the relays', error: err instanceof Error ? err.message : 'Could not reach the relays',
checkedAt: Date.now(), checkedAt: Date.now(),
...rpc,
}); });
} }
}, [coordinator, relays, nostr]); }, [coordinator, relays, nostr]);

View file

@ -738,7 +738,8 @@ export function useConversation(otherPubkey: string) {
/** /**
* Hook to fetch all conversations * Hook to fetch all conversations
*/ */
export function useConversations() { export function useConversations(options: { enabled?: boolean } = {}) {
const callerEnabled = options.enabled ?? true;
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
@ -843,7 +844,7 @@ export function useConversations() {
); );
return { conversations: sorted, undecryptable, wraps: uniqueEvents.length }; return { conversations: sorted, undecryptable, wraps: uniqueEvents.length };
}, },
enabled: nip44Available, enabled: nip44Available && callerEnabled,
...amberSafeQueryOptions(isInteractiveSigner(user, logins)), ...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
}); });
} }
@ -860,7 +861,8 @@ export function useConversations() {
* the protocols to adopt if those guarantees are required later. * the protocols to adopt if those guarantees are required later.
* See GROUP_MESSAGING.md for the full decision and upgrade paths. * See GROUP_MESSAGING.md for the full decision and upgrade paths.
*/ */
export function useGroupConversations() { export function useGroupConversations(options: { enabled?: boolean } = {}) {
const callerEnabled = options.enabled ?? true;
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
@ -943,7 +945,7 @@ export function useGroupConversations() {
(a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0), (a, b) => (b.lastMessage?.createdAt || 0) - (a.lastMessage?.createdAt || 0),
); );
}, },
enabled: nip44Available, enabled: nip44Available && callerEnabled,
...amberSafeQueryOptions(isInteractiveSigner(user, logins)), ...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
}); });
} }

View file

@ -0,0 +1,62 @@
import { useCallback, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useRelays } from '@/lib/relays';
import { SKATE_RELAY } from '@/lib/types';
import { onlyVerified } from '@/lib/verifyEvents';
import {
compareRelaySnapshots,
type RelayDivergenceResult,
type RelaySnapshot,
} from '@/lib/relayDivergence';
/**
* On-demand relay consistency diagnostic.
*
* Queries a bounded profile snapshot from the community relay and the user's
* read relays, then reports newer/different signed profiles. This is not a vote
* and it never mutates canonical map/community data.
*/
export function useRelayDivergence() {
const { nostr } = useNostr();
const { read } = useRelays();
const [result, setResult] = useState<RelayDivergenceResult | null>(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const check = useCallback(async () => {
setChecking(true);
setError(null);
try {
const relays = Array.from(new Set([SKATE_RELAY, ...read])).slice(0, 5);
const signal = AbortSignal.timeout(12_000);
const snapshots = await Promise.all(
relays.map(async (relayUrl): Promise<RelaySnapshot> => {
try {
const relay = nostr.relay(relayUrl);
const events = await relay.query([{ kinds: [0], limit: 100 }], { signal });
return { relay: relayUrl, events: onlyVerified(events) };
} catch (e) {
return {
relay: relayUrl,
events: [],
error: e instanceof Error ? e.message : 'relay error',
};
}
}),
);
const canonical = snapshots.find((s) => s.relay === SKATE_RELAY);
if (!canonical) throw new Error('Community relay snapshot missing.');
const others = snapshots.filter((s) => s.relay !== SKATE_RELAY);
setResult(compareRelaySnapshots(canonical, others));
} catch (e) {
setResult(null);
setError(e instanceof Error ? e.message : 'check failed');
} finally {
setChecking(false);
}
}, [nostr, read]);
return { result, checking, error, check };
}

View file

@ -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, {
isDeleted: (event) => {
const dTag = event.tags.find(([name]) => name === 'd')?.[1]; const dTag = event.tags.find(([name]) => name === 'd')?.[1];
let key: string; const coord = dTag !== undefined ? `${event.kind}:${event.pubkey}:${dTag}` : undefined;
if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`; return !!(
else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) { (coord && deleted.has(coord)) ||
key = `${event.pubkey}:${event.kind}`; deleted.has(`e:${event.id}`)
} 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());
} }
/** /**

View file

@ -178,6 +178,8 @@ export interface TradeReputation {
purchases: number; purchases: number;
/** Deals where both sides attested the same listing. */ /** Deals where both sides attested the same listing. */
verified: number; verified: number;
/** Mutually confirmed deals with an attestation in the last 90 days. */
recentVerified: number;
/** /**
* Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim). * Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim).
* ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a * ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a
@ -199,7 +201,7 @@ export interface TradeReputation {
*/ */
verifiedCounterparties: number; verifiedCounterparties: number;
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */ /** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
verifiedWith: { pubkey: string; count: number }[]; verifiedWith: { pubkey: string; count: number; recentCount: number; lastAt: number }[];
/** Distinct counterparties involved. */ /** Distinct counterparties involved. */
counterparties: string[]; counterparties: string[];
/** Raw trade attestations this pubkey is involved in. */ /** Raw trade attestations this pubkey is involved in. */
@ -222,7 +224,7 @@ export function useTradeReputation(pubkey: string | undefined) {
enabled: !!pubkey, enabled: !!pubkey,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
queryFn: async (c): Promise<TradeReputation> => { queryFn: async (c): Promise<TradeReputation> => {
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] }; const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, recentVerified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] };
if (!pubkey) return empty; if (!pubkey) return empty;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
@ -253,10 +255,14 @@ export function useTradeReputation(pubkey: string | undefined) {
const counterparties = new Set<string>(); const counterparties = new Set<string>();
const verifiedWith = new Map<string, number>(); const verifiedWith = new Map<string, number>();
const verifiedRecentWith = new Map<string, number>();
const verifiedLastAt = new Map<string, number>();
let sales = 0; let sales = 0;
let purchases = 0; let purchases = 0;
let verified = 0; let verified = 0;
let recentVerified = 0;
let withPaymentProof = 0; let withPaymentProof = 0;
const recentCutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
for (const [key, attestations] of byListing) { for (const [key, attestations] of byListing) {
const coord = parseListingCoord(key); const coord = parseListingCoord(key);
@ -271,9 +277,16 @@ export function useTradeReputation(pubkey: string | undefined) {
else purchases++; else purchases++;
if (isVerified) { if (isVerified) {
verified++; verified++;
const latestAt = Math.max(...attestations.map((e) => e.created_at));
const isRecent = latestAt >= recentCutoff;
if (isRecent) recentVerified++;
// Attribute the verified trade to the other attestor(s). // Attribute the verified trade to the other attestor(s).
for (const a of attestors) { for (const a of attestors) {
if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1); if (a !== pubkey) {
verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
if (isRecent) verifiedRecentWith.set(a, (verifiedRecentWith.get(a) ?? 0) + 1);
verifiedLastAt.set(a, Math.max(verifiedLastAt.get(a) ?? 0, latestAt));
}
} }
// A `proof` tag is a payment CLAIM, not verification — count it on its // A `proof` tag is a payment CLAIM, not verification — count it on its
// own tier so it is never confused with a confirmed trade. // own tier so it is never confused with a confirmed trade.
@ -329,10 +342,16 @@ export function useTradeReputation(pubkey: string | undefined) {
sales, sales,
purchases, purchases,
verified, verified,
recentVerified,
withPaymentProof, withPaymentProof,
paymentVerified, paymentVerified,
verifiedCounterparties: verifiedWith.size, verifiedCounterparties: verifiedWith.size,
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })), verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({
pubkey,
count,
recentCount: verifiedRecentWith.get(pubkey) ?? 0,
lastAt: verifiedLastAt.get(pubkey) ?? 0,
})),
counterparties: [...counterparties], counterparties: [...counterparties],
attestations: unique, attestations: unique,
}; };

View file

@ -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 {

View file

@ -87,11 +87,13 @@ export function useCashuEscrow() {
async ({ async ({
amountSats, amountSats,
sellerPubkey, sellerPubkey,
arbiterPubkey,
lockDays, lockDays,
mintUrl, mintUrl,
}: { }: {
amountSats: number; amountSats: number;
sellerPubkey: string; sellerPubkey: string;
arbiterPubkey?: string;
lockDays?: number; lockDays?: number;
mintUrl?: string; mintUrl?: string;
}): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => { }): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => {
@ -105,7 +107,7 @@ export function useCashuEscrow() {
const { token, locktime } = await createEscrowToken( const { token, locktime } = await createEscrowToken(
{ wallet: makeMintWallet(mint), payInvoice }, { wallet: makeMintWallet(mint), payInvoice },
{ mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, lockDays }, { mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, arbiterPubkey, lockDays },
); );
return { token, locktime, info: parseEscrowToken(token) }; return { token, locktime, info: parseEscrowToken(token) };

View file

@ -1959,6 +1959,7 @@ marketUi: {
newTrader: 'New trader', newTrader: 'New trader',
title: 'Trades', title: 'Trades',
verified: 'Verified', verified: 'Verified',
recentConfirmed: 'confirmed in the last 90 days',
sales: 'Sales', sales: 'Sales',
purchases: 'Purchases', purchases: 'Purchases',
tradeConfirmedOne: 'mutually-confirmed trade', tradeConfirmedOne: 'mutually-confirmed trade',
@ -1974,7 +1975,7 @@ marketUi: {
singlePartnerWarning: 'Every confirmation is with the same account. Mutual confirmation is self-reported, so treat this record with care.', singlePartnerWarning: 'Every confirmation is with the same account. Mutual confirmation is self-reported, so treat this record with care.',
lowDiversityWarning: 'Most confirmations come from very few accounts. A small group vouching for each other can look busy — treat this record with care.', lowDiversityWarning: 'Most confirmations come from very few accounts. A small group vouching for each other can look busy — treat this record with care.',
ringWarning: 'These accounts mostly trade with each other — a closed cluster. Mutual confirmation inside a ring is weak evidence.', ringWarning: 'These accounts mostly trade with each other — a closed cluster. Mutual confirmation inside a ring is weak evidence.',
weighted: 'Trust & activity weighted', weighted: 'Trust, activity & recency weighted',
footnote: 'Self-reported and mutually signed — a signal, not proof of payment.', footnote: 'Self-reported and mutually signed — a signal, not proof of payment.',
}, },
reputationPanel: { reputationPanel: {
@ -3924,6 +3925,8 @@ socialUi: {
eventFoundOne: '{{count}} event found.', eventFoundOne: '{{count}} event found.',
eventFoundMany: '{{count}} events found.', eventFoundMany: '{{count}} events found.',
check: 'Check', check: 'Check',
rpcOk: 'RPC reachable in {{ms}}ms',
rpcError: 'Presence may be visible, but Coordinator RPC is not reachable.',
addLinkCopied: 'Add link copied', addLinkCopied: 'Add link copied',
copyFailed: 'Copy failed', copyFailed: 'Copy failed',
copyManually: 'Copy it manually.', copyManually: 'Copy it manually.',
@ -3957,6 +3960,10 @@ socialUi: {
customBadge: '· experimental · your own relay', customBadge: '· experimental · your own relay',
customDescShort: "Optional — your crew already has end-to-end encrypted chat built in, so you don't need this. Only set it if you run your own", customDescShort: "Optional — your crew already has end-to-end encrypted chat built in, so you don't need this. Only set it if you run your own",
remove: 'Remove', remove: 'Remove',
operatorLabel: 'Coordinator operator (optional)',
operatorPlaceholder: 'npub1… or 64-character hex pubkey',
operatorHint: 'Who runs this coordinator? This is a label, not an admin key.',
invalidOperator: 'Enter a valid npub or 64-character hex operator pubkey.',
pointerNote: "Pointer only for now: a wss:// endpoint is used as the crew's chat relay; an https:// one via the shared ContextVM relay. Real Cordn MLS isn't wired yet.", pointerNote: "Pointer only for now: a wss:// endpoint is used as the crew's chat relay; an https:// one via the shared ContextVM relay. Real Cordn MLS isn't wired yet.",
cordnCoordinator: 'Cordn coordinator', cordnCoordinator: 'Cordn coordinator',
invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.", invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.",
@ -4086,6 +4093,9 @@ socialUi: {
runBy: 'Coordinator run by', runBy: 'Coordinator run by',
}, },
dms: { dms: {
amberUnlockTitle: 'Unlock encrypted messages',
amberUnlockBody: 'Amber asks for approval for each message it decrypts. Load recent conversations only when you are ready — opening a thread may still ask a few times.',
amberUnlockCta: 'Unlock messages',
securityReminder: 'Security reminder', securityReminder: 'Security reminder',
phishingStrong: 'SkateSpots will never DM you', phishingStrong: 'SkateSpots will never DM you',
phishingRest: "asking to verify your account, share your keys, or send payments. If someone claims to be SkateSpots support via DM — it's a scam.", phishingRest: "asking to verify your account, share your keys, or send payments. If someone claims to be SkateSpots support via DM — it's a scam.",
@ -5018,6 +5028,14 @@ settingsUi: {
localDesc: 'App-only (e.g. a LAN node). Used for reads and writes; never published.', localDesc: 'App-only (e.g. a LAN node). Used for reads and writes; never published.',
blockedTitle: 'Blocked relays', blockedTitle: 'Blocked relays',
blockedDesc: 'Never connect to these — excluded from every read and write.', blockedDesc: 'Never connect to these — excluded from every read and write.',
relayDiagTitle: 'Relay consistency',
relayDiagDesc: 'Compare the community relay with your own read relays. This is a diagnostic, not a vote.',
relayDiagCheck: 'Check profiles',
relayDiagChecking: 'Checking…',
relayDiagAgree: 'Relays agree',
relayDiagDiff: '{{count}} differences',
relayDiagFailed: 'Could not check relays.',
relayDiagDetails: 'A newer or different profile exists on one relay. No data was changed.',
}, },
relatr: { relatr: {
searchingTitle: 'Searching for Relatr service...', searchingTitle: 'Searching for Relatr service...',
@ -7585,6 +7603,8 @@ walletUi: {
privacyLink: 'What leaves your device', privacyLink: 'What leaves your device',
safetyTitle: 'Trade safely', safetyTitle: 'Trade safely',
safetyBody: 'Meet in public for handovers, use escrow or a good-faith deposit for shipped deals, and never send money outside the app.', safetyBody: 'Meet in public for handovers, use escrow or a good-faith deposit for shipped deals, and never send money outside the app.',
highValueTitle: 'High-value deal',
noArbitration: 'Escrow is time-locked, not arbitration: it protects the payment, not the delivery.',
moderationTitle: 'Moderation is soft and reversible', moderationTitle: 'Moderation is soft and reversible',
moderationBody: 'Hidden content stays on relays and can be restored; disputes between people are yours to resolve.', moderationBody: 'Hidden content stays on relays and can be restored; disputes between people are yours to resolve.',
}, },

View file

@ -1959,6 +1959,7 @@ marketUi: {
newTrader: 'Comerciante nuevo', newTrader: 'Comerciante nuevo',
title: 'Tratos', title: 'Tratos',
verified: 'Verificados', verified: 'Verificados',
recentConfirmed: 'confirmados en los últimos 90 días',
sales: 'Ventas', sales: 'Ventas',
purchases: 'Compras', purchases: 'Compras',
tradeConfirmedOne: 'trato confirmado mutuamente', tradeConfirmedOne: 'trato confirmado mutuamente',
@ -1974,7 +1975,7 @@ marketUi: {
singlePartnerWarning: 'Todas las confirmaciones son con la misma cuenta. La confirmación mutua es autodeclarada, así que trata este registro con cuidado.', singlePartnerWarning: 'Todas las confirmaciones son con la misma cuenta. La confirmación mutua es autodeclarada, así que trata este registro con cuidado.',
lowDiversityWarning: 'La mayoría de las confirmaciones vienen de muy pocas cuentas. Un grupo pequeño que se avala entre sí puede parecer muy activo: trata este registro con cuidado.', lowDiversityWarning: 'La mayoría de las confirmaciones vienen de muy pocas cuentas. Un grupo pequeño que se avala entre sí puede parecer muy activo: trata este registro con cuidado.',
ringWarning: 'Estas cuentas comercian sobre todo entre ellas: un grupo cerrado. La confirmación mutua dentro de un círculo es una prueba débil.', ringWarning: 'Estas cuentas comercian sobre todo entre ellas: un grupo cerrado. La confirmación mutua dentro de un círculo es una prueba débil.',
weighted: 'Ponderado por confianza y actividad', weighted: 'Ponderado por confianza, actividad y recencia',
footnote: 'Autodeclarado y firmado mutuamente: una señal, no una prueba de pago.', footnote: 'Autodeclarado y firmado mutuamente: una señal, no una prueba de pago.',
}, },
reputationPanel: { reputationPanel: {
@ -3924,6 +3925,8 @@ socialUi: {
eventFoundOne: '{{count}} evento encontrado.', eventFoundOne: '{{count}} evento encontrado.',
eventFoundMany: '{{count}} eventos encontrados.', eventFoundMany: '{{count}} eventos encontrados.',
check: 'Comprobar', check: 'Comprobar',
rpcOk: 'RPC accesible en {{ms}} ms',
rpcError: 'La presencia puede ser visible, pero el RPC del coordinador no responde.',
addLinkCopied: 'Enlace de añadir copiado', addLinkCopied: 'Enlace de añadir copiado',
copyFailed: 'No se pudo copiar', copyFailed: 'No se pudo copiar',
copyManually: 'Cópialo manualmente.', copyManually: 'Cópialo manualmente.',
@ -3957,6 +3960,10 @@ socialUi: {
customBadge: '· experimental · tu propio relé', customBadge: '· experimental · tu propio relé',
customDescShort: 'Opcional: tu peña ya tiene chat cifrado de extremo a extremo integrado, así que no lo necesitas. Configúralo solo si usas tu propio', customDescShort: 'Opcional: tu peña ya tiene chat cifrado de extremo a extremo integrado, así que no lo necesitas. Configúralo solo si usas tu propio',
remove: 'Quitar', remove: 'Quitar',
operatorLabel: 'Operador del coordinador (opcional)',
operatorPlaceholder: 'npub1… o pubkey hex de 64 caracteres',
operatorHint: '¿Quién ejecuta este coordinador? Es una etiqueta, no una clave de admin.',
invalidOperator: 'Introduce un npub válido o una pubkey hex de 64 caracteres.',
pointerNote: 'Por ahora es solo un puntero: un endpoint wss:// se usa como relé del chat de la peña y uno https:// a través del relé ContextVM compartido. El MLS real de Cordn aún no está conectado.', pointerNote: 'Por ahora es solo un puntero: un endpoint wss:// se usa como relé del chat de la peña y uno https:// a través del relé ContextVM compartido. El MLS real de Cordn aún no está conectado.',
cordnCoordinator: 'coordinador Cordn', cordnCoordinator: 'coordinador Cordn',
invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.', invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.',
@ -4086,6 +4093,9 @@ socialUi: {
runBy: 'Coordinador gestionado por', runBy: 'Coordinador gestionado por',
}, },
dms: { dms: {
amberUnlockTitle: 'Desbloquear mensajes cifrados',
amberUnlockBody: 'Amber pide aprobación por cada mensaje que descifra. Carga las conversaciones recientes cuando quieras; abrir un hilo aún puede pedirte varias aprobaciones.',
amberUnlockCta: 'Desbloquear mensajes',
securityReminder: 'Recordatorio de seguridad', securityReminder: 'Recordatorio de seguridad',
phishingStrong: 'SkateSpots nunca te enviará un MD', phishingStrong: 'SkateSpots nunca te enviará un MD',
phishingRest: 'pidiéndote que verifiques tu cuenta, compartas tus claves o envíes pagos. Si alguien dice ser soporte de SkateSpots por MD — es una estafa.', phishingRest: 'pidiéndote que verifiques tu cuenta, compartas tus claves o envíes pagos. Si alguien dice ser soporte de SkateSpots por MD — es una estafa.',
@ -5018,6 +5028,14 @@ settingsUi: {
localDesc: 'Solo de la app (p. ej. un nodo de LAN). Se usan para lectura y escritura; nunca se publican.', localDesc: 'Solo de la app (p. ej. un nodo de LAN). Se usan para lectura y escritura; nunca se publican.',
blockedTitle: 'Relays bloqueados', blockedTitle: 'Relays bloqueados',
blockedDesc: 'No te conectes nunca a estos: se excluyen de toda lectura y escritura.', blockedDesc: 'No te conectes nunca a estos: se excluyen de toda lectura y escritura.',
relayDiagTitle: 'Consistencia de relays',
relayDiagDesc: 'Compara el relay comunitario con tus relays de lectura. Es un diagnóstico, no una votación.',
relayDiagCheck: 'Comprobar perfiles',
relayDiagChecking: 'Comprobando…',
relayDiagAgree: 'Los relays coinciden',
relayDiagDiff: '{{count}} diferencias',
relayDiagFailed: 'No se pudieron comprobar los relays.',
relayDiagDetails: 'Hay un perfil más nuevo o distinto en otro relay. No se cambió ningún dato.',
}, },
relatr: { relatr: {
searchingTitle: 'Buscando el servicio Relatr...', searchingTitle: 'Buscando el servicio Relatr...',
@ -7585,6 +7603,8 @@ walletUi: {
privacyLink: 'Qué sale de tu dispositivo', privacyLink: 'Qué sale de tu dispositivo',
safetyTitle: 'Comercia con seguridad', safetyTitle: 'Comercia con seguridad',
safetyBody: 'Quedad en lugares públicos para las entregas, usa escrow o una señal de buena fe en envíos, y nunca envíes dinero fuera de la app.', safetyBody: 'Quedad en lugares públicos para las entregas, usa escrow o una señal de buena fe en envíos, y nunca envíes dinero fuera de la app.',
highValueTitle: 'Trato de alto valor',
noArbitration: 'El escrow tiene bloqueo temporal, no es arbitraje: protege el pago, no la entrega.',
moderationTitle: 'La moderación es suave y reversible', moderationTitle: 'La moderación es suave y reversible',
moderationBody: 'El contenido oculto sigue en los relays y puede restaurarse; las disputas entre personas las resolvéis vosotros.', moderationBody: 'El contenido oculto sigue en los relays y puede restaurarse; las disputas entre personas las resolvéis vosotros.',
}, },

25
src/lib/amber.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getAmberPendingOperation, setAmberPendingOperation } from './amber';
describe('Amber pending redirect operations', () => {
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});
it('accepts a fresh operation', () => {
setAmberPendingOperation({ type: 'get_public_key', id: 'fresh', timestamp: Date.now() });
expect(getAmberPendingOperation()?.id).toBe('fresh');
});
it('rejects and clears a stale callback operation', () => {
setAmberPendingOperation({
type: 'get_public_key',
id: 'stale',
timestamp: Date.now() - 11 * 60 * 1000,
});
expect(getAmberPendingOperation()).toBeNull();
expect(localStorage.getItem('amber_pending_operation')).toBeNull();
});
});

View file

@ -23,6 +23,8 @@ export interface AmberPendingOperation {
const AMBER_PENDING_KEY = 'amber_pending_operation'; const AMBER_PENDING_KEY = 'amber_pending_operation';
const AMBER_LOGIN_KEY = 'amber_login'; const AMBER_LOGIN_KEY = 'amber_login';
/** Pending redirect operations are short-lived; never accept a stale callback. */
const AMBER_PENDING_TTL_MS = 10 * 60 * 1000;
/** /**
* Store pending operation info for when Amber returns. * Store pending operation info for when Amber returns.
@ -63,6 +65,10 @@ export function setAmberPendingOperation(operation: AmberPendingOperation): void
} }
} }
function isExpiredPending(op: AmberPendingOperation | null): boolean {
return !!op && (!Number.isFinite(op.timestamp) || Date.now() - op.timestamp > AMBER_PENDING_TTL_MS);
}
/** /**
* Get pending operation from any available storage. * Get pending operation from any available storage.
*/ */
@ -81,7 +87,12 @@ export function getAmberPendingOperation(): AmberPendingOperation | null {
if (!stored) return null; if (!stored) return null;
try { try {
return JSON.parse(stored) as AmberPendingOperation; const parsed = JSON.parse(stored) as AmberPendingOperation;
if (isExpiredPending(parsed)) {
clearAmberPendingOperation();
return null;
}
return parsed;
} catch { } catch {
return null; return null;
} }
@ -117,7 +128,13 @@ export async function getAmberPendingOperationAsync(): Promise<AmberPendingOpera
if (getReq.result) { if (getReq.result) {
console.log('[Amber] Found pending op in IndexedDB'); console.log('[Amber] Found pending op in IndexedDB');
try { try {
resolve(JSON.parse(getReq.result)); const parsed = JSON.parse(getReq.result) as AmberPendingOperation;
if (isExpiredPending(parsed)) {
clearAmberPendingOperation();
resolve(null);
} else {
resolve(parsed);
}
} catch { } catch {
resolve(null); resolve(null);
} }
@ -529,11 +546,12 @@ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): voi
const operationId = generateOperationId(); const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl(); const callbackUrl = buildCallbackUrl();
// Never persist the plaintext in a redirect pending-op record. The result
// comes back from Amber; the payload only needs to travel in the intent URL.
setAmberPendingOperation({ setAmberPendingOperation({
type: 'nip04_encrypt', type: 'nip04_encrypt',
id: operationId, id: operationId,
targetPubkey: pubkey, targetPubkey: pubkey,
plaintext,
timestamp: Date.now(), timestamp: Date.now(),
}); });
@ -581,11 +599,11 @@ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): voi
const operationId = generateOperationId(); const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl(); const callbackUrl = buildCallbackUrl();
// Never persist plaintext in a redirect pending-op record.
setAmberPendingOperation({ setAmberPendingOperation({
type: 'nip44_encrypt', type: 'nip44_encrypt',
id: operationId, id: operationId,
targetPubkey: pubkey, targetPubkey: pubkey,
plaintext,
timestamp: Date.now(), timestamp: Date.now(),
}); });

View file

@ -122,6 +122,25 @@ describe('escrowP2PKOptions', () => {
expect(() => escrowP2PKOptions({ sellerPubkey: 'bad', buyerPubkey: BUYER })).toThrow(); expect(() => escrowP2PKOptions({ sellerPubkey: 'bad', buyerPubkey: BUYER })).toThrow();
expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: '' })).toThrow(); expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: '' })).toThrow();
}); });
it('builds an optional seller+arbiter / buyer+arbiter 2-of-3 policy', () => {
const arbiter = '02' + 'c'.repeat(64);
const opts = escrowP2PKOptions({
sellerPubkey: SELLER_XONLY,
buyerPubkey: BUYER,
arbiterPubkey: arbiter,
});
expect(opts.pubkey).toEqual([SELLER, arbiter]);
expect(opts.refundKeys).toEqual([BUYER, arbiter]);
expect(opts.requiredSignatures).toBe(2);
expect(opts.requiredRefundSignatures).toBe(2);
});
it('rejects an arbiter that is also the buyer or seller', () => {
expect(() =>
escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: BUYER, arbiterPubkey: SELLER }),
).toThrow();
});
}); });
describe('parseEscrowToken', () => { describe('parseEscrowToken', () => {

View file

@ -85,7 +85,8 @@ export function escrowLocktime(days: number, from: number = Date.now()): number
} }
export interface EscrowP2PKOptions { export interface EscrowP2PKOptions {
pubkey: string; /** Single seller key, or seller + arbiter for an optional 2-of-3 policy. */
pubkey: string | string[];
locktime: number; locktime: number;
refundKeys: string[]; refundKeys: string[];
requiredSignatures: number; requiredSignatures: number;
@ -100,11 +101,14 @@ export interface EscrowP2PKOptions {
export function escrowP2PKOptions({ export function escrowP2PKOptions({
sellerPubkey, sellerPubkey,
buyerPubkey, buyerPubkey,
arbiterPubkey,
lockDays = ESCROW_DEFAULT_LOCK_DAYS, lockDays = ESCROW_DEFAULT_LOCK_DAYS,
from = Date.now(), from = Date.now(),
}: { }: {
sellerPubkey: string; sellerPubkey: string;
buyerPubkey: string; buyerPubkey: string;
/** Optional third key for a 2-of-3 policy: seller+arbiter or buyer+arbiter. */
arbiterPubkey?: string;
lockDays?: number; lockDays?: number;
from?: number; from?: number;
}): EscrowP2PKOptions { }): EscrowP2PKOptions {
@ -112,6 +116,22 @@ export function escrowP2PKOptions({
if (!seller) throw new Error('Seller key is not a valid Cashu pubkey.'); if (!seller) throw new Error('Seller key is not a valid Cashu pubkey.');
const buyer = normalizeCashuPubkey(buyerPubkey); const buyer = normalizeCashuPubkey(buyerPubkey);
if (!buyer) throw new Error('Your escrow key is not a valid Cashu pubkey.'); if (!buyer) throw new Error('Your escrow key is not a valid Cashu pubkey.');
const arbiter = arbiterPubkey ? normalizeCashuPubkey(arbiterPubkey) : null;
if (arbiterPubkey && !arbiter) throw new Error('Arbiter key is not a valid Cashu pubkey.');
if (arbiter && (arbiter === seller || arbiter === buyer)) {
throw new Error('Arbiter must be a different key from the buyer and seller.');
}
if (arbiter) {
return {
// Normal path needs seller + arbiter; refund path needs buyer + arbiter.
pubkey: [seller, arbiter],
locktime: escrowLocktime(lockDays, from),
refundKeys: [buyer, arbiter],
requiredSignatures: 2,
requiredRefundSignatures: 2,
};
}
return { return {
pubkey: seller, pubkey: seller,
@ -146,7 +166,11 @@ export function secretPolicy(secret: string): Partial<EscrowPolicy> | null {
const values = (name: string): string[] => const values = (name: string): string[] =>
tags.find((t) => t[0] === name)?.slice(1) ?? []; tags.find((t) => t[0] === name)?.slice(1) ?? [];
const primary = typeof obj.data === 'string' ? [obj.data] : []; const primary = Array.isArray(obj.data)
? obj.data.filter((value): value is string => typeof value === 'string')
: typeof obj.data === 'string'
? [obj.data]
: [];
const pubkeys = [...primary, ...values('pubkeys')]; const pubkeys = [...primary, ...values('pubkeys')];
const refund = values('refund'); const refund = values('refund');
const locktime = Number(values('locktime')[0] ?? 0); const locktime = Number(values('locktime')[0] ?? 0);
@ -256,6 +280,7 @@ export async function createEscrowToken(
amountSats: number; amountSats: number;
sellerPubkey: string; sellerPubkey: string;
buyerPubkey: string; buyerPubkey: string;
arbiterPubkey?: string;
lockDays?: number; lockDays?: number;
}, },
): Promise<{ token: string; amount: number; locktime: number }> { ): Promise<{ token: string; amount: number; locktime: number }> {
@ -267,6 +292,7 @@ export async function createEscrowToken(
const p2pk = escrowP2PKOptions({ const p2pk = escrowP2PKOptions({
sellerPubkey: params.sellerPubkey, sellerPubkey: params.sellerPubkey,
buyerPubkey: params.buyerPubkey, buyerPubkey: params.buyerPubkey,
arbiterPubkey: params.arbiterPubkey,
lockDays: params.lockDays, lockDays: params.lockDays,
}); });

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { nip19 } from 'nostr-tools';
import { parseOperatorPubkey } from './coordinator';
const PUBKEY = 'a'.repeat(64);
describe('parseOperatorPubkey', () => {
it('accepts npub and raw hex', () => {
expect(parseOperatorPubkey(nip19.npubEncode(PUBKEY))).toBe(PUBKEY);
expect(parseOperatorPubkey(PUBKEY)).toBe(PUBKEY);
});
it('rejects empty, malformed and coordinator URLs', () => {
expect(parseOperatorPubkey('')).toBeNull();
expect(parseOperatorPubkey('not-a-key')).toBeNull();
expect(parseOperatorPubkey('https://cordn.net/chat/coordinators?c=nprofile1bad')).toBeNull();
});
});

View file

@ -24,7 +24,12 @@ export const CORDN_ADD_BASE = 'https://cordn.net/chat/coordinators';
export const CORDN_HOME = 'https://cordn.net'; export const CORDN_HOME = 'https://cordn.net';
/** The self-host one-liner advertised on cordn.net. */ /** The self-host one-liner advertised on cordn.net. */
export const CORDN_DOCKER_COMMAND = 'docker run --rm ghcr.io/cordn-msg/cordn:latest'; export const CORDN_DOCKER_COMMAND =
'docker run -d --name cordn --restart unless-stopped -v cordn-data:/data -e CORDN_STORAGE_BACKEND=sqlite -e CORDN_SQLITE_PATH=/data/cordn.sqlite -e CORDN_RELAY_URLS=wss://relay.contextvm.org -e CORDN_ANNOUNCED=true -e CORDN_SERVER_PRIVATE_KEY=YOUR_64_HEX_KEY ghcr.io/cordn-msg/cordn:latest';
/** Production deployment kit (persistent SQLite volume + backup/restore). */
export const CORDN_DEPLOY_KIT_URL =
'https://git.skate-spots.org/skatespots/SkateSpots/tree/main/deploy/cordn';
/** Where a user gets a coordinator they can run on a phone. */ /** Where a user gets a coordinator they can run on a phone. */
export const CORDN_APP_URL = 'https://cordn.net'; export const CORDN_APP_URL = 'https://cordn.net';
@ -134,6 +139,20 @@ export function parseCrewCoordinatorInput(raw: string): CrewCoordinatorPointer |
return null; return null;
} }
/** Parse an operator pubkey from npub/nprofile/hex. Not a coordinator pointer. */
export function parseOperatorPubkey(raw: string): string | null {
const input = raw.trim();
if (!input) return null;
try {
const decoded = nip19.decode(input);
if (decoded.type === 'npub') return decoded.data;
if (decoded.type === 'nprofile') return decoded.data.pubkey;
} catch {
// fall through to raw hex
}
return HEX64.test(input) ? input.toLowerCase() : null;
}
/** Rebuild a paste-able input from a stored crew coordinator (round-trips). */ /** Rebuild a paste-able input from a stored crew coordinator (round-trips). */
export function crewCoordinatorInputValue( export function crewCoordinatorInputValue(
coordinator: { url: string; pubkey?: string } | undefined, coordinator: { url: string; pubkey?: string } | undefined,

View file

@ -0,0 +1,22 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { randomBytes } from 'node:crypto';
import { CordnCoordinatorClient } from './coordinator';
import { SKATESPOTS_COORDINATOR } from '../../coordinator';
const live = process.env.CORDN_LIVE === '1';
describe.skipIf(!live)('coordinator RPC health', () => {
it('lists key packages with a throwaway client key', async () => {
const client = new CordnCoordinatorClient({
serverPubkey: SKATESPOTS_COORDINATOR.pubkey,
relays: SKATESPOTS_COORDINATOR.relays,
privateKeyHex: randomBytes(32).toString('hex'),
});
try {
await expect(client.listAvailableKeyPackages()).resolves.toBeTruthy();
} finally {
await client.disconnect();
}
}, 30_000);
});

View 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
View 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()];
}

View file

@ -5,6 +5,8 @@ export interface LanguageOption {
name: string; name: string;
nativeName: string; nativeName: string;
flag: string; flag: string;
/** Kept for stored preferences, but omitted from the visible picker. */
hidden?: boolean;
} }
/** /**
@ -26,7 +28,9 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
{ code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' }, { code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' }, { code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺' },
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' }, { code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷' },
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵' }, // North Korean reuses the Korean dictionary; hide the duplicate from the
// picker while keeping the code valid for existing stored preferences.
{ code: 'ko-KP', name: 'Korean (North)', nativeName: '조선어', flag: '🇰🇵', hidden: true },
{ code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' }, { code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' }, { code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' },
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' }, { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', flag: '🇨🇳' },

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import { compareRelaySnapshots } from './relayDivergence';
function profile(id: string, pubkey: string, created_at: number): NostrEvent {
return {
id,
pubkey,
created_at,
kind: 0,
tags: [],
content: '{}',
sig: '0'.repeat(128),
};
}
describe('compareRelaySnapshots', () => {
it('reports a newer profile on another relay', () => {
const pubkey = 'a'.repeat(64);
const result = compareRelaySnapshots(
{ relay: 'canonical', events: [profile('old', pubkey, 10)] },
[{ relay: 'personal', events: [profile('new', pubkey, 20)] }],
);
expect(result.differences).toHaveLength(1);
expect(result.differences[0].relay).toBe('personal');
});
it('reports a profile missing from canonical', () => {
const pubkey = 'b'.repeat(64);
const result = compareRelaySnapshots(
{ relay: 'canonical', events: [] },
[{ relay: 'personal', events: [profile('only', pubkey, 10)] }],
);
expect(result.differences).toHaveLength(1);
expect(result.differences[0].canonical).toBeNull();
});
it('does not report equal snapshots', () => {
const pubkey = 'c'.repeat(64);
const event = profile('same', pubkey, 10);
const result = compareRelaySnapshots(
{ relay: 'canonical', events: [event] },
[{ relay: 'personal', events: [event] }],
);
expect(result.differences).toEqual([]);
});
it('records relay errors instead of treating them as empty', () => {
const result = compareRelaySnapshots(
{ relay: 'canonical', events: [] },
[{ relay: 'personal', events: [], error: 'timeout' }],
);
expect(result.errors).toEqual(['personal: timeout']);
expect(result.checkedCount).toBe(0);
});
});

View file

@ -0,0 +1,71 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { eventMergeKey, eventReplaces } from './eventMerge';
/**
* Compare profile snapshots from several relays.
*
* This is deliberately a diagnostic, not a consensus mechanism. A difference
* means "another relay served a newer or different signed profile", not that
* either relay is lying. We never merge canonical map/community data from this
* result.
*/
export interface RelaySnapshot {
relay: string;
events: NostrEvent[];
error?: string;
}
export interface RelayDifference {
key: string;
canonical: NostrEvent | null;
other: NostrEvent;
relay: string;
}
export interface RelayDivergenceResult {
relayCount: number;
checkedCount: number;
differences: RelayDifference[];
errors: string[];
}
function latestByKey(events: NostrEvent[]): Map<string, NostrEvent> {
const byKey = new Map<string, NostrEvent>();
for (const event of events) {
const key = eventMergeKey(event);
const current = byKey.get(key);
if (!current || eventReplaces(current, event)) byKey.set(key, event);
}
return byKey;
}
export function compareRelaySnapshots(
canonical: RelaySnapshot,
others: RelaySnapshot[],
): RelayDivergenceResult {
const canonicalByKey = latestByKey(canonical.events);
const differences: RelayDifference[] = [];
const errors: string[] = [];
let checkedCount = 0;
for (const snapshot of others) {
if (snapshot.error) {
errors.push(`${snapshot.relay}: ${snapshot.error}`);
continue;
}
checkedCount += 1;
for (const [key, event] of latestByKey(snapshot.events)) {
const current = canonicalByKey.get(key) ?? null;
if (!current || eventReplaces(current, event)) {
differences.push({ key, canonical: current, other: event, relay: snapshot.relay });
}
}
}
return {
relayCount: 1 + others.length,
checkedCount,
differences,
errors,
};
}

View file

@ -123,7 +123,7 @@ export default function AmberCallbackPage() {
// ── encrypt / decrypt ─────────────────────────────────────────────── // ── encrypt / decrypt ───────────────────────────────────────────────
if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) { if (pendingOp.type.includes('encrypt') || pendingOp.type.includes('decrypt')) {
localStorage.setItem('amber_crypto_result', result); // Do not persist crypto results (decrypt output can be plaintext).
clearAmberPendingOperation(); clearAmberPendingOperation();
setStatus('success'); setStatus('success');
setMessage('Operation complete! Returning...'); setMessage('Operation complete! Returning...');

View file

@ -12,7 +12,7 @@ import { NewMessageDialog } from '@/components/NewMessageDialog';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages'; import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages';
import { useUserCrews } from '@/hooks/useCrews'; import { useUserCrews } from '@/hooks/useCrews';
import { useCordnGroupGids } from '@/hooks/useCordnNative'; import { useCordnGroupGids } from '@/hooks/useCordnNative';
@ -277,10 +277,13 @@ export default function DirectMessagesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
const { data: inbox, isLoading, error, refetch, isRefetching } = useConversations(); const interactive = isInteractiveSigner(user, logins);
const [amberInboxUnlocked, setAmberInboxUnlocked] = useState(false);
const inboxEnabled = !interactive || amberInboxUnlocked;
const { data: inbox, isLoading, error, refetch, isRefetching } = useConversations({ enabled: inboxEnabled });
const conversations = inbox?.conversations; const conversations = inbox?.conversations;
const undecryptable = inbox?.undecryptable ?? 0; const undecryptable = inbox?.undecryptable ?? 0;
const { data: groups, refetch: refetchGroups } = useGroupConversations(); const { data: groups, refetch: refetchGroups } = useGroupConversations({ enabled: inboxEnabled });
const { getLastRead } = useDMRead(); const { getLastRead } = useDMRead();
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user); const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { isMuted } = useDmMute(); const { isMuted } = useDmMute();
@ -412,7 +415,7 @@ export default function DirectMessagesPage() {
void refetch(); void refetch();
void refetchGroups(); void refetchGroups();
}} }}
disabled={isRefetching} disabled={isRefetching || !inboxEnabled}
aria-label={t('socialUi.dms.refresh')} aria-label={t('socialUi.dms.refresh')}
className="h-10 px-3 sm:h-auto sm:px-4" className="h-10 px-3 sm:h-auto sm:px-4"
> >
@ -430,6 +433,28 @@ export default function DirectMessagesPage() {
} }
/> />
{interactive && !amberInboxUnlocked && (
<Card className="mb-5 border-teal-500/25 bg-teal-500/[0.04]">
<CardContent className="flex items-start gap-3 p-4">
<Lock className="mt-0.5 h-5 w-5 shrink-0 text-teal-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">{t('socialUi.dms.amberUnlockTitle')}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">
{t('socialUi.dms.amberUnlockBody')}
</p>
<Button
size="sm"
onClick={() => setAmberInboxUnlocked(true)}
className="mt-3 gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-400"
>
<Lock className="h-3.5 w-3.5" />
{t('socialUi.dms.amberUnlockCta')}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Cordn private rooms — community + each crew's MLS chat */} {/* Cordn private rooms — community + each crew's MLS chat */}
<div className="mb-5"> <div className="mb-5">
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500"> <h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
@ -465,6 +490,8 @@ export default function DirectMessagesPage() {
</div> </div>
</div> </div>
{inboxEnabled && (
<>
{/* Group conversations (NIP-17 group DMs) */} {/* Group conversations (NIP-17 group DMs) */}
{groups && groups.length > 0 && ( {groups && groups.length > 0 && (
<div className="mb-5"> <div className="mb-5">
@ -536,6 +563,8 @@ export default function DirectMessagesPage() {
</p> </p>
)} )}
</div> </div>
</>
)}
</div> </div>
{/* New Message — searchable people picker */} {/* New Message — searchable people picker */}

View file

@ -23,6 +23,7 @@ import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useSellerInfo } from '@/hooks/useSellerInfo'; import { useSellerInfo } from '@/hooks/useSellerInfo';
import { useTradeReputation } from '@/hooks/useTradeAttestations';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES, isValidNoffer } from '@/lib/types'; import { formatPrice, GEAR_CONDITIONS, GEAR_CATEGORIES, isValidNoffer } from '@/lib/types';
@ -40,6 +41,7 @@ import {
Tag, Tag,
Handshake, Handshake,
Share2, Share2,
ShieldAlert,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -61,6 +63,7 @@ export default function ListingDetailPage() {
const author = useAuthor(pubkey); const author = useAuthor(pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const { data: sellerInfo } = useSellerInfo(pubkey); const { data: sellerInfo } = useSellerInfo(pubkey);
const { data: sellerReputation } = useTradeReputation(pubkey);
const { relist, isPending: isUpdatingStatus } = useMarkListingSold(); const { relist, isPending: isUpdatingStatus } = useMarkListingSold();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -128,6 +131,18 @@ export default function ListingDetailPage() {
? ((metadata as Record<string, unknown>).noffer as string) ? ((metadata as Record<string, unknown>).noffer as string)
: undefined; : undefined;
const priceCurrency = item.price?.currency?.toUpperCase();
const priceAmount = Number(item.price?.amount);
const isHighValue =
item.priceType === 'fixed' &&
Number.isFinite(priceAmount) &&
((['SATS', 'SAT'].includes(priceCurrency ?? '') && priceAmount >= 100_000) ||
(['USD', 'EUR', 'GBP'].includes(priceCurrency ?? '') && priceAmount >= 100));
const isNewSeller =
(sellerInfo?.accountAge !== undefined && sellerInfo.accountAge < 30) ||
(!!sellerReputation && sellerReputation.verifiedCounterparties === 0);
const showSafetyNote = isNewSeller || isHighValue;
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing })); const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
const handleShare = async () => { const handleShare = async () => {
@ -340,6 +355,22 @@ export default function ListingDetailPage() {
) : ( ) : (
<Card className="bg-black/90 border-white/10"> <Card className="bg-black/90 border-white/10">
<CardContent className="p-4"> <CardContent className="p-4">
{showSafetyNote ? (
<div className="mb-3 space-y-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-amber-300">
<ShieldAlert className="h-3.5 w-3.5" />{' '}
{isNewSeller
? t('marketUi.card.newSeller')
: t('marketTerms.highValueTitle')}
</p>
<p className="text-xs leading-relaxed text-gray-400">
{t('marketTerms.safetyBody')}
</p>
<p className="text-[11px] leading-relaxed text-amber-200/80">
{t('marketTerms.noArbitration')}
</p>
</div>
) : null}
<ListingTradeActions item={item} /> <ListingTradeActions item={item} />
{isValidNoffer(sellerNoffer) && ( {isValidNoffer(sellerNoffer) && (
<ClinkPayButton <ClinkPayButton

View file

@ -1,5 +1,5 @@
import { useSeoMeta } from "@unhead/react"; import { useSeoMeta } from "@unhead/react";
import { useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -24,9 +24,9 @@ const NotFound = () => {
<div className="text-center"> <div className="text-center">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1> <h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-4">{t('settingsUi.notFound.message')}</p> <p className="text-xl text-gray-600 dark:text-gray-400 mb-4">{t('settingsUi.notFound.message')}</p>
<a href="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"> <Link to="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
{t('settingsUi.notFound.returnHome')} {t('settingsUi.notFound.returnHome')}
</a> </Link>
</div> </div>
</div> </div>
); );

View file

@ -6,6 +6,7 @@ import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard'; import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard'; import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
import { RelayListManager } from '@/components/RelayListManager'; import { RelayListManager } from '@/components/RelayListManager';
import { RelayDivergenceCard } from '@/components/RelayDivergenceCard';
import { WalletModal } from '@/components/WalletModal'; import { WalletModal } from '@/components/WalletModal';
import { SecuritySettings } from '@/components/SecuritySettings'; import { SecuritySettings } from '@/components/SecuritySettings';
@ -607,6 +608,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
)} )}
<CitrineCard /> <CitrineCard />
<RelayListManager /> <RelayListManager />
<RelayDivergenceCard />
</div> </div>
</div> </div>
); );

View file

@ -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>
); );