feat(client): invite and hard-delete admin methods, unsigned Register
Add the client-library counterparts the admin panel and the /join client page consume: - CreateInvite, ListInvites, CancelInvite, DeleteUser: signed as admin. - Register(token, signPub, kexPub): UNSIGNED, via a new doUnsigned helper that fails over across control-plane endpoints and surfaces the server's structured error. The registering peer is not in the allowlist, so it cannot sign; the bearer token is the authorization. - InviteInfo flat view for the panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
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
|
// 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
|
// field cleared) with the client's Ed25519 key. It is symmetric with the
|
||||||
// server's verifyOwnerSig. This is the PAYLOAD-level owner signature that
|
// server's verifyOwnerSig. This is the PAYLOAD-level owner signature that
|
||||||
@@ -473,6 +527,35 @@ type addUserReq struct {
|
|||||||
Role string `json:"role"`
|
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 ------------------------------------------------------
|
// ---- room operations ------------------------------------------------------
|
||||||
|
|
||||||
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
|
// 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)
|
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.
|
// newRoomKey returns 32 random bytes for a symmetric room key.
|
||||||
func newRoomKey() ([]byte, error) {
|
func newRoomKey() ([]byte, error) {
|
||||||
k := make([]byte, 32)
|
k := make([]byte, 32)
|
||||||
|
|||||||
Reference in New Issue
Block a user