feat: improve notify domain verification reliability and add status endpoints
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
parent
2612de8446
commit
fa0d030def
@ -635,6 +635,9 @@ func main() {
|
|||||||
// Initialize verify handler (for visual verification tasks)
|
// Initialize verify handler (for visual verification tasks)
|
||||||
verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub)
|
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)
|
// Initialize operations handler (for debugging project failures)
|
||||||
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
||||||
|
|
||||||
@ -717,6 +720,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
verifyHandler.Mount(app.Router())
|
verifyHandler.Mount(app.Router())
|
||||||
sagaHandler.Mount(app.Router())
|
sagaHandler.Mount(app.Router())
|
||||||
|
notifyHandler.Mount(app.Router())
|
||||||
|
|
||||||
// Start queue processor worker (per-project command queue)
|
// Start queue processor worker (per-project command queue)
|
||||||
queueProcessor := worker.NewQueueProcessor(
|
queueProcessor := worker.NewQueueProcessor(
|
||||||
|
|||||||
216
cookbooks/trees/persona-community.yaml
Normal file
216
cookbooks/trees/persona-community.yaml
Normal file
@ -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 }}"
|
||||||
@ -48,9 +48,34 @@ steps:
|
|||||||
action: wait_pipeline
|
action: wait_pipeline
|
||||||
project_id: "{{ .outputs.create-project.project_id }}"
|
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 ---
|
# --- SDLC: Build Auth ---
|
||||||
create-feature:
|
create-feature:
|
||||||
depends_on: [wait-init]
|
depends_on: [verify-notify-domain]
|
||||||
action: api
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features"
|
||||||
|
|||||||
@ -61,10 +61,35 @@ steps:
|
|||||||
action: wait_pipeline
|
action: wait_pipeline
|
||||||
project_id: "{{ .outputs.create-project.project_id }}"
|
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 ---
|
# --- Implementation ---
|
||||||
implement-mesh:
|
implement-mesh:
|
||||||
description: "Agent implements Service-to-Service calls (Chat calls Auth, Chat queues to Worker)"
|
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
|
action: api
|
||||||
method: POST
|
method: POST
|
||||||
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
|
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
|
||||||
|
|||||||
@ -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 != "" {
|
if p.resend != nil && resendDomainID != "" {
|
||||||
go func() {
|
go func() {
|
||||||
verifyCtx := context.WithoutCancel(ctx)
|
verifyCtx := context.WithoutCancel(ctx)
|
||||||
if err := p.resend.verifyDomain(verifyCtx, resendDomainID); err != nil {
|
p.verifyWithRetry(verifyCtx, resendDomainID, host, projectID)
|
||||||
p.logger.Warn("async resend domain verification failed",
|
|
||||||
"domain_id", resendDomainID,
|
|
||||||
"host", host,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,6 +177,57 @@ func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug s
|
|||||||
}, nil
|
}, 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.
|
// DeleteProjectNotify removes all notify resources for a project.
|
||||||
// Failures are logged as warnings — cleanup continues regardless.
|
// Failures are logged as warnings — cleanup continues regardless.
|
||||||
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error {
|
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
|
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.
|
// 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
|
// Note: Only AccountID and CreatedAt are populated — APIKey, Host, and From are not
|
||||||
// recoverable after provisioning. Use this method solely to check whether provisioning
|
// recoverable after provisioning. Use this method solely to check whether provisioning
|
||||||
|
|||||||
@ -138,6 +138,10 @@ func (m *mockResendClient) deleteDomain(_ context.Context, _ string) error {
|
|||||||
return m.deleteDomainErr
|
return m.deleteDomainErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockResendClient) getDomainStatus(_ context.Context, _ string) (string, error) {
|
||||||
|
return "verified", nil
|
||||||
|
}
|
||||||
|
|
||||||
// mockDNS is a controllable implementation of port.DNSProvider.
|
// mockDNS is a controllable implementation of port.DNSProvider.
|
||||||
type mockDNS struct {
|
type mockDNS struct {
|
||||||
upsertErr error
|
upsertErr error
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const resendBaseURL = "https://api.resend.com"
|
|||||||
type resendAPI interface {
|
type resendAPI interface {
|
||||||
createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error)
|
createDomain(ctx context.Context, name, region string) (domainID string, records []resendDNSRecord, err error)
|
||||||
verifyDomain(ctx context.Context, domainID string) 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
|
deleteDomain(ctx context.Context, domainID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,6 +43,13 @@ type resendCreateDomainResponse struct {
|
|||||||
Records []resendDNSRecord `json:"records"`
|
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 {
|
func newResendClient(apiKey string) *resendClient {
|
||||||
return &resendClient{
|
return &resendClient{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
@ -75,6 +83,20 @@ func (r *resendClient) verifyDomain(ctx context.Context, domainID string) error
|
|||||||
return nil
|
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.
|
// deleteDomain removes a Resend domain by ID.
|
||||||
func (r *resendClient) deleteDomain(ctx context.Context, domainID string) error {
|
func (r *resendClient) deleteDomain(ctx context.Context, domainID string) error {
|
||||||
_, err := r.doRequest(ctx, http.MethodDelete, "/domains/"+domainID, nil)
|
_, err := r.doRequest(ctx, http.MethodDelete, "/domains/"+domainID, nil)
|
||||||
|
|||||||
@ -26,3 +26,15 @@ type NotifyCredentials struct {
|
|||||||
// CreatedAt is when the credentials were provisioned.
|
// CreatedAt is when the credentials were provisioned.
|
||||||
CreatedAt time.Time
|
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
|
||||||
|
}
|
||||||
|
|||||||
145
internal/handlers/notify.go
Normal file
145
internal/handlers/notify.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -23,4 +23,11 @@ type NotifyProvisioner interface {
|
|||||||
|
|
||||||
// TestConnection verifies the admin API key and notify service are reachable.
|
// TestConnection verifies the admin API key and notify service are reachable.
|
||||||
TestConnection(ctx context.Context) error
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -491,9 +491,11 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
|
|||||||
storeErr = err
|
storeErr = err
|
||||||
log.Error("failed to store NOTIFY_FROM", logging.FieldProjectID, projectID, logging.FieldError, 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 {
|
if notifyCreds.ResendDomainID != "" {
|
||||||
storeErr = err
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyResendDomainID, notifyCreds.ResendDomainID); err != nil {
|
||||||
log.Error("failed to store NOTIFY_RESEND_DOMAIN_ID", logging.FieldProjectID, projectID, logging.FieldError, err)
|
storeErr = err
|
||||||
|
log.Error("failed to store NOTIFY_RESEND_DOMAIN_ID", logging.FieldProjectID, projectID, logging.FieldError, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if storeErr != nil {
|
if storeErr != nil {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user