From fa0d030def35e5cc14297262c7a4c8062d9ac1be Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 23 Feb 2026 16:25:55 -0700 Subject: [PATCH] feat: improve notify domain verification reliability and add status endpoints - 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 --- cmd/rdev-api/main.go | 4 + cookbooks/trees/persona-community.yaml | 216 ++++++++++++++++++ .../slackpath-1-authenticated-service.yaml | 27 ++- ...lackpath-4-microservice-constellation.yaml | 27 ++- internal/adapter/notify/provisioner.go | 97 +++++++- internal/adapter/notify/provisioner_test.go | 4 + internal/adapter/notify/resend_client.go | 22 ++ internal/domain/notify.go | 12 + internal/handlers/notify.go | 145 ++++++++++++ internal/port/notify_provisioner.go | 7 + internal/service/project_infra_crud.go | 8 +- 11 files changed, 556 insertions(+), 13 deletions(-) create mode 100644 cookbooks/trees/persona-community.yaml create mode 100644 internal/handlers/notify.go diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index e59c595..e9c229d 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -635,6 +635,9 @@ func main() { // Initialize verify handler (for visual verification tasks) verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub) + // Initialize notify handler (domain status and re-verification) + notifyHandler := handlers.NewNotifyHandler(notifyProvisioner, credentialStore, logger) + // Initialize operations handler (for debugging project failures) operationsHandler := handlers.NewOperationsHandler(operationRepo) @@ -717,6 +720,7 @@ func main() { } verifyHandler.Mount(app.Router()) sagaHandler.Mount(app.Router()) + notifyHandler.Mount(app.Router()) // Start queue processor worker (per-project command queue) queueProcessor := worker.NewQueueProcessor( diff --git a/cookbooks/trees/persona-community.yaml b/cookbooks/trees/persona-community.yaml new file mode 100644 index 0000000..5ab80ff --- /dev/null +++ b/cookbooks/trees/persona-community.yaml @@ -0,0 +1,216 @@ +name: persona-community +description: "AI Persona Generation Community: describe a person, AI generates their full identity, photos, and videos. Browse the community grid." +version: 1 + +vars: + project_name: "" + service_name: "persona-api" + worker_name: "media-worker" + app_name: "creator-ui" + +steps: + # --- Infrastructure --- + create-project: + action: api + method: POST + endpoint: /project + body: + name: "{{ .vars.project_name }}" + description: "AI Persona Generation Community" + outputs: + - project_id: .data.name + - domain: .data.domain + + add-db: + description: CockroachDB for persona + media persistence + depends_on: [create-project] + on_error: continue + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" + body: { type: postgres, name: "persona-db" } + + add-redis: + description: Redis for SSE pub/sub and generation job queue (may already be provisioned by skeleton) + depends_on: [create-project] + on_error: continue + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" + body: { type: redis, name: "event-bus" } + + add-components: + description: Add persona-api + media-worker + creator-ui as a single atomic commit + depends_on: [add-db, add-redis] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch" + body: + components: + - type: service + name: "{{ .vars.service_name }}" + - type: worker + name: "{{ .vars.worker_name }}" + - type: app-react + name: "{{ .vars.app_name }}" + + wait-infra: + description: Wait for CI to build and deploy all components + depends_on: [add-components] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 120 + poll_interval: 10 + + verify-notify-domain: + description: Wait for the project email domain to be verified by Resend + depends_on: [wait-infra] + on_error: continue + action: shell + command: | + PROJECT_ID="{{ .outputs.create-project.project_id }}" + API_URL="${RDEV_API_URL:-https://rdev.masq-ops.orchard9.ai}" + for i in $(seq 1 12); do + STATUS=$(curl -sf "$API_URL/projects/$PROJECT_ID/notify/status" \ + -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // empty' 2>/dev/null) + echo "notify domain status (attempt $i/12): $STATUS" + if [ "$STATUS" = "verified" ]; then + echo "Email domain verified — OTP and auth emails will work" + exit 0 + fi + if [ "$STATUS" = "not_configured" ]; then + echo "Notify not configured — skipping" + exit 0 + fi + sleep 30 + done + echo "Email domain not verified after 6 minutes — continuing, but OTP emails may fail" + exit 0 + + # --- Feature 1: Persona Data Model & CRUD --- + implement-persona-model: + description: "Persona table, domain model, CRUD endpoints, SSE events" + depends_on: [verify-notify-domain] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature persona-model --requirements 'DB migration in persona-api: CREATE TABLE personas (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, handle TEXT UNIQUE NOT NULL, gender TEXT NOT NULL, description TEXT NOT NULL, tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], spec_json JSONB, anchor_url TEXT, avatar_url TEXT, banner_url TEXT, image_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], video_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). Domain model Persona with same fields. PersonaService: Create(ctx, description, gender, customName string) (*Persona, error) — saves row then enqueues a generate_spec job. GetByID(ctx, id). List(ctx, limit, offset int) ([]*Persona, error). Queue job type PersonaGenerateJob {PersonaID string, Stage string} where Stage values are: spec, anchor, avatar, banner, gallery_batch, video. Endpoints all under /api/persona-api: POST /personas (body: {description, gender, custom_name?}, returns 202 with persona), GET /personas (query: limit=20, offset=0), GET /personas/{id}. All routes require JWT auth (use skeleton auth.Middleware). After any persona field update publish SSE event persona_updated to channel:personas via the SSE hub.'" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/jordan/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-persona-model: + depends_on: [implement-persona-model] + action: wait_build + build_id: "{{ .outputs.implement-persona-model.build_id }}" + max_attempts: 120 + poll_interval: 5 + + # --- Feature 2: AI Generation Pipeline --- + implement-generation: + description: "spec → anchor → avatar + banner → gallery batches → 4 videos" + depends_on: [wait-persona-model] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature persona-generation --requirements 'Implement the generation pipeline in media-worker using the skeleton ai-client package (LAOZHANG_API_KEY for images, GEMINI_API_KEY for video). Worker consumes PersonaGenerateJob from queue and executes the stage: STAGE spec — call LLM to generate a PersonaSpec JSON: {name, handle, gender, tags:[8 strings], personality_archetype, appearance:{hair, eyes, skin, body_type, distinctive_features}, style_signature, image_matrix:[{position:int, pose:string, clothing:string, background:string} x 30]}. Save full spec to spec_json, update name/handle/tags on the persona row. Then enqueue stage=anchor job. STAGE anchor — generate one reference face-front portrait image from appearance description. Save URL to anchor_url. Then enqueue stage=avatar AND stage=banner as parallel jobs. STAGE avatar — generate circular portrait derived from anchor appearance. Save to avatar_url. STAGE banner — generate 3:1 landscape lifestyle image derived from anchor. Save to banner_url. STAGE gallery_batch — generate 10 images using next 10 unfilled positions from image_matrix. Append URLs to image_urls array. If fewer than 30 images exist, enqueue another gallery_batch job. STAGE video — generate the next missing video from: [smile_reveal, personality_moment, lifestyle, invitation]. Append URL to video_urls. If fewer than 4 videos, enqueue next video job. After each stage: update persona row, publish job_update SSE event {persona_id, stage, status:complete|error, progress} to channel:personas.'" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/jordan/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-generation: + depends_on: [implement-generation] + action: wait_build + build_id: "{{ .outputs.implement-generation.build_id }}" + max_attempts: 120 + poll_interval: 5 + + wait-deploy-2: + description: Deploy generation pipeline + depends_on: [wait-generation] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 120 + poll_interval: 10 + + # --- Feature 3: Community UI --- + implement-ui: + description: "Community grid, persona card, creation form, real-time SSE, detail page" + depends_on: [wait-deploy-2] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature community-ui --requirements 'Build the React community UI using skeleton packages (@project/ui, @project/auth, @project/layout, @project/realtime, @project/api-client). App uses DashboardShell layout from @project/layout. Routes: /login (public) and / and /personas/:id (protected via ProtectedRoute from @project/auth). LOGIN PAGE: OTP flow — email input, Send Code button, then code input + Verify button. On success store token and redirect to /. COMMUNITY PAGE (/): Top bar has Create Persona button. Main area is a responsive grid of PersonaCard components. PersonaCard shows: banner_url as card background image, avatar_url as circular overlay (bottom-left), name + handle + up to 4 tags as pills, image/video count badges. While status=pending|generating show a subtle pulsing overlay with current stage label (Crafting identity... / Generating anchor... etc). Click card → navigate to /personas/:id. CREATE PANEL: slides in from right, contains: description textarea (placeholder: Describe your persona...), gender pill selector (Female / Male / Non-binary), optional Name field, Create button. On submit POST /api/persona-api/personas — new card appears immediately in grid with generating state. DETAIL PAGE (/personas/:id): full-width banner header, large circular avatar centered, name + handle + all tags. Image gallery grid (masonry or even grid, click any image to open fullscreen lightbox with arrow nav). Videos section shows 4 video players (smile reveal, personality moment, lifestyle, invitation) with labels. REALTIME: useEventChannel from @project/realtime subscribed to channel:personas. On persona_updated event refresh that card data. On job_update event show stage progress on the card overlay.'" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/jordan/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-ui: + depends_on: [implement-ui] + action: wait_build + build_id: "{{ .outputs.implement-ui.build_id }}" + max_attempts: 120 + poll_interval: 5 + + wait-deploy-final: + description: Deploy final build + depends_on: [wait-ui] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 120 + poll_interval: 10 + + # --- Verification --- + verify-health: + description: Verify persona-api is healthy + depends_on: [wait-deploy-final] + action: shell + command: | + HEALTH=$(curl -sf "https://{{ .outputs.create-project.domain }}/api/persona-api/health" | jq -r '.data.status // empty') + if [ "$HEALTH" = "healthy" ]; then + echo "persona-api healthy" + exit 0 + else + echo "persona-api not healthy: $HEALTH" + exit 1 + fi + + verify-site: + description: Verify creator-ui frontend loads + depends_on: [wait-deploy-final] + action: wait_site + domain: "{{ .outputs.create-project.domain }}" + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 30 + poll_interval: 5 + + verify-auth-endpoint: + description: Verify OTP auth endpoint exists (401 confirms it) + depends_on: [verify-health] + on_error: continue + action: shell + command: | + DOMAIN="{{ .outputs.create-project.domain }}" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "https://$DOMAIN/api/persona-api/personas" \ + -H "Content-Type: application/json" \ + -d '{"description":"test","gender":"female"}') + echo "POST /personas without auth returned: $STATUS" + if [ "$STATUS" = "401" ]; then echo "Auth guard confirmed"; exit 0; fi + echo "Unexpected status — endpoint may not exist" + exit 1 + +teardown: + - description: Delete project and all provisioned resources + action: api + method: DELETE + endpoint: "/project/{{ .outputs.create-project.project_id }}" diff --git a/cookbooks/trees/slackpath-1-authenticated-service.yaml b/cookbooks/trees/slackpath-1-authenticated-service.yaml index 853b1ce..8caee35 100644 --- a/cookbooks/trees/slackpath-1-authenticated-service.yaml +++ b/cookbooks/trees/slackpath-1-authenticated-service.yaml @@ -48,9 +48,34 @@ steps: action: wait_pipeline project_id: "{{ .outputs.create-project.project_id }}" + verify-notify-domain: + description: Wait for the project email domain to be verified by Resend + depends_on: [wait-init] + on_error: continue + action: shell + command: | + PROJECT_ID="{{ .outputs.create-project.project_id }}" + API_URL="${RDEV_API_URL:-https://rdev.masq-ops.orchard9.ai}" + for i in $(seq 1 12); do + STATUS=$(curl -sf "$API_URL/projects/$PROJECT_ID/notify/status" \ + -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // empty' 2>/dev/null) + echo "notify domain status (attempt $i/12): $STATUS" + if [ "$STATUS" = "verified" ]; then + echo "Email domain verified — OTP and auth emails will work" + exit 0 + fi + if [ "$STATUS" = "not_configured" ]; then + echo "Notify not configured — skipping" + exit 0 + fi + sleep 30 + done + echo "Email domain not verified after 6 minutes — continuing, but OTP emails may fail" + exit 0 + # --- SDLC: Build Auth --- create-feature: - depends_on: [wait-init] + depends_on: [verify-notify-domain] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features" diff --git a/cookbooks/trees/slackpath-4-microservice-constellation.yaml b/cookbooks/trees/slackpath-4-microservice-constellation.yaml index 3312cfa..72b6e9f 100644 --- a/cookbooks/trees/slackpath-4-microservice-constellation.yaml +++ b/cookbooks/trees/slackpath-4-microservice-constellation.yaml @@ -61,10 +61,35 @@ steps: action: wait_pipeline project_id: "{{ .outputs.create-project.project_id }}" + verify-notify-domain: + description: Wait for the project email domain to be verified by Resend + depends_on: [wait-infra] + on_error: continue + action: shell + command: | + PROJECT_ID="{{ .outputs.create-project.project_id }}" + API_URL="${RDEV_API_URL:-https://rdev.masq-ops.orchard9.ai}" + for i in $(seq 1 12); do + STATUS=$(curl -sf "$API_URL/projects/$PROJECT_ID/notify/status" \ + -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // empty' 2>/dev/null) + echo "notify domain status (attempt $i/12): $STATUS" + if [ "$STATUS" = "verified" ]; then + echo "Email domain verified — OTP and auth emails will work" + exit 0 + fi + if [ "$STATUS" = "not_configured" ]; then + echo "Notify not configured — skipping" + exit 0 + fi + sleep 30 + done + echo "Email domain not verified after 6 minutes — continuing, but OTP emails may fail" + exit 0 + # --- Implementation --- implement-mesh: description: "Agent implements Service-to-Service calls (Chat calls Auth, Chat queues to Worker)" - depends_on: [wait-infra] + depends_on: [verify-notify-domain] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" diff --git a/internal/adapter/notify/provisioner.go b/internal/adapter/notify/provisioner.go index 12da017..2e5fed2 100644 --- a/internal/adapter/notify/provisioner.go +++ b/internal/adapter/notify/provisioner.go @@ -151,17 +151,12 @@ func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug s } } - // 9. Fire-and-forget async domain verification + // 9. Fire-and-forget async domain verification. + // Waits 60 seconds for DNS propagation, then retries verification up to 5 times with 30s backoff. if p.resend != nil && resendDomainID != "" { go func() { verifyCtx := context.WithoutCancel(ctx) - if err := p.resend.verifyDomain(verifyCtx, resendDomainID); err != nil { - p.logger.Warn("async resend domain verification failed", - "domain_id", resendDomainID, - "host", host, - "error", err, - ) - } + p.verifyWithRetry(verifyCtx, resendDomainID, host, projectID) }() } @@ -182,6 +177,57 @@ func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug s }, nil } +// verifyWithRetry waits for DNS propagation then attempts domain verification with retries. +// Called in a goroutine — all errors are logged and do not propagate. +func (p *Provisioner) verifyWithRetry(ctx context.Context, resendDomainID, host, projectID string) { + const ( + initialDelay = 60 * time.Second + retryInterval = 30 * time.Second + maxAttempts = 5 + ) + + // Wait for DNS propagation before first attempt. + select { + case <-ctx.Done(): + return + case <-time.After(initialDelay): + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { + if err := p.resend.verifyDomain(ctx, resendDomainID); err != nil { + p.logger.Warn("resend domain verification attempt failed", + "attempt", attempt, + "max_attempts", maxAttempts, + "domain_id", resendDomainID, + "host", host, + "project_id", projectID, + "error", err, + ) + if attempt < maxAttempts { + select { + case <-ctx.Done(): + return + case <-time.After(retryInterval): + } + } + continue + } + p.logger.Info("resend domain verified", + "domain_id", resendDomainID, + "host", host, + "project_id", projectID, + "attempt", attempt, + ) + return + } + + p.logger.Warn("resend domain verification exhausted all attempts — re-verify manually via API", + "domain_id", resendDomainID, + "host", host, + "project_id", projectID, + ) +} + // DeleteProjectNotify removes all notify resources for a project. // Failures are logged as warnings — cleanup continues regardless. func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error { @@ -260,6 +306,41 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, return nil } +// VerifyProjectNotify triggers Resend domain verification for the given domain ID. +// Call this after DNS records have had time to propagate (~60 seconds minimum). +func (p *Provisioner) VerifyProjectNotify(ctx context.Context, projectID, resendDomainID string) error { + if p.resend == nil { + return fmt.Errorf("notify: resend not configured") + } + if resendDomainID == "" { + return fmt.Errorf("notify: resend domain ID not available for project %s", projectID) + } + if err := p.resend.verifyDomain(ctx, resendDomainID); err != nil { + return fmt.Errorf("notify: verify domain for project %s: %w", projectID, err) + } + return nil +} + +// GetNotifyDomainStatus returns the Resend verification status for the project's email domain. +func (p *Provisioner) GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error) { + if p.resend == nil || resendDomainID == "" { + return &domain.NotifyDomainStatus{ + Host: host, + ResendDomainID: resendDomainID, + Status: "not_configured", + }, nil + } + status, err := p.resend.getDomainStatus(ctx, resendDomainID) + if err != nil { + return nil, fmt.Errorf("notify: get domain status for %s: %w", host, err) + } + return &domain.NotifyDomainStatus{ + Host: host, + ResendDomainID: resendDomainID, + Status: status, + }, nil +} + // GetProjectNotify returns notify credentials for the project, or nil if not provisioned. // Note: Only AccountID and CreatedAt are populated — APIKey, Host, and From are not // recoverable after provisioning. Use this method solely to check whether provisioning diff --git a/internal/adapter/notify/provisioner_test.go b/internal/adapter/notify/provisioner_test.go index bda4201..c22ad7e 100644 --- a/internal/adapter/notify/provisioner_test.go +++ b/internal/adapter/notify/provisioner_test.go @@ -138,6 +138,10 @@ func (m *mockResendClient) deleteDomain(_ context.Context, _ string) error { return m.deleteDomainErr } +func (m *mockResendClient) getDomainStatus(_ context.Context, _ string) (string, error) { + return "verified", nil +} + // mockDNS is a controllable implementation of port.DNSProvider. type mockDNS struct { upsertErr error diff --git a/internal/adapter/notify/resend_client.go b/internal/adapter/notify/resend_client.go index ce92d4b..57c6d5e 100644 --- a/internal/adapter/notify/resend_client.go +++ b/internal/adapter/notify/resend_client.go @@ -17,6 +17,7 @@ const resendBaseURL = "https://api.resend.com" type resendAPI interface { createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error) verifyDomain(ctx context.Context, domainID string) error + getDomainStatus(ctx context.Context, domainID string) (status string, err error) deleteDomain(ctx context.Context, domainID string) error } @@ -42,6 +43,13 @@ type resendCreateDomainResponse struct { Records []resendDNSRecord `json:"records"` } +// resendGetDomainResponse is the shape returned by GET /domains/{id}. +type resendGetDomainResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // "verified", "pending", "failed" +} + func newResendClient(apiKey string) *resendClient { return &resendClient{ apiKey: apiKey, @@ -75,6 +83,20 @@ func (r *resendClient) verifyDomain(ctx context.Context, domainID string) error return nil } +// getDomainStatus returns the verification status of a Resend domain. +// Returned status is one of: "verified", "pending", "failed". +func (r *resendClient) getDomainStatus(ctx context.Context, domainID string) (string, error) { + body, err := r.doRequest(ctx, http.MethodGet, "/domains/"+domainID, nil) + if err != nil { + return "", fmt.Errorf("get resend domain %s status: %w", domainID, err) + } + var resp resendGetDomainResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "", fmt.Errorf("unmarshal resend domain status response: %w", err) + } + return resp.Status, nil +} + // deleteDomain removes a Resend domain by ID. func (r *resendClient) deleteDomain(ctx context.Context, domainID string) error { _, err := r.doRequest(ctx, http.MethodDelete, "/domains/"+domainID, nil) diff --git a/internal/domain/notify.go b/internal/domain/notify.go index 1fa247c..c5870d8 100644 --- a/internal/domain/notify.go +++ b/internal/domain/notify.go @@ -26,3 +26,15 @@ type NotifyCredentials struct { // CreatedAt is when the credentials were provisioned. CreatedAt time.Time } + +// NotifyDomainStatus holds the Resend verification status for a project's email domain. +type NotifyDomainStatus struct { + // Host is the per-project sending host (e.g., "mail.slug.threesix.ai"). + Host string + + // ResendDomainID is the Resend domain UUID. + ResendDomainID string + + // Status is the Resend verification status: "verified", "pending", "failed", or "not_configured". + Status string +} diff --git a/internal/handlers/notify.go b/internal/handlers/notify.go new file mode 100644 index 0000000..e570148 --- /dev/null +++ b/internal/handlers/notify.go @@ -0,0 +1,145 @@ +// 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 +} diff --git a/internal/port/notify_provisioner.go b/internal/port/notify_provisioner.go index 1e53bb6..8b63284 100644 --- a/internal/port/notify_provisioner.go +++ b/internal/port/notify_provisioner.go @@ -23,4 +23,11 @@ type NotifyProvisioner interface { // TestConnection verifies the admin API key and notify service are reachable. TestConnection(ctx context.Context) error + + // VerifyProjectNotify triggers Resend domain verification for the given resend domain ID. + // Should be called after DNS records have had time to propagate. + VerifyProjectNotify(ctx context.Context, projectID, resendDomainID string) error + + // GetNotifyDomainStatus returns the Resend verification status for the project's email domain. + GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error) } diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index 4586671..09c204f 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -491,9 +491,11 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr storeErr = err log.Error("failed to store NOTIFY_FROM", logging.FieldProjectID, projectID, logging.FieldError, err) } - if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyResendDomainID, notifyCreds.ResendDomainID); err != nil { - storeErr = err - log.Error("failed to store NOTIFY_RESEND_DOMAIN_ID", logging.FieldProjectID, projectID, logging.FieldError, err) + if notifyCreds.ResendDomainID != "" { + if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyResendDomainID, notifyCreds.ResendDomainID); err != nil { + storeErr = err + log.Error("failed to store NOTIFY_RESEND_DOMAIN_ID", logging.FieldProjectID, projectID, logging.FieldError, err) + } } if storeErr != nil {