Add signed invites, hang links, multi-share, and EXTENSIONS.md

- Signed invites: waste: URI gains inviter+sig fields (Ed25519); hello
  carries the invite so receiving peers can verify against known keys
- RequireInvite per-network flag: rejects peers without valid signed invite
- Hash-based hang links: #waste:base64 fragment pre-fills join form without
  server-side leakage of network name
- Multi-share: shares.json (daemon) + waste_shares localStorage (browser);
  IPC add_share/remove_share/list_shares commands
- EXTENSIONS.md: addendum documenting all waste-go protocol deviations from
  YAW/2; all extensions are additive and backward compatible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-26 22:05:56 +02:00
parent 0e8ddbf4f4
commit 31e13fd509
12 changed files with 382 additions and 37 deletions

173
EXTENSIONS.md Normal file
View File

@@ -0,0 +1,173 @@
# waste-go Protocol Extensions
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
They do **not** break compatibility — YAW/2-only peers silently ignore all new
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
simply has no effect on that peer.
---
## EXT-001 — Signed Invites
**Status:** implemented
**Affects:** `waste:` invite format, `hello` DataChannel message
### Motivation
The base YAW/2 network model is open to anyone who knows the anchor URL and
network name (or hash). This extension adds opt-in cryptographic membership
gating: invites are signed by an existing peer, and peers that enforce
`RequireInvite` reject hellos that carry no valid signed invite.
### Invite format changes
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
```json
{
"anchor": "wss://...",
"network": "friends",
"net": "<64-hex SHA-256(yaw2-net:name)>",
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
"sig": "<hex Ed25519 signature>"
}
```
The signature covers the following bytes (null-separated):
```
anchor \x00 network \x00 net \x00 inviter
```
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
### Hello message extension
The YAW/2 §6 hello message gains one optional field:
```json
{
"type": "hello",
"id": "<hex pubkey>",
"nick": "alice",
"caps": ["chat", "file"],
"sig": "<DTLS binding sig>",
"invite": "waste:eyJ..."
}
```
`invite` carries the full `waste:` string the connecting peer used to join.
YAW/2-only peers ignore this field.
### Enforcement
Per-network flag `RequireInvite` (set via `join_network` IPC command).
When enabled:
1. A peer that presents no `invite` in hello is disconnected immediately.
2. A peer that presents an invite with no signature is disconnected.
3. A peer whose invite signature is invalid is disconnected.
4. A peer whose invite was signed by an unknown peer ID (not in the store or
currently connected) is disconnected.
The inviter's key must be a **known peer** — i.e. previously connected and
stored in the per-network SQLite store, or currently connected. This forms a
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
invite Carol, whose invite Alice will also accept.
**Default:** off. Networks opt in. Existing networks with no RequireInvite
behave exactly as before.
---
## EXT-002 — Hash-based Hang Link
**Status:** implemented
**Affects:** web UI URL handling only, no wire changes
### Motivation
A shareable URL that pre-fills the join form without conveying cryptographic
membership. Suitable for public announcements ("come hang out here"). The
fragment is never sent to the server, keeping the network name opaque to
server logs and HTTP intermediaries.
### Format
```
https://host/#waste:eyJ...
```
The fragment payload is the standard `waste:` base64 JSON with only `network`
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
membership on networks with `RequireInvite` enabled; it only pre-fills the
join form.
The web UI generates hang links via the 🔗 button in the Networks sidebar
section. Arriving users see the join form pre-populated and still need a
proper signed invite (if the network enforces it) to be accepted by peers.
---
## EXT-003 — Multi-Share Configuration
**Status:** implemented
**Affects:** IPC protocol only, no peer-to-peer wire changes
### New IPC commands
```jsonc
{"type":"add_share","path":"/home/alice/Music"} // global
{"type":"add_share","path":"/home/alice/Docs","networks":["abc123"]} // scoped
{"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"}
```
### New IPC event
```jsonc
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
```
### Persistence
`shares.json` in the data directory (next to `identity.json`). Each entry:
```json
{ "path": "/absolute/path", "networks": ["*"] }
```
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
Coexists with the legacy `set_share_dir` single-dir mechanism.
File listings returned by `get_file_list` and `MsgFileListReq` include
entries from all applicable share roots, with relative `path` fields
(e.g. `"path": "docs/report.pdf"`).
---
## EXT-004 — TURN Relay (browser mode)
**Status:** implemented (browser mode); pending (daemon mode)
**Affects:** ICE server configuration only, no wire changes
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme).
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
opt-in via server configuration and does not affect peers that omit it.
---
## EXT-005 — Per-Network Path in FileEntry
**Status:** implemented
**Affects:** `MsgFileListResp` wire message (additive field)
`FileEntry` gains an optional `path` field carrying the file's relative path
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
this field continue to use `name` for display and download requests.
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
falling back to `name` for backward compat with peers that don't send `path`.

