Add invite strings (waste: URI) for peer onboarding
- internal/invite: Encode/Decode waste:<base64json{anchor,network}>
- proto: CmdGenerateInvite + EvtInviteGenerated + InviteString field
- ipc: track active network name; handle generate_invite (returns invite
string when joined to a network, errors if not joined or no anchor)
- daemon: --join <invite> flag — decodes anchor URL + network name,
sets anchor and auto-joins on startup
- tui: --join <invite> flag — extracts network name, skips -network
requirement; ctrl+i generates invite and shows it full-screen;
Esc dismisses the invite overlay
- FUTURE.md: document multi-network derived-identity design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
27
FUTURE.md
27
FUTURE.md
@@ -59,6 +59,33 @@ No DHT needed at small group scale (10–50 nodes). Keep it simple:
|
||||
- Public key = stable identity, not a mutable nickname
|
||||
- No phone number, no central registry — closer to Signal's model than WASTE's original unregistered aliases
|
||||
|
||||
### Multi-Network Support
|
||||
|
||||
A single client should be able to participate in multiple networks simultaneously (e.g. "work" and "friends") without leaking that both identities belong to the same person.
|
||||
|
||||
**Privacy constraint:** if the same Ed25519 keypair is used across networks, any peer who is a member of both networks can trivially correlate you. The anchor also sees the same public key across networks.
|
||||
|
||||
**Solution — per-network derived identities:**
|
||||
- One master Ed25519 seed in `identity.json`
|
||||
- Per-network keypair = `HKDF(masterSeed, "yaw2-net", networkHash)`
|
||||
- Same master + same network name = same derived keypair (stable identity within a network)
|
||||
- Different networks = different peer IDs; correlation is impossible without knowing both network names
|
||||
- The anchor sees only the derived public key
|
||||
|
||||
**Daemon changes:**
|
||||
- Replace the single `networkCancel` with a `map[networkID]*networkCtx`
|
||||
- Each context holds its own: derived identity, mesh, anchor connection, store (`messages-<netHash>.db`)
|
||||
- `join_network` returns a `network_id` token used to scope subsequent commands
|
||||
|
||||
**IPC changes (breaking):**
|
||||
- All commands and events gain a `network_id` field
|
||||
- `get_state` returns an array of all joined networks
|
||||
- `join_network` responds with `network_joined` carrying the derived peer ID for that network
|
||||
|
||||
**TUI changes:**
|
||||
- Top-level network switcher (e.g. `[work] [friends]`)
|
||||
- Rooms and peers are scoped per network underneath
|
||||
|
||||
### Transport (Long-term)
|
||||
Current transport is TCP with custom framing. QUIC is worth revisiting once the core is solid — it gives multiplexing and better NAT traversal behavior essentially for free.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/waste-go/internal/anchor"
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/ipc"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
@@ -22,8 +23,21 @@ func main() {
|
||||
alias := flag.String("alias", "anon", "display name shown to peers (advisory only)")
|
||||
ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)")
|
||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||
flag.Parse()
|
||||
|
||||
// --join overrides/sets the anchor URL and triggers an auto-join.
|
||||
var autoJoinNetwork string
|
||||
if *joinInvite != "" {
|
||||
inv, err := invite.Decode(*joinInvite)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid invite: %v", err)
|
||||
}
|
||||
*anchorURL = inv.Anchor
|
||||
autoJoinNetwork = inv.Network
|
||||
log.Printf("daemon: invite decoded — anchor=%s network=%s", inv.Anchor, inv.Network)
|
||||
}
|
||||
|
||||
dir := expandHome(*dataDir)
|
||||
id, err := crypto.LoadOrCreate(dir, *alias)
|
||||
if err != nil {
|
||||
@@ -51,7 +65,14 @@ func main() {
|
||||
log.Printf("daemon: left network %q", networkName)
|
||||
}
|
||||
|
||||
if err := ipc.Run(m, *ipcPort, joinFn); err != nil {
|
||||
// Auto-join from --join flag before starting IPC (non-blocking).
|
||||
if autoJoinNetwork != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
_ = cancel // lifecycle managed by the joinFn / ipc.Run leave
|
||||
go joinFn(ctx, autoJoinNetwork)
|
||||
}
|
||||
|
||||
if err := ipc.Run(m, *ipcPort, *anchorURL, joinFn); err != nil {
|
||||
log.Fatalf("ipc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
@@ -114,8 +115,9 @@ type model struct {
|
||||
viewport viewport.Model
|
||||
vpReady bool
|
||||
|
||||
status string
|
||||
errMsg string
|
||||
status string
|
||||
errMsg string
|
||||
invitePopup string // non-empty = show invite overlay
|
||||
}
|
||||
|
||||
func newModel(ipcPort int, network string) model {
|
||||
@@ -200,7 +202,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case msg.Type == tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case msg.Type == tea.KeyEsc:
|
||||
if m.invitePopup != "" {
|
||||
m.invitePopup = ""
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case msg.String() == "ctrl+i":
|
||||
if m.enc != nil {
|
||||
cmds = append(cmds, sendIPC(m.enc, proto.IpcMessage{Type: proto.CmdGenerateInvite}))
|
||||
}
|
||||
case msg.Type == tea.KeyEnter:
|
||||
m, cmds = m.doSend(cmds)
|
||||
case msg.Type == tea.KeyTab:
|
||||
@@ -272,6 +282,9 @@ func (m model) applyEvent(evt proto.IpcMessage) model {
|
||||
m.peerOrder = filterIDs(m.peerOrder, pid)
|
||||
}
|
||||
|
||||
case proto.EvtInviteGenerated:
|
||||
m.invitePopup = evt.InviteString
|
||||
|
||||
case proto.EvtMessageReceived:
|
||||
if evt.Message != nil {
|
||||
msg := evt.Message
|
||||
@@ -417,11 +430,31 @@ func (m model) View() string {
|
||||
if m.errMsg != "" {
|
||||
statusLine = styleErr.Render(" ✗ " + m.errMsg)
|
||||
} else {
|
||||
hint := " tab: rooms · pgup/dn: scroll · ctrl+c: quit"
|
||||
hint := " tab: rooms · ctrl+i: invite · ctrl+c: quit"
|
||||
statusLine = styleStatus.Width(m.width).Render(" " + m.status + hint)
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
|
||||
view := lipgloss.JoinVertical(lipgloss.Left, mainRow, inputBox, statusLine)
|
||||
|
||||
// ── invite popup (full-screen overlay) ───────────────────────────────────
|
||||
if m.invitePopup != "" {
|
||||
label := styleActive.Render("Invite — share this with anyone you want to add:")
|
||||
code := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("238")).
|
||||
Padding(0, 1).
|
||||
Width(m.width - 6).
|
||||
Render(m.invitePopup)
|
||||
hint := styleStatus.Render("Esc to close · ctrl+c to quit")
|
||||
popup := lipgloss.NewStyle().
|
||||
Width(m.width).
|
||||
Height(m.height).
|
||||
Align(lipgloss.Center, lipgloss.Center).
|
||||
Render(lipgloss.JoinVertical(lipgloss.Left, label, "", code, "", hint))
|
||||
return popup
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (m model) renderRooms(boxH int) string {
|
||||
@@ -565,12 +598,23 @@ func min(a, b int) int {
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
||||
network := flag.String("network", "", "network name to join on startup (required)")
|
||||
ipcPort := flag.Int("ipc", 17337, "daemon IPC port")
|
||||
network := flag.String("network", "", "network name to join on startup")
|
||||
joinInvite := flag.String("join", "", "waste: invite string — auto-sets the network name")
|
||||
flag.Parse()
|
||||
|
||||
// --join overrides --network.
|
||||
if *joinInvite != "" {
|
||||
inv, err := invite.Decode(*joinInvite)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: invalid invite:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
*network = inv.Network
|
||||
}
|
||||
|
||||
if *network == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: -network is required")
|
||||
fmt.Fprintln(os.Stderr, "error: -network or -join is required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
54
internal/invite/invite.go
Normal file
54
internal/invite/invite.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Package invite encodes and decodes waste invite strings.
|
||||
// An invite carries the anchor URL and network name needed to join a network.
|
||||
// Format: "waste:<url-safe-base64(json)>"
|
||||
package invite
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const prefix = "waste:"
|
||||
|
||||
// Invite holds the information needed to join a network.
|
||||
type Invite struct {
|
||||
Anchor string `json:"anchor"` // WebSocket anchor URL
|
||||
Network string `json:"network"` // plaintext network name
|
||||
}
|
||||
|
||||
// Encode returns a waste: invite string for the given anchor URL and network name.
|
||||
func Encode(anchor, network string) (string, error) {
|
||||
if anchor == "" {
|
||||
return "", fmt.Errorf("anchor URL is required")
|
||||
}
|
||||
if network == "" {
|
||||
return "", fmt.Errorf("network name is required")
|
||||
}
|
||||
b, err := json.Marshal(Invite{Anchor: anchor, Network: network})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Decode parses a waste: invite string and returns the Invite.
|
||||
func Decode(s string) (Invite, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return Invite{}, fmt.Errorf("not a waste invite (expected 'waste:' prefix)")
|
||||
}
|
||||
b, err := base64.URLEncoding.DecodeString(strings.TrimPrefix(s, prefix))
|
||||
if err != nil {
|
||||
return Invite{}, fmt.Errorf("invalid invite: %w", err)
|
||||
}
|
||||
var inv Invite
|
||||
if err := json.Unmarshal(b, &inv); err != nil {
|
||||
return Invite{}, fmt.Errorf("invalid invite payload: %w", err)
|
||||
}
|
||||
if inv.Anchor == "" || inv.Network == "" {
|
||||
return Invite{}, fmt.Errorf("invite is missing anchor or network name")
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
38
internal/invite/invite_test.go
Normal file
38
internal/invite/invite_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package invite
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
s, err := Encode("ws://my-vps:17339/ws", "friends")
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
if len(s) < 7 || s[:6] != "waste:" {
|
||||
t.Fatalf("expected waste: prefix, got %q", s)
|
||||
}
|
||||
|
||||
inv, err := Decode(s)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if inv.Anchor != "ws://my-vps:17339/ws" {
|
||||
t.Fatalf("anchor = %q", inv.Anchor)
|
||||
}
|
||||
if inv.Network != "friends" {
|
||||
t.Fatalf("network = %q", inv.Network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInvalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"notaninvite",
|
||||
"waste:!!!",
|
||||
"waste:" + "eyJhbmNob3IiOiIifQ==", // missing network
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := Decode(c); err == nil {
|
||||
t.Errorf("Decode(%q) expected error, got nil", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/waste-go/internal/invite"
|
||||
"github.com/waste-go/internal/mesh"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
@@ -24,8 +25,9 @@ import (
|
||||
type JoinFunc func(ctx context.Context, networkName string)
|
||||
|
||||
// Run starts the IPC listener. Blocks until the listener fails.
|
||||
// anchorURL is the configured anchor WebSocket URL (used for invite generation).
|
||||
// Network join/leave state is daemon-scoped (shared across all IPC clients).
|
||||
func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
func Run(m *mesh.Mesh, port int, anchorURL string, join JoinFunc) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -33,11 +35,12 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
}
|
||||
log.Printf("ipc: listening on %s", addr)
|
||||
|
||||
// networkCancel is shared across all clients — any client can join/leave,
|
||||
// and the join persists even after the commanding client disconnects.
|
||||
// networkCancel / networkName are shared across all clients — any client
|
||||
// can join/leave, and the join persists after the commanding client disconnects.
|
||||
var (
|
||||
networkMu sync.Mutex
|
||||
networkCancel context.CancelFunc
|
||||
networkName string
|
||||
)
|
||||
|
||||
doJoin := func(name string) {
|
||||
@@ -47,6 +50,7 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
networkCancel = cancel
|
||||
networkName = name
|
||||
networkMu.Unlock()
|
||||
go join(ctx, name)
|
||||
}
|
||||
@@ -56,21 +60,28 @@ func Run(m *mesh.Mesh, port int, join JoinFunc) error {
|
||||
if networkCancel != nil {
|
||||
networkCancel()
|
||||
networkCancel = nil
|
||||
networkName = ""
|
||||
}
|
||||
networkMu.Unlock()
|
||||
}
|
||||
|
||||
currentNetwork := func() string {
|
||||
networkMu.Lock()
|
||||
defer networkMu.Unlock()
|
||||
return networkName
|
||||
}
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc accept: %w", err)
|
||||
}
|
||||
log.Printf("ipc: UI client connected from %s", conn.RemoteAddr())
|
||||
go handleClient(conn, m, doJoin, doLeave)
|
||||
go handleClient(conn, m, anchorURL, currentNetwork, doJoin, doLeave)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func()) {
|
||||
func handleClient(conn net.Conn, m *mesh.Mesh, anchorURL string, currentNetwork func() string, doJoin func(string), doLeave func()) {
|
||||
defer conn.Close()
|
||||
|
||||
events := m.Subscribe()
|
||||
@@ -187,6 +198,23 @@ func handleClient(conn net.Conn, m *mesh.Mesh, doJoin func(string), doLeave func
|
||||
Rooms: []string{"general"},
|
||||
})
|
||||
|
||||
case proto.CmdGenerateInvite:
|
||||
net := currentNetwork()
|
||||
if net == "" {
|
||||
send(errMsg("generate_invite: not currently joined to a network"))
|
||||
continue
|
||||
}
|
||||
if anchorURL == "" {
|
||||
send(errMsg("generate_invite: daemon was started without -anchor flag"))
|
||||
continue
|
||||
}
|
||||
inv, err := invite.Encode(anchorURL, net)
|
||||
if err != nil {
|
||||
send(errMsg(fmt.Sprintf("generate_invite: %v", err)))
|
||||
continue
|
||||
}
|
||||
send(proto.IpcMessage{Type: proto.EvtInviteGenerated, InviteString: inv})
|
||||
|
||||
case proto.CmdSendFile:
|
||||
send(errMsg("file transfer not yet implemented"))
|
||||
|
||||
|
||||
@@ -185,7 +185,8 @@ const (
|
||||
CmdJoinNetwork IpcMsgType = "join_network" // fields: network_name (plaintext)
|
||||
CmdLeaveNetwork IpcMsgType = "leave_network"
|
||||
CmdGetState IpcMsgType = "get_state"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdSendFile IpcMsgType = "send_file"
|
||||
CmdGenerateInvite IpcMsgType = "generate_invite"
|
||||
|
||||
// Events (daemon → UI)
|
||||
EvtMessageReceived IpcMsgType = "message_received"
|
||||
@@ -196,6 +197,7 @@ const (
|
||||
EvtFileProgress IpcMsgType = "file_progress"
|
||||
EvtStateSnapshot IpcMsgType = "state_snapshot"
|
||||
EvtError IpcMsgType = "error"
|
||||
EvtInviteGenerated IpcMsgType = "invite_generated"
|
||||
)
|
||||
|
||||
// IpcMessage covers both commands and events.
|
||||
@@ -226,4 +228,5 @@ type IpcMessage struct {
|
||||
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
|
||||
Rooms []string `json:"rooms,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
InviteString string `json:"invite,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user