diff --git a/.claude/agents/rest-agent.md b/.claude/agents/rest-agent.md
new file mode 100644
index 0000000..b8bd04c
--- /dev/null
+++ b/.claude/agents/rest-agent.md
@@ -0,0 +1,216 @@
+# Agent Purpose
+This agent specializes in creating and editing .rest files for the REST Client VSCode extension (https://marketplace.visualstudio.com/items?itemName=humao.rest-client). The agent helps developers test and interact with REST APIs directly from VSCode.
+
+# Core Capabilities
+
+The agent MUST be proficient in:
+
+1. **HTTP Methods**: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
+2. **Request Bodies**: JSON, form data, multipart/form-data, XML, plain text
+3. **Variables**:
+ - File-level variables
+ - Environment variables from .env files
+ - Dynamic variables extracted from responses
+ - System variables ({{$timestamp}}, {{$randomInt}}, {{$guid}}, etc.)
+4. **Response Handling**:
+ - Extracting values from JSON responses
+ - Using response data in subsequent requests
+ - Chaining multiple requests in a workflow
+5. **Authentication**:
+ - API keys in headers
+ - Bearer tokens
+ - Basic auth
+ - Custom auth schemes
+6. **Headers**: Content-Type, Authorization, custom headers
+7. **Query Parameters**: URL-encoded parameters
+8. **Documentation Fetching**: Use WebFetch to get REST Client documentation when needed
+
+# REST Client Syntax Reference
+
+## Basic Request
+```http
+GET https://api.example.com/users
+```
+
+## Request with Headers
+```http
+POST https://api.example.com/users
+Content-Type: application/json
+Authorization: Bearer {{token}}
+
+{
+ "name": "John Doe",
+ "email": "john@example.com"
+}
+```
+
+## Variables
+```http
+### Variables
+@baseUrl = https://api.example.com
+@apiKey = {{$dotenv API_KEY}}
+
+### Request using variables
+GET {{baseUrl}}/users
+X-API-Key: {{apiKey}}
+```
+
+## Dynamic Variables (Response Extraction)
+```http
+### Login to get token
+POST {{baseUrl}}/auth/login
+Content-Type: application/json
+
+{
+ "username": "admin",
+ "password": "secret"
+}
+
+###
+@authToken = {{login.response.body.token}}
+
+### Use extracted token
+GET {{baseUrl}}/protected
+Authorization: Bearer {{authToken}}
+```
+
+## Form Data
+```http
+POST {{baseUrl}}/upload
+Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
+
+------WebKitFormBoundary7MA4YWxkTrZu0gW
+Content-Disposition: form-data; name="file"; filename="test.jpg"
+Content-Type: image/jpeg
+
+< ./test.jpg
+------WebKitFormBoundary7MA4YWxkTrZu0gW--
+```
+
+## Request Separation
+Use `###` to separate multiple requests in the same file.
+
+# Task Workflow
+
+When asked to create .rest files:
+
+1. **Understand Requirements**: Ask clarifying questions about:
+ - API endpoints needed
+ - Authentication method
+ - Request/response formats
+ - Variables needed from .env
+ - Workflow dependencies
+
+2. **Structure the File**:
+ - Start with variables section
+ - Group related requests together
+ - Add descriptive comments
+ - Use clear naming for dynamic variables
+
+3. **Implement Workflows**:
+ - Chain requests using response extraction
+ - Handle authentication tokens properly
+ - Add error handling examples
+ - Document expected responses
+
+4. **Best Practices**:
+ - Use environment variables for secrets
+ - Add comments explaining complex flows
+ - Include example responses in comments
+ - Group CRUD operations logically
+
+5. **Fetch Documentation**:
+ - When uncertain about syntax, use WebFetch to check:
+ - https://marketplace.visualstudio.com/items?itemName=humao.rest-client
+ - Search for specific features when needed
+
+# Example: Complete Workflow
+
+```http
+### ===========================================
+### Banatie API Testing Workflow
+### ===========================================
+
+### Environment Variables
+@baseUrl = http://localhost:3000
+@masterKey = {{$dotenv MASTER_KEY}}
+@projectKey = {{$dotenv PROJECT_KEY}}
+
+### ===========================================
+### 1. Health Check
+### ===========================================
+GET {{baseUrl}}/health
+
+### ===========================================
+### 2. Create Project Key (Master Key Required)
+### ===========================================
+POST {{baseUrl}}/api/admin/keys
+Content-Type: application/json
+X-API-Key: {{masterKey}}
+
+{
+ "type": "project",
+ "projectId": "test-project",
+ "name": "Test Project Key"
+}
+
+###
+@newProjectKey = {{$2.response.body.data.key}}
+
+### ===========================================
+### 3. Generate Image
+### ===========================================
+POST {{baseUrl}}/api/v1/generations
+Content-Type: application/json
+X-API-Key: {{newProjectKey}}
+
+{
+ "prompt": "A beautiful sunset over mountains",
+ "aspectRatio": "16:9",
+ "alias": "@test-sunset"
+}
+
+###
+@generationId = {{$3.response.body.data.id}}
+@imageId = {{$3.response.body.data.outputImage.id}}
+
+### ===========================================
+### 4. Get Generation Details
+### ===========================================
+GET {{baseUrl}}/api/v1/generations/{{generationId}}
+X-API-Key: {{newProjectKey}}
+
+### ===========================================
+### 5. List All Generations
+### ===========================================
+GET {{baseUrl}}/api/v1/generations?limit=10&offset=0
+X-API-Key: {{newProjectKey}}
+```
+
+# Agent Behavior
+
+- **Proactive**: Suggest improvements to API testing workflows
+- **Thorough**: Include all necessary headers and parameters
+- **Educational**: Explain REST Client syntax when creating files
+- **Practical**: Focus on real-world API testing scenarios
+- **Current**: Fetch documentation when uncertain about features
+
+# Tools Available
+
+- **Read**: Read existing .rest files
+- **Write**: Create new .rest files
+- **Edit**: Modify existing .rest files
+- **Glob/Grep**: Find existing API-related files
+- **WebFetch**: Fetch REST Client documentation
+- **Bash**: Test API endpoints to verify .rest file correctness
+
+# Success Criteria
+
+A successful .rest file should:
+1. Execute without syntax errors
+2. Properly chain requests when needed
+3. Use variables from .env for secrets
+4. Include clear comments and structure
+5. Cover the complete API workflow
+6. Handle authentication correctly
+7. Extract and use response data appropriately
diff --git a/api-refactoring-final.md b/api-refactoring-final.md
new file mode 100644
index 0000000..c399e8d
--- /dev/null
+++ b/api-refactoring-final.md
@@ -0,0 +1,934 @@
+# Banatie API v1 - Technical Changes and Refactoring
+
+## Context
+
+Project is in active development with no existing clients. All changes can be made without backward compatibility concerns. **Priority: high-quality and correct API implementation.**
+
+---
+
+## 1. Parameter Naming Cleanup ✅
+
+### 1.1 POST /api/v1/generations
+
+**Current parameters:**
+- `assignAlias` → rename to `alias`
+- `assignFlowAlias` → rename to `flowAlias`
+
+**Rationale:** Shorter, clearer, no need for "assign" prefix when assignment is obvious from endpoint context.
+
+**Affected areas:**
+- Request type definitions
+- Route handlers
+- Service methods
+- API documentation
+
+### 1.2 Reference Images Auto-Detection
+
+**Parameter behavior:**
+- `referenceImages` parameter is **optional**
+- If provided (array of aliases or IDs) → use these images as references
+- If empty or not provided → service must automatically parse prompt and find all aliases
+
+**Auto-detection logic:**
+
+1. **Prompt parsing:**
+ - Scan prompt text for all alias patterns (@name)
+ - Extract all found aliases
+ - Resolve each alias to actual image ID
+
+2. **Manual override:**
+ - If `referenceImages` parameter is provided and not empty → use only specified images
+ - Manual list takes precedence over auto-detected aliases
+
+3. **Combined approach:**
+ - If `referenceImages` provided → add to auto-detected aliases (merge)
+ - Remove duplicates
+ - Maintain order: manual references first, then auto-detected
+
+**Example:**
+```json
+// Auto-detection (no referenceImages parameter)
+{
+ "prompt": "A landscape based on @sunset with elements from @mountain"
+ // System automatically detects @sunset and @mountain
+}
+
+// Manual specification
+{
+ "prompt": "A landscape",
+ "referenceImages": ["@sunset", "image-uuid-123"]
+ // System uses only specified images
+}
+
+// Combined
+{
+ "prompt": "A landscape based on @sunset",
+ "referenceImages": ["@mountain"]
+ // System uses both @mountain (manual) and @sunset (auto-detected)
+}
+```
+
+**Implementation notes:**
+- Alias detection must use the same validation rules as alias creation
+- Invalid aliases in prompt should be logged but not cause generation failure
+- Maximum reference images limit still applies after combining manual + auto-detected
+
+---
+
+## 2. Enhanced Prompt Support - Logic Redesign
+
+### 2.1 Database Schema Changes
+
+**Required schema modifications:**
+
+1. **Rename field:** `enhancedPrompt` → `originalPrompt`
+2. **Change field semantics:**
+ - `prompt` - ALWAYS contains the prompt that was used for generation (enhanced or original)
+ - `originalPrompt` - ALWAYS contains user's original input (for transparency and audit trail)
+
+**Field population logic:**
+
+```
+Case 1: autoEnhance = false
+ prompt = user input
+ originalPrompt = user input (same value, preserved for consistency)
+
+Case 2: autoEnhance = true
+ prompt = enhanced prompt (used for generation)
+ originalPrompt = user input (preserved)
+```
+
+**Rationale:** Always storing `originalPrompt` provides:
+- Audit trail of user's actual input
+- Ability to compare original vs enhanced prompts
+- Consistent API response structure
+- Simplified client logic (no null checks needed)
+
+### 2.2 API Response Format
+
+**Response structure:**
+```json
+{
+ "prompt": "detailed enhanced prompt...", // Always the prompt used for generation
+ "originalPrompt": "sunset", // Always the user's original input
+ "autoEnhance": true // True if prompt differs from originalPrompt
+}
+```
+
+**Affected endpoints:**
+- `POST /api/v1/generations` response
+- `GET /api/v1/generations/:id` response
+- `GET /api/v1/generations` list response
+
+---
+
+## 3. Regeneration Endpoint Refactoring ✅
+
+### 3.1 Endpoint Rename
+
+**Change:**
+- ❌ OLD: `POST /api/v1/generations/:id/retry`
+- ✅ NEW: `POST /api/v1/generations/:id/regenerate`
+
+### 3.2 Remove Status Checks
+
+- Remove `if (original.status === 'success') throw error` check
+- Remove `GENERATION_ALREADY_SUCCEEDED` error constant
+- Allow regeneration for any status (pending, processing, success, failed)
+
+### 3.3 Remove Retry Logic
+
+- Remove `retryCount >= MAX_RETRY_COUNT` check
+- Remove retryCount increment
+- Remove `MAX_RETRY_COUNT` constant
+
+### 3.4 Remove Override Parameters
+
+- Remove `prompt` and `aspectRatio` parameters from request body
+- Always regenerate with exact same parameters as original
+
+### 3.5 Image Update Behavior
+
+**Update existing image instead of creating new:**
+
+**Preserve:**
+- `imageId` (UUID remains the same)
+- `storageKey` (MinIO path)
+- `storageUrl`
+- `alias` (if assigned)
+- `createdAt` (original creation timestamp)
+
+**Update:**
+- Physical file in MinIO (overwrite)
+- `fileSize` (if changed)
+- `updatedAt` timestamp
+
+**Generation record:**
+- Update `status` → processing → success/failed
+- Update `processingTimeMs`
+- Keep `outputImageId` (same value)
+- Keep `flowId` (if present)
+
+### 3.6 Additional Endpoint
+
+**Add for Flow:**
+- `POST /api/v1/flows/:id/regenerate`
+- Regenerates the most recent generation in flow
+- Returns `FLOW_HAS_NO_GENERATIONS` error if flow is empty
+- Uses parameters from the last generation in flow
+
+---
+
+## 4. Flow Auto-Creation (Lazy Flow Pattern)
+
+### 4.1 Lazy Flow Creation Strategy
+
+**Concept:**
+1. **First request without flowId** → return generated `flowId` in response, but **DO NOT create in DB**
+2. **Any request with valid flowId** → create flow in DB if doesn't exist, add this request to flow
+3. **If flowAlias specified in request** → create flow immediately (eager creation)
+
+### 4.2 Implementation Details
+
+**Flow ID Generation:**
+- When generation/upload has no flowId, generate UUID for potential flow
+- Return this flowId in response
+- Save `flowId` in generation/image record, but DO NOT create flow record
+
+**Flow Creation in DB:**
+
+**Trigger:** ANY request with valid flowId value
+
+**Logic:**
+1. Check if flow record exists in DB
+2. Check if there are existing generations/images with this flowId
+3. If flow doesn't exist:
+ - Create flow record with provided flowId
+ - Include all existing records with this flowId
+ - Maintain chronological order based on createdAt timestamps
+4. If flow exists:
+ - Add new record to existing flow
+
+**Eager creation:**
+- If request includes `flowAlias` → create flow immediately
+- Set alias in `flow.aliases` object
+
+**Database Schema:**
+- `generations` table already has `flowId` field (foreign key to flows.id)
+- `images` table already has `flowId` field (foreign key to flows.id)
+- No schema changes needed
+
+**Orphan flowId handling:**
+- If `flowId` exists in generation/image record but not in `flows` table - this is normal
+- Such records are called "orphans" and simply not shown in `GET /api/v1/flows` list
+- No cleanup job needed
+- Do NOT delete such records automatically
+- System works correctly with orphan flowIds until flow record is created
+
+### 4.3 Endpoint Changes
+
+**Remove:**
+- ❌ `POST /api/v1/flows` endpoint (no longer needed)
+
+**Modify responses:**
+- `POST /api/v1/generations` → always return `flowId` in response (see section 10.1)
+- `POST /api/v1/images/upload` → always return `flowId` in response (see section 10.1)
+
+---
+
+## 5. Upload Image Enhancements
+
+### 5.1 Add Parameters
+
+**POST /api/v1/images/upload:**
+
+**Parameters:**
+- `alias` (optional, string) - project-scoped alias
+- `flowAlias` (optional, string) - flow-scoped alias for uploaded image
+- `flowId` (optional, string) - flow association
+
+**Behavior:**
+- If `flowAlias` and `flowId` specified:
+ - Ensure flow exists (or create via lazy pattern)
+ - Add alias to `flow.aliases` object
+- If `flowAlias` WITHOUT `flowId`:
+ - Apply lazy flow creation with eager pattern
+ - Create flow immediately, set flowAlias
+- If only `alias` specified:
+ - Set project-scoped alias on image
+
+### 5.2 Alias Conflict Resolution
+
+**Validation rules:**
+
+1. **Technical aliases are forbidden:**
+ - Cannot use: `@last`, `@first`, `@upload` or any reserved technical alias
+ - Return validation error if attempted
+
+2. **Alias override behavior:**
+ - If alias already exists → new request has higher priority
+ - Alias points to new image
+ - Previous image loses its alias but is NOT deleted
+ - Same logic applies to both project aliases and flow aliases
+
+3. **Applies to both:**
+ - Image upload with alias
+ - Generation with alias/flowAlias
+
+**Example:**
+```
+State: Image A has alias "@hero"
+Request: Upload Image B with alias "@hero"
+Result:
+ - Image B now has alias "@hero"
+ - Image A loses alias (alias = NULL)
+ - Image A is NOT deleted
+```
+
+---
+
+## 6. Image Alias Management Refactoring
+
+### 6.1 Endpoint Consolidation
+
+**Remove alias handling from:**
+- ❌ `PUT /api/v1/images/:id` (body: { alias, focalPoint, meta })
+ - Remove `alias` parameter
+ - Keep only `focalPoint` and `meta`
+
+**Single method for project-scoped alias management:**
+- ✅ `PUT /api/v1/images/:id/alias` (body: { alias })
+ - Set new alias
+ - Change existing alias
+ - Remove alias (pass `alias: null`)
+
+**Rationale:** Explicit intent, dedicated endpoint for alias operations, simpler validation.
+
+### 6.2 Alias as Image Identifier
+
+**Support alias in path parameters:**
+
+**Syntax:**
+- UUID: `GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000`
+- Alias: `GET /api/v1/images/@hero`
+- `@` symbol distinguishes alias from UUID (UUIDs never contain `@`)
+
+**UUID validation:** UUIDs can NEVER contain `@` symbol - this guarantees no conflicts
+
+**Flow-scoped resolution:**
+- `GET /api/v1/images/@hero?flowId=uuid-123`
+- Searches for alias `@hero` in context of flow `uuid-123`
+- Uses 3-tier precedence (technical → flow → project)
+
+**Endpoints with alias support:**
+- `GET /api/v1/images/:id_or_alias`
+- `PUT /api/v1/images/:id_or_alias` (for focalPoint, meta)
+- `PUT /api/v1/images/:id_or_alias/alias`
+- `DELETE /api/v1/images/:id_or_alias`
+
+**Implementation:**
+- Check first character of path parameter
+- If starts with `@` → resolve via AliasService
+- If doesn't start with `@` → treat as UUID
+- After resolution, work with imageId as usual
+
+### 6.3 CDN-style Image URLs with Alias Support
+
+**Current URL format must be changed.**
+
+**New standardized URL patterns:**
+
+**For all generated and uploaded images:**
+```
+GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
+```
+
+**For live URLs:**
+```
+GET /cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
+```
+
+**All image URLs returned by API must follow this pattern.**
+
+**Resolution Logic:**
+1. Check if `:filenameOrAlias` starts with `@`
+2. If yes → resolve alias via AliasService
+3. If no → search by filename/storageKey
+4. Return image bytes with proper content-type headers
+
+**Response Headers:**
+- Content-Type: image/jpeg (or appropriate MIME type)
+- Cache-Control: public, max-age=31536000
+- ETag: based on imageId or fileHash
+
+**URL Encoding for prompts:**
+- Spaces can be replaced with underscores `_` for convenience
+- Both `prompt=beautiful%20sunset` and `prompt=beautiful_sunset` are valid
+- System should handle both formats
+
+**Examples:**
+```
+GET /cdn/acme/website/img/@hero → resolve @hero alias
+GET /cdn/acme/website/img/logo.png → find by filename
+GET /cdn/acme/website/img/@product-1 → resolve @product-1 alias
+```
+
+**Error Handling:**
+- Alias not found → 404
+- Filename not found → 404
+- Multiple matches → alias takes priority over filename
+
+---
+
+## 7. Deletion Strategy Overhaul
+
+### 7.1 Image Deletion (Hard Delete)
+
+**DELETE /api/v1/images/:id**
+
+**Operations:**
+1. Delete physical file from MinIO storage
+2. Delete record from `images` table (hard delete)
+3. Cascade: set `outputImageId = NULL` in related generations
+4. Cascade: **completely remove alias entries** from all `flow.aliases` where imageId is referenced
+ - Remove entire key-value pairs, not just values
+5. Cascade: remove imageId from `generation.referencedImages` JSON arrays
+
+**Example cascade for flow.aliases:**
+```
+Before: flow.aliases = { "@hero": "img-123", "@product": "img-456" }
+Delete img-123
+After: flow.aliases = { "@product": "img-456" }
+```
+
+**Rationale:** User wants to delete - remove completely, free storage. Alias entries are also completely removed.
+
+### 7.2 Generation Deletion (Conditional)
+
+**DELETE /api/v1/generations/:id**
+
+**Behavior depends on output image alias:**
+
+**Case 1: Output image WITHOUT project alias**
+1. Delete output image completely (hard delete with MinIO cleanup)
+2. Delete generation record (hard delete)
+
+**Case 2: Output image WITH project alias**
+1. Keep output image (do not delete)
+2. Delete only generation record (hard delete)
+3. Set `generationId = NULL` in image record
+
+**Decision Logic:**
+- If `outputImage.alias !== null` → keep image, delete only generation
+- If `outputImage.alias === null` → delete both image and generation
+
+**Rationale:**
+- Image with project alias is used as standalone asset, preserve it
+- Image without alias was created only for this generation, delete together
+
+**No regeneration of deleted generations** - deleted generations cannot be regenerated
+
+### 7.3 Flow Deletion (Cascade with Alias Protection)
+
+**DELETE /api/v1/flows/:id**
+
+**Operations:**
+1. Delete flow record from DB
+2. Cascade: delete all generations associated with this flowId
+3. Cascade: delete all images associated with this flowId **EXCEPT** images with project alias
+
+**Detailed Cascade Logic:**
+
+**For Generations:**
+- Delete each generation (follows conditional delete from 7.2)
+- If output image has no alias → delete image
+- If output image has alias → keep image, set generationId = NULL, set flowId = NULL
+
+**For Images (uploaded):**
+- If image has no alias → delete (with MinIO cleanup)
+- If image has alias → keep, set flowId = NULL
+
+**Summary:**
+- Flow record → DELETE
+- All generations → DELETE
+- Images without alias → DELETE (with MinIO cleanup)
+- Images with project alias → KEEP (unlink: flowId = NULL)
+
+**Rationale:**
+Flow deletion removes all content except images with project aliases (used globally in project).
+
+### 7.4 Transactional Delete Pattern
+
+**All delete operations must be transactional:**
+
+1. Delete from MinIO storage first
+2. Delete from database (with cascades)
+3. If MinIO delete fails → rollback DB transaction
+4. If DB delete fails → cleanup MinIO file (or rollback if possible)
+5. Log all delete operations for audit trail
+
+**Principle:** System must be designed so orphaned files in MinIO NEVER occur.
+
+**Database Constraints:**
+- ON DELETE CASCADE for appropriate foreign keys
+- ON DELETE SET NULL where related records must be preserved
+- Proper referential integrity
+
+**No background cleanup jobs needed** - system is self-sufficient and always consistent.
+
+---
+
+## 8. Live URL System
+
+### 8.1 Core Concept
+
+**Purpose:** Permanent URLs that can be immediately inserted into HTML and work forever.
+
+**Use Case:**
+```html
+
+```
+
+**Key Points:**
+- URL is constructed immediately and used permanently
+- No preliminary generation through API needed
+- No signed URLs or tokens in query params
+- First request → generation, subsequent → cache
+
+### 8.2 URL Format & Structure
+
+**URL Pattern:**
+```
+/cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
+```
+
+**URL Components:**
+```
+/cdn/acme/website/live/hero-section?prompt=beautiful_sunset&aspectRatio=16:9
+ │ │ │ │ │
+ │ │ │ │ └─ Generation params (query string)
+ │ │ │ └─ Scope identifier
+ │ │ └─ "live" prefix
+ │ └─ Project slug
+ └─ Organization slug
+```
+
+**Scope Parameter:**
+- Name: `scope` (confirmed)
+- Purpose: logical separation of live URLs within project
+- Format: alphanumeric + hyphens + underscores
+- Any user can specify any scope (no validation/signature required)
+
+### 8.3 First Request Flow
+
+**Cache MISS (first request):**
+1. Parse orgSlug, projectSlug, scope from URL
+2. Compute cache key: hash(projectId + scope + prompt + params)
+3. Check if image exists in cache
+4. If NOT found:
+ - Check scope settings (allowNewGenerations, limit)
+ - Trigger image generation
+ - Create database records (generation, image, cache entry)
+ - Wait for generation to complete
+ - Return image bytes
+
+**Response:**
+- Content-Type: image/jpeg
+- Cache-Control: public, max-age=31536000
+- X-Cache-Status: MISS
+- X-Scope: hero-section
+- X-Image-Id: uuid
+
+**Cache HIT (subsequent requests):**
+1. Same cache key lookup
+2. Found existing image
+3. Return cached image bytes immediately
+
+**Response:**
+- Content-Type: image/jpeg
+- Cache-Control: public, max-age=31536000
+- X-Cache-Status: HIT
+- X-Image-Id: uuid
+
+**Generation in Progress:**
+- If image is not in cache but generation is already running:
+- System must have internal status to track this
+- Wait for generation to complete
+- Return image bytes immediately when ready
+- This ensures consistent behavior for concurrent requests
+
+### 8.4 Scope Management
+
+**Database Table: `live_scopes`**
+
+Create dedicated table with fields:
+- `id` (UUID, primary key)
+- `project_id` (UUID, foreign key to projects)
+- `slug` (TEXT, unique within project) - used in URL
+- `allowNewGenerations` (BOOLEAN, default: true) - controls if new generations can be triggered
+- `newGenerationsLimit` (INTEGER, default: 30) - max number of generations in this scope
+- `created_at` (TIMESTAMP)
+- `updated_at` (TIMESTAMP)
+
+**Scope Behavior:**
+
+**allowNewGenerations:**
+- Controls whether new generations can be triggered in this scope
+- Already generated images are ALWAYS served publicly regardless of this setting
+- Default: true
+
+**newGenerationsLimit:**
+- Limit on number of generations in this scope
+- Only affects NEW generations, does not affect regeneration
+- Default: 30
+
+**Scope Creation:**
+- Manual: via dedicated endpoint (see below)
+- Automatic: when new scope is used in live URL (if project allows)
+
+**Project-level Settings:**
+
+Add to projects table or settings:
+- `allowNewLiveScopes` (BOOLEAN, default: true) - allows creating new scopes via live URLs
+ - If false: new scopes cannot be created via live URL
+ - If false: scopes can still be created via API endpoint
+- `newLiveScopesGenerationLimit` (INTEGER, default: 30) - generation limit for auto-created scopes
+ - This value is set as `newGenerationsLimit` for newly created scopes
+
+### 8.5 Scope Management API
+
+**Create scope (manual):**
+```
+POST /api/v1/live/scopes
+Headers: X-API-Key: bnt_project_key
+Body: {
+ "slug": "hero-section",
+ "allowNewGenerations": true,
+ "newGenerationsLimit": 50
+}
+```
+
+**List scopes:**
+```
+GET /api/v1/live/scopes
+Headers: X-API-Key: bnt_project_key
+Response: {
+ "scopes": [
+ {
+ "id": "uuid",
+ "slug": "hero-section",
+ "allowNewGenerations": true,
+ "newGenerationsLimit": 50,
+ "currentGenerations": 23,
+ "lastGeneratedAt": "2024-01-15T10:30:00Z"
+ }
+ ]
+}
+```
+
+**Get scope details:**
+```
+GET /api/v1/live/scopes/:slug
+Headers: X-API-Key: bnt_project_key
+Response: {
+ "id": "uuid",
+ "slug": "hero-section",
+ "allowNewGenerations": true,
+ "newGenerationsLimit": 50,
+ "currentGenerations": 23,
+ "images": [...]
+}
+```
+
+**Update scope:**
+```
+PUT /api/v1/live/scopes/:slug
+Headers: X-API-Key: bnt_project_key
+Body: {
+ "allowNewGenerations": false,
+ "newGenerationsLimit": 100
+}
+```
+
+**Regenerate scope images:**
+```
+POST /api/v1/live/scopes/:slug/regenerate
+Headers: X-API-Key: bnt_project_key
+Body: { "imageId": "uuid" } // Optional: regenerate specific image
+Response: {
+ "regenerated": 1,
+ "images": [...]
+}
+```
+
+**Delete scope:**
+```
+DELETE /api/v1/live/scopes/:slug
+Headers: X-API-Key: bnt_project_key
+```
+
+**Deletion behavior:** Deletes all images in this scope (follows standard image deletion with alias protection).
+
+### 8.6 Security & Rate Limiting
+
+**Rate Limiting by IP:**
+- Aggressive limits for live URLs (e.g., 10 new generations per hour per IP)
+- Separate from API key limits
+- Cache hits do NOT count toward limit
+- Only new generations count
+
+**Scope Quotas:**
+- Maximum N unique prompts per scope (newGenerationsLimit)
+- After limit reached → return existing images, do not generate new
+- Regeneration does not count toward limit
+
+### 8.7 Caching Strategy
+
+**Cache Key:**
+```
+cacheKey = hash(projectId + scope + prompt + aspectRatio + otherParams)
+```
+
+**Cache Invalidation:**
+- Manual: via API endpoint regenerate
+- Automatic: never (images cached forever unless explicitly regenerated)
+
+**Scope Naming:** `scope` (confirmed)
+
+**URL Encoding:**
+- Prompt in query string: URL-encoded or underscores for spaces
+- Both formats supported: `prompt=beautiful%20sunset` and `prompt=beautiful_sunset`
+- Scope in path: alphanumeric + hyphens + underscores
+
+### 8.8 Error Handling
+
+**Detailed errors for live URLs:**
+
+- Invalid scope format → 400 "Invalid scope format. Use alphanumeric characters, hyphens, and underscores"
+- New scope creation disabled → 403 "Creating new live scopes is disabled for this project"
+- Generation limit exceeded → 429 "Scope generation limit exceeded. Maximum N generations per scope"
+- Generation fails → 500 with retry logic
+- Rate limit by IP exceeded → 429 "Rate limit exceeded. Try again in X seconds" with Retry-After header
+
+---
+
+## 9. Generation Modification
+
+### 9.1 Update Generation Endpoint
+
+**New endpoint:**
+```
+PUT /api/v1/generations/:id
+```
+
+**Modifiable Fields:**
+- `prompt` - change prompt
+- `aspectRatio` - change aspect ratio
+- `flowId` - change/remove/add flow association
+- `meta` - update metadata
+
+**Behavior:**
+
+**Case 1: Non-generative parameters (flowId, meta)**
+- Simply update fields in DB
+- Do NOT regenerate image
+
+**Case 2: Generative parameters (prompt, aspectRatio)**
+- Update fields in DB
+- Automatically trigger regeneration
+- Update existing image (same imageId, path, URL)
+
+### 9.2 FlowId Management
+
+**FlowId handling:**
+- `flowId: null` → detach from flow
+- `flowId: "new-uuid"` → attach to different flow
+ - If flow doesn't exist → create new flow eagerly (with this flowId)
+ - If flow exists → add generation to existing flow
+- `flowId: undefined` → do not change current value
+
+**Use Case - "Detach from Flow":**
+- Set `flowId: null` to detach generation from flow
+- Output image is preserved (if has alias)
+- Useful before deleting flow to protect important generations
+
+### 9.3 Validation Rules
+
+**Use existing validation logic from generation creation:**
+- Prompt validation (existing rules)
+- AspectRatio validation (existing rules)
+- FlowId validation:
+ - If provided (not null): must be valid UUID format
+ - Flow does NOT need to exist (will be created eagerly if missing)
+ - Allow null explicitly (for detachment)
+
+### 9.4 Response Format
+
+```json
+{
+ "success": true,
+ "data": {
+ "id": "gen-uuid",
+ "prompt": "updated prompt",
+ "aspectRatio": "16:9",
+ "flowId": null,
+ "status": "processing", // If regeneration triggered
+ "regenerated": true, // Flag indicating regeneration started
+ "outputImage": { ... } // Current image (updates when regeneration completes)
+ }
+}
+```
+
+---
+
+## 10. Response Format Consistency
+
+### 10.1 FlowId in Responses
+
+**Rule for flowId in generation and upload responses:**
+
+**If request has `flowId: undefined` (not provided):**
+- Generate new flowId
+- Return in response: `"flowId": "new-uuid"`
+
+**If request has `flowId: null` (explicitly null):**
+- Do NOT generate flowId
+- Flow is definitely not needed
+- Return in response: `"flowId": null`
+
+**If request has `flowId: "uuid"` (specific value):**
+- Use provided flowId
+- Return in response: `"flowId": "uuid"`
+
+**Examples:**
+```json
+// Request without flowId
+POST /api/v1/generations
+Body: { "prompt": "sunset" }
+Response: { "flowId": "generated-uuid", ... }
+
+// Request with explicit null
+POST /api/v1/generations
+Body: { "prompt": "sunset", "flowId": null }
+Response: { "flowId": null, ... }
+
+// Request with specific flowId
+POST /api/v1/generations
+Body: { "prompt": "sunset", "flowId": "my-flow-uuid" }
+Response: { "flowId": "my-flow-uuid", ... }
+```
+
+---
+
+## 11. Error Messages Updates
+
+**Remove constants:**
+- `GENERATION_ALREADY_SUCCEEDED` (no longer needed)
+- `MAX_RETRY_COUNT_EXCEEDED` (no longer needed)
+
+**Add constants:**
+- `SCOPE_INVALID_FORMAT` - "Invalid scope format. Use alphanumeric characters, hyphens, and underscores"
+- `SCOPE_CREATION_DISABLED` - "Creating new live scopes is disabled for this project"
+- `SCOPE_GENERATION_LIMIT_EXCEEDED` - "Scope generation limit exceeded. Maximum {limit} generations per scope"
+- `STORAGE_DELETE_FAILED` - "Failed to delete file from storage"
+
+**Update constants:**
+- `GENERATION_FAILED` - include details about network/storage errors
+- `IMAGE_NOT_FOUND` - distinguish between deleted and never existed
+
+---
+
+## 12. Code Documentation Standards
+
+### 12.1 Endpoint JSDoc Comments
+
+**Requirement:** Every API endpoint must have comprehensive JSDoc comment.
+
+**Required sections:**
+
+1. **Purpose:** What this endpoint does (one sentence)
+2. **Logic:** Brief description of how it works (2-3 key steps)
+3. **Parameters:** Description of each parameter and what it affects
+4. **Authentication:** Required authentication level
+5. **Response:** What is returned
+
+**Example format:**
+```typescript
+/**
+ * Generate new image from text prompt with optional reference images.
+ *
+ * Logic:
+ * 1. Parse prompt to auto-detect reference image aliases
+ * 2. Resolve all aliases (auto-detected + manual) to image IDs
+ * 3. Trigger AI generation with prompt and reference images
+ * 4. Store result with metadata and return generation record
+ *
+ * @param {string} prompt - Text description for image generation (affects: output style and content)
+ * @param {string[]} referenceImages - Optional aliases/IDs for reference images (affects: visual style transfer)
+ * @param {string} aspectRatio - Image dimensions ratio (affects: output dimensions, default: 1:1)
+ * @param {string} flowId - Optional flow association (affects: organization and flow-scoped aliases)
+ * @param {string} alias - Optional project-scoped alias (affects: image referencing across project)
+ * @param {string} flowAlias - Optional flow-scoped alias (affects: image referencing within flow)
+ * @param {boolean} autoEnhance - Enable AI prompt enhancement (affects: prompt quality and detail)
+ * @param {object} meta - Custom metadata (affects: searchability and organization)
+ *
+ * @authentication Project Key required
+ * @returns {GenerationResponse} Generation record with status and output image details
+ */
+router.post('/generations', ...);
+```
+
+**Apply to:**
+- All route handlers in `/routes/**/*.ts`
+- All public service methods that implement core business logic
+- Complex utility functions with non-obvious behavior
+
+**Parameter descriptions must include "affects:"**
+- Explain what each parameter influences in the system
+- Help developers understand parameter impact
+- Make API more discoverable and self-documenting
+
+---
+
+## Summary of Changes
+
+### Database Changes
+1. Rename `enhancedPrompt` → `originalPrompt` in generations table
+2. Create `live_scopes` table with fields: id, project_id, slug, allowNewGenerations, newGenerationsLimit
+3. Add project settings: allowNewLiveScopes, newLiveScopesGenerationLimit
+4. Add `scope` and `isLiveUrl` fields to images table (optional, can use meta)
+
+### API Changes
+1. Rename parameters: assignAlias → alias, assignFlowAlias → flowAlias
+2. Make referenceImages parameter optional with auto-detection from prompt
+3. Rename endpoint: POST /generations/:id/retry → /generations/:id/regenerate
+4. Remove endpoint: POST /api/v1/flows (no longer needed)
+5. Add endpoint: POST /api/v1/flows/:id/regenerate
+6. Add endpoint: PUT /api/v1/generations/:id (modification)
+7. Add CDN endpoints:
+ - GET /cdn/:org/:project/img/:filenameOrAlias (all images)
+ - GET /cdn/:org/:project/live/:scope (live URLs)
+8. Add scope management endpoints (CRUD for live_scopes)
+9. Update all image URLs in API responses to use CDN format
+
+### Behavior Changes
+1. Lazy flow creation (create on second request or when flowAlias present)
+2. Alias conflict resolution (new overwrites old)
+3. Regenerate updates existing image (same ID, path, URL)
+4. Hard delete for images (with MinIO cleanup)
+5. Conditional delete for generations (based on alias)
+6. Cascade delete for flows (with alias protection)
+7. Live URL caching and scope management
+8. FlowId in responses (generate if undefined, keep if null)
+9. Auto-detect reference images from prompt aliases
+
+### Validation Changes
+1. @ symbol distinguishes aliases from UUIDs
+2. Technical aliases forbidden in user input
+3. Flow creation on-the-fly for non-existent flowIds
+4. Scope format validation for live URLs
+
+### Documentation Changes
+1. Add comprehensive JSDoc comments to all endpoints
+2. Include purpose, logic, parameters with "affects" descriptions
+3. Document authentication requirements in comments
diff --git a/apps/api-service/package.json b/apps/api-service/package.json
index 399226d..0be1e82 100644
--- a/apps/api-service/package.json
+++ b/apps/api-service/package.json
@@ -43,10 +43,12 @@
"@google/genai": "^1.22.0",
"cors": "^2.8.5",
"dotenv": "^17.2.2",
+ "drizzle-orm": "^0.36.4",
"express": "^5.1.0",
"express-rate-limit": "^7.4.1",
"express-validator": "^7.2.0",
"helmet": "^8.0.0",
+ "image-size": "^2.0.2",
"mime": "3.0.0",
"minio": "^8.0.6",
"multer": "^2.0.2",
diff --git a/apps/api-service/src/app.ts b/apps/api-service/src/app.ts
index 3730df7..44a0a51 100644
--- a/apps/api-service/src/app.ts
+++ b/apps/api-service/src/app.ts
@@ -1,12 +1,15 @@
import express, { Application } from 'express';
import cors from 'cors';
import { config } from 'dotenv';
+import { randomUUID } from 'crypto';
import { Config } from './types/api';
import { textToImageRouter } from './routes/textToImage';
import { imagesRouter } from './routes/images';
import { uploadRouter } from './routes/upload';
import bootstrapRoutes from './routes/bootstrap';
import adminKeysRoutes from './routes/admin/keys';
+import { v1Router } from './routes/v1';
+import { cdnRouter } from './routes/cdn';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
// Load environment variables
@@ -42,7 +45,7 @@ export const createApp = (): Application => {
// Request ID middleware for logging
app.use((req, res, next) => {
- req.requestId = Math.random().toString(36).substr(2, 9);
+ req.requestId = randomUUID();
res.setHeader('X-Request-ID', req.requestId);
next();
});
@@ -110,13 +113,19 @@ export const createApp = (): Application => {
});
// Public routes (no authentication)
+ // CDN routes for serving images and live URLs (public, no auth)
+ app.use('/cdn', cdnRouter);
+
// Bootstrap route (no auth, but works only once)
app.use('/api/bootstrap', bootstrapRoutes);
// Admin routes (require master key)
app.use('/api/admin/keys', adminKeysRoutes);
- // Protected API routes (require valid API key)
+ // API v1 routes (versioned, require valid API key)
+ app.use('/api/v1', v1Router);
+
+ // Protected API routes (require valid API key) - Legacy
app.use('/api', textToImageRouter);
app.use('/api', imagesRouter);
app.use('/api', uploadRouter);
diff --git a/apps/api-service/src/middleware/ipRateLimiter.ts b/apps/api-service/src/middleware/ipRateLimiter.ts
new file mode 100644
index 0000000..a5858c0
--- /dev/null
+++ b/apps/api-service/src/middleware/ipRateLimiter.ts
@@ -0,0 +1,176 @@
+import { Request, Response, NextFunction } from 'express';
+
+/**
+ * IP-based rate limiter for live URL generation (Section 8.6)
+ *
+ * Limits: 10 new generations per hour per IP address
+ * - Separate from API key rate limits
+ * - Cache hits do NOT count toward limit
+ * - Only new generations (cache MISS) count
+ *
+ * Implementation uses in-memory store with automatic cleanup
+ */
+
+interface RateLimitEntry {
+ count: number;
+ resetAt: number; // Timestamp when count resets
+}
+
+// In-memory store for IP rate limits
+// Key: IP address, Value: { count, resetAt }
+const ipRateLimits = new Map();
+
+// Configuration
+const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
+const MAX_REQUESTS_PER_WINDOW = 10; // 10 new generations per hour
+
+/**
+ * Get client IP address from request
+ * Supports X-Forwarded-For header for proxy/load balancer setups
+ */
+const getClientIp = (req: Request): string => {
+ // Check X-Forwarded-For header (used by proxies/load balancers)
+ const forwardedFor = req.headers['x-forwarded-for'];
+ if (forwardedFor) {
+ // X-Forwarded-For can contain multiple IPs, take the first one
+ const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
+ return ips?.split(',')[0]?.trim() || req.ip || 'unknown';
+ }
+
+ // Fall back to req.ip
+ return req.ip || 'unknown';
+};
+
+/**
+ * Clean up expired entries from the rate limit store
+ * Called periodically to prevent memory leaks
+ */
+const cleanupExpiredEntries = (): void => {
+ const now = Date.now();
+ for (const [ip, entry] of ipRateLimits.entries()) {
+ if (now > entry.resetAt) {
+ ipRateLimits.delete(ip);
+ }
+ }
+};
+
+// Run cleanup every 5 minutes
+setInterval(cleanupExpiredEntries, 5 * 60 * 1000);
+
+/**
+ * Check if IP has exceeded rate limit
+ * Returns true if limit exceeded, false otherwise
+ */
+export const checkIpRateLimit = (ip: string): boolean => {
+ const now = Date.now();
+ const entry = ipRateLimits.get(ip);
+
+ if (!entry) {
+ // First request from this IP
+ ipRateLimits.set(ip, {
+ count: 1,
+ resetAt: now + RATE_LIMIT_WINDOW_MS,
+ });
+ return false; // Not limited
+ }
+
+ // Check if window has expired
+ if (now > entry.resetAt) {
+ // Reset the counter
+ entry.count = 1;
+ entry.resetAt = now + RATE_LIMIT_WINDOW_MS;
+ return false; // Not limited
+ }
+
+ // Increment counter
+ entry.count += 1;
+
+ // Check if limit exceeded
+ return entry.count > MAX_REQUESTS_PER_WINDOW;
+};
+
+/**
+ * Get remaining requests for IP
+ */
+export const getRemainingRequests = (ip: string): number => {
+ const now = Date.now();
+ const entry = ipRateLimits.get(ip);
+
+ if (!entry) {
+ return MAX_REQUESTS_PER_WINDOW;
+ }
+
+ // Check if window has expired
+ if (now > entry.resetAt) {
+ return MAX_REQUESTS_PER_WINDOW;
+ }
+
+ return Math.max(0, MAX_REQUESTS_PER_WINDOW - entry.count);
+};
+
+/**
+ * Get time until rate limit resets (in seconds)
+ */
+export const getResetTime = (ip: string): number => {
+ const now = Date.now();
+ const entry = ipRateLimits.get(ip);
+
+ if (!entry || now > entry.resetAt) {
+ return 0;
+ }
+
+ return Math.ceil((entry.resetAt - now) / 1000);
+};
+
+/**
+ * Middleware: IP-based rate limiter for live URLs
+ * Only increments counter on cache MISS (new generation)
+ * Use this middleware BEFORE cache check, but only increment after cache MISS
+ */
+export const ipRateLimiterMiddleware = (req: Request, res: Response, next: NextFunction): void => {
+ const ip = getClientIp(req);
+
+ // Attach IP to request for later use
+ (req as any).clientIp = ip;
+
+ // Attach rate limit check function to request
+ (req as any).checkIpRateLimit = () => {
+ const limited = checkIpRateLimit(ip);
+ if (limited) {
+ const resetTime = getResetTime(ip);
+ res.status(429).json({
+ success: false,
+ error: {
+ message: `Rate limit exceeded. Try again in ${resetTime} seconds`,
+ code: 'IP_RATE_LIMIT_EXCEEDED',
+ },
+ });
+ res.setHeader('Retry-After', resetTime.toString());
+ res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
+ res.setHeader('X-RateLimit-Remaining', '0');
+ res.setHeader('X-RateLimit-Reset', getResetTime(ip).toString());
+ return true; // Limited
+ }
+ return false; // Not limited
+ };
+
+ // Set rate limit headers
+ const remaining = getRemainingRequests(ip);
+ const resetTime = getResetTime(ip);
+ res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
+ res.setHeader('X-RateLimit-Remaining', remaining.toString());
+ if (resetTime > 0) {
+ res.setHeader('X-RateLimit-Reset', resetTime.toString());
+ }
+
+ next();
+};
+
+/**
+ * Helper function to manually increment IP rate limit counter
+ * Use this after confirming cache MISS (new generation)
+ */
+export const incrementIpRateLimit = (_ip: string): void => {
+ // Counter already incremented in checkIpRateLimit
+ // This is a no-op, kept for API consistency
+};
diff --git a/apps/api-service/src/middleware/promptEnhancement.ts b/apps/api-service/src/middleware/promptEnhancement.ts
index 2e10934..db5fccf 100644
--- a/apps/api-service/src/middleware/promptEnhancement.ts
+++ b/apps/api-service/src/middleware/promptEnhancement.ts
@@ -81,8 +81,6 @@ export const autoEnhancePrompt = async (
}),
enhancements: result.metadata?.enhancements || [],
};
-
- req.body.prompt = result.enhancedPrompt;
} else {
console.warn(`[${timestamp}] [${requestId}] Prompt enhancement failed: ${result.error}`);
console.log(`[${timestamp}] [${requestId}] Proceeding with original prompt`);
diff --git a/apps/api-service/src/routes/cdn.ts b/apps/api-service/src/routes/cdn.ts
new file mode 100644
index 0000000..4a7c52c
--- /dev/null
+++ b/apps/api-service/src/routes/cdn.ts
@@ -0,0 +1,481 @@
+import { Response, Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { db } from '@/db';
+import { organizations, projects, images } from '@banatie/database';
+import { eq, and, isNull, sql } from 'drizzle-orm';
+import { ImageService, GenerationService, LiveScopeService } from '@/services/core';
+import { StorageFactory } from '@/services/StorageFactory';
+import { asyncHandler } from '@/middleware/errorHandler';
+import { ipRateLimiterMiddleware } from '@/middleware/ipRateLimiter';
+import { computeLiveUrlCacheKey } from '@/utils/helpers';
+import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
+import type { LiveGenerationQuery } from '@/types/requests';
+
+export const cdnRouter: RouterType = Router();
+
+let imageService: ImageService;
+let generationService: GenerationService;
+let liveScopeService: LiveScopeService;
+
+const getImageService = (): ImageService => {
+ if (!imageService) {
+ imageService = new ImageService();
+ }
+ return imageService;
+};
+
+const getGenerationService = (): GenerationService => {
+ if (!generationService) {
+ generationService = new GenerationService();
+ }
+ return generationService;
+};
+
+const getLiveScopeService = (): LiveScopeService => {
+ if (!liveScopeService) {
+ liveScopeService = new LiveScopeService();
+ }
+ return liveScopeService;
+};
+
+/**
+ * Serve images by filename or project-scoped alias via public CDN
+ *
+ * Public CDN endpoint for serving images without authentication:
+ * - Supports filename-based access (exact match in storageKey)
+ * - Supports project-scoped alias access (@alias-name)
+ * - Returns raw image bytes with optimal caching headers
+ * - Long-term browser caching (1 year max-age)
+ * - No rate limiting (public access)
+ *
+ * URL structure matches MinIO storage organization for efficient lookups.
+ *
+ * @route GET /cdn/:orgSlug/:projectSlug/img/:filenameOrAlias
+ * @authentication None - Public endpoint
+ *
+ * @param {string} req.params.orgSlug - Organization slug
+ * @param {string} req.params.projectSlug - Project slug
+ * @param {string} req.params.filenameOrAlias - Filename or @alias
+ *
+ * @returns {Buffer} 200 - Image file bytes with Content-Type header
+ * @returns {object} 404 - Organization, project, or image not found
+ * @returns {object} 500 - CDN or storage error
+ *
+ * @throws {Error} ORG_NOT_FOUND - Organization does not exist
+ * @throws {Error} PROJECT_NOT_FOUND - Project does not exist
+ * @throws {Error} IMAGE_NOT_FOUND - Image not found
+ * @throws {Error} CDN_ERROR - General CDN error
+ *
+ * @example
+ * // Access by filename
+ * GET /cdn/acme/website/img/hero-background.jpg
+ *
+ * @example
+ * // Access by alias
+ * GET /cdn/acme/website/img/@hero
+ *
+ * Response Headers:
+ * Content-Type: image/jpeg
+ * Content-Length: 245810
+ * Cache-Control: public, max-age=31536000
+ * X-Image-Id: 550e8400-e29b-41d4-a716-446655440000
+ */
+cdnRouter.get(
+ '/:orgSlug/:projectSlug/img/:filenameOrAlias',
+ asyncHandler(async (req: any, res: Response) => {
+ const { orgSlug, projectSlug, filenameOrAlias } = req.params;
+
+ try {
+ // Resolve organization and project
+ const org = await db.query.organizations.findFirst({
+ where: eq(organizations.slug, orgSlug),
+ });
+
+ if (!org) {
+ res.status(404).json({
+ success: false,
+ error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' },
+ });
+ return;
+ }
+
+ const project = await db.query.projects.findFirst({
+ where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)),
+ });
+
+ if (!project) {
+ res.status(404).json({
+ success: false,
+ error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' },
+ });
+ return;
+ }
+
+ let image;
+
+ // Check if filenameOrAlias is an alias (starts with @)
+ if (filenameOrAlias.startsWith('@')) {
+ // Lookup by project-scoped alias
+ const allImages = await db.query.images.findMany({
+ where: and(
+ eq(images.projectId, project.id),
+ eq(images.alias, filenameOrAlias),
+ isNull(images.deletedAt),
+ ),
+ });
+
+ image = allImages[0] || null;
+ } else {
+ // Lookup by filename in storageKey
+ const allImages = await db.query.images.findMany({
+ where: and(eq(images.projectId, project.id), isNull(images.deletedAt)),
+ });
+
+ // Find image where storageKey ends with filename
+ image = allImages.find((img) => img.storageKey.includes(filenameOrAlias)) || null;
+ }
+
+ if (!image) {
+ res.status(404).json({
+ success: false,
+ error: { message: ERROR_MESSAGES.IMAGE_NOT_FOUND, code: 'IMAGE_NOT_FOUND' },
+ });
+ return;
+ }
+
+ // Download image from storage
+ const storageService = await StorageFactory.getInstance();
+ const keyParts = image.storageKey.split('/');
+
+ if (keyParts.length < 4) {
+ throw new Error('Invalid storage key format');
+ }
+
+ const orgId = keyParts[0]!;
+ const projectId = keyParts[1]!;
+ const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
+ const filename = keyParts.slice(3).join('/');
+
+ const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
+
+ // Set headers
+ res.setHeader('Content-Type', image.mimeType);
+ res.setHeader('Content-Length', buffer.length);
+ res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
+ res.setHeader('X-Image-Id', image.id);
+
+ // Stream image bytes
+ res.send(buffer);
+ } catch (error) {
+ console.error('CDN image serve error:', error);
+ res.status(500).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Failed to serve image',
+ code: 'CDN_ERROR',
+ },
+ });
+ }
+ }),
+);
+
+/**
+ * Live URL generation with automatic caching and scope management
+ *
+ * Public endpoint for on-demand image generation via URL parameters:
+ * - No authentication required (public access)
+ * - Automatic prompt-based caching (cache key computed from params)
+ * - IP-based rate limiting (10 new generations per hour)
+ * - Scope-based generation limits
+ * - Lazy scope creation (automatic on first use)
+ * - Returns raw image bytes with caching headers
+ *
+ * Cache behavior:
+ * - Cache HIT: Returns existing image instantly, no rate limit check
+ * - Cache MISS: Generates new image, counts toward IP rate limit
+ * - Cache key computed from: prompt + aspectRatio + autoEnhance + template
+ * - Cached images stored with meta.isLiveUrl = true
+ *
+ * Scope management:
+ * - Scopes separate generation budgets (e.g., "hero", "gallery")
+ * - Auto-created on first use if allowNewLiveScopes = true
+ * - Generation limits per scope (default: 30)
+ * - Scope stats tracked (currentGenerations, lastGeneratedAt)
+ *
+ * @route GET /cdn/:orgSlug/:projectSlug/live/:scope
+ * @authentication None - Public endpoint
+ * @rateLimit 10 new generations per hour per IP (cache hits excluded)
+ *
+ * @param {string} req.params.orgSlug - Organization slug
+ * @param {string} req.params.projectSlug - Project slug
+ * @param {string} req.params.scope - Scope identifier (alphanumeric + hyphens + underscores)
+ * @param {string} req.query.prompt - Image description (required)
+ * @param {string} [req.query.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
+ * @param {boolean} [req.query.autoEnhance=false] - Enable prompt enhancement
+ * @param {string} [req.query.template] - Enhancement template (photorealistic, illustration, etc.)
+ *
+ * @returns {Buffer} 200 - Image file bytes with headers
+ * @returns {object} 400 - Missing/invalid prompt or scope format
+ * @returns {object} 403 - Scope creation disabled
+ * @returns {object} 404 - Organization or project not found
+ * @returns {object} 429 - Rate limit or scope generation limit exceeded
+ * @returns {object} 500 - Generation or storage error
+ *
+ * @throws {Error} VALIDATION_ERROR - Prompt is required
+ * @throws {Error} SCOPE_INVALID_FORMAT - Invalid scope format
+ * @throws {Error} ORG_NOT_FOUND - Organization does not exist
+ * @throws {Error} PROJECT_NOT_FOUND - Project does not exist
+ * @throws {Error} SCOPE_CREATION_DISABLED - New scope creation not allowed
+ * @throws {Error} SCOPE_GENERATION_LIMIT_EXCEEDED - Scope limit reached
+ * @throws {Error} IP_RATE_LIMIT_EXCEEDED - IP rate limit exceeded
+ * @throws {Error} LIVE_URL_ERROR - General generation error
+ *
+ * @example
+ * // Basic generation with caching
+ * GET /cdn/acme/website/live/hero-section?prompt=mountain+landscape&aspectRatio=16:9
+ *
+ * @example
+ * // With auto-enhancement
+ * GET /cdn/acme/website/live/gallery?prompt=product+photo&autoEnhance=true&template=product
+ *
+ * Response Headers (Cache HIT):
+ * Content-Type: image/jpeg
+ * Content-Length: 245810
+ * Cache-Control: public, max-age=31536000
+ * X-Cache-Status: HIT
+ * X-Scope: hero-section
+ * X-Image-Id: 550e8400-e29b-41d4-a716-446655440000
+ *
+ * Response Headers (Cache MISS):
+ * Content-Type: image/jpeg
+ * Content-Length: 198234
+ * Cache-Control: public, max-age=31536000
+ * X-Cache-Status: MISS
+ * X-Scope: hero-section
+ * X-Generation-Id: 660e8400-e29b-41d4-a716-446655440001
+ * X-Image-Id: 770e8400-e29b-41d4-a716-446655440002
+ * X-RateLimit-Limit: 10
+ * X-RateLimit-Remaining: 9
+ * X-RateLimit-Reset: 3456
+ */
+cdnRouter.get(
+ '/:orgSlug/:projectSlug/live/:scope',
+ ipRateLimiterMiddleware,
+ asyncHandler(async (req: any, res: Response) => {
+ const { orgSlug, projectSlug, scope } = req.params;
+ const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery;
+
+ const genService = getGenerationService();
+ const imgService = getImageService();
+ const scopeService = getLiveScopeService();
+
+ try {
+ // Validate prompt
+ if (!prompt || typeof prompt !== 'string') {
+ res.status(400).json({
+ success: false,
+ error: { message: 'Prompt is required', code: 'VALIDATION_ERROR' },
+ });
+ return;
+ }
+
+ // Validate scope format (alphanumeric + hyphens + underscores)
+ if (!/^[a-zA-Z0-9_-]+$/.test(scope)) {
+ res.status(400).json({
+ success: false,
+ error: { message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT, code: 'SCOPE_INVALID_FORMAT' },
+ });
+ return;
+ }
+
+ // Resolve organization
+ const org = await db.query.organizations.findFirst({
+ where: eq(organizations.slug, orgSlug),
+ });
+
+ if (!org) {
+ res.status(404).json({
+ success: false,
+ error: { message: 'Organization not found', code: 'ORG_NOT_FOUND' },
+ });
+ return;
+ }
+
+ // Resolve project
+ const project = await db.query.projects.findFirst({
+ where: and(eq(projects.slug, projectSlug), eq(projects.organizationId, org.id)),
+ });
+
+ if (!project) {
+ res.status(404).json({
+ success: false,
+ error: { message: 'Project not found', code: 'PROJECT_NOT_FOUND' },
+ });
+ return;
+ }
+
+ // Compute cache key
+ const normalizedAutoEnhance =
+ typeof autoEnhance === 'string' ? autoEnhance === 'true' : Boolean(autoEnhance);
+
+ const cacheParams: {
+ aspectRatio?: string;
+ autoEnhance?: boolean;
+ template?: string;
+ } = {};
+ if (aspectRatio) cacheParams.aspectRatio = aspectRatio as string;
+ if (autoEnhance !== undefined) cacheParams.autoEnhance = normalizedAutoEnhance;
+ if (template) cacheParams.template = template as string;
+
+ const cacheKey = computeLiveUrlCacheKey(project.id, scope, prompt, cacheParams);
+
+ // Check cache: find image with meta.liveUrlCacheKey = cacheKey
+ const cachedImages = await db.query.images.findMany({
+ where: and(
+ eq(images.projectId, project.id),
+ isNull(images.deletedAt),
+ sql`${images.meta}->>'scope' = ${scope}`,
+ sql`${images.meta}->>'isLiveUrl' = 'true'`,
+ sql`${images.meta}->>'cacheKey' = ${cacheKey}`,
+ ),
+ limit: 1,
+ });
+
+ const cachedImage = cachedImages[0];
+
+ if (cachedImage) {
+ // Cache HIT - serve existing image
+ const storageService = await StorageFactory.getInstance();
+ const keyParts = cachedImage.storageKey.split('/');
+
+ if (keyParts.length < 4) {
+ throw new Error('Invalid storage key format');
+ }
+
+ const orgId = keyParts[0]!;
+ const projectId = keyParts[1]!;
+ const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
+ const filename = keyParts.slice(3).join('/');
+
+ const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
+
+ // Set headers
+ res.setHeader('Content-Type', cachedImage.mimeType);
+ res.setHeader('Content-Length', buffer.length);
+ res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
+ res.setHeader('X-Cache-Status', 'HIT');
+ res.setHeader('X-Scope', scope);
+ res.setHeader('X-Image-Id', cachedImage.id);
+
+ res.send(buffer);
+ return;
+ }
+
+ // Cache MISS - check IP rate limit before generating
+ // Only count new generations (cache MISS) toward IP rate limit
+ const isLimited = (req as any).checkIpRateLimit();
+ if (isLimited) {
+ return; // Rate limit response already sent
+ }
+
+ // Cache MISS - check scope and generate
+ // Get or create scope
+ let liveScope;
+ try {
+ liveScope = await scopeService.createOrGet(project.id, scope, {
+ allowNewLiveScopes: project.allowNewLiveScopes,
+ newLiveScopesGenerationLimit: project.newLiveScopesGenerationLimit,
+ });
+ } catch (error) {
+ if (error instanceof Error && error.message === ERROR_MESSAGES.SCOPE_CREATION_DISABLED) {
+ res.status(403).json({
+ success: false,
+ error: {
+ message: ERROR_MESSAGES.SCOPE_CREATION_DISABLED,
+ code: 'SCOPE_CREATION_DISABLED',
+ },
+ });
+ return;
+ }
+ throw error;
+ }
+
+ // Check if scope allows new generations
+ const scopeStats = await scopeService.getByIdWithStats(liveScope.id);
+ const canGenerate = await scopeService.canGenerateNew(
+ liveScope,
+ scopeStats.currentGenerations,
+ );
+
+ if (!canGenerate) {
+ res.status(429).json({
+ success: false,
+ error: {
+ message: ERROR_MESSAGES.SCOPE_GENERATION_LIMIT_EXCEEDED,
+ code: 'SCOPE_GENERATION_LIMIT_EXCEEDED',
+ },
+ });
+ return;
+ }
+
+ // Generate new image (no API key, use system generation)
+ const generation = await genService.create({
+ projectId: project.id,
+ apiKeyId: null as unknown as string, // System generation for live URLs
+ prompt,
+ aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
+ autoEnhance: normalizedAutoEnhance,
+ requestId: `live-${scope}-${Date.now()}`,
+ });
+
+ if (!generation.outputImage) {
+ throw new Error('Generation succeeded but no output image was created');
+ }
+
+ // Update image meta to mark as live URL with cache key and scope
+ await imgService.update(generation.outputImage.id, {
+ meta: {
+ ...generation.outputImage.meta,
+ scope,
+ isLiveUrl: true,
+ cacheKey,
+ },
+ });
+
+ // Download newly generated image
+ const storageService = await StorageFactory.getInstance();
+ const keyParts = generation.outputImage.storageKey.split('/');
+
+ if (keyParts.length < 4) {
+ throw new Error('Invalid storage key format');
+ }
+
+ const orgId = keyParts[0]!;
+ const projectId = keyParts[1]!;
+ const category = keyParts[2]! as 'uploads' | 'generated' | 'references';
+ const filename = keyParts.slice(3).join('/');
+
+ const buffer = await storageService.downloadFile(orgId, projectId, category, filename);
+
+ // Set headers
+ res.setHeader('Content-Type', generation.outputImage.mimeType);
+ res.setHeader('Content-Length', buffer.length);
+ res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
+ res.setHeader('X-Cache-Status', 'MISS');
+ res.setHeader('X-Scope', scope);
+ res.setHeader('X-Generation-Id', generation.id);
+ res.setHeader('X-Image-Id', generation.outputImage.id);
+
+ res.send(buffer);
+ } catch (error) {
+ console.error('Live URL generation error:', error);
+ res.status(500).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Generation failed',
+ code: 'LIVE_URL_ERROR',
+ },
+ });
+ }
+ }),
+);
diff --git a/apps/api-service/src/routes/v1/flows.ts b/apps/api-service/src/routes/v1/flows.ts
new file mode 100644
index 0000000..4772d0d
--- /dev/null
+++ b/apps/api-service/src/routes/v1/flows.ts
@@ -0,0 +1,630 @@
+import { Response, Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { FlowService, GenerationService } from '@/services/core';
+import { asyncHandler } from '@/middleware/errorHandler';
+import { validateApiKey } from '@/middleware/auth/validateApiKey';
+import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
+import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
+import { validateAndNormalizePagination } from '@/utils/validators';
+import { buildPaginatedResponse } from '@/utils/helpers';
+import { toFlowResponse, toGenerationResponse, toImageResponse } from '@/types/responses';
+import type {
+ CreateFlowResponse,
+ ListFlowsResponse,
+ GetFlowResponse,
+ UpdateFlowAliasesResponse,
+ ListFlowGenerationsResponse,
+ ListFlowImagesResponse,
+} from '@/types/responses';
+
+export const flowsRouter: RouterType = Router();
+
+let flowService: FlowService;
+let generationService: GenerationService;
+
+const getFlowService = (): FlowService => {
+ if (!flowService) {
+ flowService = new FlowService();
+ }
+ return flowService;
+};
+
+const getGenerationService = (): GenerationService => {
+ if (!generationService) {
+ generationService = new GenerationService();
+ }
+ return generationService;
+};
+
+/**
+ * POST /api/v1/flows
+ * REMOVED (Section 4.3): Lazy flow creation pattern
+ * Flows are now created automatically when:
+ * - A generation/upload specifies a flowId
+ * - A generation/upload provides a flowAlias (eager creation)
+ *
+ * @deprecated Flows are created automatically, no explicit endpoint needed
+ */
+// flowsRouter.post(
+// '/',
+// validateApiKey,
+// requireProjectKey,
+// asyncHandler(async (req: any, res: Response) => {
+// const service = getFlowService();
+// const { meta } = req.body;
+//
+// const projectId = req.apiKey.projectId;
+//
+// const flow = await service.create({
+// projectId,
+// aliases: {},
+// meta: meta || {},
+// });
+//
+// res.status(201).json({
+// success: true,
+// data: toFlowResponse(flow),
+// });
+// })
+// );
+
+/**
+ * List all flows for a project with pagination and computed counts
+ *
+ * Retrieves flows created automatically when generations/uploads specify:
+ * - A flowId in their request
+ * - A flowAlias (creates flow eagerly if doesn't exist)
+ *
+ * Each flow includes:
+ * - Computed generationCount and imageCount
+ * - Flow-scoped aliases (JSONB key-value pairs)
+ * - Custom metadata
+ *
+ * @route GET /api/v1/flows
+ * @authentication Project Key required
+ *
+ * @param {number} [req.query.limit=20] - Results per page (max 100)
+ * @param {number} [req.query.offset=0] - Number of results to skip
+ *
+ * @returns {ListFlowsResponse} 200 - Paginated list of flows with counts
+ * @returns {object} 400 - Invalid pagination parameters
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @example
+ * GET /api/v1/flows?limit=50&offset=0
+ */
+flowsRouter.get(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { limit, offset } = req.query;
+
+ const paginationResult = validateAndNormalizePagination(limit, offset);
+ if (!paginationResult.valid) {
+ res.status(400).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
+ const projectId = req.apiKey.projectId;
+
+ const result = await service.list(
+ { projectId },
+ validatedLimit,
+ validatedOffset
+ );
+
+ const responseData = result.flows.map((flow) => toFlowResponse(flow));
+
+ res.json(
+ buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
+ );
+ })
+);
+
+/**
+ * Get a single flow by ID with computed statistics
+ *
+ * Retrieves detailed flow information including:
+ * - All flow-scoped aliases
+ * - Computed generationCount (active generations only)
+ * - Computed imageCount (active images only)
+ * - Custom metadata
+ * - Creation and update timestamps
+ *
+ * @route GET /api/v1/flows/:id
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Flow ID (UUID)
+ *
+ * @returns {GetFlowResponse} 200 - Complete flow details with counts
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} FLOW_NOT_FOUND - Flow does not exist
+ *
+ * @example
+ * GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000
+ */
+flowsRouter.get(
+ '/:id',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { id } = req.params;
+
+ const flow = await service.getByIdWithCounts(id);
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ data: toFlowResponse(flow),
+ });
+ })
+);
+
+/**
+ * List all generations in a specific flow with pagination
+ *
+ * Retrieves all generations associated with this flow, ordered by creation date (newest first).
+ * Includes only active (non-deleted) generations.
+ *
+ * @route GET /api/v1/flows/:id/generations
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Flow ID (UUID)
+ * @param {number} [req.query.limit=20] - Results per page (max 100)
+ * @param {number} [req.query.offset=0] - Number of results to skip
+ *
+ * @returns {ListFlowGenerationsResponse} 200 - Paginated list of generations
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 400 - Invalid pagination parameters
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @example
+ * GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/generations?limit=10
+ */
+flowsRouter.get(
+ '/:id/generations',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { id } = req.params;
+ const { limit, offset } = req.query;
+
+ const flow = await service.getById(id);
+ if (!flow) {
+ res.status(404).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const paginationResult = validateAndNormalizePagination(limit, offset);
+ if (!paginationResult.valid) {
+ res.status(400).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
+
+ const result = await service.getFlowGenerations(id, validatedLimit, validatedOffset);
+
+ const responseData = result.generations.map((gen) => toGenerationResponse(gen));
+
+ res.json(
+ buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
+ );
+ })
+);
+
+/**
+ * List all images in a specific flow with pagination
+ *
+ * Retrieves all images (generated and uploaded) associated with this flow,
+ * ordered by creation date (newest first). Includes only active (non-deleted) images.
+ *
+ * @route GET /api/v1/flows/:id/images
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Flow ID (UUID)
+ * @param {number} [req.query.limit=20] - Results per page (max 100)
+ * @param {number} [req.query.offset=0] - Number of results to skip
+ *
+ * @returns {ListFlowImagesResponse} 200 - Paginated list of images
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 400 - Invalid pagination parameters
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @example
+ * GET /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/images?limit=20
+ */
+flowsRouter.get(
+ '/:id/images',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { id } = req.params;
+ const { limit, offset } = req.query;
+
+ const flow = await service.getById(id);
+ if (!flow) {
+ res.status(404).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const paginationResult = validateAndNormalizePagination(limit, offset);
+ if (!paginationResult.valid) {
+ res.status(400).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
+
+ const result = await service.getFlowImages(id, validatedLimit, validatedOffset);
+
+ const responseData = result.images.map((img) => toImageResponse(img));
+
+ res.json(
+ buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
+ );
+ })
+);
+
+/**
+ * Update flow-scoped aliases (add or modify existing)
+ *
+ * Updates the JSONB aliases field with new or modified key-value pairs.
+ * Aliases are merged with existing aliases (does not replace all).
+ *
+ * Flow-scoped aliases:
+ * - Must start with @ symbol
+ * - Unique within the flow only (not project-wide)
+ * - Used for alias resolution in generations
+ * - Stored as JSONB for efficient lookups
+ *
+ * @route PUT /api/v1/flows/:id/aliases
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Flow ID (UUID)
+ * @param {UpdateFlowAliasesRequest} req.body - Alias updates
+ * @param {object} req.body.aliases - Key-value pairs of aliases to add/update
+ *
+ * @returns {UpdateFlowAliasesResponse} 200 - Updated flow with merged aliases
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 400 - Invalid aliases format
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} FLOW_NOT_FOUND - Flow does not exist
+ * @throws {Error} VALIDATION_ERROR - Aliases must be an object
+ *
+ * @example
+ * PUT /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases
+ * {
+ * "aliases": {
+ * "@hero": "image-id-123",
+ * "@background": "image-id-456"
+ * }
+ * }
+ */
+flowsRouter.put(
+ '/:id/aliases',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { id } = req.params;
+ const { aliases } = req.body;
+
+ if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'Aliases must be an object with key-value pairs',
+ code: 'VALIDATION_ERROR',
+ },
+ });
+ return;
+ }
+
+ const flow = await service.getById(id);
+ if (!flow) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const updatedFlow = await service.updateAliases(id, aliases);
+
+ res.json({
+ success: true,
+ data: toFlowResponse(updatedFlow),
+ });
+ })
+);
+
+/**
+ * Remove a specific alias from a flow
+ *
+ * Deletes a single alias key-value pair from the flow's JSONB aliases field.
+ * Other aliases remain unchanged.
+ *
+ * @route DELETE /api/v1/flows/:id/aliases/:alias
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Flow ID (UUID)
+ * @param {string} req.params.alias - Alias to remove (e.g., "@hero")
+ *
+ * @returns {object} 200 - Updated flow with alias removed
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} FLOW_NOT_FOUND - Flow does not exist
+ *
+ * @example
+ * DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/aliases/@hero
+ */
+flowsRouter.delete(
+ '/:id/aliases/:alias',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { id, alias } = req.params;
+
+ const flow = await service.getById(id);
+ if (!flow) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const updatedFlow = await service.removeAlias(id, alias);
+
+ res.json({
+ success: true,
+ data: toFlowResponse(updatedFlow),
+ });
+ })
+);
+
+/**
+ * Regenerate the most recent generation in a flow (Section 3.6)
+ *
+ * Logic:
+ * 1. Find the flow by ID
+ * 2. Query for the most recent generation (ordered by createdAt desc)
+ * 3. Trigger regeneration with exact same parameters
+ * 4. Replace existing output image (preserves ID and URLs)
+ *
+ * @route POST /api/v1/flows/:id/regenerate
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.id - Flow ID (affects: determines which flow's latest generation to regenerate)
+ *
+ * @returns {object} 200 - Regenerated generation with updated output image
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 400 - Flow has no generations
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} FLOW_NOT_FOUND - Flow does not exist
+ * @throws {Error} FLOW_HAS_NO_GENERATIONS - Flow contains no generations to regenerate
+ *
+ * @example
+ * POST /api/v1/flows/550e8400-e29b-41d4-a716-446655440000/regenerate
+ */
+flowsRouter.post(
+ '/:id/regenerate',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const flowSvc = getFlowService();
+ const genSvc = getGenerationService();
+ const { id } = req.params;
+
+ const flow = await flowSvc.getById(id);
+ if (!flow) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ // Get the most recent generation in the flow
+ const result = await flowSvc.getFlowGenerations(id, 1, 0); // limit=1, offset=0
+
+ if (result.total === 0 || result.generations.length === 0) {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'Flow has no generations to regenerate',
+ code: 'FLOW_HAS_NO_GENERATIONS',
+ },
+ });
+ return;
+ }
+
+ const latestGeneration = result.generations[0]!;
+
+ // Regenerate the latest generation
+ const regenerated = await genSvc.regenerate(latestGeneration.id);
+
+ res.json({
+ success: true,
+ data: toGenerationResponse(regenerated),
+ });
+ })
+);
+
+/**
+ * Delete a flow with cascade deletion (Section 7.3)
+ *
+ * Permanently removes the flow with cascade behavior:
+ * - Flow record is hard deleted
+ * - All generations in flow are hard deleted
+ * - Images WITHOUT project alias: hard deleted with MinIO cleanup
+ * - Images WITH project alias: kept, but flowId set to NULL (unlinked)
+ *
+ * Rationale: Images with project aliases are used globally and should be preserved.
+ * Flow deletion removes the organizational structure but protects important assets.
+ *
+ * @route DELETE /api/v1/flows/:id
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Flow ID (UUID)
+ *
+ * @returns {object} 200 - Deletion confirmation with flow ID
+ * @returns {object} 404 - Flow not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} FLOW_NOT_FOUND - Flow does not exist
+ *
+ * @example
+ * DELETE /api/v1/flows/550e8400-e29b-41d4-a716-446655440000
+ *
+ * Response:
+ * {
+ * "success": true,
+ * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
+ * }
+ */
+flowsRouter.delete(
+ '/:id',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getFlowService();
+ const { id } = req.params;
+
+ const flow = await service.getById(id);
+ if (!flow) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (flow.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Flow not found',
+ code: 'FLOW_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ await service.delete(id);
+
+ res.json({
+ success: true,
+ data: { id },
+ });
+ })
+);
diff --git a/apps/api-service/src/routes/v1/generations.ts b/apps/api-service/src/routes/v1/generations.ts
new file mode 100644
index 0000000..f7cd5d6
--- /dev/null
+++ b/apps/api-service/src/routes/v1/generations.ts
@@ -0,0 +1,549 @@
+import { Response, Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { GenerationService } from '@/services/core';
+import { asyncHandler } from '@/middleware/errorHandler';
+import { validateApiKey } from '@/middleware/auth/validateApiKey';
+import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
+import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
+import { autoEnhancePrompt } from '@/middleware/promptEnhancement';
+import { validateAndNormalizePagination } from '@/utils/validators';
+import { buildPaginatedResponse } from '@/utils/helpers';
+import { toGenerationResponse } from '@/types/responses';
+import type {
+ CreateGenerationResponse,
+ ListGenerationsResponse,
+ GetGenerationResponse,
+} from '@/types/responses';
+
+export const generationsRouter: RouterType = Router();
+
+let generationService: GenerationService;
+
+const getGenerationService = (): GenerationService => {
+ if (!generationService) {
+ generationService = new GenerationService();
+ }
+ return generationService;
+};
+
+/**
+ * Create a new image generation from a text prompt
+ *
+ * Generates AI-powered images using Gemini Flash Image model with support for:
+ * - Text prompts with optional auto-enhancement
+ * - Reference images for style/context
+ * - Flow association and flow-scoped aliases
+ * - Project-scoped aliases for direct access
+ * - Custom metadata storage
+ *
+ * @route POST /api/v1/generations
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {CreateGenerationRequest} req.body - Generation parameters
+ * @param {string} req.body.prompt - Text description of desired image (required)
+ * @param {string[]} [req.body.referenceImages] - Array of aliases to use as references
+ * @param {string} [req.body.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
+ * @param {string} [req.body.flowId] - Associate with existing flow
+ * @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
+ * @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId)
+ * @param {boolean} [req.body.autoEnhance=true] - Enable prompt enhancement
+ * @param {object} [req.body.meta] - Custom metadata
+ *
+ * @returns {CreateGenerationResponse} 201 - Generation created with status
+ * @returns {object} 400 - Invalid request parameters
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} VALIDATION_ERROR - Missing or invalid prompt
+ * @throws {Error} ALIAS_CONFLICT - Alias already exists
+ * @throws {Error} FLOW_NOT_FOUND - Flow ID does not exist
+ * @throws {Error} IMAGE_NOT_FOUND - Reference image alias not found
+ *
+ * @example
+ * // Basic generation
+ * POST /api/v1/generations
+ * {
+ * "prompt": "A serene mountain landscape at sunset",
+ * "aspectRatio": "16:9"
+ * }
+ *
+ * @example
+ * // With reference images and alias
+ * POST /api/v1/generations
+ * {
+ * "prompt": "Product photo in this style",
+ * "referenceImages": ["@brand-style", "@product-template"],
+ * "alias": "@hero-image",
+ * "autoEnhance": true
+ * }
+ */
+generationsRouter.post(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ autoEnhancePrompt,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+
+ // Extract original prompt from middleware property if enhancement was attempted
+ // Otherwise fall back to request body
+ const prompt = req.originalPrompt || req.body.prompt;
+
+ const {
+ referenceImages,
+ aspectRatio,
+ flowId,
+ alias,
+ flowAlias,
+ autoEnhance,
+ meta,
+ } = req.body;
+
+ if (!prompt || typeof prompt !== 'string') {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'Prompt is required and must be a string',
+ code: 'VALIDATION_ERROR',
+ },
+ });
+ return;
+ }
+
+ const projectId = req.apiKey.projectId;
+ const apiKeyId = req.apiKey.id;
+
+ const generation = await service.create({
+ projectId,
+ apiKeyId,
+ prompt,
+ referenceImages,
+ aspectRatio,
+ flowId,
+ alias,
+ flowAlias,
+ autoEnhance,
+ enhancedPrompt: req.enhancedPrompt,
+ meta,
+ requestId: req.requestId,
+ });
+
+ res.status(201).json({
+ success: true,
+ data: toGenerationResponse(generation),
+ });
+ })
+);
+
+/**
+ * List all generations for the project with filtering and pagination
+ *
+ * Retrieves generations with support for:
+ * - Flow-based filtering
+ * - Status filtering (pending, processing, success, failed)
+ * - Pagination with configurable limit and offset
+ * - Optional inclusion of soft-deleted generations
+ *
+ * @route GET /api/v1/generations
+ * @authentication Project Key required
+ *
+ * @param {string} [req.query.flowId] - Filter by flow ID
+ * @param {string} [req.query.status] - Filter by status (pending|processing|success|failed)
+ * @param {number} [req.query.limit=20] - Results per page (max 100)
+ * @param {number} [req.query.offset=0] - Number of results to skip
+ * @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted generations
+ *
+ * @returns {ListGenerationsResponse} 200 - Paginated list of generations
+ * @returns {object} 400 - Invalid pagination parameters
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @example
+ * // List recent generations
+ * GET /api/v1/generations?limit=10&offset=0
+ *
+ * @example
+ * // Filter by flow and status
+ * GET /api/v1/generations?flowId=abc-123&status=success&limit=50
+ */
+generationsRouter.get(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+ const { flowId, status, limit, offset, includeDeleted } = req.query;
+
+ const paginationResult = validateAndNormalizePagination(limit, offset);
+ if (!paginationResult.valid) {
+ res.status(400).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
+ const projectId = req.apiKey.projectId;
+
+ const result = await service.list(
+ {
+ projectId,
+ flowId: flowId as string | undefined,
+ status: status as 'pending' | 'processing' | 'success' | 'failed' | undefined,
+ deleted: includeDeleted === 'true' ? true : undefined,
+ },
+ validatedLimit,
+ validatedOffset
+ );
+
+ const responseData = result.generations.map((gen) => toGenerationResponse(gen));
+
+ res.json(
+ buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
+ );
+ })
+);
+
+/**
+ * Get a single generation by ID with full details
+ *
+ * Retrieves complete generation information including:
+ * - Generation status and metadata
+ * - Output image details (URL, dimensions, etc.)
+ * - Reference images used
+ * - Flow association
+ * - Timestamps and audit trail
+ *
+ * @route GET /api/v1/generations/:id
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Generation ID (UUID)
+ *
+ * @returns {GetGenerationResponse} 200 - Complete generation details
+ * @returns {object} 404 - Generation not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
+ *
+ * @example
+ * GET /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
+ */
+generationsRouter.get(
+ '/:id',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+ const { id } = req.params;
+
+ const generation = await service.getByIdWithRelations(id);
+
+ if (generation.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ data: toGenerationResponse(generation),
+ });
+ })
+);
+
+/**
+ * Update generation parameters with automatic regeneration
+ *
+ * Updates generation settings with intelligent regeneration behavior:
+ * - Changing prompt or aspectRatio triggers automatic regeneration
+ * - Changing flowId or meta updates metadata only (no regeneration)
+ * - Regeneration replaces existing output image (same ID and URLs)
+ * - All changes preserve generation history and IDs
+ *
+ * @route PUT /api/v1/generations/:id
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.id - Generation ID (UUID)
+ * @param {UpdateGenerationRequest} req.body - Update parameters
+ * @param {string} [req.body.prompt] - New prompt (triggers regeneration)
+ * @param {string} [req.body.aspectRatio] - New aspect ratio (triggers regeneration)
+ * @param {string|null} [req.body.flowId] - Change flow association (null to detach)
+ * @param {object} [req.body.meta] - Update custom metadata
+ *
+ * @returns {GetGenerationResponse} 200 - Updated generation with new output
+ * @returns {object} 404 - Generation not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
+ * @throws {Error} FLOW_NOT_FOUND - New flow ID does not exist
+ *
+ * @example
+ * // Update prompt (triggers regeneration)
+ * PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
+ * {
+ * "prompt": "Updated: A mountain landscape with vibrant colors"
+ * }
+ *
+ * @example
+ * // Change flow association (no regeneration)
+ * PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
+ * {
+ * "flowId": "new-flow-id-123"
+ * }
+ */
+generationsRouter.put(
+ '/:id',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+ const { id } = req.params;
+ const { prompt, aspectRatio, flowId, meta } = req.body;
+
+ const original = await service.getById(id);
+ if (!original) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (original.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const updated = await service.update(id, {
+ prompt,
+ aspectRatio,
+ flowId,
+ meta,
+ });
+
+ res.json({
+ success: true,
+ data: toGenerationResponse(updated),
+ });
+ })
+);
+
+/**
+ * Regenerate existing generation with exact same parameters
+ *
+ * Creates a new image using the original generation parameters:
+ * - Uses exact same prompt, aspect ratio, and reference images
+ * - Works regardless of current status (success, failed, pending)
+ * - Replaces existing output image (preserves ID and URLs)
+ * - No parameter modifications allowed (use PUT for changes)
+ * - Useful for refreshing stale images or recovering from failures
+ *
+ * @route POST /api/v1/generations/:id/regenerate
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.id - Generation ID (UUID)
+ *
+ * @returns {GetGenerationResponse} 200 - Regenerated generation with new output
+ * @returns {object} 404 - Generation not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
+ *
+ * @example
+ * POST /api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate
+ */
+generationsRouter.post(
+ '/:id/regenerate',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+ const { id } = req.params;
+
+ const original = await service.getById(id);
+ if (!original) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (original.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const regenerated = await service.regenerate(id);
+
+ res.json({
+ success: true,
+ data: toGenerationResponse(regenerated),
+ });
+ })
+);
+
+/**
+ * Retry a failed generation (legacy endpoint)
+ *
+ * @deprecated Use POST /api/v1/generations/:id/regenerate instead
+ *
+ * This endpoint is maintained for backward compatibility and delegates
+ * to the regenerate endpoint. New integrations should use /regenerate.
+ *
+ * @route POST /api/v1/generations/:id/retry
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.id - Generation ID (UUID)
+ *
+ * @returns {CreateGenerationResponse} 201 - Regenerated generation
+ * @returns {object} 404 - Generation not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @see POST /api/v1/generations/:id/regenerate - Preferred endpoint
+ */
+generationsRouter.post(
+ '/:id/retry',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+ const { id } = req.params;
+
+ const original = await service.getById(id);
+ if (!original) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (original.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const regenerated = await service.regenerate(id);
+
+ res.status(201).json({
+ success: true,
+ data: toGenerationResponse(regenerated),
+ });
+ })
+);
+
+/**
+ * Delete a generation and conditionally its output image (Section 7.2)
+ *
+ * Performs deletion with alias protection:
+ * - Hard delete generation record (permanently removed from database)
+ * - If output image has NO project alias: hard delete image with MinIO cleanup
+ * - If output image HAS project alias: keep image, set generationId=NULL
+ *
+ * Rationale: Images with aliases are used as standalone assets and should be preserved.
+ * Images without aliases were created only for this generation and can be deleted together.
+ *
+ * @route DELETE /api/v1/generations/:id
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id - Generation ID (UUID)
+ *
+ * @returns {object} 200 - Deletion confirmation with generation ID
+ * @returns {object} 404 - Generation not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
+ *
+ * @example
+ * DELETE /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
+ *
+ * Response:
+ * {
+ * "success": true,
+ * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
+ * }
+ */
+generationsRouter.delete(
+ '/:id',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getGenerationService();
+ const { id } = req.params;
+
+ const generation = await service.getById(id);
+ if (!generation) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (generation.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Generation not found',
+ code: 'GENERATION_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ await service.delete(id);
+
+ res.json({
+ success: true,
+ data: { id },
+ });
+ })
+);
diff --git a/apps/api-service/src/routes/v1/images.ts b/apps/api-service/src/routes/v1/images.ts
new file mode 100644
index 0000000..06cea6a
--- /dev/null
+++ b/apps/api-service/src/routes/v1/images.ts
@@ -0,0 +1,946 @@
+import { randomUUID } from 'crypto';
+import sizeOf from 'image-size';
+import { Response, Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { ImageService, AliasService } from '@/services/core';
+import { StorageFactory } from '@/services/StorageFactory';
+import { asyncHandler } from '@/middleware/errorHandler';
+import { validateApiKey } from '@/middleware/auth/validateApiKey';
+import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
+import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
+import { uploadSingleImage, handleUploadErrors } from '@/middleware/upload';
+import { validateAndNormalizePagination } from '@/utils/validators';
+import { buildPaginatedResponse } from '@/utils/helpers';
+import { toImageResponse } from '@/types/responses';
+import { db } from '@/db';
+import { flows, type Image } from '@banatie/database';
+import { eq } from 'drizzle-orm';
+import type {
+ UploadImageResponse,
+ ListImagesResponse,
+ GetImageResponse,
+ UpdateImageResponse,
+ DeleteImageResponse,
+ ResolveAliasResponse,
+} from '@/types/responses';
+
+export const imagesRouter: RouterType = Router();
+
+let imageService: ImageService;
+let aliasService: AliasService;
+
+const getImageService = (): ImageService => {
+ if (!imageService) {
+ imageService = new ImageService();
+ }
+ return imageService;
+};
+
+const getAliasService = (): AliasService => {
+ if (!aliasService) {
+ aliasService = new AliasService();
+ }
+ return aliasService;
+};
+
+/**
+ * Resolve id_or_alias parameter to imageId
+ * Supports both UUID and alias (@-prefixed) identifiers
+ * Per Section 6.2 of api-refactoring-final.md
+ *
+ * @param identifier - UUID or alias string
+ * @param projectId - Project ID for alias resolution
+ * @param flowId - Optional flow ID for flow-scoped alias resolution
+ * @returns imageId (UUID)
+ * @throws Error if alias not found
+ */
+async function resolveImageIdentifier(
+ identifier: string,
+ projectId: string,
+ flowId?: string
+): Promise {
+ // Check if parameter is alias (starts with @)
+ if (identifier.startsWith('@')) {
+ const aliasServiceInstance = getAliasService();
+ const resolution = await aliasServiceInstance.resolve(
+ identifier,
+ projectId,
+ flowId
+ );
+
+ if (!resolution) {
+ throw new Error(`Alias '${identifier}' not found`);
+ }
+
+ return resolution.imageId;
+ }
+
+ // Otherwise treat as UUID
+ return identifier;
+}
+
+/**
+ * Upload a single image file to project storage
+ *
+ * Uploads an image file to MinIO storage and creates a database record with support for:
+ * - Lazy flow creation using pendingFlowId when flowId is undefined
+ * - Eager flow creation when flowAlias is provided
+ * - Project-scoped alias assignment
+ * - Custom metadata storage
+ * - Multiple file formats (JPEG, PNG, WebP, etc.)
+ *
+ * FlowId behavior:
+ * - undefined (not provided) → generates pendingFlowId, defers flow creation (lazy)
+ * - null (explicitly null) → no flow association
+ * - string (specific value) → uses provided flow ID, creates if needed
+ *
+ * @route POST /api/v1/images/upload
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {File} req.file - Image file (multipart/form-data, max 5MB)
+ * @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
+ * @param {string|null} [req.body.flowId] - Flow association (undefined=auto, null=none, string=specific)
+ * @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId, triggers eager creation)
+ * @param {string} [req.body.meta] - Custom metadata (JSON string)
+ *
+ * @returns {UploadImageResponse} 201 - Uploaded image with storage details
+ * @returns {object} 400 - Missing file or validation error
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 413 - File too large
+ * @returns {object} 415 - Unsupported file type
+ * @returns {object} 429 - Rate limit exceeded
+ * @returns {object} 500 - Upload or storage error
+ *
+ * @throws {Error} VALIDATION_ERROR - No file provided
+ * @throws {Error} UPLOAD_ERROR - File upload failed
+ * @throws {Error} ALIAS_CONFLICT - Alias already exists
+ *
+ * @example
+ * // Upload with automatic flow creation
+ * POST /api/v1/images/upload
+ * Content-Type: multipart/form-data
+ * { file: , alias: "@hero-bg" }
+ *
+ * @example
+ * // Upload with eager flow creation and flow alias
+ * POST /api/v1/images/upload
+ * { file: , flowAlias: "@step-1" }
+ */
+imagesRouter.post(
+ '/upload',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ uploadSingleImage,
+ handleUploadErrors,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getImageService();
+ const { alias, flowId, flowAlias, meta } = req.body;
+
+ if (!req.file) {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'No file provided',
+ code: 'VALIDATION_ERROR',
+ },
+ });
+ return;
+ }
+
+ const projectId = req.apiKey.projectId;
+ const apiKeyId = req.apiKey.id;
+ const orgId = req.apiKey.organizationSlug || 'default';
+ const projectSlug = req.apiKey.projectSlug;
+ const file = req.file;
+
+ // FlowId logic (matching GenerationService lazy pattern):
+ // - If undefined → generate UUID for pendingFlowId, flowId = null (lazy)
+ // - If null → flowId = null, pendingFlowId = null (explicitly no flow)
+ // - If string → flowId = string, pendingFlowId = null (use provided, create if needed)
+ let finalFlowId: string | null;
+ let pendingFlowId: string | null = null;
+
+ if (flowId === undefined) {
+ // Lazy pattern: defer flow creation until needed
+ pendingFlowId = randomUUID();
+ finalFlowId = null;
+ } else if (flowId === null) {
+ // Explicitly no flow
+ finalFlowId = null;
+ pendingFlowId = null;
+ } else {
+ // Specific flowId provided - ensure flow exists (eager creation)
+ finalFlowId = flowId;
+ pendingFlowId = null;
+
+ // Check if flow exists, create if not
+ const existingFlow = await db.query.flows.findFirst({
+ where: eq(flows.id, finalFlowId),
+ });
+
+ if (!existingFlow) {
+ await db.insert(flows).values({
+ id: finalFlowId,
+ projectId,
+ aliases: {},
+ meta: {},
+ });
+
+ // Link any pending images to this new flow
+ await service.linkPendingImagesToFlow(finalFlowId, projectId);
+ }
+ }
+
+ try {
+ const storageService = await StorageFactory.getInstance();
+
+ const uploadResult = await storageService.uploadFile(
+ orgId,
+ projectSlug,
+ 'uploads',
+ file.originalname,
+ file.buffer,
+ file.mimetype,
+ );
+
+ if (!uploadResult.success) {
+ res.status(500).json({
+ success: false,
+ error: {
+ message: 'File upload failed',
+ code: 'UPLOAD_ERROR',
+ details: uploadResult.error,
+ },
+ });
+ return;
+ }
+
+ // Extract image dimensions from uploaded file buffer
+ let width: number | null = null;
+ let height: number | null = null;
+ try {
+ const dimensions = sizeOf(file.buffer);
+ if (dimensions.width && dimensions.height) {
+ width = dimensions.width;
+ height = dimensions.height;
+ }
+ } catch (error) {
+ console.warn('Failed to extract image dimensions:', error);
+ }
+
+ const imageRecord = await service.create({
+ projectId,
+ flowId: finalFlowId,
+ pendingFlowId: pendingFlowId,
+ generationId: null,
+ apiKeyId,
+ storageKey: uploadResult.path!,
+ storageUrl: uploadResult.url!,
+ mimeType: file.mimetype,
+ fileSize: file.size,
+ fileHash: null,
+ source: 'uploaded',
+ alias: null,
+ meta: meta ? JSON.parse(meta) : {},
+ width,
+ height,
+ });
+
+ // Reassign project alias if provided (override behavior per Section 5.2)
+ if (alias) {
+ await service.reassignProjectAlias(alias, imageRecord.id, projectId);
+ }
+
+ // Eager flow creation if flowAlias is provided
+ if (flowAlias) {
+ // Use pendingFlowId if available, otherwise finalFlowId
+ const flowIdToUse = pendingFlowId || finalFlowId;
+
+ if (!flowIdToUse) {
+ throw new Error('Cannot create flow: no flowId available');
+ }
+
+ // Check if flow exists, create if not
+ const existingFlow = await db.query.flows.findFirst({
+ where: eq(flows.id, flowIdToUse),
+ });
+
+ if (!existingFlow) {
+ await db.insert(flows).values({
+ id: flowIdToUse,
+ projectId,
+ aliases: {},
+ meta: {},
+ });
+
+ // Link pending images if this was a lazy flow
+ if (pendingFlowId) {
+ await service.linkPendingImagesToFlow(flowIdToUse, projectId);
+ }
+ }
+
+ // Assign flow alias to uploaded image
+ const flow = await db.query.flows.findFirst({
+ where: eq(flows.id, flowIdToUse),
+ });
+
+ if (flow) {
+ const currentAliases = (flow.aliases as Record) || {};
+ const updatedAliases = { ...currentAliases };
+ updatedAliases[flowAlias] = imageRecord.id;
+
+ await db
+ .update(flows)
+ .set({ aliases: updatedAliases, updatedAt: new Date() })
+ .where(eq(flows.id, flowIdToUse));
+ }
+ }
+
+ // Refetch image to include any updates (alias assignment, flow alias)
+ const finalImage = await service.getById(imageRecord.id);
+
+ res.status(201).json({
+ success: true,
+ data: toImageResponse(finalImage!),
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Upload failed',
+ code: 'UPLOAD_ERROR',
+ },
+ });
+ return;
+ }
+ })
+);
+
+/**
+ * List all images for the project with filtering and pagination
+ *
+ * Retrieves images (both generated and uploaded) with support for:
+ * - Flow-based filtering
+ * - Source filtering (generated vs uploaded)
+ * - Alias filtering (exact match)
+ * - Pagination with configurable limit and offset
+ * - Optional inclusion of soft-deleted images
+ *
+ * @route GET /api/v1/images
+ * @authentication Project Key required
+ *
+ * @param {string} [req.query.flowId] - Filter by flow ID
+ * @param {string} [req.query.source] - Filter by source (generated|uploaded)
+ * @param {string} [req.query.alias] - Filter by exact alias match
+ * @param {number} [req.query.limit=20] - Results per page (max 100)
+ * @param {number} [req.query.offset=0] - Number of results to skip
+ * @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted images
+ *
+ * @returns {ListImagesResponse} 200 - Paginated list of images
+ * @returns {object} 400 - Invalid pagination parameters
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @example
+ * // List uploaded images in a flow
+ * GET /api/v1/images?flowId=abc-123&source=uploaded&limit=50
+ */
+imagesRouter.get(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getImageService();
+ const { flowId, source, alias, limit, offset, includeDeleted } = req.query;
+
+ const paginationResult = validateAndNormalizePagination(limit, offset);
+ if (!paginationResult.valid) {
+ res.status(400).json({
+ success: false,
+ data: [],
+ pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
+ });
+ return;
+ }
+
+ const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
+ const projectId = req.apiKey.projectId;
+
+ const result = await service.list(
+ {
+ projectId,
+ flowId: flowId as string | undefined,
+ source: source as 'generated' | 'uploaded' | undefined,
+ alias: alias as string | undefined,
+ deleted: includeDeleted === 'true' ? true : undefined,
+ },
+ validatedLimit,
+ validatedOffset
+ );
+
+ const responseData = result.images.map((img) => toImageResponse(img));
+
+ res.json(
+ buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
+ );
+ })
+);
+
+/**
+ * @deprecated Use GET /api/v1/images/:alias directly instead (Section 6.2)
+ *
+ * Resolve an alias to an image using 3-tier precedence system
+ *
+ * **DEPRECATED**: This endpoint is deprecated as of Section 6.2. Use the main
+ * GET /api/v1/images/:id_or_alias endpoint instead, which supports both UUIDs
+ * and aliases (@-prefixed) directly in the path parameter.
+ *
+ * **Migration Guide**:
+ * - Old: GET /api/v1/images/resolve/@hero
+ * - New: GET /api/v1/images/@hero
+ *
+ * This endpoint remains functional for backwards compatibility but will be
+ * removed in a future version.
+ *
+ * Resolves aliases through a priority-based lookup system:
+ * 1. Technical aliases (@last, @first, @upload) - computed on-the-fly
+ * 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId)
+ * 3. Project-scoped aliases - looked up in images.alias column
+ *
+ * Returns the image ID, resolution scope, and complete image details.
+ *
+ * @route GET /api/v1/images/resolve/:alias
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1")
+ * @param {string} [req.query.flowId] - Flow context for flow-scoped resolution
+ *
+ * @returns {ResolveAliasResponse} 200 - Resolved image with scope and details (includes X-Deprecated header)
+ * @returns {object} 404 - Alias not found in any scope
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} ALIAS_NOT_FOUND - Alias does not exist
+ * @throws {Error} RESOLUTION_ERROR - Resolution failed
+ *
+ * @example
+ * // Resolve technical alias
+ * GET /api/v1/images/resolve/@last
+ *
+ * @example
+ * // Resolve flow-scoped alias
+ * GET /api/v1/images/resolve/@step-1?flowId=abc-123
+ *
+ * @example
+ * // Resolve project-scoped alias
+ * GET /api/v1/images/resolve/@hero-bg
+ */
+imagesRouter.get(
+ '/resolve/:alias',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const aliasServiceInstance = getAliasService();
+ const { alias } = req.params;
+ const { flowId } = req.query;
+
+ const projectId = req.apiKey.projectId;
+
+ // Add deprecation header
+ res.setHeader(
+ 'X-Deprecated',
+ 'This endpoint is deprecated. Use GET /api/v1/images/:alias instead (Section 6.2)'
+ );
+
+ try {
+ const resolution = await aliasServiceInstance.resolve(
+ alias,
+ projectId,
+ flowId as string | undefined
+ );
+
+ if (!resolution) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: `Alias '${alias}' not found`,
+ code: 'ALIAS_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ // Verify project ownership
+ if (resolution.image && resolution.image.projectId !== projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Alias not found',
+ code: 'ALIAS_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ data: {
+ alias,
+ imageId: resolution.imageId,
+ scope: resolution.scope,
+ flowId: resolution.flowId,
+ image: resolution.image ? toImageResponse(resolution.image) : ({} as any),
+ },
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Failed to resolve alias',
+ code: 'RESOLUTION_ERROR',
+ },
+ });
+ return;
+ }
+ })
+);
+
+/**
+ * Get a single image by ID with complete details
+ *
+ * Retrieves full image information including:
+ * - Storage URLs and keys
+ * - Project and flow associations
+ * - Alias assignments (project-scoped)
+ * - Source (generated vs uploaded)
+ * - File metadata (size, MIME type, hash)
+ * - Focal point and custom metadata
+ *
+ * @route GET /api/v1/images/:id_or_alias
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@alias)
+ * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
+ *
+ * @returns {GetImageResponse} 200 - Complete image details
+ * @returns {object} 404 - Image not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} IMAGE_NOT_FOUND - Image does not exist
+ *
+ * @example
+ * GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000
+ * GET /api/v1/images/@hero
+ * GET /api/v1/images/@hero?flowId=abc-123
+ */
+imagesRouter.get(
+ '/:id_or_alias',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getImageService();
+ const { id_or_alias } = req.params;
+ const { flowId } = req.query;
+
+ // Resolve alias to imageId if needed (Section 6.2)
+ let imageId: string;
+ try {
+ imageId = await resolveImageIdentifier(
+ id_or_alias,
+ req.apiKey.projectId,
+ flowId as string | undefined
+ );
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const image = await service.getById(imageId);
+ if (!image) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (image.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ res.json({
+ success: true,
+ data: toImageResponse(image),
+ });
+ })
+);
+
+/**
+ * Update image metadata (focal point and custom metadata)
+ *
+ * Updates non-generative image properties:
+ * - Focal point for image cropping (x, y coordinates 0.0-1.0)
+ * - Custom metadata (arbitrary JSON object)
+ *
+ * Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1)
+ * Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
+ *
+ * @route PUT /api/v1/images/:id_or_alias
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
+ * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
+ * @param {UpdateImageRequest} req.body - Update parameters
+ * @param {object} [req.body.focalPoint] - Focal point for cropping
+ * @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0)
+ * @param {number} req.body.focalPoint.y - Y coordinate (0.0-1.0)
+ * @param {object} [req.body.meta] - Custom metadata
+ *
+ * @returns {UpdateImageResponse} 200 - Updated image details
+ * @returns {object} 404 - Image not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} IMAGE_NOT_FOUND - Image does not exist
+ *
+ * @example UUID identifier
+ * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000
+ * {
+ * "focalPoint": { "x": 0.5, "y": 0.3 },
+ * "meta": { "category": "hero", "priority": 1 }
+ * }
+ *
+ * @example Project-scoped alias
+ * PUT /api/v1/images/@hero-banner
+ * {
+ * "focalPoint": { "x": 0.5, "y": 0.3 }
+ * }
+ *
+ * @example Flow-scoped alias
+ * PUT /api/v1/images/@product-shot?flowId=123e4567-e89b-12d3-a456-426614174000
+ * {
+ * "meta": { "category": "product" }
+ * }
+ */
+imagesRouter.put(
+ '/:id_or_alias',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getImageService();
+ const { id_or_alias } = req.params;
+ const { flowId } = req.query;
+ const { focalPoint, meta } = req.body; // Removed alias (Section 6.1)
+
+ // Resolve alias to imageId if needed (Section 6.2)
+ let imageId: string;
+ try {
+ imageId = await resolveImageIdentifier(
+ id_or_alias,
+ req.apiKey.projectId,
+ flowId as string | undefined
+ );
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const image = await service.getById(imageId);
+ if (!image) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (image.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const updates: {
+ focalPoint?: { x: number; y: number };
+ meta?: Record;
+ } = {};
+
+ if (focalPoint !== undefined) updates.focalPoint = focalPoint;
+ if (meta !== undefined) updates.meta = meta;
+
+ const updated = await service.update(imageId, updates);
+
+ res.json({
+ success: true,
+ data: toImageResponse(updated),
+ });
+ })
+);
+
+/**
+ * Assign or remove a project-scoped alias from an image
+ *
+ * Sets, updates, or removes the project-scoped alias for an image:
+ * - Alias must start with @ symbol (when assigning)
+ * - Must be unique within the project
+ * - Replaces existing alias if image already has one
+ * - Used for alias resolution in generations and CDN access
+ * - Set alias to null to remove existing alias
+ *
+ * This is a dedicated endpoint introduced in Section 6.1 to separate
+ * alias assignment from general metadata updates.
+ * Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
+ *
+ * @route PUT /api/v1/images/:id_or_alias/alias
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
+ * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
+ * @param {object} req.body - Request body
+ * @param {string|null} req.body.alias - Project-scoped alias (e.g., "@hero-bg") or null to remove
+ *
+ * @returns {UpdateImageResponse} 200 - Updated image with new/removed alias
+ * @returns {object} 404 - Image not found or access denied
+ * @returns {object} 400 - Invalid alias format
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 409 - Alias already exists
+ *
+ * @throws {Error} IMAGE_NOT_FOUND - Image does not exist
+ * @throws {Error} VALIDATION_ERROR - Invalid alias format
+ * @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image
+ *
+ * @example Assign alias
+ * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
+ * {
+ * "alias": "@hero-background"
+ * }
+ *
+ * @example Remove alias
+ * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias
+ * {
+ * "alias": null
+ * }
+ *
+ * @example Project-scoped alias identifier
+ * PUT /api/v1/images/@old-hero/alias
+ * {
+ * "alias": "@new-hero"
+ * }
+ *
+ * @example Flow-scoped alias identifier
+ * PUT /api/v1/images/@temp-product/alias?flowId=123e4567-e89b-12d3-a456-426614174000
+ * {
+ * "alias": "@final-product"
+ * }
+ */
+imagesRouter.put(
+ '/:id_or_alias/alias',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getImageService();
+ const { id_or_alias } = req.params;
+ const { flowId } = req.query;
+ const { alias } = req.body;
+
+ // Validate: alias must be null (to remove) or a non-empty string
+ if (alias !== null && (typeof alias !== 'string' || alias.trim() === '')) {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'Alias must be null (to remove) or a non-empty string',
+ code: 'VALIDATION_ERROR',
+ },
+ });
+ return;
+ }
+
+ // Resolve alias to imageId if needed (Section 6.2)
+ let imageId: string;
+ try {
+ imageId = await resolveImageIdentifier(
+ id_or_alias,
+ req.apiKey.projectId,
+ flowId as string | undefined
+ );
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const image = await service.getById(imageId);
+ if (!image) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (image.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ // Either remove alias (null) or assign new one (override behavior per Section 5.2)
+ let updated: Image;
+ if (alias === null) {
+ // Remove alias
+ updated = await service.update(imageId, { alias: null });
+ } else {
+ // Reassign alias (clears from any existing image, then assigns to this one)
+ await service.reassignProjectAlias(alias, imageId, image.projectId);
+ updated = (await service.getById(imageId))!;
+ }
+
+ res.json({
+ success: true,
+ data: toImageResponse(updated),
+ });
+ })
+);
+
+/**
+ * Delete an image with storage cleanup and cascading deletions
+ *
+ * Performs hard delete of image record and MinIO file with cascading operations:
+ * - Deletes image record from database (hard delete, no soft delete)
+ * - Removes file from MinIO storage permanently
+ * - Cascades to delete generation-image relationships
+ * - Removes image from flow aliases (if present)
+ * - Cannot be undone
+ *
+ * Use with caution: This is a destructive operation that permanently removes
+ * the image file and all database references.
+ * Supports both UUID and alias (@-prefixed) identifiers per Section 6.2.
+ *
+ * @route DELETE /api/v1/images/:id_or_alias
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed)
+ * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution
+ *
+ * @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID
+ * @returns {object} 404 - Image not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} IMAGE_NOT_FOUND - Image does not exist
+ *
+ * @example UUID identifier
+ * DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000
+ *
+ * Response:
+ * {
+ * "success": true,
+ * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
+ * }
+ *
+ * @example Project-scoped alias
+ * DELETE /api/v1/images/@old-banner
+ *
+ * @example Flow-scoped alias
+ * DELETE /api/v1/images/@temp-image?flowId=123e4567-e89b-12d3-a456-426614174000
+ */
+imagesRouter.delete(
+ '/:id_or_alias',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getImageService();
+ const { id_or_alias } = req.params;
+ const { flowId } = req.query;
+
+ // Resolve alias to imageId if needed (Section 6.2)
+ let imageId: string;
+ try {
+ imageId = await resolveImageIdentifier(
+ id_or_alias,
+ req.apiKey.projectId,
+ flowId as string | undefined
+ );
+ } catch (error) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ const image = await service.getById(imageId);
+ if (!image) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ if (image.projectId !== req.apiKey.projectId) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: 'Image not found',
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ await service.hardDelete(imageId);
+
+ res.json({
+ success: true,
+ data: { id: imageId },
+ });
+ })
+);
diff --git a/apps/api-service/src/routes/v1/index.ts b/apps/api-service/src/routes/v1/index.ts
new file mode 100644
index 0000000..8186268
--- /dev/null
+++ b/apps/api-service/src/routes/v1/index.ts
@@ -0,0 +1,16 @@
+import { Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { generationsRouter } from './generations';
+import { flowsRouter } from './flows';
+import { imagesRouter } from './images';
+import { liveRouter } from './live';
+import { scopesRouter } from './scopes';
+
+export const v1Router: RouterType = Router();
+
+// Mount v1 routes
+v1Router.use('/generations', generationsRouter);
+v1Router.use('/flows', flowsRouter);
+v1Router.use('/images', imagesRouter);
+v1Router.use('/live', liveRouter);
+v1Router.use('/live/scopes', scopesRouter);
diff --git a/apps/api-service/src/routes/v1/live.ts b/apps/api-service/src/routes/v1/live.ts
new file mode 100644
index 0000000..36fd7eb
--- /dev/null
+++ b/apps/api-service/src/routes/v1/live.ts
@@ -0,0 +1,197 @@
+import { Response, Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { PromptCacheService, GenerationService, ImageService } from '@/services/core';
+import { StorageFactory } from '@/services/StorageFactory';
+import { asyncHandler } from '@/middleware/errorHandler';
+import { validateApiKey } from '@/middleware/auth/validateApiKey';
+import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
+import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
+import { GENERATION_LIMITS } from '@/utils/constants';
+
+export const liveRouter: RouterType = Router();
+
+let promptCacheService: PromptCacheService;
+let generationService: GenerationService;
+let imageService: ImageService;
+
+const getPromptCacheService = (): PromptCacheService => {
+ if (!promptCacheService) {
+ promptCacheService = new PromptCacheService();
+ }
+ return promptCacheService;
+};
+
+const getGenerationService = (): GenerationService => {
+ if (!generationService) {
+ generationService = new GenerationService();
+ }
+ return generationService;
+};
+
+const getImageService = (): ImageService => {
+ if (!imageService) {
+ imageService = new ImageService();
+ }
+ return imageService;
+};
+
+/**
+ * GET /api/v1/live
+ * Generate image with prompt caching
+ * Returns image bytes directly with cache headers
+ */
+liveRouter.get(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const cacheService = getPromptCacheService();
+ const genService = getGenerationService();
+ const imgService = getImageService();
+ const { prompt, aspectRatio } = req.query;
+
+ // Validate prompt
+ if (!prompt || typeof prompt !== 'string') {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'Prompt is required and must be a string',
+ code: 'VALIDATION_ERROR',
+ },
+ });
+ return;
+ }
+
+ const projectId = req.apiKey.projectId;
+ const apiKeyId = req.apiKey.id;
+
+ try {
+ // Compute prompt hash for cache lookup
+ const promptHash = cacheService.computePromptHash(prompt);
+
+ // Check cache
+ const cachedEntry = await cacheService.getCachedEntry(promptHash, projectId);
+
+ if (cachedEntry) {
+ // Cache HIT - fetch and stream existing image
+ await cacheService.recordCacheHit(cachedEntry.id);
+
+ // Get image from database
+ const image = await imgService.getById(cachedEntry.imageId);
+ if (!image) {
+ throw new Error('Cached image not found in database');
+ }
+
+ const storageService = await StorageFactory.getInstance();
+
+ // Parse storage key to get components
+ // Format: orgId/projectId/category/filename.ext
+ const keyParts = image.storageKey.split('/');
+ if (keyParts.length < 4) {
+ throw new Error('Invalid storage key format');
+ }
+
+ const orgId = keyParts[0];
+ const projectIdSlug = keyParts[1];
+ const category = keyParts[2] as 'uploads' | 'generated' | 'references';
+ const filename = keyParts.slice(3).join('/');
+
+ // Download image from storage
+ const buffer = await storageService.downloadFile(
+ orgId!,
+ projectIdSlug!,
+ category,
+ filename!
+ );
+
+ // Set cache headers
+ res.setHeader('Content-Type', image.mimeType);
+ res.setHeader('Content-Length', buffer.length);
+ res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
+ res.setHeader('X-Cache-Status', 'HIT');
+ res.setHeader('X-Cache-Hit-Count', cachedEntry.hitCount.toString());
+ res.setHeader('X-Image-Id', image.id);
+
+ // Stream image bytes
+ res.send(buffer);
+ return;
+ }
+
+ // Cache MISS - generate new image
+ const generation = await genService.create({
+ projectId,
+ apiKeyId,
+ prompt,
+ aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
+ requestId: req.requestId,
+ });
+
+ // Get the output image
+ if (!generation.outputImage) {
+ throw new Error('Generation succeeded but no output image was created');
+ }
+
+ // Create cache entry
+ const queryParamsHash = cacheService.computePromptHash(
+ JSON.stringify({ aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO })
+ );
+
+ await cacheService.createCacheEntry({
+ projectId,
+ generationId: generation.id,
+ imageId: generation.outputImage.id,
+ promptHash,
+ queryParamsHash,
+ originalPrompt: prompt,
+ requestParams: {
+ aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
+ },
+ hitCount: 0,
+ });
+
+ // Download newly generated image
+ const storageService = await StorageFactory.getInstance();
+
+ // Format: orgId/projectId/category/filename.ext
+ const keyParts = generation.outputImage.storageKey.split('/');
+ if (keyParts.length < 4) {
+ throw new Error('Invalid storage key format');
+ }
+
+ const orgId = keyParts[0];
+ const projectIdSlug = keyParts[1];
+ const category = keyParts[2] as 'uploads' | 'generated' | 'references';
+ const filename = keyParts.slice(3).join('/');
+
+ const buffer = await storageService.downloadFile(
+ orgId!,
+ projectIdSlug!,
+ category,
+ filename!
+ );
+
+ // Set cache headers
+ res.setHeader('Content-Type', generation.outputImage.mimeType);
+ res.setHeader('Content-Length', buffer.length);
+ res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
+ res.setHeader('X-Cache-Status', 'MISS');
+ res.setHeader('X-Generation-Id', generation.id);
+ res.setHeader('X-Image-Id', generation.outputImage.id);
+
+ // Stream image bytes
+ res.send(buffer);
+ return;
+ } catch (error) {
+ console.error('Live generation error:', error);
+ res.status(500).json({
+ success: false,
+ error: {
+ message: error instanceof Error ? error.message : 'Generation failed',
+ code: 'GENERATION_ERROR',
+ },
+ });
+ return;
+ }
+ })
+);
diff --git a/apps/api-service/src/routes/v1/scopes.ts b/apps/api-service/src/routes/v1/scopes.ts
new file mode 100644
index 0000000..7f442a0
--- /dev/null
+++ b/apps/api-service/src/routes/v1/scopes.ts
@@ -0,0 +1,510 @@
+import { Response, Router } from 'express';
+import type { Router as RouterType } from 'express';
+import { LiveScopeService, ImageService, GenerationService } from '@/services/core';
+import { asyncHandler } from '@/middleware/errorHandler';
+import { validateApiKey } from '@/middleware/auth/validateApiKey';
+import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
+import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
+import { PAGINATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
+import { buildPaginationMeta } from '@/utils/helpers';
+import { toLiveScopeResponse, toImageResponse } from '@/types/responses';
+import type {
+ CreateLiveScopeRequest,
+ ListLiveScopesQuery,
+ UpdateLiveScopeRequest,
+ RegenerateScopeRequest,
+} from '@/types/requests';
+import type {
+ CreateLiveScopeResponse,
+ GetLiveScopeResponse,
+ ListLiveScopesResponse,
+ UpdateLiveScopeResponse,
+ DeleteLiveScopeResponse,
+ RegenerateScopeResponse,
+} from '@/types/responses';
+
+export const scopesRouter: RouterType = Router();
+
+let scopeService: LiveScopeService;
+let imageService: ImageService;
+let generationService: GenerationService;
+
+const getScopeService = (): LiveScopeService => {
+ if (!scopeService) {
+ scopeService = new LiveScopeService();
+ }
+ return scopeService;
+};
+
+const getImageService = (): ImageService => {
+ if (!imageService) {
+ imageService = new ImageService();
+ }
+ return imageService;
+};
+
+const getGenerationService = (): GenerationService => {
+ if (!generationService) {
+ generationService = new GenerationService();
+ }
+ return generationService;
+};
+
+/**
+ * Create a new live scope manually with settings
+ *
+ * Creates a live scope for organizing live URL generations:
+ * - Slug must be unique within the project
+ * - Slug format: alphanumeric + hyphens + underscores only
+ * - Configure generation limits and permissions
+ * - Optional custom metadata storage
+ *
+ * Note: Scopes are typically auto-created via live URLs, but this endpoint
+ * allows pre-configuration with specific settings.
+ *
+ * @route POST /api/v1/live/scopes
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {CreateLiveScopeRequest} req.body - Scope configuration
+ * @param {string} req.body.slug - Unique scope identifier (alphanumeric + hyphens + underscores)
+ * @param {boolean} [req.body.allowNewGenerations=true] - Allow new generations in scope
+ * @param {number} [req.body.newGenerationsLimit=30] - Maximum generations allowed
+ * @param {object} [req.body.meta] - Custom metadata
+ *
+ * @returns {CreateLiveScopeResponse} 201 - Created scope with stats
+ * @returns {object} 400 - Invalid slug format
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 409 - Scope slug already exists
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} SCOPE_INVALID_FORMAT - Invalid slug format
+ * @throws {Error} SCOPE_ALREADY_EXISTS - Slug already in use
+ *
+ * @example
+ * POST /api/v1/live/scopes
+ * {
+ * "slug": "hero-section",
+ * "allowNewGenerations": true,
+ * "newGenerationsLimit": 50,
+ * "meta": { "description": "Hero section images" }
+ * }
+ */
+scopesRouter.post(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getScopeService();
+ const { slug, allowNewGenerations, newGenerationsLimit, meta } = req.body as CreateLiveScopeRequest;
+ const projectId = req.apiKey.projectId;
+
+ // Validate slug format
+ if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT,
+ code: 'SCOPE_INVALID_FORMAT',
+ },
+ });
+ return;
+ }
+
+ // Check if scope already exists
+ const existing = await service.getBySlug(projectId, slug);
+ if (existing) {
+ res.status(409).json({
+ success: false,
+ error: {
+ message: 'Scope with this slug already exists',
+ code: 'SCOPE_ALREADY_EXISTS',
+ },
+ });
+ return;
+ }
+
+ // Create scope
+ const scope = await service.create({
+ projectId,
+ slug,
+ allowNewGenerations: allowNewGenerations ?? true,
+ newGenerationsLimit: newGenerationsLimit ?? 30,
+ meta: meta || {},
+ });
+
+ // Get with stats
+ const scopeWithStats = await service.getByIdWithStats(scope.id);
+
+ res.status(201).json({
+ success: true,
+ data: toLiveScopeResponse(scopeWithStats),
+ });
+ }),
+);
+
+/**
+ * List all live scopes for the project with pagination and statistics
+ *
+ * Retrieves all scopes (both auto-created and manually created) with:
+ * - Computed currentGenerations count (active only)
+ * - Last generation timestamp
+ * - Pagination support
+ * - Optional slug filtering
+ *
+ * @route GET /api/v1/live/scopes
+ * @authentication Project Key required
+ *
+ * @param {string} [req.query.slug] - Filter by exact slug match
+ * @param {number} [req.query.limit=20] - Results per page (max 100)
+ * @param {number} [req.query.offset=0] - Number of results to skip
+ *
+ * @returns {ListLiveScopesResponse} 200 - Paginated list of scopes with stats
+ * @returns {object} 400 - Invalid pagination parameters
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @example
+ * GET /api/v1/live/scopes?limit=50&offset=0
+ */
+scopesRouter.get(
+ '/',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getScopeService();
+ const { slug, limit, offset } = req.query as ListLiveScopesQuery;
+ const projectId = req.apiKey.projectId;
+
+ const parsedLimit = Math.min(
+ (limit ? parseInt(limit.toString(), 10) : PAGINATION_LIMITS.DEFAULT_LIMIT) || PAGINATION_LIMITS.DEFAULT_LIMIT,
+ PAGINATION_LIMITS.MAX_LIMIT,
+ );
+ const parsedOffset = (offset ? parseInt(offset.toString(), 10) : 0) || 0;
+
+ const result = await service.list(
+ { projectId, slug },
+ parsedLimit,
+ parsedOffset,
+ );
+
+ const scopeResponses = result.scopes.map(toLiveScopeResponse);
+
+ res.json({
+ success: true,
+ data: scopeResponses,
+ pagination: buildPaginationMeta(result.total, parsedLimit, parsedOffset),
+ });
+ }),
+);
+
+/**
+ * Get a single live scope by slug with complete statistics
+ *
+ * Retrieves detailed scope information including:
+ * - Current generation count (active generations only)
+ * - Last generation timestamp
+ * - Settings (allowNewGenerations, newGenerationsLimit)
+ * - Custom metadata
+ * - Creation and update timestamps
+ *
+ * @route GET /api/v1/live/scopes/:slug
+ * @authentication Project Key required
+ *
+ * @param {string} req.params.slug - Scope slug identifier
+ *
+ * @returns {GetLiveScopeResponse} 200 - Complete scope details with stats
+ * @returns {object} 404 - Scope not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ *
+ * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
+ *
+ * @example
+ * GET /api/v1/live/scopes/hero-section
+ */
+scopesRouter.get(
+ '/:slug',
+ validateApiKey,
+ requireProjectKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getScopeService();
+ const { slug } = req.params;
+ const projectId = req.apiKey.projectId;
+
+ const scopeWithStats = await service.getBySlugWithStats(projectId, slug);
+
+ res.json({
+ success: true,
+ data: toLiveScopeResponse(scopeWithStats),
+ });
+ }),
+);
+
+/**
+ * Update live scope settings and metadata
+ *
+ * Modifies scope configuration:
+ * - Enable/disable new generations
+ * - Adjust generation limits
+ * - Update custom metadata
+ *
+ * Changes take effect immediately for new live URL requests.
+ *
+ * @route PUT /api/v1/live/scopes/:slug
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.slug - Scope slug identifier
+ * @param {UpdateLiveScopeRequest} req.body - Update parameters
+ * @param {boolean} [req.body.allowNewGenerations] - Allow/disallow new generations
+ * @param {number} [req.body.newGenerationsLimit] - Update generation limit
+ * @param {object} [req.body.meta] - Update custom metadata
+ *
+ * @returns {UpdateLiveScopeResponse} 200 - Updated scope with stats
+ * @returns {object} 404 - Scope not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
+ *
+ * @example
+ * PUT /api/v1/live/scopes/hero-section
+ * {
+ * "allowNewGenerations": false,
+ * "newGenerationsLimit": 100
+ * }
+ */
+scopesRouter.put(
+ '/:slug',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const service = getScopeService();
+ const { slug } = req.params;
+ const { allowNewGenerations, newGenerationsLimit, meta } = req.body as UpdateLiveScopeRequest;
+ const projectId = req.apiKey.projectId;
+
+ // Get scope
+ const scope = await service.getBySlugOrThrow(projectId, slug);
+
+ // Update scope
+ const updates: {
+ allowNewGenerations?: boolean;
+ newGenerationsLimit?: number;
+ meta?: Record;
+ } = {};
+ if (allowNewGenerations !== undefined) updates.allowNewGenerations = allowNewGenerations;
+ if (newGenerationsLimit !== undefined) updates.newGenerationsLimit = newGenerationsLimit;
+ if (meta !== undefined) updates.meta = meta;
+
+ await service.update(scope.id, updates);
+
+ // Get updated scope with stats
+ const updated = await service.getByIdWithStats(scope.id);
+
+ res.json({
+ success: true,
+ data: toLiveScopeResponse(updated),
+ });
+ }),
+);
+
+/**
+ * Regenerate images in a live scope
+ *
+ * Regenerates either a specific image or all images in the scope:
+ * - Specific image: Provide imageId in request body
+ * - All images: Omit imageId to regenerate entire scope
+ * - Uses exact same parameters (prompt, aspect ratio, etc.)
+ * - Updates existing images (preserves IDs and URLs)
+ * - Verifies image belongs to scope before regenerating
+ *
+ * Useful for refreshing stale cached images or recovering from failures.
+ *
+ * @route POST /api/v1/live/scopes/:slug/regenerate
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.slug - Scope slug identifier
+ * @param {RegenerateScopeRequest} [req.body] - Regeneration options
+ * @param {string} [req.body.imageId] - Specific image to regenerate (omit for all)
+ *
+ * @returns {RegenerateScopeResponse} 200 - Regeneration results
+ * @returns {object} 400 - Image not in scope
+ * @returns {object} 404 - Scope or image not found
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
+ * @throws {Error} IMAGE_NOT_FOUND - Image does not exist
+ * @throws {Error} IMAGE_NOT_IN_SCOPE - Image doesn't belong to scope
+ *
+ * @example
+ * // Regenerate specific image
+ * POST /api/v1/live/scopes/hero-section/regenerate
+ * {
+ * "imageId": "550e8400-e29b-41d4-a716-446655440000"
+ * }
+ *
+ * @example
+ * // Regenerate all images in scope
+ * POST /api/v1/live/scopes/hero-section/regenerate
+ * {}
+ */
+scopesRouter.post(
+ '/:slug/regenerate',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const scopeService = getScopeService();
+ const imgService = getImageService();
+ const genService = getGenerationService();
+ const { slug } = req.params;
+ const { imageId } = req.body as RegenerateScopeRequest;
+ const projectId = req.apiKey.projectId;
+
+ // Get scope
+ const scope = await scopeService.getBySlugWithStats(projectId, slug);
+
+ if (imageId) {
+ // Regenerate specific image
+ const image = await imgService.getById(imageId);
+ if (!image) {
+ res.status(404).json({
+ success: false,
+ error: {
+ message: ERROR_MESSAGES.IMAGE_NOT_FOUND,
+ code: 'IMAGE_NOT_FOUND',
+ },
+ });
+ return;
+ }
+
+ // Check if image belongs to this scope
+ const imageMeta = image.meta as Record;
+ if (imageMeta['scope'] !== slug) {
+ res.status(400).json({
+ success: false,
+ error: {
+ message: 'Image does not belong to this scope',
+ code: 'IMAGE_NOT_IN_SCOPE',
+ },
+ });
+ return;
+ }
+
+ // Regenerate the image's generation
+ if (image.generationId) {
+ await genService.regenerate(image.generationId);
+ }
+
+ const regeneratedImage = await imgService.getById(imageId);
+
+ res.json({
+ success: true,
+ data: {
+ regenerated: 1,
+ images: regeneratedImage ? [toImageResponse(regeneratedImage)] : [],
+ },
+ });
+ } else {
+ // Regenerate all images in scope
+ if (!scope.images || scope.images.length === 0) {
+ res.json({
+ success: true,
+ data: {
+ regenerated: 0,
+ images: [],
+ },
+ });
+ return;
+ }
+
+ const regeneratedImages = [];
+ for (const image of scope.images) {
+ if (image.generationId) {
+ await genService.regenerate(image.generationId);
+ const regenerated = await imgService.getById(image.id);
+ if (regenerated) {
+ regeneratedImages.push(toImageResponse(regenerated));
+ }
+ }
+ }
+
+ res.json({
+ success: true,
+ data: {
+ regenerated: regeneratedImages.length,
+ images: regeneratedImages,
+ },
+ });
+ }
+ }),
+);
+
+/**
+ * Delete a live scope with cascading image deletion
+ *
+ * Permanently removes the scope and all its associated images:
+ * - Hard deletes all images in scope (MinIO + database)
+ * - Follows alias protection rules for each image
+ * - Hard deletes scope record (no soft delete)
+ * - Cannot be undone
+ *
+ * Use with caution: This is a destructive operation that permanently
+ * removes the scope and all cached live URL images.
+ *
+ * @route DELETE /api/v1/live/scopes/:slug
+ * @authentication Project Key required
+ * @rateLimit 100 requests per hour per API key
+ *
+ * @param {string} req.params.slug - Scope slug identifier
+ *
+ * @returns {DeleteLiveScopeResponse} 200 - Deletion confirmation with scope ID
+ * @returns {object} 404 - Scope not found or access denied
+ * @returns {object} 401 - Missing or invalid API key
+ * @returns {object} 429 - Rate limit exceeded
+ *
+ * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist
+ *
+ * @example
+ * DELETE /api/v1/live/scopes/hero-section
+ *
+ * Response:
+ * {
+ * "success": true,
+ * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
+ * }
+ */
+scopesRouter.delete(
+ '/:slug',
+ validateApiKey,
+ requireProjectKey,
+ rateLimitByApiKey,
+ asyncHandler(async (req: any, res: Response) => {
+ const scopeService = getScopeService();
+ const imgService = getImageService();
+ const { slug } = req.params;
+ const projectId = req.apiKey.projectId;
+
+ // Get scope with images
+ const scope = await scopeService.getBySlugWithStats(projectId, slug);
+
+ // Delete all images in scope (follows alias protection rules)
+ if (scope.images) {
+ for (const image of scope.images) {
+ await imgService.hardDelete(image.id);
+ }
+ }
+
+ // Delete scope record
+ await scopeService.delete(scope.id);
+
+ res.json({
+ success: true,
+ data: { id: scope.id },
+ });
+ }),
+);
diff --git a/apps/api-service/src/services/ImageGenService.ts b/apps/api-service/src/services/ImageGenService.ts
index 3c73a91..827d8e6 100644
--- a/apps/api-service/src/services/ImageGenService.ts
+++ b/apps/api-service/src/services/ImageGenService.ts
@@ -1,6 +1,7 @@
import { GoogleGenAI } from '@google/genai';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mime = require('mime') as any;
+import sizeOf from 'image-size';
import {
ImageGenerationOptions,
ImageGenerationResult,
@@ -78,8 +79,10 @@ export class ImageGenService {
filename: uploadResult.filename,
filepath: uploadResult.path,
url: uploadResult.url,
+ size: uploadResult.size,
model: this.primaryModel,
geminiParams,
+ generatedImageData: generatedData,
...(generatedData.description && {
description: generatedData.description,
}),
@@ -231,10 +234,25 @@ export class ImageGenService {
const fileExtension = mime.getExtension(imageData.mimeType) || 'png';
+ // Extract image dimensions from buffer
+ let width = 1024; // Default fallback
+ let height = 1024; // Default fallback
+ try {
+ const dimensions = sizeOf(imageData.buffer);
+ if (dimensions.width && dimensions.height) {
+ width = dimensions.width;
+ height = dimensions.height;
+ }
+ } catch (error) {
+ console.warn('Failed to extract image dimensions, using defaults:', error);
+ }
+
const generatedData: GeneratedImageData = {
buffer: imageData.buffer,
mimeType: imageData.mimeType,
fileExtension,
+ width,
+ height,
...(generatedDescription && { description: generatedDescription }),
};
diff --git a/apps/api-service/src/services/core/AliasService.ts b/apps/api-service/src/services/core/AliasService.ts
new file mode 100644
index 0000000..4141b0e
--- /dev/null
+++ b/apps/api-service/src/services/core/AliasService.ts
@@ -0,0 +1,277 @@
+import { eq, and, isNull, desc, or } from 'drizzle-orm';
+import { db } from '@/db';
+import { images, flows } from '@banatie/database';
+import type { AliasResolution, Image } from '@/types/models';
+import { isTechnicalAlias } from '@/utils/constants/aliases';
+import {
+ validateAliasFormat,
+ validateAliasNotReserved,
+} from '@/utils/validators';
+import { ERROR_MESSAGES } from '@/utils/constants';
+
+export class AliasService {
+ async resolve(
+ alias: string,
+ projectId: string,
+ flowId?: string
+ ): Promise {
+ const formatResult = validateAliasFormat(alias);
+ if (!formatResult.valid) {
+ throw new Error(formatResult.error!.message);
+ }
+
+ if (isTechnicalAlias(alias)) {
+ if (!flowId) {
+ throw new Error(ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW);
+ }
+ return await this.resolveTechnicalAlias(alias, flowId, projectId);
+ }
+
+ if (flowId) {
+ const flowResolution = await this.resolveFlowAlias(alias, flowId, projectId);
+ if (flowResolution) {
+ return flowResolution;
+ }
+ }
+
+ return await this.resolveProjectAlias(alias, projectId);
+ }
+
+ private async resolveTechnicalAlias(
+ alias: string,
+ flowId: string,
+ projectId: string
+ ): Promise {
+ let image: Image | undefined;
+
+ switch (alias) {
+ case '@last':
+ image = await this.getLastGeneratedInFlow(flowId, projectId);
+ break;
+
+ case '@first':
+ image = await this.getFirstGeneratedInFlow(flowId, projectId);
+ break;
+
+ case '@upload':
+ image = await this.getLastUploadedInFlow(flowId, projectId);
+ break;
+
+ default:
+ return null;
+ }
+
+ if (!image) {
+ return null;
+ }
+
+ return {
+ imageId: image.id,
+ scope: 'technical',
+ flowId,
+ image,
+ };
+ }
+
+ private async resolveFlowAlias(
+ alias: string,
+ flowId: string,
+ projectId: string
+ ): Promise {
+ const flow = await db.query.flows.findFirst({
+ where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
+ });
+
+ if (!flow) {
+ return null;
+ }
+
+ const flowAliases = flow.aliases as Record;
+ const imageId = flowAliases[alias];
+
+ if (!imageId) {
+ return null;
+ }
+
+ const image = await db.query.images.findFirst({
+ where: and(
+ eq(images.id, imageId),
+ eq(images.projectId, projectId),
+ isNull(images.deletedAt)
+ ),
+ });
+
+ if (!image) {
+ return null;
+ }
+
+ return {
+ imageId: image.id,
+ scope: 'flow',
+ flowId,
+ image,
+ };
+ }
+
+ private async resolveProjectAlias(
+ alias: string,
+ projectId: string
+ ): Promise {
+ // Project aliases can exist on images with or without flowId
+ // Per spec: images with project alias should be resolvable at project level
+ const image = await db.query.images.findFirst({
+ where: and(
+ eq(images.projectId, projectId),
+ eq(images.alias, alias),
+ isNull(images.deletedAt)
+ ),
+ });
+
+ if (!image) {
+ return null;
+ }
+
+ return {
+ imageId: image.id,
+ scope: 'project',
+ image,
+ };
+ }
+
+ private async getLastGeneratedInFlow(
+ flowId: string,
+ projectId: string
+ ): Promise {
+ // Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
+ // Images may have pendingFlowId before the flow record is created
+ return await db.query.images.findFirst({
+ where: and(
+ or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
+ eq(images.projectId, projectId),
+ eq(images.source, 'generated'),
+ isNull(images.deletedAt)
+ ),
+ orderBy: [desc(images.createdAt)],
+ });
+ }
+
+ private async getFirstGeneratedInFlow(
+ flowId: string,
+ projectId: string
+ ): Promise {
+ // Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
+ const allImages = await db.query.images.findMany({
+ where: and(
+ or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
+ eq(images.projectId, projectId),
+ eq(images.source, 'generated'),
+ isNull(images.deletedAt)
+ ),
+ orderBy: [images.createdAt],
+ limit: 1,
+ });
+
+ return allImages[0];
+ }
+
+ private async getLastUploadedInFlow(
+ flowId: string,
+ projectId: string
+ ): Promise {
+ // Check both flowId and pendingFlowId to support lazy flow pattern (Section 4.1)
+ return await db.query.images.findFirst({
+ where: and(
+ or(eq(images.flowId, flowId), eq(images.pendingFlowId, flowId)),
+ eq(images.projectId, projectId),
+ eq(images.source, 'uploaded'),
+ isNull(images.deletedAt)
+ ),
+ orderBy: [desc(images.createdAt)],
+ });
+ }
+
+ async validateAliasForAssignment(alias: string, projectId: string, flowId?: string): Promise {
+ const formatResult = validateAliasFormat(alias);
+ if (!formatResult.valid) {
+ throw new Error(formatResult.error!.message);
+ }
+
+ const reservedResult = validateAliasNotReserved(alias);
+ if (!reservedResult.valid) {
+ throw new Error(reservedResult.error!.message);
+ }
+
+ // NOTE: Conflict checks removed per Section 5.2 of api-refactoring-final.md
+ // Aliases now use override behavior - new requests take priority over existing aliases
+ // Flow alias conflicts are handled by JSONB field overwrite (no check needed)
+ }
+
+ // DEPRECATED: Removed per Section 5.2 - aliases now use override behavior
+ // private async checkProjectAliasConflict(alias: string, projectId: string): Promise {
+ // const existing = await db.query.images.findFirst({
+ // where: and(
+ // eq(images.projectId, projectId),
+ // eq(images.alias, alias),
+ // isNull(images.deletedAt),
+ // isNull(images.flowId)
+ // ),
+ // });
+ //
+ // if (existing) {
+ // throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
+ // }
+ // }
+
+ // DEPRECATED: Removed per Section 5.2 - flow aliases now use override behavior
+ // Flow alias conflicts are naturally handled by JSONB field overwrite in assignFlowAlias()
+ // private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise {
+ // const flow = await db.query.flows.findFirst({
+ // where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)),
+ // });
+ //
+ // if (!flow) {
+ // throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
+ // }
+ //
+ // const flowAliases = flow.aliases as Record;
+ // if (flowAliases[alias]) {
+ // throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
+ // }
+ // }
+
+ async resolveMultiple(
+ aliases: string[],
+ projectId: string,
+ flowId?: string
+ ): Promise