From 32d50a69525415616f88fa310f64daaa4beec6de Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 28 Feb 2026 17:32:21 -0700 Subject: [PATCH] feat: make infra provisioning idempotent + aeries-daeya public discovery feed - Make postgres and redis provisioning idempotent: return success when already provisioned with credentials stored, allowing cookbook trees to safely include explicit add-db/add-redis steps alongside auto-provisioned project creation - Update tests to reflect new idempotent behavior - Consolidate docs CI into single multi-stage Docker build (remove separate build-docs step; Dockerfile.nginx now builds Slate then serves with nginx) - Delete redundant skeleton docs/Dockerfile (replaced by multi-stage nginx image) - Add watch verb to woodpecker-deployer RBAC (required by kubectl rollout status) - Aeries Daeya cookbook: add public discovery feed (/) + character profiles (/c/:handle), characters.published/handle/tagline fields, dark pink design system, /studio/* routes, verify-public-discovery + verify-otp-endpoint smoke test steps - Fix Input.tsx: remove non-existent --border-hover CSS variable hover effect --- cookbooks/scripts/aeries-daeya-test.sh | 43 +++++++++--- cookbooks/trees/aeries-daeya.yaml | 48 +++++++++++-- .../k8s/base/woodpecker-deployer-rbac.yaml | 12 ++-- .../templates/skeleton/.woodpecker.yml.tmpl | 39 ++--------- .../templates/skeleton/docs/Dockerfile | 27 -------- .../templates/skeleton/docs/Dockerfile.nginx | 28 +++++++- .../packages/ui/src/components/Input.tsx | 2 +- internal/service/component_infra.go | 67 +++++++++++++++++-- internal/service/component_test.go | 22 +++--- 9 files changed, 188 insertions(+), 100 deletions(-) delete mode 100644 internal/adapter/templates/templates/skeleton/docs/Dockerfile diff --git a/cookbooks/scripts/aeries-daeya-test.sh b/cookbooks/scripts/aeries-daeya-test.sh index 192f8a2..c3e323a 100755 --- a/cookbooks/scripts/aeries-daeya-test.sh +++ b/cookbooks/scripts/aeries-daeya-test.sh @@ -47,29 +47,32 @@ run_flow() { echo "Running tree: aeries-daeya for project: $PROJECT_NAME" echo "" echo " Phase 1 — Infrastructure (DB + Redis + service + worker + app)" - echo " Phase 2 — Avatar & Look Data Model (characters, looks, albums, posts, mutations)" + echo " Phase 2 — Avatar & Look Data Model (characters + public discovery endpoints)" echo " Phase 3 — AI Generation Pipeline (portrait gen, outfit styling, mutation explorer)" - echo " Phase 4 — Studio UI (creation wizard, mutation explorer, look panel, albums)" + echo " Phase 4 — Studio UI (public feed + character profiles + studio + dark pink theme)" echo "" "$SCRIPT_DIR/tree-runner.sh" run aeries-daeya \ --project-name "$PROJECT_NAME" \ - ${AUTO_TEARDOWN:+--auto-teardown} + $([[ "$AUTO_TEARDOWN" == "true" ]] && echo "--auto-teardown") DOMAIN=$(api_call GET "/project/$PROJECT_NAME" | jq -r '.data.domain // empty') if [[ -n "$DOMAIN" ]]; then print_success "Aeries Daeya is live at https://$DOMAIN" echo "" - echo " Studio: https://$DOMAIN" + echo " Discovery: https://$DOMAIN (public — no login)" + echo " Studio: https://$DOMAIN/studio (requires login)" echo " API health: https://$DOMAIN/api/daeya-api/health" - echo " Characters: https://$DOMAIN/api/daeya-api/characters (requires auth)" + echo " Public feed: https://$DOMAIN/api/daeya-api/characters/public" echo "" echo " Flow:" - echo " 1. Open https://$DOMAIN → login with OTP" - echo " 2. Create Character → 4-step wizard (describe, shape, soul, generate)" - echo " 3. Open Character → use Mutation Explorer to adjust skin tone, background, lighting" - echo " 4. Add Look → upload a photo of an outfit to style on your character" + echo " 1. Open https://$DOMAIN → browse published characters (no login needed)" + echo " 2. Click 'Create Your Character' → OTP login → /studio" + echo " 3. Create Character → 4-step wizard (describe, shape, soul, generate)" + echo " 4. In studio: toggle 'Public' → character appears on discovery feed" + echo " 5. Open Character → Mutation Explorer (skin tone, background, lighting, style)" + echo " 6. Add Look → upload a photo of an outfit to style on your character" fi } @@ -114,6 +117,18 @@ diagnose() { diagnose_site_failure "$domain" "$PROJECT_NAME" fi + print_diagnostic_header "Notify / Email Checks" + echo "" + echo " If OTP email never arrives:" + print_fix "Check NOTIFY_API_KEY is injected into the daeya-api pod" + print_cmd "kubectl exec -n projects deploy/$PROJECT_NAME-daeya-api -- env | grep NOTIFY" + echo "" + print_fix "If NOTIFY_API_KEY is empty, notify provisioning failed during project creation" + print_cmd "curl -s -H 'X-API-Key: \$RDEV_API_KEY' \$RDEV_API_URL/projects/$PROJECT_NAME/notify/status | jq" + echo "" + print_fix "Test OTP endpoint directly (200/202/429 = notify working; 404/500 = broken)" + print_cmd "curl -s -o /dev/null -w '%{http_code}' -X POST https://$domain/api/daeya-api/auth/otp/send -H 'Content-Type: application/json' -d '{\"email\":\"test@example.com\"}'" + print_diagnostic_header "AI Generation Checks" echo "" echo " If characters are stuck in 'pending' status:" @@ -127,6 +142,16 @@ diagnose() { echo " If look generation fails with photo upload:" print_fix "GEMINI_API_KEY may not be injected or vision endpoint unreachable" print_cmd "kubectl exec -n projects deploy/$PROJECT_NAME-media-worker -- env | grep GEMINI" + + print_diagnostic_header "Public Discovery Checks" + echo "" + echo " If /characters/public returns 404:" + print_fix "implement-avatar-model build may not have created public endpoints" + print_cmd "curl -s https://$domain/api/daeya-api/characters/public | jq" + echo "" + echo " If published characters don't show on the discovery feed:" + print_fix "Character may not be toggled to published=true in the studio" + print_cmd "curl -s -H 'Authorization: Bearer ' https://$domain/api/daeya-api/characters | jq '.[].published'" } teardown() { diff --git a/cookbooks/trees/aeries-daeya.yaml b/cookbooks/trees/aeries-daeya.yaml index b2b7b04..75be56a 100644 --- a/cookbooks/trees/aeries-daeya.yaml +++ b/cookbooks/trees/aeries-daeya.yaml @@ -1,5 +1,5 @@ name: aeries-daeya -description: "Aeries Daeya: Privacy-first avatar social platform. Create a digital identity, style it with AI from photo references, share experiences without exposing your real face. Mutation explorer for iterating on looks and backgrounds." +description: "Aeries Daeya: Privacy-first avatar social platform. Create a digital identity, style it with AI from photo references, share experiences without exposing your real face. Public discovery feed + mutation explorer for iterating on looks and backgrounds." version: 1 vars: @@ -62,13 +62,13 @@ steps: # --- Feature 1: Avatar & Look Data Model --- implement-avatar-model: - description: "Characters, looks, albums, posts, mutation history — full data layer + CRUD API" + description: "Characters, looks, albums, posts, mutation history — full data layer + CRUD API + public discovery endpoints" depends_on: [wait-infra] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" body: - prompt: "/implement-feature avatar-model --requirements 'DB migrations in daeya-api: (1) CREATE TABLE characters (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, skin_tone INT NOT NULL DEFAULT 5, face_shape TEXT NOT NULL DEFAULT ''oval'', hair_color TEXT NOT NULL DEFAULT ''brown'', hair_style TEXT NOT NULL DEFAULT ''medium'', eye_color TEXT NOT NULL DEFAULT ''brown'', body_type TEXT NOT NULL DEFAULT ''average'', description TEXT NOT NULL DEFAULT '''', avatar_url TEXT, status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (2) CREATE TABLE looks (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, name TEXT NOT NULL, outfit_description TEXT NOT NULL DEFAULT '''', outfit_url TEXT, source_photo_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (3) CREATE TABLE albums (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, cover_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (4) CREATE TABLE posts (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, character_id UUID REFERENCES characters(id), look_id UUID REFERENCES looks(id), album_id UUID REFERENCES albums(id), image_url TEXT, video_url TEXT, caption TEXT NOT NULL DEFAULT '''', location_name TEXT, location_lat DECIMAL, location_lng DECIMAL, prompt_text TEXT, show_prompt BOOLEAN NOT NULL DEFAULT false, frame_style TEXT NOT NULL DEFAULT ''none'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (5) CREATE TABLE mutation_history (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, parameters_json JSONB NOT NULL, result_url TEXT, committed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). Domain models: Character, Look, Album, Post, MutationRecord with same fields. Services with interfaces in ports: CharacterService (Create, GetByID, ListByUser, Update, UpdateAvatarURL), LookService (Create, GetByID, ListByCharacter, UpdateOutfitURL), AlbumService (Create, GetByID, ListByUser), PostService (Create, GetByID, ListByAlbum, ListByUser), MutationService (Create, GetByCharacter, Commit). Endpoints all under /api/daeya-api (require JWT auth via skeleton auth.Middleware): POST /characters (body: {name, description, skin_tone?, face_shape?, hair_color?, hair_style?, eye_color?, body_type?} → 202 + character), GET /characters (returns user characters), GET /characters/{id}, PATCH /characters/{id} (update fields), POST /characters/{id}/looks (body: {name, outfit_description?, source_photo_urls?[]}, → 202 + look), GET /characters/{id}/looks, POST /characters/{id}/mutations (body: {skin_tone?, background?, lighting?, style_filter?} → 202 + mutation_id), POST /characters/{id}/mutations/{mutation_id}/commit (commits mutation as new character baseline), POST /albums (body: {name}), GET /albums, GET /albums/{id}/posts, POST /posts (body: {character_id, look_id?, album_id?, caption?, location_name?, location_lat?, location_lng?, prompt_text?, show_prompt?, frame_style?} → post), GET /posts/{id}. After creating a character enqueue job {type:generate_character, character_id}. After creating a look enqueue job {type:generate_look, look_id}. After creating a mutation enqueue job {type:generate_mutation, mutation_id}. Publish SSE events via SSE hub: character_updated {character_id, status, avatar_url} to channel:daeya, look_updated {look_id, status, outfit_url} to channel:daeya, mutation_ready {mutation_id, character_id, result_url} to channel:daeya.'" + prompt: "/implement-feature avatar-model --requirements 'IMPORTANT: Do NOT modify or reimplement the existing skeleton auth system. The skeleton already ships working OTP email auth at /auth/otp/send and /auth/otp/verify using NOTIFY_URL and NOTIFY_API_KEY env vars. Only add new domain-specific endpoints below. DB migrations in daeya-api: (1) CREATE TABLE characters (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, handle TEXT UNIQUE NOT NULL, name TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT false, skin_tone INT NOT NULL DEFAULT 5, face_shape TEXT NOT NULL DEFAULT ''oval'', hair_color TEXT NOT NULL DEFAULT ''brown'', hair_style TEXT NOT NULL DEFAULT ''medium'', eye_color TEXT NOT NULL DEFAULT ''brown'', body_type TEXT NOT NULL DEFAULT ''average'', description TEXT NOT NULL DEFAULT '''', tagline TEXT NOT NULL DEFAULT '''', avatar_url TEXT, status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (2) CREATE TABLE looks (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, name TEXT NOT NULL, outfit_description TEXT NOT NULL DEFAULT '''', outfit_url TEXT, source_photo_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (3) CREATE TABLE albums (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, cover_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (4) CREATE TABLE posts (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, character_id UUID REFERENCES characters(id), look_id UUID REFERENCES looks(id), album_id UUID REFERENCES albums(id), image_url TEXT, video_url TEXT, caption TEXT NOT NULL DEFAULT '''', location_name TEXT, location_lat DECIMAL, location_lng DECIMAL, prompt_text TEXT, show_prompt BOOLEAN NOT NULL DEFAULT false, frame_style TEXT NOT NULL DEFAULT ''none'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (5) CREATE TABLE mutation_history (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, parameters_json JSONB NOT NULL, result_url TEXT, committed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). Domain models: Character, Look, Album, Post, MutationRecord with same fields. Services with interfaces in ports: CharacterService (Create, GetByID, GetByHandle, ListByUser, ListPublished, Update, SetPublished, UpdateAvatarURL), LookService (Create, GetByID, ListByCharacter, UpdateOutfitURL), AlbumService (Create, GetByID, ListByUser), PostService (Create, GetByID, ListByAlbum, ListByUser), MutationService (Create, GetByCharacter, Commit). Endpoints under /api/daeya-api: PUBLIC (no auth): GET /characters/public?limit&offset (returns published=true characters ordered by created_at desc, includes avatar_url, name, handle, tagline, look count), GET /c/:handle (returns single published character by handle including looks list). AUTHENTICATED (require JWT auth via skeleton auth.Middleware): POST /characters (body: {name, handle, description, tagline?, skin_tone?, face_shape?, hair_color?, hair_style?, eye_color?, body_type?} → 202 + character), GET /characters (returns user characters), GET /characters/{id}, PATCH /characters/{id} (update fields including published bool), POST /characters/{id}/looks (body: {name, outfit_description?, source_photo_urls?[]} → 202 + look), GET /characters/{id}/looks, POST /characters/{id}/mutations (body: {skin_tone?, background?, lighting?, style_filter?} → 202 + mutation), POST /characters/{id}/mutations/{mutation_id}/commit (commits mutation as new character baseline), POST /albums (body: {name}), GET /albums, GET /albums/{id}/posts, POST /posts (body: {character_id, look_id?, album_id?, caption?, location_name?, location_lat?, location_lng?, prompt_text?, show_prompt?, frame_style?} → post), GET /posts/{id}. After creating a character enqueue job {type:generate_character, character_id}. After creating a look enqueue job {type:generate_look, look_id}. After creating a mutation enqueue job {type:generate_mutation, mutation_id}. Publish SSE events via SSE hub: character_updated {character_id, status, avatar_url} to channel:daeya, look_updated {look_id, status, outfit_url} to channel:daeya, mutation_ready {mutation_id, character_id, result_url} to channel:daeya.'" auto_commit: true auto_push: true git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" @@ -90,7 +90,7 @@ steps: method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" body: - prompt: "/implement-feature ai-generation --requirements 'Implement generation pipeline in media-worker using skeleton ai-client package (LAOZHANG_API_KEY for image generation via Gemini vision for photo analysis). Worker consumes jobs from queue by type: JOB generate_character {character_id}: Load character row. Build a portrait prompt from character fields: face_shape, hair_color, hair_style, eye_color, skin_tone (map 1-10 to descriptive terms: 1=very fair, 3=light, 5=medium, 7=tan, 9=deep, 10=very dark), body_type, description. Prompt template: \"Portrait photo of a [gender-neutral] person. [description]. [skin_tone_desc] skin, [face_shape] face, [hair_color] [hair_style] hair, [eye_color] eyes. [body_type] build. Front-facing, neutral expression, soft studio lighting, clean white background. High quality, photorealistic.\" Generate one image via LAOZHANG_API_KEY. Save URL to character.avatar_url, set status=ready. Publish SSE character_updated {character_id, status:ready, avatar_url}. JOB generate_look {look_id}: Load look + character rows. If source_photo_urls non-empty: use Gemini vision (GEMINI_API_KEY) to analyze first photo and extract style descriptors (garment type, color palette, fabric, silhouette, styling details). Build outfit prompt using descriptors + character appearance. If no source photos: use outfit_description directly. Prompt template: \"The same person from the reference portrait is now wearing [outfit_description]. Full body view. [character appearance details]. Natural lighting. High quality, photorealistic.\" Use character.avatar_url as style reference if available. Generate one image. Save to look.outfit_url, set status=ready. Publish SSE look_updated {look_id, status:ready, outfit_url}. JOB generate_mutation {mutation_id}: Load mutation_history + character rows. Parameters from parameters_json may include: skin_tone (1-10, update descriptive term), background (city_day|city_night|nature_park|studio_white|studio_dark|beach|mountain), lighting (natural|golden_hour|dramatic|neon|soft_box), style_filter (realistic|stylized|anime|painterly). Build variation prompt starting from character description but applying parameter overrides. Background mapping: city_day=busy city street daytime, city_night=city street at night with neon lights, nature_park=lush green park, studio_white=plain white studio, studio_dark=dark minimalist studio, beach=sandy beach golden hour, mountain=mountain vista clear sky. Style filter suffix: anime=anime art style illustration, painterly=oil painting impressionist style (others=photorealistic). Generate image. Save to mutation_history.result_url. Publish SSE mutation_ready {mutation_id, character_id, result_url}. JOB generate_action {character_id, look_id, action_text, post_id}: Load character + look. Build action prompt: the character performing the action described. Examples: spin while showing outfit=full body 3/4 turn pose showing clothing detail; say Happy Birthday=person holding birthday cake smiling waving; wave=person waving at camera smiling. Generate image (static). Save image_url to posts table for post_id. Publish SSE action_ready {post_id, image_url}.'" + prompt: "/implement-feature ai-generation --requirements 'Implement generation pipeline in media-worker using skeleton ai-client package (LAOZHANG_API_KEY for image generation, GEMINI_API_KEY for Gemini vision photo analysis). Worker consumes jobs from queue by type: JOB generate_character {character_id}: Load character row. Build a portrait prompt from character fields: face_shape, hair_color, hair_style, eye_color, skin_tone (map 1-10 to descriptive terms: 1=very fair, 3=light, 5=medium, 7=tan, 9=deep, 10=very dark), body_type, description. Prompt template: \"Portrait photo of a [gender-neutral] person. [description]. [skin_tone_desc] skin, [face_shape] face, [hair_color] [hair_style] hair, [eye_color] eyes. [body_type] build. Front-facing, neutral expression, soft studio lighting, clean white background. High quality, photorealistic.\" Generate one image via LAOZHANG_API_KEY. Save URL to character.avatar_url, set status=ready. Publish SSE character_updated {character_id, status:ready, avatar_url}. JOB generate_look {look_id}: Load look + character rows. If source_photo_urls non-empty: use Gemini vision (GEMINI_API_KEY) to analyze first photo and extract style descriptors (garment type, color palette, fabric, silhouette, styling details). Build outfit prompt using descriptors + character appearance. If no source photos: use outfit_description directly. Prompt template: \"The same person from the reference portrait is now wearing [outfit_description]. Full body view. [character appearance details]. Natural lighting. High quality, photorealistic.\" Use character.avatar_url as style reference if available. Generate one image. Save to look.outfit_url, set status=ready. Publish SSE look_updated {look_id, status:ready, outfit_url}. JOB generate_mutation {mutation_id}: Load mutation_history + character rows. Parameters from parameters_json may include: skin_tone (1-10), background (city_day|city_night|nature_park|studio_white|studio_dark|beach|mountain), lighting (natural|golden_hour|dramatic|neon|soft_box), style_filter (realistic|stylized|anime|painterly). Build variation prompt applying parameter overrides. Background mapping: city_day=busy city street daytime, city_night=city street at night with neon lights, nature_park=lush green park, studio_white=plain white studio, studio_dark=dark minimalist studio, beach=sandy beach golden hour, mountain=mountain vista clear sky. Style filter suffix: anime=anime art style illustration, painterly=oil painting impressionist style (others=photorealistic). Generate image. Save to mutation_history.result_url. Publish SSE mutation_ready {mutation_id, character_id, result_url}. JOB generate_action {character_id, look_id, action_text, post_id}: Load character + look. Build action prompt: the character performing the action described (spin while showing outfit=full body 3/4 turn pose; say Happy Birthday=person holding birthday cake smiling waving; wave=person waving at camera smiling). Generate image. Save image_url to posts table for post_id. Publish SSE action_ready {post_id, image_url}.'" auto_commit: true auto_push: true git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" @@ -114,13 +114,14 @@ steps: # --- Feature 3: Studio UI --- implement-studio-ui: - description: "Character creation wizard, mutation explorer, look styling, album management" + description: "Public discovery feed, character profile, creation wizard, mutation explorer, look styling" depends_on: [wait-deploy-2] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" body: - prompt: "/implement-feature studio-ui --requirements 'Build the React studio UI using skeleton packages (@project/ui, @project/auth, @project/layout, @project/realtime, @project/api-client). DashboardShell layout from @project/layout. Routes: /login (public) and all others protected via ProtectedRoute from @project/auth. LOGIN PAGE: OTP flow — email input, Send Code, then code input + Verify. DASHBOARD (/): Header with \"Create Character\" button. Grid of CharacterCard components showing avatar_url (circular, 96px), character name, look count badge. Clicking card goes to /characters/:id. Empty state: centered illustration + \"Create your first character\" button. CHARACTER CREATION (/characters/new): 4-step wizard with progress indicator. STEP 1 SPARK: Large prompt: \"Describe your character\". Textarea for free-form description. Skin tone picker: row of 10 circular swatches from #FDDBB4 to #3D1A00, selected = ring highlight. Hair color pills: Blonde, Brown, Black, Red, Auburn, White, Silver. Face shape pills: Oval, Round, Square, Heart, Diamond. Step nav: Back (disabled on step 1) + Continue. STEP 2 SHAPE: Hair style pills: Short, Medium, Long, Curly, Wavy, Straight, Bob, Bun. Eye color pills: Brown, Blue, Green, Hazel, Gray, Amber. Body type pills: Slim, Athletic, Average, Curvy, Plus. Name field with placeholder \"Give your character a name\". Live summary card on right: lists all selected traits as pills. STEP 3 SOUL (optional): \"What does your character like to do?\". Interest pills (multi-select up to 3): Travel, Fashion, Music, Food, Art, Fitness, Books, Tech, Nature. tagline field: \"Add a tagline\" (optional). STEP 4 CREATE: Shows summary card. \"Generate Character\" button. On submit POST /api/daeya-api/characters. Shows generating spinner with messages cycling: \"Bringing your character to life...\", \"Crafting their appearance...\", \"Almost there...\". Redirects to /characters/:id on success. CHARACTER DETAIL (/characters/:id): Left column (1/3): circular avatar image (200px, pending state shows pulsing gray circle). Character name + tagline. Trait pills row (skin tone swatch + hair + eyes). \"Add Look\" button. Looks list: each row shows look name, outfit_url thumbnail (48px), status badge. Clicking look shows full outfit image modal. Right column (2/3): MUTATION EXPLORER panel. Header: \"Explore Variations\". Parameter controls: Skin Tone — horizontal slider (1-10) with live swatch preview at current value. Background — pill row: City Day, City Night, Nature, Studio, Beach, Mountain. Lighting — pill row: Natural, Golden Hour, Dramatic, Neon. Style — pill row: Realistic, Stylized, Anime, Painterly. \"Generate Preview\" button — calls POST /api/daeya-api/characters/:id/mutations with current params. Preview area: shows result_url when ready, else shows generating spinner. \"Commit as New Baseline\" button (enabled only when preview ready) — calls POST /api/daeya-api/characters/:id/mutations/:mutation_id/commit, then refreshes character avatar. \"Discard\" button resets params to character current values. ADD LOOK PANEL (slide-in from right): Look name field. Photo upload area: drag-drop or click to upload up to 3 reference photos. \"Or describe the outfit\" textarea (shown when no photos uploaded). \"Generate Look\" button. On submit POST /api/daeya-api/characters/:id/looks with uploaded photo URLs (or description). Shows generating state. ALBUMS (/albums): Grid of AlbumCard (cover_url thumbnail, name, post count). \"New Album\" button → modal with name input. REALTIME: useEventChannel from @project/realtime subscribed to channel:daeya. On character_updated: refresh CharacterCard avatar_url + status. On look_updated: refresh look thumbnail in character detail. On mutation_ready: show result in Mutation Explorer preview area. On action_ready: show generated post image.'" + prompt: |- + /implement-feature studio-ui --requirements 'Build the React studio UI using skeleton packages (@project/ui, @project/auth, @project/layout, @project/realtime, @project/api-client). DESIGN SYSTEM: Use these exact CSS custom properties or Tailwind custom tokens throughout — bg-base:#0a0a0f, bg-card:#12121a, bg-panel:#1a1a2e, border-default:#2a2a3e, text-primary:#e0e0e0, text-muted:#888888, accent:#ff6b9d, accent-dark:#c44569, success:#4ade80, error:#ef4444. Apply: cards use bg-card + 16px border-radius + 1px solid border-default + hover:translateY(-4px) + hover:box-shadow 0 8px 30px rgba(0,0,0,0.3). Primary buttons use gradient background linear-gradient(135deg,#ff6b9d,#c44569) + 12px border-radius + hover:translateY(-2px) + hover:box-shadow 0 8px 20px rgba(255,107,157,0.3). Secondary buttons use border 1px solid border-default + text-muted + hover:border-accent + hover:text-accent. Inputs/textareas use bg-base + border 1px solid border-default + 12px border-radius + focus:border-accent. Pills (selected state): border accent + bg rgba(255,107,157,0.1) + text white. Status badges: pending=bg rgba(42,42,62,1) text #888, generating=bg rgba(255,107,157,0.2) text #ff6b9d, ready=bg rgba(74,222,128,0.2) text #4ade80. All transitions 200ms ease. Body font: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif. ROUTE STRUCTURE — two surfaces, same domain: PUBLIC ROUTES (no auth required): / = Discovery feed, /c/:handle = Public character profile. PROTECTED ROUTES (require auth via ProtectedRoute from @project/auth): /login = OTP login, /studio = Your characters dashboard, /studio/characters/new = Creation wizard, /studio/characters/:id = Character detail + mutation explorer, /studio/albums = Album management. DISCOVERY FEED (/): Full-width page, bg-base. Top nav: logo left, "Create Your Character" button right (links to /login if not authed, else /studio/characters/new). Hero: centered headline "Meet characters created by real people" + subtext "Browse, follow, and collaborate without sharing your real face." Below hero: responsive grid (auto-fill minmax 260px) of CharacterCard components fetched from GET /api/daeya-api/characters/public. CharacterCard: bg-card, 16px radius, overflow hidden, hover elevation. Top: avatar_url as square image (aspect-ratio:1, object-fit:cover). Bottom: padding 16px, name in white 600 weight, handle in text-muted 14px (@handle), tagline in text-muted 13px truncated, row of up to 4 look-count and status badges. Click card → /c/:handle. Empty discovery state: "No characters published yet. Be the first." Infinite scroll or Load More button calling GET /characters/public?offset=N. PUBLIC CHARACTER PROFILE (/c/:handle): Fetch from GET /api/daeya-api/c/:handle. Large hero: avatar_url as circular 160px centered, name heading, handle in text-muted, tagline. Trait pills row (skin tone swatch + hair color + hair style + eye color). "Looks" section: masonry or even grid of look outfit_url images. Clicking image opens fullscreen modal with prev/next. "Collaborate" button (deferred, shows "Coming soon" toast). OTP LOGIN (/login): bg-base centered card. Email input → "Send Code" button (primary). Then code input → "Verify" button. On success store token and redirect to /studio. Uses POST /api/daeya-api/auth/otp/send and POST /api/daeya-api/auth/otp/verify. STUDIO DASHBOARD (/studio): DashboardShell from @project/layout. Header: "Your Characters" title + "New Character" primary button. Grid of CharacterCard (same card component as discovery but with edit controls: Publish toggle + Edit link). Published characters show a small accent dot. Empty state: centered + "Create your first character" button. CREATION WIZARD (/studio/characters/new): 4-step wizard, progress bar in accent color. STEP 1 DESCRIBE: Textarea placeholder "Describe your character — their vibe, energy, how they carry themselves." Handle field: @handle (lowercase, alphanumeric, used in public URL). Skin tone picker: 10 circular swatches (#FDDBB4 lightest to #3D1A00 darkest), selected=2px ring in accent. STEP 2 SHAPE: Hair color pills (Blonde,Brown,Black,Red,Auburn,White,Silver). Hair style pills (Short,Medium,Long,Curly,Wavy,Straight,Bob,Bun). Eye color pills (Brown,Blue,Green,Hazel,Gray,Amber). Body type pills (Slim,Athletic,Average,Curvy,Plus). Face shape pills (Oval,Round,Square,Heart,Diamond). Name field. Tagline field. All pills: default=secondary style, selected=accent pill style. STEP 3 SOUL (optional): Interest pills multi-select up to 3 (Travel,Fashion,Music,Food,Art,Fitness,Books,Tech,Nature). Live summary card updates as user selects — shows all chosen traits as accent pills. STEP 4 GENERATE: Summary card. "Generate Character" primary button. On submit POST /api/daeya-api/characters. Shows full-screen generating overlay: avatar generating animation (pulsing circle in accent color) + cycling messages "Bringing your character to life..." / "Crafting their appearance..." / "Almost there...". On SSE character_updated with status=ready redirect to /studio/characters/:id. CHARACTER DETAIL (/studio/characters/:id): Two-column layout. Left (1/3): circular avatar 200px (pending=pulsing bg-panel circle). Name + @handle + tagline. Trait pills. PUBLISH TOGGLE: labeled "Public" — toggle switch in accent when on. On toggle call PATCH /api/daeya-api/characters/:id {published: true/false}. Published characters get a small "Live on discovery" badge. "Add Look" button opens look panel. Looks list: each row shows look name, 48px outfit_url thumbnail, status badge. Clicking look opens fullscreen modal. Right (2/3): MUTATION EXPLORER panel (bg-panel, 16px radius, p-24). Header "Explore Variations" in white. Parameters: Skin Tone — row of 10 swatches, click to select (highlight in accent). Background — pill row: City Day, City Night, Nature, Studio, Beach, Mountain. Lighting — pill row: Natural, Golden Hour, Dramatic, Neon. Style — pill row: Realistic, Stylized, Anime, Painterly. "Generate Preview" primary button → POST /api/daeya-api/characters/:id/mutations. Preview area 1:1 aspect ratio: shows result_url when ready, else shows pulsing bg-panel placeholder + "Generating..." text. "Commit as New Baseline" button (accent, enabled only when preview ready) → POST commit endpoint → refresh avatar. "Discard" secondary button resets params. ADD LOOK PANEL (slide-in from right, 400px, bg-panel): Look name field. Upload area: dashed border border-default, "Drop photos here or click to upload" in text-muted, max 3. Thumbnail previews of uploaded photos. "Or describe the outfit" textarea below. "Generate Look" primary button. ALBUMS (/studio/albums): Grid of AlbumCard. "New Album" button → inline modal. REALTIME: useEventChannel from @project/realtime subscribed to channel:daeya. On character_updated: refresh avatar + status in dashboard and detail. On look_updated: refresh look thumbnail. On mutation_ready: show in mutation explorer preview. On action_ready: show in post.' auto_commit: true auto_push: true git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" @@ -166,8 +167,22 @@ steps: max_attempts: 30 poll_interval: 5 + verify-public-discovery: + description: Verify public discovery feed responds without auth + depends_on: [verify-health] + on_error: continue + action: shell + command: | + DOMAIN="{{ .outputs.create-project.domain }}" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + "https://$DOMAIN/api/daeya-api/characters/public") + echo "GET /characters/public (no auth) returned: $STATUS" + if [ "$STATUS" = "200" ]; then echo "Public discovery endpoint confirmed"; exit 0; fi + echo "Unexpected status — public endpoint may not exist" + exit 1 + verify-auth-endpoint: - description: Verify character endpoint requires auth (401 confirms it) + description: Verify authenticated character endpoint requires auth (401 confirms it) depends_on: [verify-health] on_error: continue action: shell @@ -181,6 +196,25 @@ steps: echo "Unexpected status — endpoint may not exist" exit 1 + verify-otp-endpoint: + description: Verify OTP email endpoint exists and notify is configured (200/202/429 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/daeya-api/auth/otp/send" \ + -H "Content-Type: application/json" \ + -d '{"email":"smoke-test@example.com"}') + echo "POST /auth/otp/send returned: $STATUS" + if [ "$STATUS" = "200" ] || [ "$STATUS" = "202" ] || [ "$STATUS" = "429" ]; then + echo "OTP endpoint + notify confirmed working" + exit 0 + fi + echo "Unexpected status — OTP endpoint may be missing or notify credentials not injected" + exit 1 + teardown: - description: Delete project and all provisioned resources action: api diff --git a/deployments/k8s/base/woodpecker-deployer-rbac.yaml b/deployments/k8s/base/woodpecker-deployer-rbac.yaml index 2e865f7..9119b46 100644 --- a/deployments/k8s/base/woodpecker-deployer-rbac.yaml +++ b/deployments/k8s/base/woodpecker-deployer-rbac.yaml @@ -22,15 +22,19 @@ metadata: app.kubernetes.io/part-of: rdev rules: # Deploy steps: set image, patch replicas, verify rollout - # - get/list: read deployment and replicaset state + # - get/list/watch: read deployment and replicaset state (watch required by kubectl rollout status) # - patch: kubectl set image, kubectl patch (replicas) - apiGroups: ["apps"] resources: ["deployments"] - verbs: ["get", "list", "patch"] - # rollout status needs to watch replicasets + verbs: ["get", "list", "patch", "watch"] + # rollout status watches replicasets to track new/old replica counts - apiGroups: ["apps"] resources: ["replicasets"] - verbs: ["get", "list"] + verbs: ["get", "list", "watch"] + # rollout status watches pods to detect readiness and crash loops + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl index dfc7e42..31a8dad 100644 --- a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl +++ b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl @@ -143,29 +143,11 @@ steps: branch: main event: push - # Build Slate static documentation (skipped if no docs infrastructure) - build-docs: - image: ruby:3.2-slim - depends_on: [generate-docs] - failure: ignore - commands: - - | - if [ ! -d "docs" ] || [ ! -f "docs/Gemfile" ]; then - echo "==> No docs/ directory or Gemfile found, skipping Slate build" - exit 0 - fi - - apt-get update && apt-get install -y build-essential nodejs - - cd docs && bundle install --jobs 4 - - cd docs && bundle exec middleman build --clean - - echo "==> Docs built to docs/build/" - when: - branch: main - event: push - - # Build and push docs-nginx image (skipped if no docs build output) - # failure: ignore allows pipeline to continue if docs weren't built + # Build and push docs-nginx image (multi-stage: builds Slate + serves with nginx) + # Depends on generate-docs which produces markdown includes from OpenAPI specs + # failure: ignore allows pipeline to continue if docs build fails build-docs-image: - depends_on: [build-docs] + depends_on: [generate-docs] image: woodpeckerci/plugin-kaniko failure: ignore settings: @@ -194,16 +176,8 @@ steps: REPO="{{PROJECT_NAME}}-docs" REGISTRY="registry.threesix.ai" - # Check if docs were built (same check as deploy-docs) - if [ ! -d "docs/build" ]; then - echo "==> No docs build output, skipping verification" - exit 0 - fi - echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry" - # Query registry v2 API to check if manifest exists - # Returns 200 if image exists, 404 if not HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ --insecure \ "https://$REGISTRY/v2/$REPO/manifests/$TAG" \ @@ -211,13 +185,10 @@ steps: if [ "$HTTP_CODE" = "200" ]; then echo "==> Image verified: $REGISTRY/$REPO:$TAG" - # Create marker file for deploy-docs to check - touch /tmp/image-verified exit 0 elif [ "$HTTP_CODE" = "404" ]; then echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry" - echo " This may indicate the build step failed or is still pushing" - echo " Deploy step will be skipped to prevent ImagePullBackOff" + echo " Build step may have failed. Deploy will be skipped." exit 1 else echo "==> WARNING: Registry check returned HTTP $HTTP_CODE" diff --git a/internal/adapter/templates/templates/skeleton/docs/Dockerfile b/internal/adapter/templates/templates/skeleton/docs/Dockerfile deleted file mode 100644 index 5228366..0000000 --- a/internal/adapter/templates/templates/skeleton/docs/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Slate documentation builder -# Used by CI to generate static HTML from OpenAPI specs - -FROM ruby:3.2-slim - -# Install build dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - nodejs \ - npm \ - git \ - && rm -rf /var/lib/apt/lists/* - -# Install widdershins globally for OpenAPI to Slate markdown conversion -RUN npm install -g widdershins - -WORKDIR /docs - -# Copy Gemfile first for layer caching -COPY Gemfile Gemfile.lock* ./ -RUN bundle install - -# Copy the rest of the docs source -COPY . . - -# Build static site -CMD ["bundle", "exec", "middleman", "build", "--clean"] diff --git a/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx b/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx index 8afb4b5..9ea54c2 100644 --- a/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx +++ b/internal/adapter/templates/templates/skeleton/docs/Dockerfile.nginx @@ -1,13 +1,35 @@ # Production nginx image for serving Slate documentation -# Built by CI after Slate generates static HTML +# Multi-stage build: generates static HTML then serves with nginx +# No dependency on external build steps — self-contained +# Stage 1: Build Slate static site +FROM ruby:3.2-slim AS builder + +RUN apt-get update && apt-get install -y \ + build-essential \ + nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /docs + +# Copy Gemfile first for layer caching +COPY Gemfile Gemfile.lock* ./ +RUN bundle install --jobs 4 + +# Copy the rest of the docs source +COPY . . + +# Build static site — produces build/ directory +RUN bundle exec middleman build --clean + +# Stage 2: Serve with nginx FROM nginx:alpine # Remove default nginx content RUN rm -rf /usr/share/nginx/html/* -# Copy built static files from Slate -COPY build/ /usr/share/nginx/html/ +# Copy built static files from Slate builder +COPY --from=builder /docs/build/ /usr/share/nginx/html/ # Custom nginx config for SPA-style routing RUN cat > /etc/nginx/conf.d/default.conf << 'EOF' diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx index da40d12..5a6a334 100644 --- a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Input.tsx @@ -14,7 +14,7 @@ const Input = React.forwardRef( 'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--text-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-50', error ? 'border-[var(--error)] focus-visible:ring-[var(--error)]' - : 'border-[var(--border)] hover:border-[var(--border-hover)]', + : 'border-[var(--border)]', className )} ref={ref} diff --git a/internal/service/component_infra.go b/internal/service/component_infra.go index 113eeed..b84d591 100644 --- a/internal/service/component_infra.go +++ b/internal/service/component_infra.go @@ -24,6 +24,7 @@ func (s *ComponentService) addInfraComponent(ctx context.Context, projectID stri } // provisionPostgres provisions a PostgreSQL/CockroachDB database for the project. +// Idempotent: returns existing component if already provisioned with credentials stored. func (s *ComponentService) provisionPostgres(ctx context.Context, projectID, name string) (*domain.Component, error) { if s.dbProvisioner == nil { return nil, fmt.Errorf("database provisioner not configured") @@ -35,7 +36,41 @@ func (s *ComponentService) provisionPostgres(ctx context.Context, projectID, nam return nil, fmt.Errorf("failed to check existing database: %w", err) } if existing != nil { - return nil, fmt.Errorf("%w: postgres already provisioned for project %s", domain.ErrDuplicateComponent, projectID) + // Already provisioned — return success if credentials are stored (idempotent). + // This allows cookbook trees to have explicit add-db steps even though + // project creation auto-provisions the database. + if s.credentialStore != nil { + storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":DATABASE_URL") + if storeErr == nil && storedURL != "" { + log := logging.FromContext(ctx).WithService("component") + log.Info("postgres already provisioned, returning existing (idempotent)", + logging.FieldProjectID, projectID) + return &domain.Component{ + Type: domain.ComponentTypePostgres, + Name: name, + Path: "infra/postgres", + Port: existing.Port, + Template: "postgres", + Dependencies: []string{}, + }, nil + } + // Credentials missing — fall through to re-provision + log := logging.FromContext(ctx).WithService("component") + log.Warn("database exists but DATABASE_URL not in credential store, re-provisioning", + logging.FieldProjectID, projectID) + } else { + log := logging.FromContext(ctx).WithService("component") + log.Info("postgres already provisioned, returning existing (idempotent)", + logging.FieldProjectID, projectID) + return &domain.Component{ + Type: domain.ComponentTypePostgres, + Name: name, + Path: "infra/postgres", + Port: existing.Port, + Template: "postgres", + Dependencies: []string{}, + }, nil + } } // Provision the database @@ -88,19 +123,41 @@ func (s *ComponentService) provisionRedis(ctx context.Context, projectID, name s return nil, fmt.Errorf("failed to check existing cache: %w", err) } if existing != nil { - // Redis user exists — check if credentials are stored. If they are, it's a true duplicate. - // If not (credentials were lost), fall through to re-provision (CreateProjectCache resets the password). + // Redis user exists — check if credentials are stored. + // If they are, return success (idempotent) so cookbook trees can safely + // have add-redis steps even though project creation auto-provisions Redis. + // If not (credentials were lost), fall through to re-provision. if s.credentialStore != nil { storedURL, storeErr := s.credentialStore.Get(ctx, projectID+":REDIS_URL") if storeErr == nil && storedURL != "" { - return nil, fmt.Errorf("%w: redis already provisioned for project %s", domain.ErrDuplicateComponent, projectID) + log := logging.FromContext(ctx).WithService("component") + log.Info("redis already provisioned, returning existing (idempotent)", + logging.FieldProjectID, projectID) + return &domain.Component{ + Type: domain.ComponentTypeRedis, + Name: name, + Path: "infra/redis", + Port: existing.Port, + Template: "redis", + Dependencies: []string{}, + }, nil } // Credentials missing from store — re-provision to recover log := logging.FromContext(ctx).WithService("component") log.Warn("redis user exists but REDIS_URL not in credential store, re-provisioning", logging.FieldProjectID, projectID) } else { - return nil, fmt.Errorf("%w: redis already provisioned for project %s", domain.ErrDuplicateComponent, projectID) + log := logging.FromContext(ctx).WithService("component") + log.Info("redis already provisioned, returning existing (idempotent)", + logging.FieldProjectID, projectID) + return &domain.Component{ + Type: domain.ComponentTypeRedis, + Name: name, + Path: "infra/redis", + Port: existing.Port, + Template: "redis", + Dependencies: []string{}, + }, nil } } diff --git a/internal/service/component_test.go b/internal/service/component_test.go index a7747e5..e7d657f 100644 --- a/internal/service/component_test.go +++ b/internal/service/component_test.go @@ -283,15 +283,18 @@ func TestProvisionPostgres(t *testing.T) { wantErr: false, }, { - name: "postgres already exists", + name: "postgres already exists with credentials (idempotent)", projectID: "test-project", componentName: "main-db", dbProvisioner: &mockDatabaseProvisioner{ - existingDB: &domain.DatabaseCredentials{ProjectID: "test-project"}, + existingDB: &domain.DatabaseCredentials{ProjectID: "test-project", Port: 26257}, }, - credStore: newMockCredentialStore(), - wantErr: true, - wantErrContains: "already provisioned", + credStore: func() *mockCredentialStore { + cs := newMockCredentialStore() + cs.stored["test-project:DATABASE_URL"] = "postgresql://proj-test-project:pass@localhost:26257/project_test_project" + return cs + }(), + wantErr: false, }, { name: "provisioning fails", @@ -369,20 +372,19 @@ func TestProvisionRedis(t *testing.T) { wantErr: false, }, { - name: "redis already exists", + name: "redis already exists with credentials (idempotent)", projectID: "test-project", componentName: "cache", cacheProvisioner: &mockCacheProvisioner{ - existingCache: &domain.CacheCredentials{ProjectID: "test-project"}, + existingCache: &domain.CacheCredentials{ProjectID: "test-project", Port: 6379}, }, credStore: func() *mockCredentialStore { - // Simulate credentials already stored — this is a true duplicate + // Simulate credentials already stored — idempotent return cs := newMockCredentialStore() cs.stored["test-project:REDIS_URL"] = "redis://proj-test-project:pass@localhost:6379" return cs }(), - wantErr: true, - wantErrContains: "already provisioned", + wantErr: false, }, { name: "provisioning fails",