All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add verifyWithRetry to provisioner: 60s initial DNS propagation delay,
5 retries with 30s backoff before marking verification as failed
- Add GetNotifyDomainStatus: polls Resend API for domain verification status,
returns "not_configured" when Resend not set up
- Add VerifyProjectNotify: synchronous re-verification for handler use
- Add getDomainStatus to resendAPI interface + resendClient implementation
- Add NotifyDomainStatus domain struct (host, resend_domain_id, status)
- Guard NOTIFY_RESEND_DOMAIN_ID storage against empty string writes
- New handler: GET /projects/{id}/notify/status (returns verification state)
- New handler: POST /projects/{id}/notify/verify (triggers re-verification)
- Add verify-notify-domain cookbook step to persona-community,
slackpath-1, and slackpath-4 trees (polls status for up to 6 min)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
146 lines
4.7 KiB
Go
146 lines
4.7 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)
|
|
})
|
|
}
|
|
|
|
// 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",
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|