// Package services — SupabaseAdminService — thin HTTP client for the // privileged Supabase Admin API endpoints. // // t-paliad-223 Slice B (#49) — the new "Add User" path on /admin/team needs // to create an auth.users row before inserting paliad.users (paliad.users.id // is FK-constrained to auth.users.id). The Supabase JS / Go client library // would be overkill for the three calls we actually make; this file is // ~150 LoC of plain net/http instead. // // Only three Admin-API calls are exercised here: // // - POST {SUPABASE_URL}/auth/v1/admin/users // Create an auth.users row with email_confirm=true so the user can log // in via a recovery link without going through the email-confirm step. // // - POST {SUPABASE_URL}/auth/v1/admin/generate_link // Mint a recovery link for the new user; paliad emails it via the // existing MailService template (NOT Supabase's default mail) so the // welcome message stays paliad-branded. // // - DELETE {SUPABASE_URL}/auth/v1/admin/users/{id} // Best-effort rollback when the paliad.users insert fails after the // auth.users row has been created. Failure here just leaves an // unonboarded auth.users row that "Onboard existing" can recover. // // All requests carry the service-role key in BOTH the `apikey` header AND // the `Authorization: Bearer` header — Supabase's PostgREST gateway checks // the former, the auth admin handlers check the latter. // // SECURITY: SUPABASE_SERVICE_ROLE_KEY is one of the most-privileged // credentials in the deploy. It must NEVER be sent to the browser or // logged. Storage is Dokploy secret, age-encrypted at rest. package services import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "time" "github.com/google/uuid" ) // Sentinel errors. Handlers map these to HTTP status codes. var ( // ErrSupabaseAdminUnavailable signals SUPABASE_SERVICE_ROLE_KEY is unset. // Handlers map to 503 — the Add-User path is the only feature that // requires it; everything else keeps working. ErrSupabaseAdminUnavailable = errors.New("supabase admin api unavailable (SUPABASE_SERVICE_ROLE_KEY not set)") // ErrSupabaseEmailExists is returned by CreateAuthUser when the email // already exists in auth.users. Handlers map to 409 with a nudge to // use "Onboard existing". ErrSupabaseEmailExists = errors.New("auth.users row already exists for this email") ) // SupabaseAdminClient is the thin HTTP client. Constructed once at server // boot; the embedded *http.Client is reused for connection pooling. // // Enabled() reports whether SUPABASE_SERVICE_ROLE_KEY is configured. When // it isn't, every call returns ErrSupabaseAdminUnavailable so the rest of // the boot path stays runnable for deployments that don't need Add-User. type SupabaseAdminClient struct { baseURL string apiKey string httpClient *http.Client } // NewSupabaseAdminClient wires the client. supabaseURL is required (already // validated at boot for the anon-key flow); serviceRoleKey may be empty. // // Timeout is 10s — Supabase Admin API calls are normally sub-second; 10s // is forgiving enough for cold starts on a slow network but short enough // that a hung call doesn't block the admin UI indefinitely. func NewSupabaseAdminClient(supabaseURL, serviceRoleKey string) *SupabaseAdminClient { return &SupabaseAdminClient{ baseURL: strings.TrimRight(supabaseURL, "/"), apiKey: strings.TrimSpace(serviceRoleKey), httpClient: &http.Client{Timeout: 10 * time.Second}, } } // Enabled reports whether the client has a service-role key to use. func (c *SupabaseAdminClient) Enabled() bool { return c != nil && c.apiKey != "" } // CreateAuthUser creates an auth.users row with email_confirm=true and no // password (the new user signs in via the recovery link emailed later). // Returns the new auth.users.id. // // 422 from Supabase typically means "email already exists" — mapped to // ErrSupabaseEmailExists so the handler nudges the admin to "Onboard // existing" instead. func (c *SupabaseAdminClient) CreateAuthUser(ctx context.Context, email string) (uuid.UUID, error) { if !c.Enabled() { return uuid.Nil, ErrSupabaseAdminUnavailable } body := map[string]any{ "email": strings.ToLower(strings.TrimSpace(email)), "email_confirm": true, } var resp struct { ID string `json:"id"` Msg string `json:"msg,omitempty"` } status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/users", body, &resp) if err != nil { return uuid.Nil, err } if status == http.StatusUnprocessableEntity || status == http.StatusConflict { // Supabase returns 422 (or sometimes 400 with "already registered" // in the body) when the email is taken. Lower-case-match the // substring so we catch both casings. if strings.Contains(strings.ToLower(string(raw)), "already") { return uuid.Nil, ErrSupabaseEmailExists } return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw)) } if status < 200 || status >= 300 { return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw)) } id, err := uuid.Parse(resp.ID) if err != nil { return uuid.Nil, fmt.Errorf("supabase admin create user: parse id %q: %w", resp.ID, err) } return id, nil } // GenerateRecoveryLink mints a one-time recovery link for an existing // auth.users row. The action_link is what we email; clicking it lands the // user on Supabase's password-reset page (which redirects to paliad.de // after the user picks a password). // // The link type is "recovery" rather than "magiclink" so the user is forced // to set a password — paliad doesn't support passwordless sign-in today. func (c *SupabaseAdminClient) GenerateRecoveryLink(ctx context.Context, email string) (string, error) { if !c.Enabled() { return "", ErrSupabaseAdminUnavailable } body := map[string]any{ "type": "recovery", "email": strings.ToLower(strings.TrimSpace(email)), } var resp struct { ActionLink string `json:"action_link"` Properties struct { ActionLink string `json:"action_link"` } `json:"properties"` } status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/generate_link", body, &resp) if err != nil { return "", err } if status < 200 || status >= 300 { return "", fmt.Errorf("supabase admin generate_link: status=%d body=%s", status, string(raw)) } // Supabase has historically returned the link in both shapes (top-level // and nested under properties). Accept either. if resp.ActionLink != "" { return resp.ActionLink, nil } if resp.Properties.ActionLink != "" { return resp.Properties.ActionLink, nil } return "", fmt.Errorf("supabase admin generate_link: response missing action_link: %s", string(raw)) } // DeleteAuthUser removes an auth.users row by id. Best-effort rollback // after the paliad.users insert has failed. A failure here is logged but // doesn't propagate to the caller — the row can be cleaned up later via // "Onboard existing" or the admin UI. func (c *SupabaseAdminClient) DeleteAuthUser(ctx context.Context, id uuid.UUID) error { if !c.Enabled() { return ErrSupabaseAdminUnavailable } status, raw, err := c.do(ctx, "DELETE", "/auth/v1/admin/users/"+id.String(), nil, nil) if err != nil { return err } if status < 200 || status >= 300 { return fmt.Errorf("supabase admin delete user: status=%d body=%s", status, string(raw)) } return nil } // do is the shared request helper. Returns (status, raw_body, err). When // `out` is non-nil and the response is 2xx with a JSON body, decodes into // it; raw_body is still returned so the caller can inspect error responses. func (c *SupabaseAdminClient) do(ctx context.Context, method, path string, payload any, out any) (int, []byte, error) { var rdr io.Reader if payload != nil { buf, err := json.Marshal(payload) if err != nil { return 0, nil, fmt.Errorf("marshal %s body: %w", path, err) } rdr = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr) if err != nil { return 0, nil, fmt.Errorf("build %s request: %w", path, err) } if rdr != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("apikey", c.apiKey) req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return 0, nil, fmt.Errorf("%s %s: %w", method, path, err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", path, err) } if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(raw) > 0 { if err := json.Unmarshal(raw, out); err != nil { return resp.StatusCode, raw, fmt.Errorf("decode %s response: %w", path, err) } } return resp.StatusCode, raw, nil } // LoadSupabaseAdminClient reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY // from the environment and returns a client. The key is optional — when // unset the client still wires (so dependents don't panic on nil-deref) // but every call short-circuits with ErrSupabaseAdminUnavailable so the // server boot stays runnable. func LoadSupabaseAdminClient() *SupabaseAdminClient { return NewSupabaseAdminClient( os.Getenv("SUPABASE_URL"), os.Getenv("SUPABASE_SERVICE_ROLE_KEY"), ) }