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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user