// Package notify provides a notify service admin client for rdev. // It manages accounts and send keys on behalf of projects. package notify import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) // adminClient calls the notify admin API to manage accounts and keys. type adminClient struct { baseURL string adminKey string httpClient *http.Client } func newAdminClient(baseURL, adminKey string) *adminClient { return &adminClient{ baseURL: baseURL, adminKey: adminKey, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // accountResponse is the shape returned by POST /admin/accounts. type accountResponse struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } // apiKeyResponse is the shape returned by POST /admin/api-keys (full key only on creation). type apiKeyResponse struct { ID int `json:"id"` Key string `json:"key"` // plaintext — only present on creation KeyPrefix string `json:"key_prefix"` // e.g. "notify_send" AccountID string `json:"account_id"` KeyType string `json:"key_type"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } // listAccountsResponse is the shape returned by GET /admin/accounts. type listAccountsResponse struct { Items []accountResponse `json:"items"` } // createAccount creates a new notify account with the given name. func (c *adminClient) createAccount(ctx context.Context, name string) (*accountResponse, error) { payload := map[string]string{"name": name} respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/accounts", payload) if err != nil { return nil, fmt.Errorf("create account: %w", err) } var acct accountResponse if err := json.Unmarshal(respBody, &acct); err != nil { return nil, fmt.Errorf("unmarshal account response: %w", err) } return &acct, nil } // createSendKey creates a send API key for the given account. // The plaintext key is only present in the response at creation time. func (c *adminClient) createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error) { payload := map[string]string{ "account_id": accountID, "key_type": "send", "name": name, } respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/api-keys", payload) if err != nil { return nil, fmt.Errorf("create send key: %w", err) } var key apiKeyResponse if err := json.Unmarshal(respBody, &key); err != nil { return nil, fmt.Errorf("unmarshal key response: %w", err) } return &key, nil } // grantHostAccess grants the given account access to send from the specified host slug. func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID string) error { payload := map[string]string{"account_id": accountID} _, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/accounts", payload) if err != nil { return fmt.Errorf("grant host access: %w", err) } return nil } // deleteAccount removes the notify account and all its keys. func (c *adminClient) deleteAccount(ctx context.Context, accountID string) error { _, err := c.doRequest(ctx, http.MethodDelete, "/admin/accounts/"+accountID, nil) if err != nil { return fmt.Errorf("delete account: %w", err) } return nil } // listAccounts returns all accounts in the notify service. func (c *adminClient) listAccounts(ctx context.Context) ([]accountResponse, error) { respBody, err := c.doRequest(ctx, http.MethodGet, "/admin/accounts", nil) if err != nil { return nil, fmt.Errorf("list accounts: %w", err) } var resp listAccountsResponse if err := json.Unmarshal(respBody, &resp); err != nil { return nil, fmt.Errorf("unmarshal accounts list: %w", err) } return resp.Items, nil } // doRequest executes an HTTP request against the notify admin API. func (c *adminClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) { var reqBody io.Reader if bodyData != nil { jsonBody, err := json.Marshal(bodyData) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } reqBody = bytes.NewReader(jsonBody) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.adminKey) if bodyData != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("http do: %w", err) } defer func() { _ = resp.Body.Close() }() // 204 No Content — success with no body (e.g., grant host access, delete) if resp.StatusCode == http.StatusNoContent { return nil, nil } respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return respBody, nil } return nil, fmt.Errorf("notify admin API error (HTTP %d): %s", resp.StatusCode, string(respBody)) }