HandleCreateAPIKey creates a new API key for the authenticated user.
(w http.ResponseWriter, r *http.Request)
| 747 | |
| 748 | // HandleCreateAPIKey creates a new API key for the authenticated user. |
| 749 | func (ua *UserAuth) HandleCreateAPIKey(w http.ResponseWriter, r *http.Request) { |
| 750 | user := ua.AuthenticateRequest(r) |
| 751 | if user == nil { |
| 752 | http.Error(w, "not authenticated", http.StatusUnauthorized) |
| 753 | return |
| 754 | } |
| 755 | |
| 756 | var req struct { |
| 757 | Name string `json:"name"` |
| 758 | ExpiresAt *string `json:"expires_at,omitempty"` // optional ISO 8601 timestamp |
| 759 | // Scope (Slice 5a) selects the credential's blast radius. Omitted/"" |
| 760 | // defaults to account (backward compatible). "agent" requires Agent to |
| 761 | // name one of the caller's agents by email; the minted key can act ONLY |
| 762 | // as that agent. |
| 763 | Scope string `json:"scope,omitempty"` |
| 764 | Agent string `json:"agent,omitempty"` // agent email, required when scope=agent |
| 765 | } |
| 766 | json.NewDecoder(r.Body).Decode(&req) |
| 767 | |
| 768 | // Parse optional expires_at. Empty string and missing field both mean |
| 769 | // "never expires" — symmetric with the NULL column default. Malformed |
| 770 | // or already-past timestamps are client errors, not "use NULL silently." |
| 771 | var expiresAt *time.Time |
| 772 | if req.ExpiresAt != nil && *req.ExpiresAt != "" { |
| 773 | t, err := time.Parse(time.RFC3339, *req.ExpiresAt) |
| 774 | if err != nil { |
| 775 | http.Error(w, "expires_at must be an RFC 3339 timestamp", http.StatusBadRequest) |
| 776 | return |
| 777 | } |
| 778 | if !t.After(time.Now()) { |
| 779 | http.Error(w, "expires_at must be in the future", http.StatusBadRequest) |
| 780 | return |
| 781 | } |
| 782 | expiresAt = &t |
| 783 | } |
| 784 | |
| 785 | // Default to an account-scoped key (the pre-Slice-5a behavior). When the |
| 786 | // caller asks for an agent-scoped key, resolve the named agent to its id — |
| 787 | // CreateScopedAPIKey re-checks ownership, so a wrong/foreign agent is |
| 788 | // rejected rather than minting an over-broad or cross-tenant key. |
| 789 | scope := req.Scope |
| 790 | if scope == "" { |
| 791 | scope = identity.ScopeAccount |
| 792 | } |
| 793 | if !identity.ValidScope(scope) { |
| 794 | http.Error(w, "scope must be 'account' or 'agent'", http.StatusBadRequest) |
| 795 | return |
| 796 | } |
| 797 | var agentID string |
| 798 | if scope == identity.ScopeAgent { |
| 799 | if req.Agent == "" { |
| 800 | http.Error(w, "agent (email) is required when scope=agent", http.StatusBadRequest) |
| 801 | return |
| 802 | } |
| 803 | ag, err := ua.store.GetAgentByEmail(r.Context(), identity.NormalizeEmail(req.Agent)) |
| 804 | if err != nil || ag == nil || ag.UserID != user.ID { |
| 805 | http.Error(w, "agent not found", http.StatusBadRequest) |
| 806 | return |