View File

@@ -121,6 +121,9 @@ Web frontend (React, already built) + Tauri shell for native packaging. The IPC
| ✅ shipped | Session persistence + logout (browser mode) | | ✅ shipped | Session persistence + logout (browser mode) |
| ✅ shipped | Persistent multi-share config (shares.json + localStorage) | | ✅ shipped | Persistent multi-share config (shares.json + localStorage) |
| ✅ shipped | Subfolder support + directory browser UI in file browser | | ✅ shipped | Subfolder support + directory browser UI in file browser |
| ✅ shipped | Signed invites + invite-only networks (`RequireInvite`) |
| ✅ shipped | Hash-based "come hang" links (`#waste:...`) |
| ✅ shipped | Protocol extensions documented in EXTENSIONS.md |
| next | TURN relay for daemon mode | | next | TURN relay for daemon mode |
| next | TUI room creation + daemon-side room persistence | | next | TUI room creation + daemon-side room persistence |
| next | File transfer resume after disconnection | | next | File transfer resume after disconnection |

View File

@@ -284,6 +284,24 @@ The invite encodes the anchor URL and network name. Sharing it only lets the rec
Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled. Invite links also work in the web UI. Share `https://your-domain.com/?invite=waste:eyJ...` and the join form is pre-filled.
### Signed invites and invite-only networks (waste-go extension)
Invites generated by `generate_invite` are **cryptographically signed** by the generating peer. The `waste:` payload carries an `inviter` field (Ed25519 public key) and a `sig` field (signature over anchor + network + inviter). When Bob joins, the invite is forwarded in the `hello` message so Alice can verify it.
To enable invite-only enforcement on a network, pass `require_invite: true` in the `join_network` command. Peers presenting no invite, an unsigned invite, or an invite signed by an unknown peer are rejected.
### "Come hang" hang links
The 🔗 button in the web UI copies a **hash-based hang link**:
```
https://your-domain.com/#waste:eyJ...
```
The fragment (`#...`) is never sent to the server, so the network name stays server-opaque. Anyone who opens the link gets the join form pre-filled — but they still need a proper signed invite to be accepted on networks with `require_invite` enabled. Suitable for public announcements of open or semi-open networks.
See [EXTENSIONS.md](EXTENSIONS.md) for the full protocol addendum.
--- ---
## Terminal UI ## Terminal UI

View File

