Compare commits
38 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5acaa3b2b4 | ||
|
|
226d0c3f95 | ||
|
|
f646d5aa14 | ||
|
|
0fe82c28d8 | ||
|
|
20de6ba8e1 | ||
|
|
bcc277a054 | ||
|
|
c8f471b290 | ||
|
|
c2750eae85 | ||
|
|
609b15be08 | ||
|
|
cc0545cc5c | ||
|
|
31d1630c50 | ||
|
|
1d810ea964 | ||
|
|
a813e1e7f7 | ||
|
|
618d961f72 | ||
|
|
e1a011045a | ||
|
|
a44665f05f | ||
|
|
4392df3202 | ||
|
|
99f04edf5d | ||
|
|
3edef9cbdf | ||
|
|
42d25818b4 | ||
|
|
5a1d849681 | ||
|
|
b4fbae693b | ||
|
|
c08d5d4baa | ||
|
|
2c8bd594a4 | ||
|
|
9ce57bedf9 | ||
|
|
bfc777c18b | ||
|
|
a2b732317e | ||
|
|
5ebf741427 | ||
|
|
4ae30ee8e6 | ||
|
|
72e5f89b49 | ||
|
|
d003748bb1 | ||
|
|
6048eafc0d | ||
|
|
562a7de7c0 | ||
|
|
8b12001fa6 | ||
|
|
383ff8a5cf | ||
|
|
65914144d1 | ||
|
|
a8780d5236 | ||
|
|
2dcf5a2aad |
64 changed files with 1840 additions and 80 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
133
AGENTS.md
133
AGENTS.md
|
|
@ -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.69**.
|
||||||
- ✅ 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.69). 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,39 @@ 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`.
|
||||||
|
|
||||||
### 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):**
|
||||||
|
|
|
||||||
19
ESCROW.md
19
ESCROW.md
|
|
@ -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
19
deploy/cordn/.env.example
Normal 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
127
deploy/cordn/README.md
Normal 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
14
deploy/cordn/backup.sh
Executable 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."
|
||||||
19
deploy/cordn/docker-compose.yml
Normal file
19
deploy/cordn/docker-compose.yml
Normal 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
17
deploy/cordn/restore.sh
Executable 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
97
distribution/README.md
Normal 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.69):
|
||||||
|
<https://git.skate-spots.org/skatespots/SkateSpots/releases/download/v1.7.69/SkateSpots-1.7.69.apk>
|
||||||
|
- SHA-256:
|
||||||
|
`45add6a37d1c73997f695618400ef2f1362e3137eb6deb276a9f566bd59601f2`
|
||||||
|
|
||||||
|
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.
|
||||||
51
distribution/fdroiddata/org.skatespots.app.yml
Normal file
51
distribution/fdroiddata/org.skatespots.app.yml
Normal 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
104
docs/THREAT_MODEL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# SkateSpots threat model
|
||||||
|
|
||||||
|
This is the short, technical version of what SkateSpots can and cannot guarantee.
|
||||||
|
It exists so product copy, support answers and future code do not accidentally
|
||||||
|
promise consensus, arbitration or delivery verification that the system does not
|
||||||
|
have.
|
||||||
|
|
||||||
|
## The model in one sentence
|
||||||
|
|
||||||
|
**SkateSpots verifies signed facts, keeps a local view, and uses social/economic
|
||||||
|
signals at value boundaries. It does not run a consensus protocol.**
|
||||||
|
|
||||||
|
Nostr relays are not voters. A relay can withhold, censor, reorder or serve a
|
||||||
|
stale event, but it cannot forge a valid signature. Different clients can
|
||||||
|
therefore see different snapshots of the network without either side being
|
||||||
|
"wrong" in a Byzantine-consensus sense.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- Nostr identity keys and local signer access.
|
||||||
|
- Local cache, drafts, pending posts and local deletion tombstones.
|
||||||
|
- Crew membership and Cordn group membership.
|
||||||
|
- Marketplace listings, offers, trade attestations and reputation.
|
||||||
|
- Cashu proofs and escrow tokens.
|
||||||
|
- Device storage and any local backup/export.
|
||||||
|
|
||||||
|
## Actors and trust boundaries
|
||||||
|
|
||||||
|
| Actor | Can do | Cannot do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Author | Sign events with their key | Forge another author's signature |
|
||||||
|
| Relay | Withhold, censor, reorder, serve stale or conflicting signed events | Forge a valid event; guarantee availability |
|
||||||
|
| Cordn coordinator | Route opaque group ciphertext; drop or delay messages | Read plaintext; forge an author proof |
|
||||||
|
| Cashu mint | Issue/redeem proofs, enforce locktime, freeze or become insolvent | Spend a correctly locked P2PK proof without a valid witness |
|
||||||
|
| Founder/admin | Modify the crew record, moderate crew content | Forge members' signatures; guarantee global agreement |
|
||||||
|
| Buyer/seller | Publish attestations and payment/receipt claims | Make a false claim true by signing it |
|
||||||
|
| WebView / host | Run the client | Be assumed trustworthy after device compromise |
|
||||||
|
|
||||||
|
## Guarantees
|
||||||
|
|
||||||
|
- **Authenticity:** a valid Schnorr signature proves which key signed an event,
|
||||||
|
not that the content is true.
|
||||||
|
- **Availability:** the client can use cached data and multiple relays; there is
|
||||||
|
no guarantee that every relay has every event.
|
||||||
|
- **Local determinism:** when two valid versions of an addressable or replaceable
|
||||||
|
event exist, the client chooses the newest `created_at`, with an event-id
|
||||||
|
tie-breaker. This is deterministic, not globally agreed.
|
||||||
|
- **Deletion:** NIP-09 is a request. Local tombstones prevent a deleted item from
|
||||||
|
flashing back on that device; relays may ignore the request.
|
||||||
|
- **Payments:** a Lightning preimage proves the invoice was paid. It does not
|
||||||
|
prove goods were delivered.
|
||||||
|
- **Escrow:** a time-locked Cashu P2PK token protects the buyer after the lock
|
||||||
|
expires and lets the seller redeem on delivery. It is not arbitration and does
|
||||||
|
not prove delivery.
|
||||||
|
- **Reputation:** reciprocal signed attestations, distinct partners and
|
||||||
|
web-of-trust weighting are evidence. Two colluding accounts can still create a
|
||||||
|
fake trade; the app must not present that as proof of goods.
|
||||||
|
|
||||||
|
## Non-guarantees
|
||||||
|
|
||||||
|
- No global ordering or consensus over relays.
|
||||||
|
- No protection from a compromised identity key, signer or device.
|
||||||
|
- No guaranteed delivery or carrier verification.
|
||||||
|
- No neutral dispute resolution.
|
||||||
|
- No guarantee that every relay honours deletion or moderation.
|
||||||
|
- No guarantee that a mint remains solvent, online or P2PK-compatible.
|
||||||
|
- No anonymity guarantee: public events, relays, image hosts and map/weather
|
||||||
|
providers may observe network metadata.
|
||||||
|
- No guarantee that a Cordn coordinator cannot censor a group. It cannot read or
|
||||||
|
forge messages, but it can drop them.
|
||||||
|
|
||||||
|
## Cordn specifically
|
||||||
|
|
||||||
|
The current Cordn group scheme is NIP-44 group delivery with a signed author
|
||||||
|
proof inside the ciphertext. It is not full MLS yet: it does not provide MLS
|
||||||
|
forward secrecy or post-compromise security. A coordinator cannot read the
|
||||||
|
message or impersonate the author, but it can refuse to relay it.
|
||||||
|
|
||||||
|
## Marketplace safety rules
|
||||||
|
|
||||||
|
- Prefer escrowed or in-person deals for high-value trades.
|
||||||
|
- Never send a plain, unlocked Cashu token in a public event.
|
||||||
|
- Treat "confirmed trade" as "both parties signed", not "goods verified".
|
||||||
|
- Treat "payment verified" as "the money leg is verified".
|
||||||
|
- A new account with no distinct confirmed partners is a warning, not a ban.
|
||||||
|
- Cash, barter and off-platform settlement have no on-chain proof; use
|
||||||
|
reputation and judgement.
|
||||||
|
|
||||||
|
## Privacy boundaries
|
||||||
|
|
||||||
|
- Public Nostr events are public forever in practice; deletion is best-effort.
|
||||||
|
- DMs and Cordn ciphertext are encrypted, but metadata (who, when, which group)
|
||||||
|
may be visible to relays/coordinators depending on the feature.
|
||||||
|
- Images are public once uploaded; strip metadata before upload and do not put
|
||||||
|
addresses in public listing images.
|
||||||
|
- Self-hosted infrastructure avoids third-party analytics, but network observers
|
||||||
|
can still see traffic to relays, image hosts, map tiles and weather services.
|
||||||
|
|
||||||
|
## What would change this model
|
||||||
|
|
||||||
|
A real consensus or arbitration layer would need a separate design with explicit
|
||||||
|
participants, quorum, liveness, slashing/appeals and privacy trade-offs. It
|
||||||
|
should not be smuggled in as "trust" copy or a global score. Until then:
|
||||||
|
**signed facts, local merge, explicit boundaries, no fake guarantees.**
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10761.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10761.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10762.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10762.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10763.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10763.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10764.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10764.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10765.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10765.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10766.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10766.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10767.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10767.txt
Normal 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.
|
||||||
1
fastlane/metadata/android/en-US/changelogs/10769.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/10769.txt
Normal 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.
|
||||||
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
15
fastlane/metadata/android/en-US/full_description.txt
Normal 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.
|
||||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Local-first skate map, crews, events and marketplace.
|
||||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
SkateSpots
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.61",
|
"version": "1.7.69",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.7.61",
|
"version": "1.7.69",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.61",
|
"version": "1.7.69",
|
||||||
"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
127
public/download/index.html
Normal 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.69/SkateSpots-1.7.69.apk"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<span>Download SkateSpots-1.7.69.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>45add6a37d1c73997f695618400ef2f1362e3137eb6deb276a9f566bd59601f2</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
BIN
public/images/opensats.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
|
|
@ -1,3 +1,104 @@
|
||||||
|
🎯 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
BIN
resources/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
41
scripts/inject-backup-policy.mjs
Normal file
41
scripts/inject-backup-policy.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Disable Android Auto Backup for SkateSpots.
|
||||||
|
*
|
||||||
|
* The app stores identity material, local settings, pending escrow tokens and
|
||||||
|
* other private state in its WebView storage. Android Auto Backup can copy that
|
||||||
|
* data to a cloud account without the user ever asking for a backup. Keep it
|
||||||
|
* device-local: SkateSpots has its own explicit export/backup flows.
|
||||||
|
*
|
||||||
|
* Idempotent; exits quietly when android/ has not been generated yet.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const manifestPath = join(root, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
||||||
|
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
console.log('[inject-backup-policy] android/ not generated yet — skipping.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = readFileSync(manifestPath, 'utf8');
|
||||||
|
const before = manifest;
|
||||||
|
|
||||||
|
if (/android:allowBackup="[^"]*"/.test(manifest)) {
|
||||||
|
manifest = manifest.replace(/android:allowBackup="[^"]*"/, 'android:allowBackup="false"');
|
||||||
|
} else {
|
||||||
|
manifest = manifest.replace(
|
||||||
|
/<application\b/,
|
||||||
|
'<application android:allowBackup="false"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest === before) {
|
||||||
|
console.log('[inject-backup-policy] allowBackup already disabled.');
|
||||||
|
} else {
|
||||||
|
writeFileSync(manifestPath, manifest);
|
||||||
|
console.log('[inject-backup-policy] ✓ Disabled Android cloud backup (allowBackup=false).');
|
||||||
|
}
|
||||||
108
scripts/publish-forgejo.sh
Executable file
108
scripts/publish-forgejo.sh
Executable 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
69
src/components/RelayDivergenceCard.tsx
Normal file
69
src/components/RelayDivergenceCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
62
src/hooks/useRelayDivergence.ts
Normal file
62
src/hooks/useRelayDivergence.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) };
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
@ -5018,6 +5025,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 +7600,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.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
@ -5018,6 +5025,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 +7600,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.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
18
src/lib/coordinator.test.ts
Normal file
18
src/lib/coordinator.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
22
src/lib/cordn/native/rpc.live.test.ts
Normal file
22
src/lib/cordn/native/rpc.live.test.ts
Normal 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);
|
||||||
|
});
|
||||||
55
src/lib/eventMerge.test.ts
Normal file
55
src/lib/eventMerge.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { eventMergeKey, eventReplaces, mergeEvents } from './eventMerge';
|
||||||
|
|
||||||
|
function ev(partial: Partial<NostrEvent> & { id: string }): NostrEvent {
|
||||||
|
return {
|
||||||
|
pubkey: 'a'.repeat(64),
|
||||||
|
created_at: 100,
|
||||||
|
kind: 1,
|
||||||
|
tags: [],
|
||||||
|
content: '',
|
||||||
|
sig: '0'.repeat(128),
|
||||||
|
...partial,
|
||||||
|
} as NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('eventMerge', () => {
|
||||||
|
it('keys addressable, replaceable and regular events correctly', () => {
|
||||||
|
expect(eventMergeKey(ev({ id: '1', kind: 30818, tags: [['d', 'spot']] }))).toBe(
|
||||||
|
`${'a'.repeat(64)}:30818:spot`,
|
||||||
|
);
|
||||||
|
expect(eventMergeKey(ev({ id: '2', kind: 0 }))).toBe(`${'a'.repeat(64)}:0`);
|
||||||
|
expect(eventMergeKey(ev({ id: '3', kind: 1 }))).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the newest addressable version regardless of input order', () => {
|
||||||
|
const old = ev({ id: 'b', kind: 30818, tags: [['d', 'spot']], created_at: 100 });
|
||||||
|
const fresh = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']], created_at: 101 });
|
||||||
|
|
||||||
|
expect(mergeEvents([old, fresh])).toEqual([fresh]);
|
||||||
|
expect(mergeEvents([fresh, old])).toEqual([fresh]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses event id as a deterministic tie-breaker', () => {
|
||||||
|
const first = ev({ id: 'aaa', kind: 30000, tags: [['d', 'x']], created_at: 100 });
|
||||||
|
const second = ev({ id: 'zzz', kind: 30000, tags: [['d', 'x']], created_at: 100 });
|
||||||
|
|
||||||
|
expect(eventReplaces(first, second)).toBe(true);
|
||||||
|
expect(mergeEvents([first, second])).toEqual([second]);
|
||||||
|
expect(mergeEvents([second, first])).toEqual([second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps distinct regular events and deduplicates by id', () => {
|
||||||
|
const a = ev({ id: 'a' });
|
||||||
|
const b = ev({ id: 'b' });
|
||||||
|
expect(mergeEvents([a, b, a])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops local tombstones', () => {
|
||||||
|
const a = ev({ id: 'a', kind: 30818, tags: [['d', 'spot']] });
|
||||||
|
const b = ev({ id: 'b', kind: 30818, tags: [['d', 'other']] });
|
||||||
|
|
||||||
|
expect(mergeEvents([a, b], { isDeleted: (event) => event.id === 'a' })).toEqual([b]);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/lib/eventMerge.ts
Normal file
74
src/lib/eventMerge.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic event merging.
|
||||||
|
*
|
||||||
|
* Nostr does not provide global ordering: relays can return the same logical
|
||||||
|
* event set in a different order, and two relays can serve different versions
|
||||||
|
* of an addressable event. We do not attempt consensus; we make the client's
|
||||||
|
* local answer deterministic and explainable.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - addressable events (`d` tag): newest `created_at` wins;
|
||||||
|
* - replaceable events (kind 0/3 and 10000-19999): newest `created_at` wins;
|
||||||
|
* - regular events: identity is the event id;
|
||||||
|
* - equal timestamps: lexicographically larger event id wins, so relay order
|
||||||
|
* and Promise timing cannot change the result.
|
||||||
|
*
|
||||||
|
* Local NIP-09 tombstones are applied by the caller before/while merging; a
|
||||||
|
* tombstone is a device-local safety net, not a claim that every relay erased
|
||||||
|
* the event.
|
||||||
|
*/
|
||||||
|
export type EventMergePredicate = (event: NostrEvent) => boolean;
|
||||||
|
|
||||||
|
export interface EventMergeOptions {
|
||||||
|
/** Return true for events that must not appear (local tombstones, moderation). */
|
||||||
|
isDeleted?: EventMergePredicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable identity used to compare two versions of the same logical event. */
|
||||||
|
export function eventMergeKey(event: NostrEvent): string {
|
||||||
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||||
|
if (dTag !== undefined) return `${event.pubkey}:${event.kind}:${dTag}`;
|
||||||
|
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
||||||
|
return `${event.pubkey}:${event.kind}`;
|
||||||
|
}
|
||||||
|
return event.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when `candidate` should replace `current` for the same key.
|
||||||
|
* Larger ids win ties; this is arbitrary but stable across relays and devices.
|
||||||
|
*/
|
||||||
|
export function eventReplaces(current: NostrEvent, candidate: NostrEvent): boolean {
|
||||||
|
if (candidate.created_at !== current.created_at) {
|
||||||
|
return candidate.created_at > current.created_at;
|
||||||
|
}
|
||||||
|
return candidate.id > current.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge events from one or more relays into one deterministic list.
|
||||||
|
*
|
||||||
|
* The returned list is sorted by key only for stable snapshots; callers that
|
||||||
|
* care about display order should sort for their own UI.
|
||||||
|
*/
|
||||||
|
export function mergeEvents(
|
||||||
|
events: readonly NostrEvent[],
|
||||||
|
options: EventMergeOptions = {},
|
||||||
|
): NostrEvent[] {
|
||||||
|
const { isDeleted } = options;
|
||||||
|
const byKey = new Map<string, NostrEvent>();
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (isDeleted?.(event)) continue;
|
||||||
|
|
||||||
|
const key = eventMergeKey(event);
|
||||||
|
const current = byKey.get(key);
|
||||||
|
if (!current || eventReplaces(current, event)) {
|
||||||
|
byKey.set(key, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byKey.values()];
|
||||||
|
}
|
||||||
|
|
@ -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: '🇨🇳' },
|
||||||
|
|
|
||||||
60
src/lib/relayDivergence.test.ts
Normal file
60
src/lib/relayDivergence.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/lib/relayDivergence.ts
Normal file
71
src/lib/relayDivergence.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,20 @@ export default function TrustPage() {
|
||||||
<p>{t('trustUi.povDesc')}</p>
|
<p>{t('trustUi.povDesc')}</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex flex-col items-center gap-3 pt-2">
|
||||||
<Link to="/settings">
|
<Link to="/settings">
|
||||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90">
|
||||||
{t('trustUi.settingsCta')}
|
{t('trustUi.settingsCta')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://git.skate-spots.org/skatespots/SkateSpots/blob/main/docs/THREAT_MODEL.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-gray-500 underline decoration-dotted underline-offset-2 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
{t('common.learnMore')}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue