diff --git a/pkg/client/client.go b/pkg/client/client.go index 80a43405..20f6ad6a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -331,6 +331,60 @@ func (c *Client) doJSON(method, path string, body, out any) error { return fmt.Errorf("client: %s %s: all control planes failed: %w", method, path, lastErr) } +// doUnsigned performs a control-plane request WITHOUT the transport signature +// headers, for the one endpoint a not-yet-registered identity must reach: POST +// /register. The registering peer is not in the allowlist, so it cannot produce +// an accepted signature; authorization is the single-use invite token inside the +// body. Like doJSON it fails over across the control-plane endpoints (any node +// serves the same state) and surfaces the server's structured error message. +func (c *Client) doUnsigned(method, path string, body, out any) error { + var bodyBytes []byte + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("client: marshal request: %w", err) + } + bodyBytes = b + } + var lastErr error + for _, base := range c.ctrlURLs { + var rdr io.Reader + if bodyBytes != nil { + rdr = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequest(method, base+path, rdr) + if err != nil { + return fmt.Errorf("client: new request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + lastErr = err + continue // dead node: try the next control plane + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + var er struct { + Error string `json:"error"` + } + if json.Unmarshal(respBody, &er) == nil && er.Error != "" { + return fmt.Errorf("%s (HTTP %d)", er.Error, resp.StatusCode) + } + return fmt.Errorf("client: %s %s -> %d: %s", method, path, resp.StatusCode, string(respBody)) + } + if out != nil { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("client: decode response: %w", err) + } + } + return nil + } + return fmt.Errorf("client: %s %s: all control planes failed: %w", method, path, lastErr) +} + // signRequest signs the canonical bytes of req (req must already have its Sig // field cleared) with the client's Ed25519 key. It is symmetric with the // server's verifyOwnerSig. This is the PAYLOAD-level owner signature that @@ -473,6 +527,35 @@ type addUserReq struct { Role string `json:"role"` } +// createInviteReq / createInviteResp mirror the server's POST /invites types. +type createInviteReq struct { + Handle string `json:"handle"` + Role string `json:"role"` + TTLSecs int `json:"ttl_secs"` +} + +type createInviteResp struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` +} + +// inviteJSON mirrors the server's GET /invites row. +type inviteJSON struct { + Token string `json:"token"` + Handle string `json:"handle"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` + Used bool `json:"used"` + CreatedAt string `json:"created_at"` +} + +// registerReq mirrors the server's POST /register body. +type registerReq struct { + Token string `json:"token"` + SignPub string `json:"sign_pub"` + KexPub string `json:"kex_pub"` +} + // ---- room operations ------------------------------------------------------ // RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the @@ -560,6 +643,82 @@ func (c *Client) RevokeUser(signPub string) error { return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil) } +// DeleteUser hard-deletes a bus user by their signing public key (64-hex) — the +// purge counterpart of RevokeUser. The allowlist row is removed entirely (no +// audit trail); the ex-user can no longer authenticate, so their room +// memberships become inert. The caller must be signing as an admin. +func (c *Client) DeleteUser(signPub string) error { + return c.doJSON("DELETE", "/users/"+signPub, nil, nil) +} + +// InviteInfo is a single-use registration invite as returned by the admin invite +// endpoints. It is a flat view for the admin panel: the bearer token (to build +// the join link), the handle and role the new user will receive, the absolute +// expiry, whether it has been used, and when it was minted. +type InviteInfo struct { + Token string + Handle string + Role string + ExpiresAt string + Used bool + CreatedAt string +} + +// CreateInvite mints a single-use registration invite. handle and role are fixed +// here (the registering client cannot change them); role is "admin" or "member" +// (empty defaults to member). ttlSecs sets the link lifetime (non-positive uses +// the server's 7-day default). The returned InviteInfo carries the token and +// expiry; the caller turns the token into a join link. Caller must sign as admin. +func (c *Client) CreateInvite(handle, role string, ttlSecs int) (InviteInfo, error) { + var resp createInviteResp + if err := c.doJSON("POST", "/invites", createInviteReq{Handle: handle, Role: role, TTLSecs: ttlSecs}, &resp); err != nil { + return InviteInfo{}, err + } + r := role + if r == "" { + r = "member" + } + return InviteInfo{Token: resp.Token, Handle: handle, Role: r, ExpiresAt: resp.ExpiresAt}, nil +} + +// ListInvites returns the pending invites (not used, not expired). Caller must +// sign as admin. +func (c *Client) ListInvites() ([]InviteInfo, error) { + var resp []inviteJSON + if err := c.doJSON("GET", "/invites", nil, &resp); err != nil { + return nil, err + } + out := make([]InviteInfo, 0, len(resp)) + for _, inv := range resp { + out = append(out, InviteInfo{ + Token: inv.Token, + Handle: inv.Handle, + Role: inv.Role, + ExpiresAt: inv.ExpiresAt, + Used: inv.Used, + CreatedAt: inv.CreatedAt, + }) + } + return out, nil +} + +// CancelInvite cancels (deletes) a pending invite by its token, so an admin can +// revoke a link before it is redeemed. Caller must sign as admin. +func (c *Client) CancelInvite(token string) error { + return c.doJSON("DELETE", "/invites/"+token, nil, nil) +} + +// Register redeems a single-use invite token, joining the bus allowlist. It is +// the wallet-model join call: the registering peer generated its own keypair +// locally and publishes ONLY its public keys here (signPub Ed25519, kexPub +// X25519, both 64-hex). It is UNSIGNED — the bearer token is the authorization, +// because this identity is not yet in the allowlist and so cannot sign an +// accepted request. On success the identity is registered with the invite's +// handle and role and can connect like any other peer. +func (c *Client) Register(token, signPub, kexPub string) error { + return c.doUnsigned("POST", "/register", registerReq{Token: token, SignPub: signPub, KexPub: kexPub}, nil) +} + // newRoomKey returns 32 random bytes for a symmetric room key. func newRoomKey() ([]byte, error) { k := make([]byte, 32)