238 lines
8.6 KiB
Markdown
238 lines
8.6 KiB
Markdown
|
|
# Web UI — Design Notes
|
||
|
|
|
||
|
|
## Invite URL pattern
|
||
|
|
|
||
|
|
The invite URL path segment is the truncated network hash — already computed
|
||
|
|
by the daemon as the first 8 bytes of `SHA-256("yaw2-net:" + name)`.
|
||
|
|
|
||
|
|
```
|
||
|
|
https://waste.dev.xplwd.com/78aa5621196bf200/
|
||
|
|
```
|
||
|
|
|
||
|
|
The web UI reads `window.location.pathname`, pre-fills:
|
||
|
|
- WebSocket URL: `wss://waste.dev.xplwd.com/ws` (or `/<hash>/signal`)
|
||
|
|
- Network ID: extracted from the path — no separate field needed
|
||
|
|
|
||
|
|
### Fragment vs path
|
||
|
|
|
||
|
|
Using `/#78aa5621196bf200` instead of a path means the network ID never
|
||
|
|
reaches the anchor's access logs. The anchor cannot distinguish an invite
|
||
|
|
visit from a regular visit. Slightly more private — worth deciding before
|
||
|
|
building the React routing layer.
|
||
|
|
|
||
|
|
### Per-network signal path
|
||
|
|
|
||
|
|
Moving the WebSocket endpoint to `/<nethash>/signal` enables nginx to route
|
||
|
|
`/<hash>/signal` to the anchor and `/<hash>/` to a CDN or static host.
|
||
|
|
The anchor never has to serve HTML. Keeps concerns cleanly separated.
|
||
|
|
|
||
|
|
### Anchor changes needed (if it serves the UI)
|
||
|
|
|
||
|
|
Right now `cmd/anchor` only handles WebSocket on `/ws`. To support the
|
||
|
|
invite URL pattern it would also need to serve static files (the React
|
||
|
|
bundle) for any other path, and optionally move the WebSocket endpoint to
|
||
|
|
`/<nethash>/signal` for per-network isolation.
|
||
|
|
|
||
|
|
### Open decision: does the anchor serve the UI at all?
|
||
|
|
|
||
|
|
Two options — decide before building the React routing layer:
|
||
|
|
|
||
|
|
**A) Anchor is purely a signaling relay**
|
||
|
|
Static files served from a CDN or separate host. Anchor only handles
|
||
|
|
WebSocket. Simpler, easier to scale, no Go HTTP file serving code.
|
||
|
|
nginx routes `/<hash>/signal` → anchor, everything else → static host.
|
||
|
|
|
||
|
|
**B) Anchor serves the React bundle**
|
||
|
|
Single deployment, one domain. Anchor handles both WebSocket and static
|
||
|
|
file serving. More convenient but mixes concerns and means deploying a
|
||
|
|
new anchor binary every time the UI changes.
|
||
|
|
|
||
|
|
### Invite expiry
|
||
|
|
|
||
|
|
Encode a TTL in the invite (e.g. 72h). The anchor rejects join attempts on
|
||
|
|
expired tokens. Permanent invites are a liability — a leaked link stays open
|
||
|
|
forever.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Privacy & safety — URL invites / anchor
|
||
|
|
|
||
|
|
- **Use the network hash in the URL, not the name.** A base64'd name is
|
||
|
|
trivially reversible. The hash reveals nothing about the network or its
|
||
|
|
members.
|
||
|
|
- **Link previews will betray you.** iMessage, Slack, WhatsApp etc.
|
||
|
|
pre-fetch `https://` links for preview generation. That pre-fetch hits
|
||
|
|
the anchor and effectively probes the network. Serve a generic preview
|
||
|
|
(no network info in og:tags), or use a `#fragment` — fragments never
|
||
|
|
leave the browser.
|
||
|
|
- **The anchor is a metadata oracle.** It can't read content but sees who
|
||
|
|
connects, when, and how often. Log as little as possible — no IPs beyond
|
||
|
|
what's needed to route, no persistent connection records. stderr only,
|
||
|
|
no disk writes.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Privacy & safety — identity / contact cards
|
||
|
|
|
||
|
|
- **Private key never leaves the device.** In Tauri, store in OS keychain
|
||
|
|
via Tauri's secure storage — not localStorage.
|
||
|
|
- **Make public vs private explicit in the UI.** The card is a public
|
||
|
|
address. Never show the private key, not even "for backup."
|
||
|
|
- **Aliases are not authenticated — say so.** Anyone can claim any alias,
|
||
|
|
including yours. The peer ID is the real identity. Make the short 4-group
|
||
|
|
hex ID glanceable so users build the habit of verifying it.
|
||
|
|
- **Contact cards expose your anchor URL.** If Alice shares her card and
|
||
|
|
later wants to cut someone off, they still know her anchor. Consider
|
||
|
|
supporting anchor rotation or anchor-less cards for LAN scenarios.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Privacy & safety — trust model
|
||
|
|
|
||
|
|
- **Default-deny inbound connections.** Unknown peers get `bye` before any
|
||
|
|
data flows. The pending prompt should show the peer ID, not just the
|
||
|
|
claimed alias.
|
||
|
|
- **Mutual acceptance before any messages.** Don't buffer messages from
|
||
|
|
unaccepted peers. Nothing stored until both sides have accepted.
|
||
|
|
- **Removal is immediate.** Close the DataChannel, remove from accepted
|
||
|
|
list, send `bye`. Don't wait for reconnect.
|
||
|
|
- **Block list separate from accept list.** Removing a contact means
|
||
|
|
"not accepted." Blocking should actively refuse — important if they
|
||
|
|
still know the anchor URL.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Tauri / local daemon
|
||
|
|
|
||
|
|
- **IPC binds localhost only.** Already the case — keep it. In Tauri,
|
||
|
|
use a random port chosen at startup (written to a local socket file)
|
||
|
|
rather than a fixed port.
|
||
|
|
- **No auto-join on startup.** Invites are processed only when the UI is
|
||
|
|
open and the user confirms.
|
||
|
|
- **Clear data means clear data.** Uninstall / "delete account" must wipe
|
||
|
|
the SQLite store, the identity key, and all cached peer data. Don't rely
|
||
|
|
on the OS.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Onboarding flow (contact card model)
|
||
|
|
|
||
|
|
Inspired by the Friends app pattern:
|
||
|
|
|
||
|
|
1. App generates an identity on first launch.
|
||
|
|
2. User picks a nickname — advisory only, not authenticated.
|
||
|
|
3. User copies their contact card (`yaw:<peerid>?n=alias&a=wss://anchor`).
|
||
|
|
UI makes clear: *this is your public address, not a password.*
|
||
|
|
4. User pastes a friend's card into an Accept box, optionally sets a local
|
||
|
|
nickname for them.
|
||
|
|
5. Trust is mutual — connection completes only once both sides have
|
||
|
|
accepted each other's card.
|
||
|
|
6. Pending inbound connections show peer ID + claimed alias; user
|
||
|
|
approves or blocks.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Daemon changes needed
|
||
|
|
|
||
|
|
- `accepted_peers` table in SQLite
|
||
|
|
- `accept_peer` / `remove_peer` / `block_peer` IPC commands
|
||
|
|
- After hello verification: check allowlist — send `bye` and close if
|
||
|
|
not accepted; emit `pending_peer` event if unknown
|
||
|
|
- Network concept may simplify to "your contact list" for the personal
|
||
|
|
use case; named group networks remain as a separate concept for group
|
||
|
|
chats
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## End-game stack
|
||
|
|
|
||
|
|
- **React + Tauri** standalone desktop application
|
||
|
|
- Go daemon runs as a Tauri sidecar
|
||
|
|
- React talks to daemon via existing IPC (local TCP, bridged through
|
||
|
|
Tauri's invoke API)
|
||
|
|
- Anchor stays as a lightweight relay — no content, minimal metadata
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Anchor host as onboarding hub
|
||
|
|
|
||
|
|
The anchor host serves a web UI regardless of which client the user ends
|
||
|
|
up on. It is the universal entry point:
|
||
|
|
|
||
|
|
- New user follows an invite link → lands on the web UI → creates an
|
||
|
|
identity → joins the network
|
||
|
|
- Existing TUI user wants to switch to Tauri client → exports identity
|
||
|
|
from current client → imports into new one
|
||
|
|
- Mobile user with no install → uses the web UI directly
|
||
|
|
|
||
|
|
nginx serves the static React bundle at `/`. The anchor handles WebSocket
|
||
|
|
only. No Go HTTP file serving needed — clean separation.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Identity portability
|
||
|
|
|
||
|
|
The identity (Ed25519 keypair + alias) is the one thing that ties all
|
||
|
|
clients together. It must be portable, stable, and independently
|
||
|
|
documented.
|
||
|
|
|
||
|
|
### Portable identity format
|
||
|
|
|
||
|
|
Use the same format as the sister project for interoperability:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"yaw": "yaw-key-backup-1",
|
||
|
|
"id": "<hex peer id>",
|
||
|
|
"alg": "argon2id-secretbox",
|
||
|
|
"ops": 2,
|
||
|
|
"mem": 67108864,
|
||
|
|
"salt": "<base64>",
|
||
|
|
"nonce": "<base64>",
|
||
|
|
"ct": "<base64 ciphertext>"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- `yaw` is the format version tag
|
||
|
|
- `id` is the public peer ID (hex) — visible without decrypting, useful
|
||
|
|
for confirming you're importing the right identity
|
||
|
|
- `alg` signals argon2id KDF + nacl secretbox encryption
|
||
|
|
- `ops`/`mem` are argon2id parameters
|
||
|
|
- `ct` unseals to the raw Ed25519 private key + alias
|
||
|
|
|
||
|
|
The passphrase is the only secret — the file itself is safe to copy
|
||
|
|
anywhere. Same format means credentials backed up via the sister project
|
||
|
|
can be imported directly into waste and vice versa.
|
||
|
|
|
||
|
|
### Migration flows
|
||
|
|
|
||
|
|
**TUI → Tauri client**
|
||
|
|
1. `waste-daemon export-identity --out identity.enc` (or IPC command)
|
||
|
|
2. Copy file to new machine, import in Tauri onboarding screen
|
||
|
|
|
||
|
|
**Web UI → any client**
|
||
|
|
1. Web UI shows "export your identity" → downloads the encrypted file
|
||
|
|
2. User imports into TUI or Tauri with passphrase
|
||
|
|
|
||
|
|
**New user via web UI, later installs Tauri**
|
||
|
|
1. Creates identity in browser (stored in secure browser storage)
|
||
|
|
2. Exports encrypted file at any point
|
||
|
|
3. Imports into Tauri — same peer ID, same contacts, history syncs
|
||
|
|
via peers (not server)
|
||
|
|
|
||
|
|
**QR code transfer (mobile / LAN)**
|
||
|
|
- Encrypted identity blob encoded as QR
|
||
|
|
- Scan on new device, enter passphrase
|
||
|
|
- No file transfer needed
|
||
|
|
|
||
|
|
### Open decisions
|
||
|
|
|
||
|
|
- Does the web UI generate and hold the private key in-browser, or does
|
||
|
|
it proxy through a server-side session? (In-browser is safer — key
|
||
|
|
never leaves the device even via the anchor host.)
|
||
|
|
- Browser storage for the key: IndexedDB + WebCrypto non-extractable key,
|
||
|
|
or just the encrypted blob with passphrase re-entry on each session?
|
||
|
|
- History portability: messages are local-only today. Cross-client sync
|
||
|
|
would require either exporting the SQLite file or accepting that history
|
||
|
|
starts fresh on each new client.
|