rdev/internal/handlers/notify.go
jordan 96219a647f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add POST /projects/{id}/notify/reprovision to migrate notify host
Implements ReprovisionNotifyHost to migrate a project's email sending
from an old notify host to a new one (e.g., from project-name-based to
slug-based host). Preserves the project's notify account and send key.

- Adds ReprovisionNotifyHost to port.NotifyProvisioner interface
- Implements revokeHostAccess on notifyAdminAPI + adminClient
- Implements Provisioner.ReprovisionNotifyHost (12-step migration)
  in provisioner_reprovision.go (split to keep provisioner.go < 500 lines)
- Adds NotifyHandler.Reprovision handler (POST /notify/reprovision)
- Updates OpenAPI spec with reprovision endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:28:59 -07:00

313 lines
10 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// NotifyHandler handles notify domain status and verification endpoints.
type NotifyHandler struct {
notifyProvisioner port.NotifyProvisioner // may be nil if not configured
credStore port.CredentialStore // may be nil
logger *slog.Logger
}
// NewNotifyHandler creates a new notify handler.
func NewNotifyHandler(notifyProvisioner port.NotifyProvisioner, credStore port.CredentialStore, logger *slog.Logger) *NotifyHandler {
return &NotifyHandler{
notifyProvisioner: notifyProvisioner,
credStore: credStore,
logger: logger,
}
}
// Mount registers the notify routes.
func (h *NotifyHandler) Mount(r api.Router) {
r.Route("/projects/{projectID}/notify", func(r chi.Router) {
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/status", h.GetStatus)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/verify", h.Verify)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/provision", h.Provision)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/reprovision", h.Reprovision)
})
}
// NotifyDomainStatusResponse is the response for GET /projects/{projectID}/notify/status.
type NotifyDomainStatusResponse struct {
Host string `json:"host"`
ResendDomainID string `json:"resend_domain_id"`
Status string `json:"status"`
}
// GetStatus returns the Resend domain verification status for the project.
// GET /projects/{projectID}/notify/status
func (h *NotifyHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
if h.notifyProvisioner == nil {
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
return
}
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
log := logging.FromContext(ctx).WithHandler("NotifyGetStatus")
host, resendDomainID := h.lookupNotifyCredentials(ctx, projectID)
status, err := h.notifyProvisioner.GetNotifyDomainStatus(ctx, host, resendDomainID)
if err != nil {
log.Error("failed to get notify domain status",
logging.FieldError, err,
logging.FieldProjectID, projectID,
)
api.WriteInternalError(w, r, "failed to get notify domain status")
return
}
api.WriteSuccess(w, r, NotifyDomainStatusResponse{
Host: status.Host,
ResendDomainID: status.ResendDomainID,
Status: status.Status,
})
}
// Verify triggers domain re-verification for the project's Resend email domain.
// POST /projects/{projectID}/notify/verify
func (h *NotifyHandler) Verify(w http.ResponseWriter, r *http.Request) {
if h.notifyProvisioner == nil {
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
return
}
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
log := logging.FromContext(ctx).WithHandler("NotifyVerify")
_, resendDomainID := h.lookupNotifyCredentials(ctx, projectID)
if err := h.notifyProvisioner.VerifyProjectNotify(ctx, projectID, resendDomainID); err != nil {
log.Error("failed to trigger notify domain verification",
logging.FieldError, err,
logging.FieldProjectID, projectID,
)
api.WriteInternalError(w, r, "failed to trigger domain verification")
return
}
api.WriteSuccess(w, r, map[string]string{
"message": "verification triggered",
})
}
// Provision creates the Resend domain and DNS records for a project whose notify account exists
// but whose Resend domain was never provisioned (e.g., RESEND_API_KEY was added after project creation).
// POST /projects/{projectID}/notify/provision
func (h *NotifyHandler) Provision(w http.ResponseWriter, r *http.Request) {
if h.notifyProvisioner == nil {
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
return
}
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
log := logging.FromContext(ctx).WithHandler("NotifyProvision")
host, existingDomainID := h.lookupNotifyCredentials(ctx, projectID)
if host == "" {
api.WriteBadRequest(w, r, "NOTIFY_HOST not found for project — notify account not provisioned")
return
}
if existingDomainID != "" {
// Already has a Resend domain ID; use /verify or /status instead
api.WriteBadRequest(w, r, "Resend domain already provisioned — use POST /notify/verify to re-verify")
return
}
resendDomainID, err := h.notifyProvisioner.ProvisionNotifyDomain(ctx, projectID, host)
if err != nil {
log.Error("failed to provision notify domain",
logging.FieldError, err,
logging.FieldProjectID, projectID,
)
api.WriteInternalError(w, r, "failed to provision Resend domain")
return
}
// Store the new domain ID so future status/verify calls work.
if h.credStore != nil {
if err := h.credStore.Set(ctx, domain.Credential{
Key: projectID + ":" + domain.CredKeyNotifyResendDomainID,
Value: resendDomainID,
Category: domain.CredentialCategoryNotify,
}); err != nil {
log.Error("provisioned Resend domain but failed to store domain ID",
logging.FieldError, err,
logging.FieldProjectID, projectID,
"resend_domain_id", resendDomainID,
)
// Return success anyway — the domain IS provisioned; the ID can be stored manually.
}
}
log.Info("notify domain provisioned",
logging.FieldProjectID, projectID,
"host", host,
"resend_domain_id", resendDomainID,
)
api.WriteSuccess(w, r, map[string]string{
"host": host,
"resend_domain_id": resendDomainID,
"status": "verifying",
})
}
// notifyReprovisionRequest is the request body for POST /projects/{projectID}/notify/reprovision.
type notifyReprovisionRequest struct {
Host string `json:"host"` // new sending host, e.g. "mail.myapp.threesix.ai"
}
// Reprovision migrates a project's notify setup to a new sending host.
// Use after adding a custom named domain to update email to send from that domain.
// POST /projects/{projectID}/notify/reprovision
func (h *NotifyHandler) Reprovision(w http.ResponseWriter, r *http.Request) {
if h.notifyProvisioner == nil {
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
return
}
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
var req notifyReprovisionRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Host == "" {
api.WriteBadRequest(w, r, "host is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
log := logging.FromContext(ctx).WithHandler("NotifyReprovision")
currentHost, currentResendDomainID := h.lookupNotifyCredentials(ctx, projectID)
if currentHost == "" {
api.WriteBadRequest(w, r, "notify not provisioned for this project — run POST /notify/provision first")
return
}
if currentHost == req.Host {
api.WriteBadRequest(w, r, "new host is the same as the current host")
return
}
creds, err := h.notifyProvisioner.ReprovisionNotifyHost(ctx, projectID, currentHost, currentResendDomainID, req.Host)
if err != nil {
log.Error("failed to reprovision notify host",
logging.FieldError, err,
logging.FieldProjectID, projectID,
"old_host", currentHost,
"new_host", req.Host,
)
api.WriteInternalError(w, r, "failed to reprovision notify host")
return
}
// Store updated credentials.
if h.credStore != nil {
for _, kv := range []struct{ key, val string }{
{domain.CredKeyNotifyHost, creds.Host},
{domain.CredKeyNotifyFrom, creds.From},
{domain.CredKeyNotifyResendDomainID, creds.ResendDomainID},
} {
if err := h.credStore.Set(ctx, domain.Credential{
Key: projectID + ":" + kv.key,
Value: kv.val,
Category: domain.CredentialCategoryNotify,
}); err != nil {
log.Error("reprovisioned notify host but failed to store credential",
logging.FieldError, err,
logging.FieldProjectID, projectID,
"credential_key", kv.key,
)
}
}
}
log.Info("notify host reprovisioned",
logging.FieldProjectID, projectID,
"old_host", currentHost,
"new_host", creds.Host,
"resend_domain_id", creds.ResendDomainID,
)
api.WriteSuccess(w, r, map[string]string{
"host": creds.Host,
"from": creds.From,
"resend_domain_id": creds.ResendDomainID,
"status": "verifying",
})
}
// lookupNotifyCredentials fetches NOTIFY_HOST and NOTIFY_RESEND_DOMAIN_ID from the credential store.
// Returns empty strings if credStore is nil or the credentials are not found.
func (h *NotifyHandler) lookupNotifyCredentials(ctx context.Context, projectID string) (host, resendDomainID string) {
if h.credStore == nil {
return "", ""
}
// Credentials are stored with project-scoped keys: "projectID:CRED_KEY"
creds, err := h.credStore.GetMultiple(ctx, []string{
projectID + ":" + domain.CredKeyNotifyHost,
projectID + ":" + domain.CredKeyNotifyResendDomainID,
})
if err != nil {
logging.FromContext(ctx).WithHandler("NotifyHandler").Warn(
"failed to fetch notify credentials from store",
logging.FieldError, err,
logging.FieldProjectID, projectID,
)
return "", ""
}
host = creds[projectID+":"+domain.CredKeyNotifyHost]
resendDomainID = creds[projectID+":"+domain.CredKeyNotifyResendDomainID]
return host, resendDomainID
}