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:
Fredrik Johansson
2026-06-21 18:56:24 +02:00
parent 5a0bcee8c6
commit abc6bf46ce
7 changed files with 229 additions and 14 deletions

54
internal/invite/invite.go Normal file
View 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
}

View 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)
}
}
}

View File

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

View File

@@ -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"`
}