@@ -283,7 +283,7 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
} }
case proto.EvtInviteGenerated: case proto.EvtInviteGenerated:
m.invitePopup = evt.InviteString m.invitePopup = evt.InviteGenerated
case proto.EvtMessageReceived: case proto.EvtMessageReceived:
if evt.Message != nil { if evt.Message != nil {

View File

@@ -131,6 +131,9 @@ func (id *Identity) PeerInfo() proto.PeerInfo {
} }
} }
// PeerIDHex satisfies the invite.Signer interface.
func (id *Identity) PeerIDHex() string { return string(id.PeerID()) }
// Sign signs data with our Ed25519 private key. Returns hex-encoded signature. // Sign signs data with our Ed25519 private key. Returns hex-encoded signature.
func (id *Identity) Sign(data []byte) string { func (id *Identity) Sign(data []byte) string {
sig := ed25519.Sign(id.privateKey, data) sig := ed25519.Sign(id.privateKey, data)

View File

@@ -6,6 +6,11 @@
// 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly // 64-char hex SHA-256("yaw2-net:"+name) hash that yaw2 clients pass directly
// to the signaling server. A yaw2 client that can parse the base64 JSON can join // to the signaling server. A yaw2 client that can parse the base64 JSON can join
// the same network without knowing the plaintext name. // the same network without knowing the plaintext name.
//
// Signed invites (waste-go extension): when `inviter` and `sig` are present,
// the invite was issued by a known peer. Receiving peers that enforce
// RequireInvite will reject hellos that carry no valid signed invite.
// YAW/2-only peers ignore both fields.
package invite package invite
import ( import (
@@ -21,29 +26,69 @@ const prefix = "waste:"
// Invite holds the information needed to join a network. // Invite holds the information needed to join a network.
type Invite struct { type Invite struct {
Anchor string `json:"anchor"` // WebSocket anchor URL Anchor string `json:"anchor"` // WebSocket anchor URL
Network string `json:"network"` // plaintext network name Network string `json:"network"` // plaintext network name
Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field Net string `json:"net,omitempty"` // 64-char hex SHA-256("yaw2-net:"+name) — yaw2 `net` field
Inviter string `json:"inviter,omitempty"` // hex Ed25519 pubkey of the signing peer (waste-go extension)
Sig string `json:"sig,omitempty"` // hex Ed25519 sig over canonical payload (waste-go extension)
} }
// Encode returns a waste: invite string for the given anchor URL and network name. // IsSigned reports whether the invite carries a signature.
func (inv Invite) IsSigned() bool { return inv.Inviter != "" && inv.Sig != "" }
// Signer can sign data and report its own peer ID.
type Signer interface {
Sign(data []byte) string
PeerIDHex() string
}
// Verifier verifies an Ed25519 signature given a hex public key.
type Verifier func(publicKeyHex string, data []byte, sigHex string) error
// Encode returns an unsigned waste: invite string (backward compatible).
func Encode(anchor, network string) (string, error) { func Encode(anchor, network string) (string, error) {
return marshal(Invite{
Anchor: anchor,
Network: network,
Net: NetHash(network),
})
}
// EncodeSigned returns a signed waste: invite string.
// The signature covers: anchor + NUL + network + NUL + net + NUL + inviter.
func EncodeSigned(anchor, network string, signer Signer) (string, error) {
if anchor == "" { if anchor == "" {
return "", fmt.Errorf("anchor URL is required") return "", fmt.Errorf("anchor URL is required")
} }
if network == "" { if network == "" {
return "", fmt.Errorf("network name is required") return "", fmt.Errorf("network name is required")
} }
h := sha256.Sum256([]byte("yaw2-net:" + network)) inviter := signer.PeerIDHex()
b, err := json.Marshal(Invite{ net := NetHash(network)
sig := signer.Sign(sigPayload(anchor, network, net, inviter))
return marshal(Invite{
Anchor: anchor, Anchor: anchor,
Network: network, Network: network,
Net: hex.EncodeToString(h[:]), Net: net,
Inviter: inviter,
Sig: sig,
}) })
if err != nil { }
return "", err
// Verify checks the invite signature and that the inviter is in the trusted set.
// Unsigned invites return nil — the caller decides whether to accept them.
func Verify(inv Invite, trusted map[string]bool, verify Verifier) error {
if !inv.IsSigned() {
return nil
} }
return prefix + base64.URLEncoding.EncodeToString(b), nil payload := sigPayload(inv.Anchor, inv.Network, inv.Net, inv.Inviter)
if err := verify(inv.Inviter, payload, inv.Sig); err != nil {
return fmt.Errorf("invite signature invalid: %w", err)
}
if !trusted[inv.Inviter] {
return fmt.Errorf("invite signed by unknown peer %s", inv.Inviter[:16])
}
return nil
} }
// Decode parses a waste: invite string and returns the Invite. // Decode parses a waste: invite string and returns the Invite.
@@ -66,9 +111,20 @@ func Decode(s string) (Invite, error) {
return inv, nil return inv, nil
} }
// NetHash returns the full 64-char hex network hash for the given name // NetHash returns the full 64-char hex network hash for the given name.
// (SHA-256("yaw2-net:" + name)). This is the `net` field sent to the anchor.
func NetHash(name string) string { func NetHash(name string) string {
h := sha256.Sum256([]byte("yaw2-net:" + name)) h := sha256.Sum256([]byte("yaw2-net:" + name))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
func marshal(inv Invite) (string, error) {
b, err := json.Marshal(inv)
if err != nil {
return "", err
}
return prefix + base64.URLEncoding.EncodeToString(b), nil
}
func sigPayload(anchor, network, net, inviter string) []byte {
return []byte(anchor + "\x00" + network + "\x00" + net + "\x00" + inviter)
}

View File

@@ -146,7 +146,6 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
case cmd.NetworkName != "": case cmd.NetworkName != "":
netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir) netID, err = mgr.Join(cmd.NetworkName, cmd.ShareDir)
case len(cmd.NetworkHash) == 64: case len(cmd.NetworkHash) == 64:
// yaw2-compatible: join by full 64-char hex hash (net field)
netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir) netID, err = mgr.JoinByHash(cmd.NetworkHash, cmd.ShareDir)
default: default:
send(errMsg("join_network: network_name or network_hash (64 hex chars) required")) send(errMsg("join_network: network_name or network_hash (64 hex chars) required"))
@@ -156,7 +155,14 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(errMsg(fmt.Sprintf("join_network: %v", err))) send(errMsg(fmt.Sprintf("join_network: %v", err)))
continue continue
} }
// network_joined event (with share_dir) is emitted by Manager.Join/JoinByHash. if n, ok := mgr.Get(netID); ok {
if cmd.RequireInvite {
n.Mesh.RequireInvite = true
}
if cmd.InviteString != "" {
n.Mesh.InviteString = cmd.InviteString
}
}
_ = netID _ = netID
case proto.CmdLeaveNetwork: case proto.CmdLeaveNetwork:
@@ -298,15 +304,15 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
send(errMsg("generate_invite: daemon was started without -anchor flag")) send(errMsg("generate_invite: daemon was started without -anchor flag"))
continue continue
} }
inv, err := invite.Encode(mgr.AnchorURL(), n.Name) inv, err := invite.EncodeSigned(mgr.AnchorURL(), n.Name, n.Identity)
if err != nil { if err != nil {
send(errMsg(fmt.Sprintf("generate_invite: %v", err))) send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
continue continue
} }
send(proto.IpcMessage{ send(proto.IpcMessage{
Type: proto.EvtInviteGenerated, Type: proto.EvtInviteGenerated,
NetworkID: n.ID, NetworkID: n.ID,
InviteString: inv, InviteGenerated: inv,
}) })
case proto.CmdSetShareDir: case proto.CmdSetShareDir:

