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