Files
terdut-operator/internal/tdclient/client.go
T
Niklas Ye a0ea13955e
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 52s
CI / test (pull_request) Successful in 2m43s
TerdutTeam: mint and surface a real invite link (spec.invite)
The actual fix for the human-onboarding gap niklas/terdut-server#23 found --
not a terdut-server change at all. A team-scoped credential is already
owner-equivalent for POST/GET/DELETE /api/teams/{teamID}/invites
(requireTeamOwner's synthetic-membership mechanism, ratified not
accidental per that repo's SERVICE-ACCOUNTS.md), and invite redemption
bypasses signup_mode entirely -- this TerdutTeam controller just never
grew a feature to use either fact.

New spec.invite{enabled, role (member|owner, default member), maxUses
(1-100, default 1)} and status.inviteSecretRef. The Secret lives in the
TerdutTeam's OWN namespace, not the operator's: unlike
status.credentialsSecretRef (a durable, high-privilege credential, kept
operator-side per DESIGN.md §6), an invite is bounded and limited-use,
meant for this namespace's own human operators to read and hand out --
same precedent as TerdutAlertSource's status.webhookURLSecretRef, same-
namespace and OwnerReference'd so deleting the TerdutTeam garbage-collects
it automatically.

internal/controller/terdutteam_invite.go: mints on first spec.invite.enabled,
refreshes a day ahead of terdut-server's fixed 7-day TTL (reading the
Secret's own stored expiresAt, no extra server round-trip per reconcile),
revokes server-side and deletes the Secret when flipped back to false. A
lost invite Secret is silently re-minted rather than treated as
unrecoverable the way TerdutAlertSource's webhook key is -- nothing
external holds a durable dependency on one specific invite link staying
stable, it's read once by one human and handed out.

New tdclient.Invite/CreateInvite/RevokeInvite. New envtest coverage: mint
into the team's own namespace, refresh-before-expiry, revoke-on-disable
(internal/controller/terdutteam_controller_test.go's new "spec.invite"
Describe block), plus the fake server growing invite support
(terdutserver_controller_test.go) -- its handleTeamSubPath dispatcher was
split further (deadman switches into their own handleDeadmanSubPath,
matching the existing handleIntegrationSubPath precedent) to stay under
golangci-lint's gocyclo threshold with the new route added.

examples/demo updated to prove this end to end: 02-team-platform.yaml
turns on spec.invite; run-demo.sh's bootstrap_login/join_demo_teams (the
psql signup_mode flip + a direct team_members INSERT) are replaced by
redeem_platform_invite (reads status.inviteSecretRef, a real POST
/api/signup with the invite token) and join_payments_team (POST
/api/teams/{teamID}/members using Payments' own credential and alice's
user id resolved via GET /api/users, deliberately not given its own
spec.invite, so the demo shows both onboarding paths this feature
unlocks) -- zero kubectl exec/psql calls remain anywhere in the script.
README.md's "First login" section rewritten to match; it no longer
documents the admin-token curl call that 403s against current
terdut-server (niklas/terdut-server#23).

Depends on niklas/terdut-server#24 (the callerMayManageServiceAccount fix
for terdut-operator#3) being released before this is deployed for real --
not required to build or test this change itself, since the envtest fake
never modeled that authorization gap to begin with.
2026-10-02 22:04:56 +02:00

616 lines
22 KiB
Go

// Package tdclient is a minimal terdut-server API client for the operator's
// own controllers. It mirrors the shape of terdut-tui's
// internal/api/client.go (baseURL/httpClient fields, a shared do/statusError
// helper, per terdut/CLAUDE.md's "any change to a server endpoint or JSON
// shape must be mirrored" convention) but authorizes with a Bearer API key
// rather than a session cookie — the operator never signs in as a human
// (DESIGN.md §6).
package tdclient
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Client talks to one terdut-server install, optionally as a service
// account. A zero-value token works for endpoints that don't need one
// (Version, Bootstrap).
// fieldName is the JSON key every create/rename request body below shares.
const fieldName = "name"
// fieldKind is the JSON key an integration's create request body shares
// with the terdutv1alpha1.TerdutAlertSourceSpec field of the same name.
const fieldKind = "kind"
type Client struct {
baseURL string
httpClient *http.Client
token string
}
// New creates a Client against baseURL, with no credential set.
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// WithToken returns a copy of c that authorizes every request as a Bearer
// credential — a user's own API key or a service-account key
// (SERVICE-ACCOUNTS.md), the server resolves either the same way.
func (c *Client) WithToken(token string) *Client {
cp := *c
cp.token = token
return &cp
}
// StatusError is a non-2xx response — the server's {"error": "..."} body
// decoded into Message, same shape terdut-tui's client uses.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
func statusError(resp *http.Response) error {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
}
func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) {
var reader *strings.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = strings.NewReader(string(data))
}
var req *http.Request
var err error
if reader != nil {
req, err = http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
} else {
req, err = http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
}
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
return req, nil
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return statusError(resp)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// Version calls GET /api/version — unauthenticated, per terdut-server's own
// router.go comment ("a client deciding whether it can talk to this server —
// terdut-tui, terdut-operator — needs to ask before it holds a credential
// for it"). Used here purely as a reachability probe: a bad endpoint fails
// here, clearly, rather than on whatever the controller tries first.
func (c *Client) Version(ctx context.Context) (string, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/version", nil)
if err != nil {
return "", err
}
var v struct {
Version string `json:"version"`
}
if err := c.do(req, &v); err != nil {
return "", err
}
return v.Version, nil
}
// APIKey is the raw key a bootstrap or service-account-key mint hands back —
// the one moment its value exists outside the request that generated it.
// Mirrors terdut-server's models.APIKey/models.ServiceAccountKey shape
// (internal/models in that repo) for the fields this client actually reads.
type APIKey struct {
ID int64 `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
CreatedAt string `json:"created_at"`
}
// BootstrapResult is /api/bootstrap's 201 response body.
type BootstrapResult struct {
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
} `json:"user"`
APIKey APIKey `json:"api_key"`
}
// Bootstrap calls POST /api/bootstrap — unauthenticated, single-shot per
// install (internal/api/users.go's handleBootstrap in terdut-server:
// gated on SELECT COUNT(*) FROM users). Returns the raw admin key directly;
// DESIGN.md §6 has the controller use it for exactly one further call
// (CreateServiceAccount) and discard it, never storing it as the lasting
// credential.
//
// A StatusError with Code 403 means this install already has a user —
// per this operator's design (DESIGN.md §1), that only happens if this
// exact TerdutServer's own controller already won this race on an earlier,
// interrupted reconcile; see the checkpoint-Secret handling in the
// controller, not a retry loop here.
func (c *Client) Bootstrap(ctx context.Context, username, email string) (*BootstrapResult, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/bootstrap", map[string]string{
"username": username,
"email": email,
})
if err != nil {
return nil, err
}
var result BootstrapResult
if err := c.do(req, &result); err != nil {
return nil, err
}
return &result, nil
}
// ServiceAccount mirrors terdut-server's models.ServiceAccount
// (internal/models/service_account.go), minus fields this client never
// reads.
type ServiceAccount struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
}
// CreateServiceAccountResult is POST /api/service-accounts' 201 response.
type CreateServiceAccountResult struct {
ServiceAccount ServiceAccount `json:"service_account"`
Key APIKey `json:"key"`
}
// CreateInstanceServiceAccount calls POST /api/service-accounts with
// scope "instance", authenticated with c's current token (the raw admin key
// from Bootstrap, for the operator's own first-ever call). A StatusError
// with Code 409 means a prior, interrupted attempt already created this
// name — DESIGN.md §6's adopt-rather-than-error rule: the caller should
// fall back to GetServiceAccountByName + CreateServiceAccountKey, not treat
// this as a hard failure.
func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{
fieldName: name,
"scope": "instance",
})
if err != nil {
return nil, err
}
var result CreateServiceAccountResult
if err := c.do(req, &result); err != nil {
return nil, err
}
return &result, nil
}
// CreateTeamServiceAccount calls POST /api/service-accounts with scope
// "team" for teamID, authenticated with c's current token -- the
// TerdutServer's instance-scoped credential, per DESIGN.md §6 point 3: an
// instance-scoped caller may mint a team-scoped account against any team
// (internal/api/service_accounts.go's handleCreateServiceAccount,
// confirmed against source), which is what lets TerdutTeam's own
// controller do this without ever touching a human credential. Same
// adopt-on-409 contract as CreateInstanceServiceAccount.
func (c *Client) CreateTeamServiceAccount(ctx context.Context, name string, teamID int64) (*CreateServiceAccountResult, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]any{
fieldName: name,
"scope": "team",
"team_id": teamID,
})
if err != nil {
return nil, err
}
var result CreateServiceAccountResult
if err := c.do(req, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetServiceAccountByName calls GET /api/service-accounts?name=... --
// authenticated (terdut-server's AuthMiddleware hard-rejects any
// unauthenticated request before this endpoint's own, more permissive
// internal check ever runs; DESIGN.md §6). Returns nil, nil if nothing
// matches, not an error -- the server's own distinction between "found
// nothing" and "the call failed".
func (c *Client) GetServiceAccountByName(ctx context.Context, name string) (*ServiceAccount, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/service-accounts?name="+name, nil)
if err != nil {
return nil, err
}
var accounts []ServiceAccount
if err := c.do(req, &accounts); err != nil {
return nil, err
}
if len(accounts) == 0 {
return nil, nil
}
return &accounts[0], nil
}
// CreateServiceAccountKey calls POST /api/service-accounts/{id}/keys to
// mint an additional key on an existing account -- rotation (DESIGN.md §6
// point 6), and the adopt-on-409 recovery path in point 1.
func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID int64, name string) (*APIKey, error) {
req, err := c.newRequest(ctx, http.MethodPost,
fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID),
map[string]string{fieldName: name})
if err != nil {
return nil, err
}
var key APIKey
if err := c.do(req, &key); err != nil {
return nil, err
}
return &key, nil
}
// Team mirrors terdut-server's models.Team (internal/models/team.go), minus
// Role/Source, which are only ever populated for a human caller's own
// membership and never apply to a service account's view of a team.
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// CreateTeam calls POST /api/teams, authenticated with c's current token --
// the TerdutServer's instance-scoped credential (DESIGN.md §6 point 3). A
// StatusError with Code 409 means a prior, interrupted attempt already
// created this name — GetTeamByName (TEAM-LOOKUP.md) is the adopt-rather-
// than-error recovery, the same contract CreateInstanceServiceAccount has.
func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams", map[string]string{fieldName: name})
if err != nil {
return nil, err
}
var team Team
if err := c.do(req, &team); err != nil {
return nil, err
}
return &team, nil
}
// GetTeamByName calls GET /api/teams?name=... (TEAM-LOOKUP.md) — open to
// any authenticated caller, not just the team's own members. Returns nil,
// nil on no match, not an error.
func (c *Client) GetTeamByName(ctx context.Context, name string) (*Team, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/teams?name="+name, nil)
if err != nil {
return nil, err
}
var teams []Team
if err := c.do(req, &teams); err != nil {
return nil, err
}
if len(teams) == 0 {
return nil, nil
}
return &teams[0], nil
}
// RenameTeam calls PUT /api/teams/{teamID} — owner-gated server-side
// (requireTeamOwner), so c must hold this team's own team-scoped
// credential, not the instance-scoped one CreateTeam used.
func (c *Client) RenameTeam(ctx context.Context, teamID int64, name string) error {
req, err := c.newRequest(ctx, http.MethodPut,
fmt.Sprintf("/api/teams/%d", teamID), map[string]string{fieldName: name})
if err != nil {
return err
}
return c.do(req, nil)
}
// DeleteTeam calls DELETE /api/teams/{teamID} — owner-gated, same
// credential requirement as RenameTeam. terdut-server refuses this while
// the team has open incidents (409) — surfaced to the caller as a
// StatusError, not retried specially here.
func (c *Client) DeleteTeam(ctx context.Context, teamID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}
// SetTeamOIDCGroups calls PUT /api/teams/{teamID}/oidc-groups — owner-gated,
// same credential requirement as RenameTeam. An empty group string clears
// that binding server-side (terdut-server's own NULLIF handling).
func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGroup, ownerGroup string) error {
req, err := c.newRequest(ctx, http.MethodPut,
fmt.Sprintf("/api/teams/%d/oidc-groups", teamID),
map[string]string{"member_group": memberGroup, "owner_group": ownerGroup})
if err != nil {
return err
}
return c.do(req, nil)
}
// User mirrors terdut-server's models.User, minus fields this client never
// reads.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
}
// GetUserByUsername calls GET /api/users and finds the one matching exactly
// -- confirmed open to any authenticated caller, not gated by team
// membership or admin (internal/api/router.go's own comment: "readable by
// anyone signed in"), so the team-scoped credential a TerdutEscalationRule's
// controller already holds is enough. There is no server-side filter, so
// this always fetches the whole list; terdut-server's own query has no
// pagination either (confirmed against source), so this matches what the
// server itself considers an acceptable cost. Returns nil, nil on no match.
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*User, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/users", nil)
if err != nil {
return nil, err
}
var users []User
if err := c.do(req, &users); err != nil {
return nil, err
}
for _, u := range users {
if u.Username == username {
return &u, nil
}
}
return nil, nil
}
// EscalationTargetRequest/EscalationLevelRequest/SetEscalationRequest mirror
// terdut-server's escalationTargetJSON/escalationLevelJSON/escalationJSON
// (internal/api/escalation.go) -- the PUT body, not the richer GET response
// (escalationView), which this client never needs to decode since the
// controller always computes its own desired state fresh from spec.
type EscalationTargetRequest struct {
Kind string `json:"kind"`
UserID *int64 `json:"user_id,omitempty"`
}
type EscalationLevelRequest struct {
Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Targets []EscalationTargetRequest `json:"targets"`
}
type SetEscalationRequest struct {
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []EscalationLevelRequest `json:"levels"`
}
// SetEscalation calls PUT /api/teams/{teamID}/escalation -- an upsert
// server-side (confirmed against source: `INSERT ... ON CONFLICT (team_id)
// DO UPDATE`), so there is no separate create step for this resource at
// all, unlike Team or the dead man's switch.
func (c *Client) SetEscalation(ctx context.Context, teamID int64, body SetEscalationRequest) error {
req, err := c.newRequest(ctx, http.MethodPut, fmt.Sprintf("/api/teams/%d/escalation", teamID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// DeadmanSwitch mirrors terdut-server's deadmanSwitchStatus
// (internal/api/deadman.go), minus fields this client never reads.
type DeadmanSwitch struct {
ID int64 `json:"id"`
Name string `json:"name"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
// ListDeadmanSwitches calls GET /api/teams/{teamID}/deadman/switches. There
// is no unique-name constraint on this resource server-side (confirmed
// against source), so this is the idempotent-create lookup for it --
// GET-list-and-match-by-name, not adopt-on-409.
func (c *Client) ListDeadmanSwitches(ctx context.Context, teamID int64) ([]DeadmanSwitch, error) {
req, err := c.newRequest(ctx, http.MethodGet, fmt.Sprintf("/api/teams/%d/deadman/switches", teamID), nil)
if err != nil {
return nil, err
}
var switches []DeadmanSwitch
if err := c.do(req, &switches); err != nil {
return nil, err
}
return switches, nil
}
// deadmanSwitchRequest mirrors terdut-server's own deadmanSwitchRequest
// (internal/api/teams.go) -- the same body shape for both create and
// update.
type deadmanSwitchRequest struct {
Name string `json:"name,omitempty"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
// CreateDeadmanSwitch calls POST /api/teams/{teamID}/deadman/switches.
func (c *Client) CreateDeadmanSwitch(ctx context.Context, teamID int64, name, matcher string, timeoutSeconds int64, severity string) (*DeadmanSwitch, error) {
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/deadman/switches", teamID),
deadmanSwitchRequest{Name: name, Matcher: matcher, TimeoutSeconds: timeoutSeconds, Severity: severity})
if err != nil {
return nil, err
}
var sw DeadmanSwitch
if err := c.do(req, &sw); err != nil {
return nil, err
}
return &sw, nil
}
// UpdateDeadmanSwitch calls PUT /api/teams/{teamID}/deadman/switches/{switchID}
// -- real update-in-place, added in terdut-server v0.33.0 specifically for
// this controller (that handler's own doc comment names terdut-operator).
func (c *Client) UpdateDeadmanSwitch(ctx context.Context, teamID, switchID int64, name, matcher string, timeoutSeconds int64, severity string) error {
req, err := c.newRequest(ctx, http.MethodPut,
fmt.Sprintf("/api/teams/%d/deadman/switches/%d", teamID, switchID),
deadmanSwitchRequest{Name: name, Matcher: matcher, TimeoutSeconds: timeoutSeconds, Severity: severity})
if err != nil {
return err
}
return c.do(req, nil)
}
// DeleteDeadmanSwitch calls DELETE /api/teams/{teamID}/deadman/switches/{switchID}.
func (c *Client) DeleteDeadmanSwitch(ctx context.Context, teamID, switchID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete,
fmt.Sprintf("/api/teams/%d/deadman/switches/%d", teamID, switchID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}
// Integration mirrors terdut-server's models.Integration, minus
// CreatedAt/LastUsedAt, which this client never reads. Key/URL are only
// ever populated by CreateIntegration's own response -- the one moment
// either value exists outside terdut-server's own database (DESIGN.md
// §4.5: shown once, never re-readable, same handling as the bootstrap
// admin key).
type Integration struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Kind string `json:"kind"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
URL string `json:"url,omitempty"`
}
// CreateIntegration calls POST /api/teams/{teamID}/integrations --
// owner-gated (requireTeamOwner), so c must hold this team's own
// team-scoped credential. No conflict handling exists server-side at all
// for this resource (no unique constraint on name, confirmed against
// source) -- deliberately not treated as this resource's idempotent-create
// recovery path; see the controller's own reasoning for why a crash
// between this call succeeding and the webhook Secret being written can't
// be recovered by listing and adopting a same-named row the way
// TerdutDeadmanSwitch does.
func (c *Client) CreateIntegration(ctx context.Context, teamID int64, name, kind string) (*Integration, error) {
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/integrations", teamID),
map[string]string{fieldName: name, fieldKind: kind})
if err != nil {
return nil, err
}
var integ Integration
if err := c.do(req, &integ); err != nil {
return nil, err
}
return &integ, nil
}
// RenameIntegration calls PATCH /api/teams/{teamID}/integrations/{integrationID}
// -- owner-gated, same credential requirement as CreateIntegration. Never
// touches the key (terdut-server's own handler comment: "the key is
// untouched, so nothing posting with it notices"), so this is safe to call
// every reconcile unconditionally rather than only on detected drift --
// the same "cheap, so just always sync it" reasoning TerdutEscalationRule's
// whole-policy PUT uses.
func (c *Client) RenameIntegration(ctx context.Context, teamID, integrationID int64, name string) error {
req, err := c.newRequest(ctx, http.MethodPatch,
fmt.Sprintf("/api/teams/%d/integrations/%d", teamID, integrationID),
map[string]string{fieldName: name})
if err != nil {
return err
}
return c.do(req, nil)
}
// DeleteIntegration calls DELETE /api/teams/{teamID}/integrations/{integrationID}
// -- owner-gated, same credential requirement as CreateIntegration. Used
// both by the finalizer and by the kind-change rotation path (DESIGN.md §5).
func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete,
fmt.Sprintf("/api/teams/%d/integrations/%d", teamID, integrationID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}
// Invite is a standing link into a team (POST /api/teams/{teamID}/invites'
// own response shape). URL carries the raw token exactly once, at creation
// -- terdut-server never shows it again (same one-time-shown shape as an
// integration's webhook key) -- so a caller that needs it later has to have
// kept this response, not re-fetched it.
type Invite struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int64 `json:"max_uses"`
URL string `json:"url,omitempty"`
}
// CreateInvite calls POST /api/teams/{teamID}/invites -- owner-gated
// (requireTeamOwner), so c must hold this team's own team-scoped
// credential, which already satisfies that check via its synthetic owner
// membership (terdut-server's SERVICE-ACCOUNTS.md). No conflict handling
// needed: unlike a team or a service account, an invite has no unique name
// to collide on -- every call mints a brand new row.
func (c *Client) CreateInvite(ctx context.Context, teamID int64, role string, maxUses int64) (*Invite, error) {
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/invites", teamID),
map[string]any{"role": role, "max_uses": maxUses})
if err != nil {
return nil, err
}
var inv Invite
if err := c.do(req, &inv); err != nil {
return nil, err
}
return &inv, nil
}
// RevokeInvite calls DELETE /api/teams/{teamID}/invites/{inviteID} -- same
// credential requirement as CreateInvite. A 404 (already revoked, or never
// existed) is the caller's to treat as success if it wants to, the same way
// DeleteTeam's own 404 handling works -- this method itself just reports
// whatever terdut-server said.
func (c *Client) RevokeInvite(ctx context.Context, teamID, inviteID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete,
fmt.Sprintf("/api/teams/%d/invites/%d", teamID, inviteID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}