View File

@@ -27,8 +27,10 @@ type PeerConn struct {
type Mesh struct { type Mesh struct {
Identity *crypto.Identity Identity *crypto.Identity
Store *store.Store // may be nil if persistence is disabled Store *store.Store // may be nil if persistence is disabled
ShareDir string // directory whose contents are shared with peers; "" = no sharing ShareDir string // directory whose contents are shared with peers; "" = no sharing
DownloadDir string // directory where received files are saved DownloadDir string // directory where received files are saved
RequireInvite bool // waste-go ext: reject peers that present no valid signed invite
InviteString string // the invite this peer used to join (sent in hello to other peers)
// ScanFiles overrides ScanShareDir when set — allows the manager to inject // ScanFiles overrides ScanShareDir when set — allows the manager to inject
// multi-share scanning without the mesh needing to know about shares.json. // multi-share scanning without the mesh needing to know about shares.json.
ScanFiles func() []proto.FileEntry ScanFiles func() []proto.FileEntry
@@ -63,6 +65,29 @@ func New(id *crypto.Identity, st *store.Store) *Mesh {
} }
} }
// trustedPeerIDs returns a set of peer IDs trusted on this network:
// all currently connected peers plus all peers in the persistent store.
func (m *Mesh) trustedPeerIDs() map[string]bool {
trusted := map[string]bool{}
// Own identity is always trusted.
trusted[string(m.Identity.PeerID())] = true
// Connected peers.
m.mu.RLock()
for id := range m.peers {
trusted[string(id)] = true
}
m.mu.RUnlock()
// Previously seen peers from the store.
if m.Store != nil {
if known, err := m.Store.KnownPeers(); err == nil {
for id := range known {
trusted[string(id)] = true
}
}
}
return trusted
}
// ScanShareDir returns the list of files in the local share directory. // ScanShareDir returns the list of files in the local share directory.
// Returns an empty slice if ShareDir is unset or the directory is empty. // Returns an empty slice if ShareDir is unset or the directory is empty.
func (m *Mesh) ScanShareDir() []proto.FileEntry { func (m *Mesh) ScanShareDir() []proto.FileEntry {

View File

@@ -14,6 +14,7 @@ import (
"github.com/pion/webrtc/v3" "github.com/pion/webrtc/v3"
"github.com/waste-go/internal/crypto" "github.com/waste-go/internal/crypto"
"github.com/waste-go/internal/invite"
"github.com/waste-go/internal/proto" "github.com/waste-go/internal/proto"
) )
@@ -43,11 +44,12 @@ func WireDataChannel(
localFP, remoteFP := dtlsFingerprints(pc) localFP, remoteFP := dtlsFingerprints(pc)
bindBytes := proto.HelloBindString(localFP, remoteFP) bindBytes := proto.HelloBindString(localFP, remoteFP)
hello := proto.HelloMessage{ hello := proto.HelloMessage{
Type: "hello", Type: "hello",
ID: string(id.PeerID()), ID: string(id.PeerID()),
Nick: id.Alias, Nick: id.Alias,
Caps: []string{"chat", "file"}, Caps: []string{"chat", "file"},
Sig: id.Sign(bindBytes), Sig: id.Sign(bindBytes),
Invite: m.InviteString,
} }
helloJSON, _ := json.Marshal(hello) helloJSON, _ := json.Marshal(hello)
if err := dc.SendText(string(helloJSON)); err != nil { if err := dc.SendText(string(helloJSON)); err != nil {
@@ -149,6 +151,37 @@ func handleDCMessage(data []byte, from proto.PeerID, localID *crypto.Identity, m
log.Printf("peer: bad hello from %s: %v", from.Short(), err) log.Printf("peer: bad hello from %s: %v", from.Short(), err)
return return
} }
// Invite enforcement (waste-go extension).
if m.RequireInvite {
if hello.Invite == "" {
log.Printf("peer: rejecting %s — no invite presented (RequireInvite=true)", from.Short())
m.Emit(proto.IpcMessage{
Type: proto.EvtError,
ErrorMessage: fmt.Sprintf("peer %s rejected: no invite", from.Short()),
})
return
}
inv, err := invite.Decode(hello.Invite)
if err != nil || !inv.IsSigned() {
log.Printf("peer: rejecting %s — invite not signed: %v", from.Short(), err)
m.Emit(proto.IpcMessage{
Type: proto.EvtError,
ErrorMessage: fmt.Sprintf("peer %s rejected: invite not signed", from.Short()),
})
return
}
trusted := m.trustedPeerIDs()
if err := invite.Verify(inv, trusted, crypto.Verify); err != nil {
log.Printf("peer: rejecting %s — %v", from.Short(), err)
m.Emit(proto.IpcMessage{
Type: proto.EvtError,
ErrorMessage: fmt.Sprintf("peer %s rejected: %v", from.Short(), err),
})
return
}
}
// Update alias once we have the verified nick. // Update alias once we have the verified nick.
m.mu.Lock() m.mu.Lock()
if conn, ok := m.peers[from]; ok { if conn, ok := m.peers[from]; ok {

View File

@@ -139,12 +139,16 @@ type FileOffer struct {
// HelloMessage is the first message sent on the "yaw" DataChannel. // HelloMessage is the first message sent on the "yaw" DataChannel.
// The signature binds this identity to the specific DTLS session. // The signature binds this identity to the specific DTLS session.
// The Invite field is a waste-go extension (§ waste-go/extensions.md):
// when RequireInvite is enabled on a network, peers that omit or present
// an invalid signed invite are disconnected. YAW/2-only peers ignore this field.
type HelloMessage struct { type HelloMessage struct {
Type string `json:"type"` // always "hello" Type string `json:"type"` // always "hello"
ID string `json:"id"` // hex pubkey ID string `json:"id"` // hex pubkey
Nick string `json:"nick"` // alias Nick string `json:"nick"` // alias
Caps []string `json:"caps"` // capability list, e.g. ["chat","file"] Caps []string `json:"caps"` // capability list, e.g. ["chat","file"]
Sig string `json:"sig"` // hex ed25519 sig over HelloBindString Sig string `json:"sig"` // hex ed25519 sig over HelloBindString
Invite string `json:"invite,omitempty"` // waste-go ext: signed waste: invite string
} }
// HelloBindString returns the bytes the hello signature covers: // HelloBindString returns the bytes the hello signature covers:
@@ -276,9 +280,11 @@ type IpcMessage struct {
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
// join_network / leave_network // join_network / leave_network
NetworkName string `json:"network_name,omitempty"` NetworkName string `json:"network_name,omitempty"`
NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name NetworkHash string `json:"network_hash,omitempty"` // 64-char hex (yaw2 `net` field); alternative to network_name
ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory ShareDir string `json:"share_dir,omitempty"` // optional per-network share directory
RequireInvite bool `json:"require_invite,omitempty"` // waste-go ext: reject peers without valid signed invite
InviteString string `json:"invite_string,omitempty"` // waste-go ext: the invite used to join (stored in mesh)
// send_file / set_share_dir / file_complete path // send_file / set_share_dir / file_complete path
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
@@ -301,7 +307,7 @@ type IpcMessage struct {
// multi-network: all joined networks (additive) // multi-network: all joined networks (additive)
Networks []NetworkInfo `json:"networks,omitempty"` Networks []NetworkInfo `json:"networks,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
InviteString string `json:"invite,omitempty"` InviteGenerated string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"` Files []FileEntry `json:"files,omitempty"`
Shares []ShareEntry `json:"shares,omitempty"` Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"networks,omitempty"` // for add_share command ShareNetworks []string `json:"networks,omitempty"` // for add_share command

View File

@@ -54,6 +54,17 @@ export function Sidebar() {
const displayId = localPeer?.id ?? masterId ?? '' const displayId = localPeer?.id ?? masterId ?? ''
const card = displayId ? makeYawCard(displayId, displayAlias) : null const card = displayId ? makeYawCard(displayId, displayAlias) : null
function copyHangLink() {
const net = networks.find(n => n.network_id === activeNetworkId)
if (!net) return
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
const anchor = cfg?.signalURL ?? ''
const payload = btoa(JSON.stringify({ network: net.network_name, anchor }))
.replace(/\+/g, '-').replace(/\//g, '_')
const url = `${window.location.origin}/#waste:${payload}`
navigator.clipboard?.writeText(url)
}
function handleLogout() { function handleLogout() {
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)') const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
logout(clearId) logout(clearId)
@@ -82,7 +93,12 @@ export function Sidebar() {
</div> </div>
<div className="sidebar-section"> <div className="sidebar-section">
<span className="sidebar-label">Networks</span> <div className="sidebar-label-row">
<span className="sidebar-label">Networks</span>
{activeNetworkId && (
<button className="sidebar-add" onClick={copyHangLink} title="Copy hang link (pre-fills join form, no invite required)">🔗</button>
)}
</div>
{networks.map(n => ( {networks.map(n => (
<button <button
key={n.network_id} key={n.network_id}

View File

@@ -14,11 +14,17 @@ interface Props {
// ?a=<url> anchor URL hint // ?a=<url> anchor URL hint
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } { function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
const p = new URLSearchParams(window.location.search) const p = new URLSearchParams(window.location.search)
const inviteString = p.get('invite') ?? '' let inviteString = p.get('invite') ?? ''
let network = p.get('n') ?? p.get('network') ?? '' let network = p.get('n') ?? p.get('network') ?? ''
let netHash = p.get('net') ?? '' let netHash = p.get('net') ?? ''
let anchor = p.get('a') ?? p.get('anchor') ?? '' let anchor = p.get('a') ?? p.get('anchor') ?? ''
// Hash-based hang link: https://host/#waste:eyJ... (opaque, not sent to server)
const hash = window.location.hash.slice(1) // strip leading #
if (!inviteString && hash.startsWith('waste:')) {
inviteString = hash
}
if (inviteString.startsWith('waste:')) { if (inviteString.startsWith('waste:')) {
try { try {
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/'))) const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))