diff --git a/apps/web/.env.example b/apps/web/.env.example index 913798c..3d1241f 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -33,3 +33,42 @@ STELLAR_WEB_AUTH_DOMAIN=localhost # Optional display name used in the wallet signing prompt. AUTH_APP_NAME=Stellar DAOs + +# DAO Artwork Generation (optional, enables image generation features) +# Set to 'true' to enable AI-powered artwork generation for DAO images +# Used by: DaoImageField (client), /api/artwork/generate (server) +NEXT_PUBLIC_IMAGE_GENERATION_ENABLED=false + +# Batch size for AI image generation (number of candidates to generate) +# Default: 4, adjust based on model and API quotas +# Server-side only, used by /api/artwork/generate +GENERATION_BATCH_SIZE=4 + +# Vercel AI Gateway API key for image generation +# Required when NEXT_PUBLIC_IMAGE_GENERATION_ENABLED=true +# Server-side only, never expose publicly +AI_GATEWAY_API_KEY= + +# Image model to use for generation (must be supported by Vercel AI Gateway) +# Example: openai:dall-e-3 +# Server-side only, used by /api/artwork/generate +IMAGE_MODEL=openai:dall-e-3 + +# Pinata Upload Configuration (optional, enables direct IPFS uploads) +# Set to 'true' to enable artwork directory uploads +# Used by: DaoImageField (client), /api/uploads/* (server) +NEXT_PUBLIC_PINATA_UPLOADS_ENABLED=false + +# Pinata JWT for upload authorization +# Required when NEXT_PUBLIC_PINATA_UPLOADS_ENABLED=true +# Can be either: +# - A standard JWT with pinFileToIPFS permission +# - An API key that can generate restricted upload tokens +# Used by: /api/pinata/generate-jwt (server), /api/uploads/pinata-url (server) +# Server-side only, never expose publicly +PINATA_JWT= + +# Preferred Pinata IPFS gateway host for resolver URLs +# Falls back to built-in gateway list if unavailable +# Available to both client and server (NEXT_PUBLIC_ prefix) +NEXT_PUBLIC_PINATA_GATEWAY=nouns-builder.mypinata.cloud diff --git a/apps/web/package.json b/apps/web/package.json index 2e42a2e..408fa20 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "panda:codegen": "panda codegen && panda cssgen" }, "dependencies": { + "@ai-sdk/gateway": "^4.0.96", "@builder-stellar/auction-bindings": "workspace:*", "@builder-stellar/governor-bindings": "workspace:*", "@builder-stellar/manager-bindings": "workspace:*", @@ -26,7 +27,9 @@ "@neondatabase/serverless": "^1.1.0", "@noble/hashes": "^2.2.0", "@stellar/stellar-sdk": "^17.0.1", + "ai": "^4.0.0", "buffer": "^6.0.3", + "framer-motion": "^13.4.4", "iron-session": "^8.0.4", "next": "^15.4.0", "papaparse": "^5.7.0", diff --git a/apps/web/public/images/dao-logo.png b/apps/web/public/images/dao-logo.png new file mode 100644 index 0000000..0c43044 Binary files /dev/null and b/apps/web/public/images/dao-logo.png differ diff --git a/apps/web/src/app/api/artwork/generate/route.ts b/apps/web/src/app/api/artwork/generate/route.ts new file mode 100644 index 0000000..d2fa5ad --- /dev/null +++ b/apps/web/src/app/api/artwork/generate/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { generateDaoImageCandidates, GenerateDaoImageInputSchema } from '@/lib/ai-image-generation'; +import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server'; + +/** + * Simple in-memory rate limiter for MVP + * TODO: Replace with Redis-backed distributed rate limiter + */ +const generationLimiter = new Map(); + +function checkRateLimit( + key: string, + maxPerHour: number = 3, + _maxConcurrent: number = 1 +): { allowed: boolean; reason?: string } { + const now = Date.now(); + const limit = generationLimiter.get(key); + + if (!limit || limit.resetAt < now) { + // Reset window + generationLimiter.set(key, { count: 1, resetAt: now + 60 * 60 * 1000 }); + return { allowed: true }; + } + + if (limit.count >= maxPerHour) { + return { + allowed: false, + reason: `Rate limit exceeded. Maximum ${maxPerHour} requests per hour.` + }; + } + + limit.count++; + return { allowed: true }; +} + +/** + * Simple CSRF token validation + * In production, consider using a proper CSRF library + */ +function validateCsrfToken(request: NextRequest): boolean { + // Get CSRF token from header + const csrfToken = request.headers.get('x-csrf-token'); + + // Verify it's a same-origin request + const origin = request.headers.get('origin'); + const requestUrl = new URL(request.url); + + if (origin && new URL(origin).origin !== requestUrl.origin) { + return false; + } + + // For MVP, just require the token header to be present + // TODO: Implement proper stateful CSRF token validation + return !!csrfToken; +} + +/** + * POST /api/artwork/generate + * + * Generates DAO identity image candidates using AI. + * + * Request body: + * { + * name: string (1-100 chars) + * description: string (1-500 chars) + * artDirection?: string (max 600 chars) + * stylePreset?: 'modern' | 'vintage' | 'abstract' | 'minimal' | 'vibrant' + * } + * + * Response: + * { + * candidates: [ + * { + * id: string + * temporaryUrl: string + * expiresAt: ISO8601 timestamp + * model: string + * revisedPrompt?: string + * } + * ] + * } + * + * Error responses: + * - 401: Unauthenticated + * - 403: CSRF validation failed + * - 404: Feature disabled + * - 422: Invalid input or rate limit exceeded + * - 500: Generation service error + */ +export async function POST(request: NextRequest): Promise { + try { + // Check if feature is enabled + if (process.env.NEXT_PUBLIC_IMAGE_GENERATION_ENABLED !== 'true') { + return NextResponse.json({ error: 'Image generation is not enabled', code: 'FEATURE_DISABLED' }, { status: 404 }); + } + + // Validate CSRF token + if (!validateCsrfToken(request)) { + return NextResponse.json({ error: 'CSRF validation failed', code: 'CSRF_INVALID' }, { status: 403 }); + } + + // Check authentication + const session = await requireAuthenticatedSession(); + + // Apply rate limiting + // Limit by both wallet address and IP + const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; + const rateLimitKey = `gen:${session.address}:${clientIp}`; + const rateLimit = checkRateLimit(rateLimitKey); + + if (!rateLimit.allowed) { + return NextResponse.json({ error: rateLimit.reason, code: 'RATE_LIMIT_EXCEEDED' }, { status: 429 }); + } + + // Parse and validate request body + const body = await request.json().catch(() => ({})); + + const validationResult = GenerateDaoImageInputSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid input', + code: 'VALIDATION_ERROR', + details: validationResult.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message + })) + }, + { status: 422 } + ); + } + + const input = validationResult.data; + + // Generate candidates + const candidates = await generateDaoImageCandidates(input); + + return NextResponse.json( + { + candidates: candidates.map((c) => ({ + id: c.id, + temporaryUrl: c.temporaryUrl, + expiresAt: c.expiresAt.toISOString(), + model: c.model, + revisedPrompt: c.revisedPrompt + })) + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof AuthError) { + return authErrorResponse(error); + } + + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON in request body', code: 'JSON_PARSE_ERROR' }, { status: 422 }); + } + + // Log error server-side only + console.error('[/api/artwork/generate]', error); + + return NextResponse.json( + { + error: 'Image generation failed. Please try again later.', + code: 'GENERATION_ERROR' + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/pinata/generate-jwt/route.ts b/apps/web/src/app/api/pinata/generate-jwt/route.ts new file mode 100644 index 0000000..2a68a47 --- /dev/null +++ b/apps/web/src/app/api/pinata/generate-jwt/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server'; +import { AuthenticationError, getPinataService, PinataError } from '@/lib/pinata-service'; + +/** + * Simple in-memory rate limiter for JWT generation + * Rate limit: 20 requests per 60 seconds per user (generous to avoid blocking legitimate use) + */ +const jwtLimiter = new Map(); + +function checkRateLimit(key: string, maxPerMinute: number = 20): { allowed: boolean; reason?: string } { + const now = Date.now(); + const limit = jwtLimiter.get(key); + + if (!limit || limit.resetAt < now) { + // Reset window + jwtLimiter.set(key, { count: 1, resetAt: now + 60 * 1000 }); + return { allowed: true }; + } + + if (limit.count >= maxPerMinute) { + return { + allowed: false, + reason: `Rate limit exceeded. Maximum ${maxPerMinute} requests per minute.` + }; + } + + limit.count++; + return { allowed: true }; +} + +/** + * POST /api/pinata/generate-jwt + * + * Generates a single-use JWT for directory uploads to Pinata's legacy endpoint. + * The JWT has restricted permissions (pinFileToIPFS only) and expires after one use. + * + * Response: + * { + * jwt: string (JWT token for Pinata API) + * expiresAt: ISO8601 timestamp + * } + * + * Error responses: + * - 401: Unauthenticated + * - 429: Rate limit exceeded + * - 500: Service error + */ +export async function POST(request: NextRequest): Promise { + try { + // Check authentication + const session = await requireAuthenticatedSession(); + + // Apply rate limiting + const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; + const rateLimitKey = `jwt:${session.address}:${clientIp}`; + const rateLimit = checkRateLimit(rateLimitKey); + + if (!rateLimit.allowed) { + return NextResponse.json( + { + error: rateLimit.reason, + code: 'RATE_LIMIT_EXCEEDED', + retryAfter: 60 + }, + { status: 429, headers: { 'Retry-After': '60' } } + ); + } + + // Generate JWT from Pinata service + const pinataService = getPinataService(); + let jwt: string; + try { + jwt = await pinataService.generateUploadJwt(); + } catch (jwtError) { + console.error('[/api/pinata/generate-jwt] Failed to generate JWT:', jwtError); + if (jwtError instanceof AuthenticationError) { + return NextResponse.json( + { + error: 'IPFS service authentication failed. The server may not be configured correctly.', + code: 'SERVICE_AUTH_FAILED', + details: process.env.NODE_ENV === 'development' ? jwtError.message : undefined + }, + { status: 500 } + ); + } + throw jwtError; + } + + if (!jwt) { + throw new Error('JWT generation returned empty token'); + } + + // JWT typically expires after one use or within a reasonable time window + // Set expiry to 1 hour from now as a safety margin + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + return NextResponse.json( + { + jwt, + expiresAt: expiresAt.toISOString() + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof AuthError) { + return authErrorResponse(error); + } + + if (error instanceof PinataError) { + console.error('[/api/pinata/generate-jwt] Pinata service error:', { + code: error.code, + message: error.message, + status: error.status + }); + return NextResponse.json( + { + error: error.message || 'Failed to generate upload token', + code: error.code || 'SERVICE_ERROR', + status: error.status + }, + { status: error.status || 500 } + ); + } + + console.error('[/api/pinata/generate-jwt] Unexpected error:', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + + return NextResponse.json( + { + error: 'Failed to generate upload token. Please try again later.', + code: 'INTERNAL_SERVER_ERROR', + retryable: true + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/pinata/pin-cid/route.ts b/apps/web/src/app/api/pinata/pin-cid/route.ts new file mode 100644 index 0000000..8bea93d --- /dev/null +++ b/apps/web/src/app/api/pinata/pin-cid/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server'; +import { getPinataService, PinataError } from '@/lib/pinata-service'; + +/** + * Request validation schema + */ +const PinCidRequestSchema = z.object({ + cid: z.string().min(1), + name: z.string().optional() +}); + +type _PinCidRequest = z.infer; + +/** + * POST /api/pinata/pin-cid + * + * Pins a CID to Pinata for redundancy. This is called asynchronously + * after a successful directory upload to ensure content is replicated. + * + * Request body: + * { + * cid: string (IPFS content identifier) + * name?: string (optional name for the pin) + * } + * + * Response: + * { + * success: boolean + * cid: string + * } + * + * Error responses: + * - 401: Unauthenticated + * - 422: Invalid input + * - 500: Service error + */ +export async function POST(request: NextRequest): Promise { + let body: Record = {}; + + try { + // Check authentication + await requireAuthenticatedSession(); + + // Parse and validate request body + body = await request.json().catch(() => ({})); + + const validationResult = PinCidRequestSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid input', + code: 'VALIDATION_ERROR', + details: validationResult.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message + })) + }, + { status: 422 } + ); + } + + const { cid, name } = validationResult.data; + + // Pin CID to IPFS for redundancy + const pinataService = getPinataService(); + await pinataService.pinCidToIPFS(cid, name); + + return NextResponse.json( + { + success: true, + cid + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof AuthError) { + return authErrorResponse(error); + } + + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON in request body', code: 'JSON_PARSE_ERROR' }, { status: 422 }); + } + + if (error instanceof PinataError) { + console.error('[/api/pinata/pin-cid] Pinata service error:', { + cid: (error as any).cid, + code: error.code, + message: error.message, + status: error.status + }); + return NextResponse.json( + { + error: error.message || 'Failed to pin content to IPFS', + code: error.code || 'SERVICE_ERROR', + status: error.status + }, + { status: error.status || 500 } + ); + } + + console.error('[/api/pinata/pin-cid] Unexpected error:', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + cid: body.cid + }); + + return NextResponse.json( + { + error: 'Failed to pin content. Please try again later.', + code: 'INTERNAL_SERVER_ERROR', + retryable: true + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/uploads/complete/route.ts b/apps/web/src/app/api/uploads/complete/route.ts new file mode 100644 index 0000000..8a30cf6 --- /dev/null +++ b/apps/web/src/app/api/uploads/complete/route.ts @@ -0,0 +1,224 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server'; +import { BackendFailedError, getPinataService, NotFoundError, PinataError } from '@/lib/pinata-service'; +import { + cidToUrls, + getPreferredGatewayHost, + UploadCompletionSchema, + validateUploadCompletion +} from '@/lib/pinata-upload'; +import { getAuthorization } from '@/lib/pinata-upload-auth'; + +/** + * POST /api/uploads/complete + * + * Verifies an uploaded file and returns the finalized IPFS URLs. + * + * Request body: + * { + * uploadId: string (returned from /api/uploads/pinata-url) + * cid: string (IPFS content identifier) + * filename: string + * mimeType: string + * sizeBytes: number + * uploadType: 'dao-image' | 'artwork-directory' + * } + * + * Response: + * { + * cid: string + * ipfsUri: string (ipfs://...) + * gatewayUrl: string (https://...) + * mimeType: string + * sizeBytes: number + * filename: string + * } + * + * Error responses: + * - 401: Unauthenticated + * - 404: Upload authorization not found + * - 410: Authorization expired + * - 422: Validation failed + * - 500: Service error + */ +export async function POST(request: NextRequest): Promise { + try { + // Check if feature is enabled + if (process.env.NEXT_PUBLIC_PINATA_UPLOADS_ENABLED !== 'true') { + return NextResponse.json({ error: 'File uploads are not enabled', code: 'FEATURE_DISABLED' }, { status: 404 }); + } + + // Check authentication + await requireAuthenticatedSession(); + + // Parse and validate request body + const body = await request.json().catch(() => ({})); + + const validationResult = UploadCompletionSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid input', + code: 'VALIDATION_ERROR', + details: validationResult.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message + })) + }, + { status: 422 } + ); + } + + const completion = validationResult.data; + + // Look up the authorization + const authorization = getAuthorization(completion.uploadId); + + if (!authorization) { + console.warn(`[/api/uploads/complete] Authorization not found for upload ${completion.uploadId}`); + return NextResponse.json( + { + error: 'Upload authorization not found. Your upload session may have expired. Please try again.', + code: 'AUTHORIZATION_NOT_FOUND' + }, + { status: 404 } + ); + } + + // Verify authorization hasn't expired + if (authorization.expiresAt < new Date()) { + console.warn(`[/api/uploads/complete] Authorization expired for upload ${completion.uploadId}`); + return NextResponse.json( + { + error: 'Upload authorization has expired. Your session lasted too long. Please try uploading again.', + code: 'AUTHORIZATION_EXPIRED' + }, + { status: 410 } + ); + } + + // Validate completion data against authorization + const completionValidation = validateUploadCompletion(completion, authorization); + + if (!completionValidation.valid) { + return NextResponse.json({ error: completionValidation.error, code: 'VALIDATION_FAILED' }, { status: 422 }); + } + + // Verify CID with Pinata API + // Fetch metadata from Pinata to verify: + // - CID exists and is accessible + // - File size is reasonable + const pinataService = getPinataService(); + const cidMetadata = await pinataService.verifyCid(completion.cid); + + // Verify file size matches + if (cidMetadata.size > 0 && cidMetadata.size !== completion.sizeBytes) { + console.warn( + `[/api/uploads/complete] Size mismatch for ${completion.cid}: expected ${completion.sizeBytes}, got ${cidMetadata.size}` + ); + // Allow slight discrepancies (< 5%) due to encoding differences + const sizeDiscrepancy = Math.abs(cidMetadata.size - completion.sizeBytes) / completion.sizeBytes; + if (sizeDiscrepancy > 0.05) { + return NextResponse.json( + { + error: 'Uploaded file size does not match reported size', + code: 'SIZE_MISMATCH' + }, + { status: 422 } + ); + } + } + + // Pin CID to IPFS for redundancy + try { + await pinataService.pinCidToIPFS(completion.cid, completion.filename); + } catch (pinError) { + // Log the error but don't fail the request + // The CID is already on Pinata, pinning is just for redundancy + console.warn('[/api/uploads/complete] Failed to pin CID to IPFS:', pinError); + } + + // Convert CID to URLs + const { ipfsUri, gatewayUrl } = cidToUrls(completion.cid, getPreferredGatewayHost()); + + return NextResponse.json( + { + cid: completion.cid, + ipfsUri, + gatewayUrl, + mimeType: completion.mimeType, + sizeBytes: completion.sizeBytes, + filename: completion.filename + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof AuthError) { + return authErrorResponse(error); + } + + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON in request body', code: 'JSON_PARSE_ERROR' }, { status: 422 }); + } + + // Handle Pinata verification errors + if (error instanceof NotFoundError) { + console.warn(`[/api/uploads/complete] CID not found on IPFS: ${(error as any).cid}`); + return NextResponse.json( + { + error: + 'Uploaded content not found on IPFS. The upload may not have completed successfully. Please try uploading again.', + code: 'CID_NOT_FOUND' + }, + { status: 404 } + ); + } + + if (error instanceof BackendFailedError) { + console.error('[/api/uploads/complete] Backend error during verification:', { + message: error.message, + cid: (error as any).cid + }); + return NextResponse.json( + { + error: error.message || 'IPFS service error during verification', + code: error.code || 'SERVICE_ERROR' + }, + { status: error.status || 500 } + ); + } + + if (error instanceof PinataError) { + console.error('[/api/uploads/complete] IPFS service error:', { + code: error.code, + message: error.message, + status: error.status, + cid: (error as any).cid + }); + return NextResponse.json( + { + error: error.message || 'Failed to verify upload with IPFS', + code: error.code || 'SERVICE_ERROR', + status: error.status + }, + { status: error.status || 500 } + ); + } + + console.error('[/api/uploads/complete] Unexpected error:', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + cid: (error as any)?.cid + }); + + return NextResponse.json( + { + error: 'Failed to complete upload. Please try again later.', + code: 'INTERNAL_SERVER_ERROR', + retryable: true + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/uploads/pinata-url/route.ts b/apps/web/src/app/api/uploads/pinata-url/route.ts new file mode 100644 index 0000000..e6f67c4 --- /dev/null +++ b/apps/web/src/app/api/uploads/pinata-url/route.ts @@ -0,0 +1,214 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { AuthError, authErrorResponse, requireAuthenticatedSession } from '@/lib/auth/server'; +import { AuthenticationError, getPinataService, PinataError, RateLimitError } from '@/lib/pinata-service'; +import { createUploadAuthorization, isValidUploadType, validateFileSize, validateMimeType } from '@/lib/pinata-upload'; +import { storeAuthorization } from '@/lib/pinata-upload-auth'; + +/** + * Request validation schema for signed URL generation + */ +const PinataUrlRequestSchema = z.object({ + uploadType: z.enum(['dao-image', 'artwork-directory']), + mimeType: z.string(), + sizeBytes: z.number().int().positive(), + filename: z.string().min(1) +}); + +type _PinataUrlRequest = z.infer; + +/** + * Simple CSRF token validation (same as in /api/artwork/generate) + */ +function validateCsrfToken(request: NextRequest): boolean { + const csrfToken = request.headers.get('x-csrf-token'); + const origin = request.headers.get('origin'); + const requestUrl = new URL(request.url); + + if (origin && new URL(origin).origin !== requestUrl.origin) { + return false; + } + + return !!csrfToken; +} + +/** + * POST /api/uploads/pinata-url + * + * Generates a signed URL for direct browser upload to Pinata. + * + * Request body: + * { + * uploadType: 'dao-image' | 'artwork-directory' + * mimeType: string (e.g., 'image/png') + * sizeBytes: number + * filename: string + * } + * + * Response: + * { + * uploadId: string (for tracking authorization) + * signedUrl: string (use this to upload directly to Pinata) + * expiresAt: ISO8601 timestamp + * uploadType: string + * expectedMimes: string[] + * maxBytes: number + * } + * + * Error responses: + * - 401: Unauthenticated + * - 403: CSRF validation failed + * - 404: Upload feature disabled + * - 422: Invalid input or validation failed + * - 500: Service error + */ +export async function POST(request: NextRequest): Promise { + let body: Record = {}; + + try { + // Check if feature is enabled + if (process.env.NEXT_PUBLIC_PINATA_UPLOADS_ENABLED !== 'true') { + return NextResponse.json({ error: 'File uploads are not enabled', code: 'FEATURE_DISABLED' }, { status: 404 }); + } + + // Validate CSRF token + if (!validateCsrfToken(request)) { + return NextResponse.json({ error: 'CSRF validation failed', code: 'CSRF_INVALID' }, { status: 403 }); + } + + // Check authentication + await requireAuthenticatedSession(); + + // Parse and validate request body + body = await request.json().catch(() => ({})); + + const validationResult = PinataUrlRequestSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid input', + code: 'VALIDATION_ERROR', + details: validationResult.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message + })) + }, + { status: 422 } + ); + } + + const data = validationResult.data; + + // Validate upload type + if (!isValidUploadType(data.uploadType)) { + return NextResponse.json({ error: 'Invalid upload type', code: 'INVALID_UPLOAD_TYPE' }, { status: 422 }); + } + + // Validate MIME type + const mimeValidation = validateMimeType(data.mimeType, data.uploadType); + if (!mimeValidation.valid) { + return NextResponse.json({ error: mimeValidation.error, code: 'INVALID_MIME_TYPE' }, { status: 422 }); + } + + // Validate file size + const sizeValidation = validateFileSize(data.sizeBytes, data.uploadType); + if (!sizeValidation.valid) { + return NextResponse.json({ error: sizeValidation.error, code: 'FILE_TOO_LARGE' }, { status: 422 }); + } + + // Generate signed URL from Pinata API + const pinataService = getPinataService(); + const signedUrl = await pinataService.createSignedUploadUrl(data.uploadType); + + // Create authorization record (will be verified on completion) + const authorization = createUploadAuthorization(data.uploadType, signedUrl); + + // Store authorization for verification during completion + const uploadId = storeAuthorization(authorization.uploadId, authorization); + + return NextResponse.json( + { + uploadId, + signedUrl: authorization.signedUrl, + expiresAt: authorization.expiresAt.toISOString(), + uploadType: authorization.uploadType, + expectedMimes: authorization.expectedMimes, + maxBytes: authorization.maxBytes + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof AuthError) { + return authErrorResponse(error); + } + + if (error instanceof SyntaxError) { + return NextResponse.json({ error: 'Invalid JSON in request body', code: 'JSON_PARSE_ERROR' }, { status: 422 }); + } + + // Handle Pinata service errors + if (error instanceof RateLimitError) { + console.warn('[/api/uploads/pinata-url] Rate limit exceeded for user'); + return NextResponse.json( + { + error: 'Too many upload requests. Please wait before trying again.', + code: 'RATE_LIMITED', + retryAfter: error.retryAfter + }, + { + status: 429, + headers: { + 'Retry-After': error.retryAfter.toString() + } + } + ); + } + + if (error instanceof AuthenticationError) { + console.error('[/api/uploads/pinata-url] IPFS service authentication failed:', { + message: error.message + }); + return NextResponse.json( + { + error: 'IPFS service authentication failed. The server may not be configured correctly.', + code: 'SERVICE_AUTH_FAILED', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }, + { status: 500 } + ); + } + + if (error instanceof PinataError) { + console.error('[/api/uploads/pinata-url] IPFS service error:', { + code: error.code, + message: error.message, + status: error.status, + uploadType: body?.uploadType + }); + return NextResponse.json( + { + error: error.message || 'Failed to generate upload URL', + code: error.code || 'SERVICE_ERROR', + status: error.status + }, + { status: error.status || 500 } + ); + } + + console.error('[/api/uploads/pinata-url] Unexpected error:', { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + uploadType: body?.uploadType + }); + + return NextResponse.json( + { + error: 'Failed to generate upload URL. Please try again later.', + code: 'INTERNAL_SERVER_ERROR', + retryable: true + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/disclaimer/page.tsx b/apps/web/src/app/disclaimer/page.tsx new file mode 100644 index 0000000..6f6ef68 --- /dev/null +++ b/apps/web/src/app/disclaimer/page.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { DashboardFooter } from '@/components/dashboard/dashboard-footer'; +import { DashboardHeader } from '@/components/dashboard/dashboard-header'; + +export default function DisclaimerPage() { + return ( +
+ + Skip to content + +
+ + +
+
+

Disclaimer

+

Last Updated: September 28, 2026

+ +
+ ⚠️ Use at your own risk. This platform involves blockchain, smart contracts, and DAOs. + All carry serious risks. See below. +
+ +
+

Not financial advice

+

+ We're not financial advisors. Don't rely on us for investment, financial, legal, or tax decisions. Talk + to real professionals first. +

+
+ +
+

Blockchain is permanent

+

+ Once you send funds or create something, it can't be undone. Wrong address? Bad DAO config? Tough luck. + We can't reverse it. +

+
+ +
+

Smart contracts have bugs

+

+ Code can fail. Contracts might have vulnerabilities. We don't guarantee they work correctly or safely. +

+
+ +
+

Network risks

+

+ Stellar network can go down. Third-party RPC providers can fail. Our platform might be unavailable. Not + our fault if these happen. +

+
+ +
+

Wallets & third parties

+

+ Your wallet can be hacked. Wallet software can have bugs. Storage services can lose data. We don't + control these. +

+
+ +
+

DAO risks

+

+ DAOs can be scams. Members can steal funds. Governance can be manipulated. We can't guarantee fair or + safe DAOs. +

+
+ +
+

Regulation unclear

+

+ Laws about DAOs and crypto change fast. Your jurisdiction might classify tokens as securities. Talk to a + lawyer. +

+
+ +
+

TL;DR

+

+ Only use money you can afford to lose. Get expert advice. Check everything twice. We're not responsible + for any losses. +

+
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx new file mode 100644 index 0000000..3dc535a --- /dev/null +++ b/apps/web/src/app/privacy/page.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { DashboardFooter } from '@/components/dashboard/dashboard-footer'; +import { DashboardHeader } from '@/components/dashboard/dashboard-header'; + +export default function PrivacyPage() { + return ( +
+ + Skip to content + +
+ + +
+
+

Privacy Policy

+

Last Updated: September 28, 2026

+ +
+

What we collect

+
    +
  • Your wallet address (public on blockchain)
  • +
  • Content you upload (images, descriptions)
  • +
  • Usage data (pages visited, features used)
  • +
  • Device info (browser, IP address)
  • +
+
+ +
+

Why we collect it

+
    +
  • To operate the platform and process transactions
  • +
  • To improve features and fix bugs
  • +
  • To prevent fraud and security issues
  • +
+
+ +
+

Blockchain data

+

+ Anything you create or transact on the Stellar blockchain is permanent, public, and cannot be deleted. + This includes your wallet address, DAO creations, and all activity. +

+

+ We have no ability to remove or modify blockchain data. +

+
+ +
+

Third parties

+

+ We use Stellar RPC providers, storage services, and analytics tools. They have their own privacy + policies. We're not responsible for their practices. +

+
+ +
+

Your rights

+
    +
  • Access data we store
  • +
  • Request deletion (except blockchain data)
  • +
  • Opt out of analytics
  • +
+
+ +
+

Kids

+

Must be 13+ to use. Under 18? Get parental consent.

+
+ +
+

Questions?

+

+ Contact us via{' '} + + Builder DAO + +

+
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx new file mode 100644 index 0000000..2ba5e51 --- /dev/null +++ b/apps/web/src/app/terms/page.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { DashboardFooter } from '@/components/dashboard/dashboard-footer'; +import { DashboardHeader } from '@/components/dashboard/dashboard-header'; + +export default function TermsPage() { + return ( +
+ + Skip to content + +
+ + +
+
+

Terms & Conditions

+

Last Updated: September 28, 2026

+ +
+

Your account

+
    +
  • You own your wallet security
  • +
  • Don't do illegal stuff
  • +
  • We can block you anytime
  • +
+
+ +
+

Your content

+

+ You keep rights to what you upload. We can display and share it to run the platform. +

+

Don't upload stuff that infringes copyrights.

+
+ +
+

Your DAOs

+

+ When you create a DAO, you're responsible for it. We don't control your DAO once it's live. Don't expect + us to reverse anything. +

+

+ Creating a DAO doesn't make it a legal entity. Get a lawyer if needed. +

+
+ +
+

No warranties

+

+ Platform is provided "as-is". Blockchain transactions are permanent and can't be undone. We're not + liable for your losses. +

+
+ +
+

Governing law

+

+ These terms are under US law. If you have a dispute, contact the Builder DAO community. +

+
+ +
+

Questions?

+

+ Contact us via{' '} + + Builder DAO + +

+
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/create-dao/ArtworkDirectoryUpload.tsx b/apps/web/src/components/create-dao/ArtworkDirectoryUpload.tsx new file mode 100644 index 0000000..38d47eb --- /dev/null +++ b/apps/web/src/components/create-dao/ArtworkDirectoryUpload.tsx @@ -0,0 +1,781 @@ +'use client'; + +import { AlertCircle, CheckCircle, Upload } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { Button, Card, Heading, Text } from '@/components/ui'; +import { hashFiles } from '@/lib/file-hash'; +import { cacheUpload, getCachedUpload } from '@/lib/upload-cache'; +import { ArtworkProperty, ArtworkSource } from '@/stores/create-dao-store'; + +/** + * Error categories for better error handling and user feedback + */ +export enum ArtworkErrorType { + DIRECTORY_STRUCTURE = 'DIRECTORY_STRUCTURE', + MIME_TYPE = 'MIME_TYPE', + FILE_SIZE = 'FILE_SIZE', + DIMENSIONS = 'DIMENSIONS', + MAX_TRAITS = 'MAX_TRAITS', + DUPLICATE_ITEM = 'DUPLICATE_ITEM', + HIDDEN_FILE = 'HIDDEN_FILE', + UPLOAD_FAILED = 'UPLOAD_FAILED', + NETWORK_ERROR = 'NETWORK_ERROR' +} + +export class ArtworkValidationError extends Error { + constructor( + public type: ArtworkErrorType, + message: string + ) { + super(message); + this.name = 'ArtworkValidationError'; + } +} + +interface DirectoryItem { + path: string; + name: string; + size: number; + file: File; +} + +interface ValidatedDirectory { + items: DirectoryItem[]; + properties: Map; // trait -> [items] + extension: '.png' | '.webp'; + totalSize: number; + itemCount: number; +} + +export interface ArtworkDirectoryUploadProps { + onComplete: (source: ArtworkSource) => void; + onCancel: () => void; +} + +/** + * Validates a directory structure for artwork. + * Expected layout: //.ext + */ +function validateDirectory(files: File[]): { + valid: boolean; + error?: ArtworkValidationError; + data?: ValidatedDirectory; +} { + const properties = new Map(); + const items: DirectoryItem[] = []; + let extension: '.png' | '.webp' | null = null; + let totalSize = 0; + + // Collect errors for better reporting + const allErrors: string[] = []; + const validFiles: File[] = []; + + // Process each file + for (const file of files) { + const path = (file as any).webkitRelativePath || file.name; + const parts = path.split('/').filter((p: string) => p); + + // Validate path depth + if (parts.length < 3) { + allErrors.push(`"${path}": File is in wrong location. Expected: collection-folder/trait-name/image-name.ext`); + continue; + } + + // Remove collection folder (first part) + parts.shift(); + const trait = parts[0]; + const filename = parts[1]; + + if (!filename || parts.length !== 2) { + allErrors.push(`"${path}": File is in wrong location. Expected: collection-folder/trait-name/image-name.ext`); + continue; + } + + // Check for hidden files (e.g., .DS_Store) + if (filename.startsWith('.')) { + allErrors.push(`"${filename}": Hidden files are not allowed. Please remove system files like .DS_Store`); + continue; + } + + // Validate filename + const lastDot = filename.lastIndexOf('.'); + if (lastDot <= 0) { + allErrors.push(`"${filename}": File has no extension. Supported: .png, .webp`); + continue; + } + + const ext = filename.substring(lastDot).toLowerCase(); + if (ext !== '.png' && ext !== '.webp') { + allErrors.push(`"${filename}": Unsupported format "${ext}". Only PNG and WebP are allowed.`); + continue; + } + + // Set or verify extension consistency + if (extension === null) { + extension = ext as '.png' | '.webp'; + } else if (extension !== ext) { + allErrors.push(`"${filename}": Uses "${ext}" but collection uses "${extension}". All files must match.`); + continue; + } + + // Check file size + if (file.size > 2 * 1024 * 1024) { + allErrors.push(`"${filename}": File is ${(file.size / 1024 / 1024).toFixed(2)}MB (max 2MB per file)`); + continue; + } + + // Add to properties + if (!properties.has(trait)) { + properties.set(trait, []); + } + const itemName = filename.substring(0, lastDot); + const traitItems = properties.get(trait)!; + if (traitItems.includes(itemName)) { + allErrors.push(`"${itemName}" in "${trait}": Duplicate item. Each trait item must be unique.`); + continue; + } + traitItems.push(itemName); + + totalSize += file.size; + validFiles.push(file); + items.push({ + path, + name: filename, + size: file.size, + file + }); + } + + // If there were validation errors, report them all at once + if (allErrors.length > 0 && validFiles.length === 0) { + // All files had errors + const errorMsg = + allErrors.length === 1 + ? allErrors[0] + : `Found ${allErrors.length} issues:\n\n${allErrors.slice(0, 5).join('\n')}${allErrors.length > 5 ? `\n\n...and ${allErrors.length - 5} more issues` : ''}`; + + return { + valid: false, + error: new ArtworkValidationError(ArtworkErrorType.DIRECTORY_STRUCTURE, errorMsg) + }; + } + + if (allErrors.length > 0 && validFiles.length > 0) { + // Some files had errors but some are valid - continue with valid ones but warn user + console.warn( + `[validateDirectory] ${allErrors.length} files had validation errors, continuing with ${validFiles.length} valid files` + ); + } + + if (items.length === 0) { + return { + valid: false, + error: new ArtworkValidationError( + ArtworkErrorType.DIRECTORY_STRUCTURE, + 'No image files found. Make sure your directory contains PNG or WebP images in subdirectories.' + ) + }; + } + + if (totalSize > 200 * 1024 * 1024) { + return { + valid: false, + error: new ArtworkValidationError( + ArtworkErrorType.FILE_SIZE, + `Collection is too large (${(totalSize / 1024 / 1024).toFixed(1)}MB total, max 200MB). Consider reducing image sizes or removing some traits.` + ) + }; + } + + if (properties.size === 0) { + return { + valid: false, + error: new ArtworkValidationError( + ArtworkErrorType.DIRECTORY_STRUCTURE, + 'No traits found. Ensure your directory structure is: collection-folder/trait-name/image-name.ext' + ) + }; + } + + if (properties.size > 16) { + return { + valid: false, + error: new ArtworkValidationError( + ArtworkErrorType.MAX_TRAITS, + `Too many traits (${properties.size}, max 16). Remove ${properties.size - 16} trait folders to proceed.` + ) + }; + } + + return { + valid: true, + data: { + items, + properties, + extension: extension!, + totalSize, + itemCount: items.length + } + }; +} + +export function ArtworkDirectoryUpload({ onComplete, onCancel }: ArtworkDirectoryUploadProps) { + const [validatedDirectory, setValidatedDirectory] = useState(null); + const [validationError, setValidationError] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStage, setUploadStage] = useState<'preparing' | 'uploading' | 'verifying' | 'finalizing'>('preparing'); + const [hasConfirmedUpload, setHasConfirmedUpload] = useState(false); + + const directoryInputRef = useRef(null); + const abortControllerRef = useRef(null); + + // Race condition guards: prevent stale operations from overwriting newer results + const uploadRunIdRef = useRef(''); + const processRunIdRef = useRef(''); + + // Handle directory selection with race condition guard + const handleDirectorySelect = useCallback(async (files: FileList | null) => { + if (!files || files.length === 0) return; + + // Generate unique run ID for this process + const runId = `process-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + processRunIdRef.current = runId; + + setValidationError(''); + setValidatedDirectory(null); + setUploadProgress(0); + setHasConfirmedUpload(false); + + const fileArray = Array.from(files); + const validation = validateDirectory(fileArray); + + // Check if this operation was superseded + if (processRunIdRef.current !== runId) { + return; + } + + if (!validation.valid) { + const errorMessage = validation.error?.message || 'Validation failed'; + setValidationError(errorMessage); + return; + } + + setValidatedDirectory(validation.data!); + + // Generate preview token IDs (random sample of items) + const allItems = validation.data!.items; + const previewIds = []; + for (let i = 0; i < Math.min(3, allItems.length); i++) { + previewIds.push(Math.floor(Math.random() * allItems.length)); + } + // Preview IDs are computed but not stored as they're not needed for the current flow + void previewIds; + }, []); + + // Handle upload with race condition guard and progress tracking + const handleUpload = useCallback(async () => { + if (!validatedDirectory || !hasConfirmedUpload) return; + + // Generate unique run ID for this upload + const runId = `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + uploadRunIdRef.current = runId; + + // Create abort controller for cancellation + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsUploading(true); + setUploadError(''); + setUploadProgress(0); + setUploadStage('preparing'); + + try { + // Check for cached upload using file hash + const files = validatedDirectory.items.map((item) => item.file); + setUploadStage('preparing'); + const fileHash = await hashFiles(files); + + const cached = getCachedUpload(fileHash); + if (cached) { + // Use cached result + if (uploadRunIdRef.current !== runId) return; + setUploadProgress(100); + setUploadStage('finalizing'); + await new Promise((resolve) => setTimeout(resolve, 300)); // Brief delay for UX + + if (uploadRunIdRef.current !== runId) return; + + const properties: ArtworkProperty[] = Array.from(validatedDirectory.properties.entries()).map( + ([name, items]) => ({ + name, + items + }) + ); + + const source: ArtworkSource = { + kind: 'uploaded', + baseUri: `${cached.uri}/`, + extension: validatedDirectory.extension, + properties, + gatewayUrl: `https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY}/ipfs/${cached.cid}/` + }; + + onComplete(source); + return; + } + + // Check if cancelled before proceeding + if (uploadRunIdRef.current !== runId) return; + + // Request a signed URL for the complete directory upload. + setUploadStage('preparing'); + setUploadProgress(5); + const urlResponse = await fetch('/api/uploads/pinata-url', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': 'required' + }, + body: JSON.stringify({ + uploadType: 'artwork-directory', + mimeType: 'directory', + sizeBytes: validatedDirectory.totalSize, + filename: 'builder' + }), + signal: abortController.signal + }); + + if (!urlResponse.ok) { + const errorData = (await urlResponse.json().catch(() => ({}))) as { error?: string }; + throw new Error( + errorData.error || 'Failed to connect to upload service. Please check your internet connection.' + ); + } + + const { signedUrl } = (await urlResponse.json()) as { signedUrl: string }; + if (!signedUrl) throw new Error('Upload service did not return a signed URL. Please try again.'); + + // Check if cancelled + if (uploadRunIdRef.current !== runId) return; + + // Build FormData for directory upload to legacy Pinata endpoint + const formData = new FormData(); + + // Add files with builder/ prefix to preserve structure + for (const item of validatedDirectory.items) { + const pathParts = item.path.split('/').filter((p) => p); + const relativePath = pathParts.slice(1).join('/'); + formData.append('file', item.file, `builder/${relativePath}`); + } + + // Add Pinata options for directory upload + formData.append( + 'pinataOptions', + JSON.stringify({ + cidVersion: 1 + }) + ); + + formData.append( + 'pinataMetadata', + JSON.stringify({ + name: 'builder' + }) + ); + + formData.append('network', 'public'); + + // Upload to the Pinata signed endpoint. + setUploadStage('uploading'); + setUploadProgress(10); + const uploadResponse = await fetch(signedUrl, { + method: 'POST', + body: formData, + signal: abortController.signal + }); + + if (!uploadResponse.ok) { + const errorData = (await uploadResponse.json().catch(() => ({}))) as any; + if (uploadResponse.status === 401) { + throw new Error('Authentication failed. Your upload session may have expired. Please try again.'); + } else if (uploadResponse.status === 429) { + throw new Error('Upload service is busy. Please wait a moment and try again.'); + } else if (uploadResponse.status >= 500) { + throw new Error('The upload service is temporarily unavailable. Please try again in a few moments.'); + } + throw new Error(errorData.error?.message || 'Failed to upload directory to IPFS. Please try again.'); + } + + const uploadData = (await uploadResponse.json()) as { + IpfsHash?: string; + data?: { cid?: string }; + }; + const cid = uploadData.data?.cid || uploadData.IpfsHash; + + if (!cid) { + throw new Error('Upload appeared successful but no storage location was returned. Please try again.'); + } + + // Check if operation was cancelled + if (uploadRunIdRef.current !== runId) { + return; + } + + // Cache the result and verify + setUploadStage('verifying'); + setUploadProgress(90); + cacheUpload(fileHash, cid); + + // Pin CID for redundancy (async, don't block) + setUploadStage('finalizing'); + setUploadProgress(95); + fetch('/api/pinata/pin-cid', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cid }) + }).catch((err) => console.warn('[handleUpload] Failed to pin CID:', err)); + + setUploadProgress(100); + + // Convert properties to ArtworkProperty[] + const properties: ArtworkProperty[] = Array.from(validatedDirectory.properties.entries()).map( + ([name, items]) => ({ + name, + items + }) + ); + + // Create source with real CID + const source: ArtworkSource = { + kind: 'uploaded', + baseUri: `ipfs://${cid}/`, + extension: validatedDirectory.extension, + properties, + gatewayUrl: `https://${process.env.NEXT_PUBLIC_PINATA_GATEWAY}/ipfs/${cid}/` + }; + + // Final check before completing + if (uploadRunIdRef.current === runId) { + onComplete(source); + } + } catch (error) { + // Only update error state if this is still the active upload + if (uploadRunIdRef.current === runId) { + if (error instanceof DOMException && error.name === 'AbortError') { + setUploadError('Upload was cancelled.'); + } else { + const errorMessage = error instanceof Error ? error.message : 'Directory upload failed. Please try again.'; + setUploadError(errorMessage); + console.error('Upload error:', error); + } + } + } finally { + if (uploadRunIdRef.current === runId) { + setIsUploading(false); + setUploadStage('preparing'); + abortControllerRef.current = null; + } + } + }, [validatedDirectory, hasConfirmedUpload, onComplete]); + + // Handle cancellation + const handleCancelUpload = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + // setIsUploading(false) will be called in finally block + }, []); + + return ( + + + {!validatedDirectory ? ( + <> + {/* Directory Selection */} + + + Upload Artwork Directory + + + directoryInputRef.current?.click()} + style={{ + borderRadius: '0.5rem', + border: '2px dashed var(--gray-6)', + padding: '2rem', + textAlign: 'center', + cursor: 'pointer', + transition: 'all 0.2s' + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = 'var(--info-9)'; + e.currentTarget.style.backgroundColor = 'var(--info-2)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--gray-6)'; + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + + + Click to select a directory + or drag and drop a folder here + + + handleDirectorySelect(e.target.files)} + style={{ display: 'none' }} + /> + + {/* Visual Directory Structure Guide */} + + Directory Structure (Example): + + + {`my-nft-collection/ +├── Background/ +│ ├── blue.png +│ ├── red.png +│ └── yellow.png +├── Eyes/ +│ ├── happy.png +│ └── sad.png +└── Mouth/ + ├── smiling.png + └── neutral.png`} + + + + + Level 1: Folder name (any name, + used only for organization) + + + Level 2: Trait names (e.g., + Background, Eyes, Mouth) - these become your properties + + + Level 3: Item names (e.g., + blue.png, happy.png) - each image in a trait + + + + + {/* Requirements */} + + Requirements: + + • Supported formats: PNG, WebP + • Max file size: 2MB per image + • Max total size: 200MB + • Max traits: 16 (trait folders) + • All images must be square (1:1 aspect ratio) + • No hidden files (e.g., .DS_Store) or subdirectories + + + + {validationError && ( + + + Validation Error: {validationError} + + + )} + + + + + ) : ( + <> + {/* Validation Summary */} + + + + + Directory Validated + + + + + + Collection Summary + + • Traits: {validatedDirectory.properties.size} + • Total items: {validatedDirectory.itemCount} + • Format: {validatedDirectory.extension} + • Size: {(validatedDirectory.totalSize / 1024 / 1024).toFixed(2)}MB + + + + {/* Trait List */} + + Traits: + + + {Array.from(validatedDirectory.properties.entries()).map(([trait, items]) => ( + + + {trait}: {items.join(', ')} + + + ))} + + + + + {/* Preview Renderers */} + + Preview (coming soon) + + Preview images will be rendered here after upload + + + + + {uploadError && ( + + + {uploadError} + + )} + + {isUploading && ( + + + + {uploadStage === 'preparing' && '⏳ Preparing upload...'} + {uploadStage === 'uploading' && '📤 Uploading files...'} + {uploadStage === 'verifying' && '✓ Verifying upload...'} + {uploadStage === 'finalizing' && '📌 Finalizing...'} + + + {uploadProgress}% + + + + + + + )} + + + + {/* Actions */} + + {!isUploading ? ( + <> + + + + ) : ( + + )} + + + + + + )} + + + ); +} diff --git a/apps/web/src/components/create-dao/ArtworkPlayground.tsx b/apps/web/src/components/create-dao/ArtworkPlayground.tsx new file mode 100644 index 0000000..204a091 --- /dev/null +++ b/apps/web/src/components/create-dao/ArtworkPlayground.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { ChevronLeft } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { Button, Card, Heading, Text } from '@/components/ui'; +import { ArtworkProperty, ArtworkSource } from '@/stores/create-dao-store'; + +import { ArtworkPreviewCanvas } from './ArtworkPreviewCanvas'; +import { LayerOrdering } from './LayerOrdering'; + +export type PlaygroundArtworkSource = Extract; + +export interface ArtworkPlaygroundProps { + source: PlaygroundArtworkSource; + onComplete: (source: PlaygroundArtworkSource) => void; + onBack: () => void; +} + +export type OrderedTraits = ArtworkProperty[]; + +/** + * Playground for reordering artwork layers and previewing the composite. + * + * Features: + * - Drag-and-drop layer reordering + * - Real-time preview generation + * - Visual feedback for layer order + * - Finalize when satisfied + */ +export function ArtworkPlayground({ source, onComplete, onBack }: ArtworkPlaygroundProps) { + const [orderedLayers, setOrderedLayers] = useState(source.properties); + const [isGenerating, setIsGenerating] = useState(false); + + // Can only proceed if we have properties + const canProceed = orderedLayers.length > 0; + + const handleComplete = useCallback(() => { + // Update source with new layer order + const updatedSource: PlaygroundArtworkSource = { + ...source, + properties: orderedLayers + }; + onComplete(updatedSource); + }, [source, orderedLayers, onComplete]); + + return ( + + {/* Header */} + + + + Preview & Organize Artwork + + + + {/* Info */} + + Reorder layers to control which traits appear on top. The preview updates in real-time as you reorganize. + + + + {/* Layer Ordering - Left Side */} + + + + + + + {/* Preview - Right Side */} + + + + + Preview + + + + + + {orderedLayers.length} layer{orderedLayers.length !== 1 ? 's' : ''} • Layers render from bottom to top + + + + + + + {/* Actions */} + + + + + + ); +} diff --git a/apps/web/src/components/create-dao/ArtworkPreviewCanvas.tsx b/apps/web/src/components/create-dao/ArtworkPreviewCanvas.tsx new file mode 100644 index 0000000..32c366d --- /dev/null +++ b/apps/web/src/components/create-dao/ArtworkPreviewCanvas.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { AlertCircle, RotateCcw } from 'lucide-react'; +import { useRef } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { Button, Text } from '@/components/ui'; +import { useArtworkPreview } from '@/hooks/useArtworkPreview'; +import { ArtworkProperty, ArtworkSource } from '@/stores/create-dao-store'; + +export interface ArtworkPreviewCanvasProps { + source: Extract; + orderedLayers: ArtworkProperty[]; +} + +/** + * Canvas-based artwork preview component. + * + * Composites layers on a canvas in order (bottom to top). + * Features: + * - Loads images from gateway URLs with fallback + * - Local Canvas API rendering + * - Error handling with real-time feedback + * - Real-time preview updates + * + * Uses useArtworkPreview hook for layer management. + */ +export function ArtworkPreviewCanvas({ source, orderedLayers }: ArtworkPreviewCanvasProps) { + const canvasRef = useRef(null); + const { layerImages, loadingProgress, error, isLoading, hasErrors, hasImages, reload } = useArtworkPreview({ + source, + orderedLayers, + canvasRef, + canvasSize: 400, + timeoutMs: 15000 + }); + + return ( + + {/* Canvas */} + + + {isLoading ? ( + + Loading layers... + + + + {loadingProgress}% + + ) : error ? ( + + + + + + + {error} + + + ) : ( + + )} + + + + {/* Status */} + + {hasErrors && ( + + + + + Some layers failed to load. Check the gateway URLs are accessible. + + + + )} + + + {/* Layer List */} + + Layer Status + + + {layerImages.map((layer, index) => ( + + + + {layer.name} + + {layer.error && ( + + {layer.error} + + )} + + + + ))} + + + + + {/* Reload */} + + + + + ); +} diff --git a/apps/web/src/components/create-dao/ArtworkSourceStep.tsx b/apps/web/src/components/create-dao/ArtworkSourceStep.tsx new file mode 100644 index 0000000..a94e958 --- /dev/null +++ b/apps/web/src/components/create-dao/ArtworkSourceStep.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { Upload } from 'lucide-react'; +import { useState } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { ArtworkDirectoryUpload } from '@/components/create-dao/ArtworkDirectoryUpload'; +import { ArtworkPlayground } from '@/components/create-dao/ArtworkPlayground'; +import { Badge, Button, Card, Heading, Text } from '@/components/ui'; +import { cidToUrls } from '@/lib/pinata-upload'; +import { getAvailableCollections, getRandomPreviewTokenId, getStarterCollection } from '@/lib/starter-collections'; +import { ArtworkSource, useCreateDaoStore } from '@/stores/create-dao-store'; + +export function ArtworkSourceStep() { + const basicInfo = useCreateDaoStore((s) => s.basicInfo); + const artworkSource = useCreateDaoStore((s) => s.artworkSource); + const setArtworkSource = useCreateDaoStore((s) => s.setArtworkSource); + + const [sourceChoice, setSourceChoice] = useState<'starter' | 'upload' | null>(null); + const [uploadMode, setUploadMode] = useState(false); + const [playgroundMode, setPlaygroundMode] = useState(false); + + const starterCollections = getAvailableCollections(); + + // Handle starter collection selection + const handleSelectStarter = (collectionId: string) => { + const collection = getStarterCollection(collectionId); + if (collection) { + const source: ArtworkSource = { + kind: 'starter', + starterId: collection.id, + baseUri: collection.baseUri, + extension: collection.extension, + properties: collection.properties, + gatewayUrl: collection.baseUri.startsWith('ipfs://') + ? cidToUrls(collection.baseUri.replace('ipfs://', '').replace(/\/$/, '')).gatewayUrl + : collection.baseUri + }; + setArtworkSource(source); + setSourceChoice('starter'); + // Show playground for layer ordering + setPlaygroundMode(true); + } + }; + + // Handle upload completion + const handleUploadComplete = (source: ArtworkSource) => { + if (source.kind === 'uploaded') { + setArtworkSource(source); + setSourceChoice('upload'); + setUploadMode(false); + // Show playground for layer ordering and preview + setPlaygroundMode(true); + } + }; + + // Handle playground completion (when user finalizes layer ordering) + const handlePlaygroundComplete = (finalSource: ArtworkSource) => { + setArtworkSource(finalSource); + setPlaygroundMode(false); + }; + + // Back from playground + const handlePlaygroundBack = () => { + setPlaygroundMode(false); + }; + + // Get the current selection description + const getSelectionDescription = () => { + if (!artworkSource || artworkSource.kind === 'legacy-unconfirmed') { + return null; + } + if (artworkSource.kind === 'starter') { + const collection = starterCollections.find((c) => c.id === artworkSource.starterId); + return collection ? ( + + {collection.name} + {collection.description} + + License: {collection.license} • {collection.attribution} + + + ) : null; + } + if (artworkSource.kind === 'uploaded') { + return ( + + Custom Uploaded Collection + + {artworkSource.properties.length} properties, {artworkSource.extension} format + + + ); + } + }; + + return ( + + {/* Current Selection Display */} + {artworkSource && artworkSource.kind !== 'legacy-unconfirmed' && ( + + + + Selected + + Artwork Source Configured + + + {getSelectionDescription()} + + + + )} + + {/* Source Selection Screen */} + {!sourceChoice && ( + + + + + How do you want to start your collection? + + + Choose from curated collections or upload your own artwork directory + + + + + {/* Starter Collections */} + + + Starter Collections + + + {starterCollections.map((collection) => ( + { + e.currentTarget.style.borderColor = 'var(--info-9)'; + e.currentTarget.style.boxShadow = '0 0 0 2px var(--info-3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--gray-6)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > + + {/* Preview Render */} + + {/* eslint-disable-next-line @next/next/no-img-element */} + {collection.name} { + // Fallback to solid color on render error + e.currentTarget.style.backgroundColor = 'var(--gray-3)'; + }} + /> + + + {/* Collection Info */} + + {collection.name} + + {collection.description} + + + {collection.properties.length} traits + {collection.license} + + {collection.attribution} + + + {/* Select Button */} + + + + ))} + + + + {/* Upload Option - Only shown when Pinata uploads are enabled */} + {process.env.NEXT_PUBLIC_PINATA_UPLOADS_ENABLED === 'true' && ( + + + Upload Your Collection + + + + + + + + Upload Your Own Collection + + Upload a directory of trait assets in PNG/WebP format + + + Required layout: collection/trait-name/item-name.png + + + + + + + )} + + {/* AI Generation (disabled for now) */} + + + AI Generation + + + + AI-Generated Collections + + Coming soon. Generate composable artwork from a DAO description. + + + + + + + + )} + + {/* Upload Mode */} + {uploadMode && setUploadMode(false)} />} + + {/* Playground Mode - Layer ordering and preview for both starter and uploaded collections */} + {playgroundMode && artworkSource && (artworkSource.kind === 'starter' || artworkSource.kind === 'uploaded') && ( + + )} + + ); +} diff --git a/apps/web/src/components/create-dao/ArtworkStep.tsx b/apps/web/src/components/create-dao/ArtworkStep.tsx index 3307498..e672d15 100644 --- a/apps/web/src/components/create-dao/ArtworkStep.tsx +++ b/apps/web/src/components/create-dao/ArtworkStep.tsx @@ -2,15 +2,19 @@ 'use client'; -import { Trash2 } from 'lucide-react'; -import { Stack } from 'styled-system/jsx'; +import { ChevronDown, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { Box, Stack } from 'styled-system/jsx'; +import { ArtworkSourceStep } from '@/components/create-dao/ArtworkSourceStep'; import { Badge, Button, Card, Heading, Input, Text } from '@/components/ui'; import { hasDuplicates, isValidIpfsUri, validateArtworkProperty } from '@/lib/validation'; import { useCreateDaoStore } from '@/stores/create-dao-store'; export function ArtworkStep() { + const [advancedOpen, setAdvancedOpen] = useState(false); const artwork = useCreateDaoStore((s) => s.artwork); + const artworkSource = useCreateDaoStore((s) => s.artworkSource); const updateArtwork = useCreateDaoStore((s) => s.updateArtwork); const addArtworkProperty = useCreateDaoStore((s) => s.addArtworkProperty); const removeArtworkProperty = useCreateDaoStore((s) => s.removeArtworkProperty); @@ -93,177 +97,238 @@ export function ArtworkStep() { setTimeout(validateProperties, 0); }; + const isSourceSelected = + artworkSource && artworkSource.kind !== 'legacy-unconfirmed' && artworkSource.kind !== undefined; + return ( - - - - IPFS Configuration - - - - - handleBaseUriChange(e.target.value)} - placeholder="ipfs://Qm.../ or https://ipfs.io/ipfs/Qm.../" - /> - {validationErrors.ipfsBaseUri && ( - {validationErrors.ipfsBaseUri} - )} - - Base URI where artwork files are stored on IPFS - - - - - - handleExtensionChange(e.target.value)} - placeholder=".png" - /> - {validationErrors.ipfsExtension && ( - {validationErrors.ipfsExtension} - )} - - File extension for artwork files (e.g., .png, .jpg, .svg) - - - - - - - -
- - Artwork Properties - {artwork.properties.length > 0 && ( - {artwork.properties.length} / 16 - )} - - - Define the traits that make up your DAO's artwork. Maximum 16 properties. - -
+ {/* Artwork Source Selection */} + - {validationErrors.properties && ( - {validationErrors.properties} - )} - - {artwork.properties.length === 0 ? ( -
+ + {/* Disclosure Toggle */} + setAdvancedOpen(!advancedOpen)} style={{ - padding: '2rem', - textAlign: 'center', - border: '1px dashed var(--gray-6)', - borderRadius: '8px' + cursor: 'pointer', + padding: '1rem', + backgroundColor: 'var(--gray-2)', + borderRadius: '0.375rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' }} > - No properties added yet - -
- ) : ( - <> + + Advanced Metadata Editor + + + + + {advancedOpen && ( - {artwork.properties.map((property, propertyIndex) => ( - - -
- Property {propertyIndex + 1} - -
+ + Customize the trait properties and items for your collection. These settings override the source + defaults. + - - - handlePropertyNameChange(propertyIndex, property, e.target.value)} - placeholder="e.g., 0-backgrounds or 1-bodies" - /> - {validationErrors[`artworkProperty${propertyIndex}`] && ( - - {validationErrors[`artworkProperty${propertyIndex}`]} - - )} - - Prefix with number for ordering (e.g., 0-backgrounds, 1-bodies) + + + + IPFS Configuration + + + + + handleBaseUriChange(e.target.value)} + placeholder="ipfs://Qm.../ or https://ipfs.io/ipfs/Qm.../" + /> + {validationErrors.ipfsBaseUri && ( + + {validationErrors.ipfsBaseUri} + + )} + + Base URI where artwork files are stored on IPFS + + + + + + handleExtensionChange(e.target.value)} + placeholder=".png" + /> + {validationErrors.ipfsExtension && ( + + {validationErrors.ipfsExtension} - + )} + + File extension for artwork files (e.g., .png, .jpg, .svg) + + + +
+ + + +
+ + Artwork Properties + {artwork.properties.length > 0 && ( + {artwork.properties.length} / 16 + )} + + + Define the traits that make up your DAO's artwork. Maximum 16 properties. + +
- - Items ({property.items.length}) + {validationErrors.properties && ( + + {validationErrors.properties} + + )} - {property.items.map((item, itemIndex) => ( -
- { - const items = [...property.items]; - items[itemIndex] = e.target.value; - handlePropertyItemChange(propertyIndex, property, items); - }} - placeholder="Item name" - style={{ flex: 1 }} - /> - +
+ ) : ( + <> + + {artwork.properties.map((property, propertyIndex) => ( + - - - - ))} + +
+ Property {propertyIndex + 1} + +
+ + + + handlePropertyNameChange(propertyIndex, property, e.target.value)} + placeholder="e.g., 0-backgrounds or 1-bodies" + /> + {validationErrors[`artworkProperty${propertyIndex}`] && ( + + {validationErrors[`artworkProperty${propertyIndex}`]} + + )} + + Prefix with number for ordering (e.g., 0-backgrounds, 1-bodies) + + - + + ))} + + +
+
+
+ ))} +
+ + -
-
- - ))} + + )} + + - - - - )} - - + )} + + + )} ); } diff --git a/apps/web/src/components/create-dao/BasicInfoStep.tsx b/apps/web/src/components/create-dao/BasicInfoStep.tsx index e3332a3..4cda8fd 100644 --- a/apps/web/src/components/create-dao/BasicInfoStep.tsx +++ b/apps/web/src/components/create-dao/BasicInfoStep.tsx @@ -4,6 +4,7 @@ import { Stack } from 'styled-system/jsx'; +import { DaoImageField } from '@/components/create-dao/DaoImageField'; import { Card, Heading, Input, Text, Textarea } from '@/components/ui'; import { isValidTokenSymbol, MAX_TOKEN_SYMBOL_LENGTH } from '@/lib/validation'; import { useCreateDaoStore } from '@/stores/create-dao-store'; @@ -54,6 +55,9 @@ export function BasicInfoStep() { return ( + {/* DAO Identity Image */} + + diff --git a/apps/web/src/components/create-dao/DaoImageField.tsx b/apps/web/src/components/create-dao/DaoImageField.tsx new file mode 100644 index 0000000..69a6029 --- /dev/null +++ b/apps/web/src/components/create-dao/DaoImageField.tsx @@ -0,0 +1,823 @@ +'use client'; + +import { AlertCircle, Upload } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { Badge, Button, Card, FallbackImage, Heading, Input, Text } from '@/components/ui'; +import { GeneratedImageCandidate } from '@/lib/ai-image-generation'; +import { loadImageUrlWithFallback } from '@/lib/image-loader'; +import { + normalizeIpfsCid, + UPLOAD_POLICIES, + validateFileSize, + validateImageDimensions, + validateMimeType +} from '@/lib/pinata-upload'; +import { DEFAULT_DAO_IMAGE_URL, LOCAL_DEFAULT_DAO_IMAGE_URL, useCreateDaoStore } from '@/stores/create-dao-store'; + +const DEFAULT_IMAGE_URL = DEFAULT_DAO_IMAGE_URL; + +export function DaoImageField() { + const basicInfo = useCreateDaoStore((s) => s.basicInfo); + const daoImageSource = useCreateDaoStore((s) => s.daoImageSource); + const setDaoImageSource = useCreateDaoStore((s) => s.setDaoImageSource); + const updateBasicInfo = useCreateDaoStore((s) => s.updateBasicInfo); + const clearValidationError = useCreateDaoStore((s) => s.clearValidationError); + const validationErrors = useCreateDaoStore((s) => s.validationErrors); + + // Local state for generation modal + const [generationOpen, setGenerationOpen] = useState(false); + const [generationPrompt, setGenerationPrompt] = useState(''); + const [stylePreset, setStylePreset] = useState<'modern' | 'vintage' | 'abstract' | 'minimal' | 'vibrant'>('modern'); + const [isGenerating, setIsGenerating] = useState(false); + const [candidates, setCandidates] = useState([]); + const [selectedCandidate, setSelectedCandidate] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [generationError, setGenerationError] = useState(''); + + // Local state for manual upload + const [uploadError, setUploadError] = useState(''); + const [uploadProgress, setUploadProgress] = useState(0); + const [imageUrl, setImageUrl] = useState(''); + const [imageUrlError, setImageUrlError] = useState(); + const [isValidatingImageUrl, setIsValidatingImageUrl] = useState(false); + const fileInputRef = useRef(null); + + // Race condition guards: prevent stale uploads from overwriting newer ones + const uploadRunIdRef = useRef(''); + + /** + * Upload blob to Pinata with XHR progress tracking + */ + const uploadBlobToSignedUrl = useCallback((blob: Blob, signedUrl: string, runId: string): Promise => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // Track upload progress + xhr.upload.addEventListener('progress', (event) => { + if (uploadRunIdRef.current !== runId) { + // Upload was superseded + xhr.abort(); + reject(new Error('Upload cancelled')); + return; + } + + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); + setUploadProgress(progress); + } + }); + + xhr.addEventListener('load', () => { + if (uploadRunIdRef.current !== runId) { + reject(new Error('Upload cancelled')); + return; + } + + if (xhr.status >= 200 && xhr.status < 300) { + // Convert XHR response to fetch Response + resolve( + new Response(xhr.response, { + status: xhr.status, + statusText: xhr.statusText, + headers: new Headers() + }) + ); + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => { + if (uploadRunIdRef.current === runId) { + reject( + new Error('Network connection lost during upload. Please check your internet connection and try again.') + ); + } + }); + + xhr.addEventListener('abort', () => { + if (uploadRunIdRef.current === runId) { + reject(new Error('Upload was cancelled')); + } + }); + + // Send upload + const formData = new FormData(); + formData.append('file', blob); + xhr.open('POST', signedUrl); + xhr.send(formData); + }); + }, []); + + // Get the current display image + const displayImage = + daoImageSource?.kind === 'generated' || daoImageSource?.kind === 'uploaded' || daoImageSource?.kind === 'url' + ? daoImageSource.gatewayUrl + : daoImageSource?.kind === 'default' + ? LOCAL_DEFAULT_DAO_IMAGE_URL + : basicInfo.contractImage === DEFAULT_IMAGE_URL + ? LOCAL_DEFAULT_DAO_IMAGE_URL + : basicInfo.contractImage || LOCAL_DEFAULT_DAO_IMAGE_URL; + + // Get source badge + const getSourceBadge = () => { + if (!daoImageSource || daoImageSource.kind === 'legacy-unconfirmed') { + return Not Selected; + } + if (daoImageSource.kind === 'generated') { + return Generated; + } + if (daoImageSource.kind === 'uploaded') { + return Uploaded; + } + if (daoImageSource.kind === 'url') { + return External URL; + } + if (daoImageSource.kind === 'default') { + return Default; + } + }; + + // Generate image candidates + const handleGenerate = async () => { + if (!basicInfo.tokenName.trim() || !basicInfo.description.trim()) { + setGenerationError('Please fill in DAO name and description first'); + return; + } + + setIsGenerating(true); + setGenerationError(''); + + try { + const response = await fetch('/api/artwork/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': 'required' // TODO: Generate proper CSRF token + }, + body: JSON.stringify({ + name: basicInfo.tokenName, + description: basicInfo.description, + artDirection: generationPrompt, + stylePreset + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + if (response.status === 429) { + throw new Error('Too many generation requests. Please wait a moment and try again.'); + } else if (response.status === 404) { + throw new Error('Image generation is not enabled. Please contact the administrator.'); + } + throw new Error(errorData.error || 'Failed to generate images. Please try again.'); + } + + const data = await response.json(); + setCandidates(data.candidates); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Image generation failed. Please try again.'; + setGenerationError(errorMessage); + console.error('Generation error:', error); + } finally { + setIsGenerating(false); + } + }; + + // Upload selected candidate to Pinata with race condition guard + const uploadCandidate = useCallback( + async (candidate: GeneratedImageCandidate) => { + // Generate unique run ID for this upload + const runId = `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + uploadRunIdRef.current = runId; + + setIsUploading(true); + setUploadProgress(0); + setGenerationError(''); + + try { + // Get signed URL from backend + const urlResponse = await fetch('/api/uploads/pinata-url', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': 'required' // TODO: Generate proper CSRF token + }, + body: JSON.stringify({ + uploadType: 'dao-image', + mimeType: 'image/png', + sizeBytes: 5 * 1024 * 1024, // Estimate + filename: `dao-image-${Date.now()}.png` + }) + }); + + if (!urlResponse.ok) { + throw new Error('Failed to get upload URL'); + } + + const authData = await urlResponse.json(); + const { signedUrl, uploadId } = authData; + + // Check if operation was superseded + if (uploadRunIdRef.current !== runId) { + return; + } + + // Download the image from temporary URL and upload to Pinata + const imageResponse = await fetch(candidate.temporaryUrl); + if (!imageResponse.ok) { + throw new Error('Failed to fetch candidate image'); + } + + const imageBlob = await imageResponse.blob(); + + // Check if operation was superseded + if (uploadRunIdRef.current !== runId) { + return; + } + + // Upload directly to signed URL with progress tracking + const pinataResponse = await uploadBlobToSignedUrl(imageBlob, signedUrl, runId); + + if (!pinataResponse.ok) { + throw new Error('Failed to upload image to Pinata. Please try again.'); + } + + // Check if operation was superseded + if (uploadRunIdRef.current !== runId) { + return; + } + + // Extract CID from Pinata response + const uploadedData = await pinataResponse.json(); + const cid = uploadedData.data?.cid || uploadedData.cid; + if (!cid) { + throw new Error('Image upload succeeded but CID was not returned. Please try again.'); + } + + // Complete the upload + const completeResponse = await fetch('/api/uploads/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + uploadId, + cid, + filename: `dao-image-${Date.now()}.png`, + mimeType: 'image/png', + sizeBytes: imageBlob.size, + uploadType: 'dao-image' + }) + }); + + if (!completeResponse.ok) { + const errorData = await completeResponse.json().catch(() => ({})); + const errorCode = errorData.code; + if (errorCode === 'CID_NOT_FOUND') { + throw new Error('Upload verification failed. The image may not have uploaded correctly. Please try again.'); + } + throw new Error('Failed to finalize image upload. Please try again.'); + } + + const result = await completeResponse.json(); + + // Final check before updating store + if (uploadRunIdRef.current === runId) { + // Update store with generated source + setDaoImageSource({ + kind: 'generated', + gatewayUrl: result.gatewayUrl, + ipfsUri: result.ipfsUri, + prompt: basicInfo.description, + model: candidate.model + }); + + updateBasicInfo({ contractImage: result.gatewayUrl }); + setGenerationOpen(false); + setCandidates([]); + setSelectedCandidate(null); + clearValidationError('daoImage'); + } + } catch (error) { + // Only update error state if this is still the active upload + if (uploadRunIdRef.current === runId) { + const errorMessage = error instanceof Error ? error.message : 'Image upload failed. Please try again.'; + setGenerationError(errorMessage); + console.error('Upload error:', error); + } + } finally { + if (uploadRunIdRef.current === runId) { + setIsUploading(false); + setUploadProgress(0); + } + } + }, + [basicInfo.description, clearValidationError, updateBasicInfo, setDaoImageSource, uploadBlobToSignedUrl] + ); + + // Handle manual file upload with race condition guard + const handleFileSelect = useCallback( + async (file: File) => { + // Generate unique run ID for this upload + const runId = `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + uploadRunIdRef.current = runId; + + setUploadError(''); + setUploadProgress(0); + + // Validate MIME type + const mimeValidation = validateMimeType(file.type, 'dao-image'); + if (!mimeValidation.valid) { + setUploadError(mimeValidation.error); + return; + } + + // Validate file size + const sizeValidation = validateFileSize(file.size, 'dao-image'); + if (!sizeValidation.valid) { + setUploadError(sizeValidation.error); + return; + } + + // Validate image dimensions + const img = new Image(); + img.onload = async () => { + // Check if operation was superseded + if (uploadRunIdRef.current !== runId) { + return; + } + + const policy = UPLOAD_POLICIES['dao-image']; + const dimensionValidation = validateImageDimensions(img.width, img.height, policy); + if (!dimensionValidation.valid) { + if (uploadRunIdRef.current === runId) { + setUploadError(dimensionValidation.error); + } + return; + } + + // Proceed with upload + setIsUploading(true); + try { + // Get signed URL + const urlResponse = await fetch('/api/uploads/pinata-url', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': 'required' // TODO: Generate proper CSRF token + }, + body: JSON.stringify({ + uploadType: 'dao-image', + mimeType: file.type, + sizeBytes: file.size, + filename: file.name + }) + }); + + if (!urlResponse.ok) { + throw new Error('Failed to get upload URL'); + } + + const authData = await urlResponse.json(); + const { signedUrl, uploadId } = authData; + + // Check if operation was superseded + if (uploadRunIdRef.current !== runId) { + return; + } + + // Upload to signed URL with progress tracking + const pinataResponse = await uploadBlobToSignedUrl(file, signedUrl, runId); + + if (!pinataResponse.ok) { + throw new Error('Failed to upload image to Pinata. Please check your internet connection and try again.'); + } + + // Check if operation was superseded + if (uploadRunIdRef.current !== runId) { + return; + } + + // Extract CID from Pinata response + const uploadedData = await pinataResponse.json(); + const cid = uploadedData.data?.cid || uploadedData.cid; + if (!cid) { + throw new Error('Image upload succeeded but CID was not returned. Please try again.'); + } + + // Complete the upload + const completeResponse = await fetch('/api/uploads/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + uploadId, + cid, + filename: file.name, + mimeType: file.type, + sizeBytes: file.size, + uploadType: 'dao-image' + }) + }); + + if (!completeResponse.ok) { + const errorData = await completeResponse.json().catch(() => ({})); + const errorCode = errorData.code; + if (errorCode === 'CID_NOT_FOUND') { + throw new Error( + 'Upload verification failed. The image may not have uploaded correctly. Please try again.' + ); + } else if (errorCode === 'AUTHORIZATION_EXPIRED') { + throw new Error('Your upload session expired. Please try uploading again.'); + } + throw new Error('Failed to finalize image upload. Please try again.'); + } + + const result = await completeResponse.json(); + + // Final check before updating store + if (uploadRunIdRef.current === runId) { + // Update store with uploaded source + setDaoImageSource({ + kind: 'uploaded', + gatewayUrl: result.gatewayUrl, + ipfsUri: result.ipfsUri, + filename: file.name + }); + + updateBasicInfo({ contractImage: result.gatewayUrl }); + clearValidationError('daoImage'); + } + } catch (error) { + // Only update error state if this is still the active upload + if (uploadRunIdRef.current === runId) { + const errorMessage = error instanceof Error ? error.message : 'Image upload failed. Please try again.'; + setUploadError(errorMessage); + console.error('Upload error:', error); + } + } finally { + if (uploadRunIdRef.current === runId) { + setIsUploading(false); + setUploadProgress(0); + } + } + }; + img.onerror = () => { + if (uploadRunIdRef.current === runId) { + setUploadError('Unable to read image dimensions. Make sure the file is a valid image.'); + } + }; + img.src = URL.createObjectURL(file); + }, + [clearValidationError, updateBasicInfo, setDaoImageSource, uploadBlobToSignedUrl] + ); + + const handleUseDefault = () => { + setDaoImageSource({ kind: 'default', gatewayUrl: DEFAULT_IMAGE_URL }); + updateBasicInfo({ contractImage: DEFAULT_IMAGE_URL }); + clearValidationError('daoImage'); + }; + + const handleUseImageUrl = () => { + const input = imageUrl.trim(); + setImageUrlError(undefined); + + const normalizedUri = normalizeImageInput(input); + if (!normalizedUri) { + setImageUrlError('Enter an HTTPS image URL or IPFS CID. HTTP URLs are not supported.'); + return; + } + + setIsValidatingImageUrl(true); + void loadImageUrlWithFallback(normalizedUri, 15000) + .then(({ url }) => { + setDaoImageSource({ kind: 'url', gatewayUrl: url }); + updateBasicInfo({ contractImage: url }); + clearValidationError('daoImage'); + }) + .catch((error) => { + setImageUrlError( + error instanceof Error + ? `That image could not be loaded. ${error.message}` + : 'That image could not be loaded. Check that it is public and still available.' + ); + }) + .finally(() => setIsValidatingImageUrl(false)); + }; + + function normalizeImageInput(input: string): string | null { + if (!input || /^http:\/\//i.test(input)) return null; + if (/^ipfs:\/\//i.test(input)) return input.replace(/^ipfs:\/\//i, 'ipfs://'); + if (/^https:\/\//i.test(input)) return input; + if (/^[a-z][a-z\d+.-]*:\/\//i.test(input)) return null; + + const cid = normalizeIpfsCid(input); + if (cid) return `ipfs://${cid}`; + + try { + const httpsUrl = `https://${input}`; + return new URL(httpsUrl).protocol === 'https:' ? httpsUrl : null; + } catch { + return null; + } + } + + return ( + + + + + DAO Identity Image + + + {/* Image Preview */} + + + + + {/* Source Badge */} + {getSourceBadge()} + + {/* Action Buttons */} + + {process.env.NEXT_PUBLIC_IMAGE_GENERATION_ENABLED === 'true' && + process.env.NEXT_PUBLIC_PINATA_UPLOADS_ENABLED === 'true' && ( + + )} + + {process.env.NEXT_PUBLIC_PINATA_UPLOADS_ENABLED === 'true' && ( + <> + + e.target.files?.[0] && handleFileSelect(e.target.files[0])} + style={{ display: 'none' }} + /> + + )} + + + + + {uploadError && ( + + + {uploadError} + + )} + + + + + External image URL + + Paste a public image URL or IPFS CID. It will be checked now, but you are responsible for keeping it + available. + + + + { + setImageUrl(event.target.value); + setImageUrlError(undefined); + }} + placeholder="https://example.com/dao-image.png or bafy..." + disabled={isValidatingImageUrl} + style={{ flex: 1 }} + /> + + + {imageUrlError && ( + + {imageUrlError} + + )} + + + + {validationErrors.daoImage && ( + + + {validationErrors.daoImage} + + )} + + {isUploading && uploadProgress > 0 && ( + + + Uploading + + {uploadProgress}% + + + + + + + )} + + {/* Generation Modal */} + {generationOpen && ( + + + + Generate DAO Image + + + + + setGenerationPrompt(e.target.value)} + placeholder="e.g., futuristic tech theme, nature elements, minimalist design" + disabled={isGenerating} + /> + + Describe additional visual elements or style preferences + + + + + + + + + {candidates.length > 0 && ( + + Select a Candidate + + {candidates.map((c) => ( + setSelectedCandidate(c)} + style={{ + width: '80px', + height: '80px', + borderRadius: '0.375rem', + overflow: 'hidden', + cursor: 'pointer', + border: + selectedCandidate?.id === c.id ? '3px solid var(--info-9)' : '1px solid var(--gray-6)' + }} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Candidate + + ))} + + + )} + + {generationError && ( + {generationError} + )} + + + {candidates.length === 0 && !isGenerating && ( + + )} + {candidates.length > 0 && ( + <> + + + + )} + + + + )} + + + + ); +} diff --git a/apps/web/src/components/create-dao/LayerOrdering.tsx b/apps/web/src/components/create-dao/LayerOrdering.tsx new file mode 100644 index 0000000..c15f07f --- /dev/null +++ b/apps/web/src/components/create-dao/LayerOrdering.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { ChevronDown, ChevronUp, GripVertical, Trash2 } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { Button, Text } from '@/components/ui'; +import { ArtworkProperty } from '@/stores/create-dao-store'; + +export interface LayerOrderingProps { + orderedLayers: ArtworkProperty[]; + setOrderedLayers: (layers: ArtworkProperty[]) => void; + onGeneratingChange?: (isGenerating: boolean) => void; +} + +/** + * Layer ordering component with drag-and-drop and keyboard support. + * + * Features: + * - Drag handles for mouse/touch + * - Keyboard navigation (Arrow Up/Down) + * - Visual feedback for current position + * - Remove layer option + */ +export function LayerOrdering({ orderedLayers, setOrderedLayers }: LayerOrderingProps) { + const [activeDragIndex, setActiveDragIndex] = useState(null); + const [dragInsertIndex, setDragInsertIndex] = useState(null); + const dragMetaRef = useRef<{ startY: number; pointerId: number } | null>(null); + const rowRefsRef = useRef>({}); + + // Move layer to new position + const moveLayer = (fromIndex: number, toIndex: number) => { + if (fromIndex < 0 || toIndex < 0 || fromIndex >= orderedLayers.length || toIndex > orderedLayers.length) { + return; + } + + const adjustedToIndex = toIndex > fromIndex ? toIndex - 1 : toIndex; + if (adjustedToIndex === fromIndex) return; + + const newLayers = [...orderedLayers]; + const [movedLayer] = newLayers.splice(fromIndex, 1); + newLayers.splice(adjustedToIndex, 0, movedLayer); + setOrderedLayers(newLayers); + }; + + // Move layer up + const handleMoveUp = (index: number) => { + if (index > 0) { + moveLayer(index, index - 1); + } + }; + + // Move layer down + const handleMoveDown = (index: number) => { + if (index < orderedLayers.length - 1) { + moveLayer(index, index + 1); + } + }; + + // Remove layer + const handleRemoveLayer = (index: number) => { + const newLayers = orderedLayers.filter((_, i) => i !== index); + setOrderedLayers(newLayers); + }; + + // Mouse/touch drag handlers + const handlePointerDown = (index: number, e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest('[data-no-drag]')) { + return; + } + + setActiveDragIndex(index); + dragMetaRef.current = { + startY: e.clientY, + pointerId: e.pointerId + }; + + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragMetaRef.current || activeDragIndex === null) return; + + // Find which layer the pointer is over + let insertIndex = activeDragIndex; + for (let i = 0; i < orderedLayers.length; i++) { + const row = rowRefsRef.current[i]; + if (!row) continue; + + const rect = row.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + + if (e.clientY < midpoint && i < activeDragIndex) { + insertIndex = i; + } else if (e.clientY > midpoint && i > activeDragIndex) { + insertIndex = i + 1; + } + } + + setDragInsertIndex(insertIndex); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!dragMetaRef.current || activeDragIndex === null) return; + + (e.currentTarget as HTMLDivElement).releasePointerCapture(dragMetaRef.current.pointerId); + + if (dragInsertIndex !== null && dragInsertIndex !== activeDragIndex) { + moveLayer(activeDragIndex, dragInsertIndex); + } + + setActiveDragIndex(null); + setDragInsertIndex(null); + dragMetaRef.current = null; + }; + + // Keyboard navigation + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + handleMoveUp(index); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + handleMoveDown(index); + } + }; + + return ( + + Layers + + {orderedLayers.length === 0 ? ( + + No layers to organize + + ) : ( + + {orderedLayers.map((layer, index) => { + const isTop = index === orderedLayers.length - 1; + const isBottom = index === 0; + const isDragging = activeDragIndex === index; + const isInsertPoint = dragInsertIndex === index; + + return ( + { + if (el) rowRefsRef.current[index] = el; + }} + > + {/* Insert indicator */} + {isInsertPoint && activeDragIndex !== null && ( + + )} + + {/* Layer row */} + handlePointerDown(index, e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onKeyDown={(e) => handleKeyDown(index, e)} + tabIndex={0} + role="button" + aria-label={`Layer ${layer.name}, position ${index + 1} of ${orderedLayers.length}`} + > + {/* Drag Handle */} + + + + + {/* Layer Info */} + + {layer.name} + + {layer.items.length} item{layer.items.length !== 1 ? 's' : ''} •{' '} + {isTop ? 'Top layer' : isBottom ? 'Base layer' : `Layer ${index + 1}`} + + + + {/* Controls */} + + + + + + + + ); + })} + + )} + + {/* Info */} + + 💡 Tip: Use drag handle, arrow keys, or buttons to reorder. Bottom layer renders first, top layer on top. + + + ); +} diff --git a/apps/web/src/components/create-dao/ReviewStep.tsx b/apps/web/src/components/create-dao/ReviewStep.tsx index 043bdab..9531783 100644 --- a/apps/web/src/components/create-dao/ReviewStep.tsx +++ b/apps/web/src/components/create-dao/ReviewStep.tsx @@ -7,10 +7,12 @@ import { Stack } from 'styled-system/jsx'; import { Badge, Callout, Card, Heading, Text } from '@/components/ui'; import { getTreasuryAssets } from '@/lib/assets-config'; import { decimalToStroops, formatStroops, getConfiguredAuctionNetwork } from '@/lib/auction-values'; +import { getStarterCollection } from '@/lib/starter-collections'; import { useCreateDaoStore } from '@/stores/create-dao-store'; export function ReviewStep({ connectedAddress }: { connectedAddress: string }) { - const { basicInfo, artwork, auction, governance, founders, validationErrors } = useCreateDaoStore(); + const { basicInfo, artwork, auction, governance, founders, validationErrors, daoImageSource, artworkSource } = + useCreateDaoStore(); const paymentAsset = getTreasuryAssets(getConfiguredAuctionNetwork()).find( (asset) => asset.contractId === auction.paymentAsset ); @@ -56,6 +58,90 @@ export function ReviewStep({ connectedAddress }: { connectedAddress: string }) { + {/* DAO Identity Image */} +
+ + DAO Identity Image + + {daoImageSource && daoImageSource.kind !== 'legacy-unconfirmed' ? ( + + {daoImageSource.kind === 'generated' && ( + <> + + + + + )} + {daoImageSource.kind === 'uploaded' && ( + <> + + + + )} + {daoImageSource.kind === 'url' && ( + <> + + + + )} + {daoImageSource.kind === 'default' && } + {(daoImageSource.kind === 'generated' || daoImageSource.kind === 'uploaded') && ( + <> + + + + )} + {daoImageSource.kind === 'default' && } + + ) : ( + + DAO image not selected or using legacy settings + + )} +
+ + {/* Artwork Source */} +
+ + Artwork Source + + {artworkSource && artworkSource.kind !== 'legacy-unconfirmed' ? ( + + {artworkSource.kind === 'starter' && ( + <> + + {(() => { + const collection = getStarterCollection(artworkSource.starterId); + return collection ? ( + <> + + + + + + ) : ( + + ); + })()} + + )} + {artworkSource.kind === 'uploaded' && ( + <> + + + + + + + )} + + ) : ( + + Artwork source not selected or using legacy settings + + )} +
+ {/* Artwork */}
diff --git a/apps/web/src/components/create-dao/TraitSelector.tsx b/apps/web/src/components/create-dao/TraitSelector.tsx new file mode 100644 index 0000000..f376895 --- /dev/null +++ b/apps/web/src/components/create-dao/TraitSelector.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { Box, Flex, Stack } from 'styled-system/jsx'; + +import { Button, Text } from '@/components/ui'; +import { ArtworkProperty } from '@/stores/create-dao-store'; + +export interface TraitSelectorProps { + properties: ArtworkProperty[]; + selectedTraits: Record; + onTraitsChange: (traits: Record) => void; +} + +/** + * Trait selector component for previewing artwork with specific trait combinations. + * + * Features: + * - Select different trait values per layer + * - Persist selections for preview updates + * - Expandable trait lists with smooth animations + * - Random trait selector button + */ +export function TraitSelector({ properties, selectedTraits, onTraitsChange }: TraitSelectorProps) { + const [expandedLayers, setExpandedLayers] = useState>({}); + + const toggleLayerExpansion = (layerName: string) => { + setExpandedLayers((prev) => ({ + ...prev, + [layerName]: !prev[layerName] + })); + }; + + const handleTraitSelect = (layerName: string, itemName: string) => { + onTraitsChange({ + ...selectedTraits, + [layerName]: itemName + }); + }; + + const handleRandomTraits = () => { + const newTraits: Record = {}; + for (const property of properties) { + if (property.items.length > 0) { + const randomIndex = Math.floor(Math.random() * property.items.length); + newTraits[property.name] = property.items[randomIndex]; + } + } + onTraitsChange(newTraits); + }; + + return ( + + + Trait Selection + + + + + {properties.map((property) => { + const isExpanded = expandedLayers[property.name]; + const selectedItem = selectedTraits[property.name]; + + return ( + + + {/* Layer Header */} + toggleLayerExpansion(property.name)} + style={{ + width: '100%', + padding: '0.75rem', + backgroundColor: 'var(--gray-2)', + border: 'none', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + transition: 'background-color 0.2s' + }} + whileHover={{ backgroundColor: 'var(--gray-3)' }} + whileTap={{ backgroundColor: 'var(--gray-4)' }} + > + + + {property.name} + + {selectedItem && ( + + {selectedItem} + + )} + + + + + + + {/* Trait Items */} + + {isExpanded && ( + + + {property.items.map((item) => { + const isSelected = selectedItem === item; + + return ( + handleTraitSelect(property.name, item)} + initial={{ opacity: 0, x: -10 }} + animate={{ opacity: 1, x: 0 }} + exit={{ opacity: 0, x: -10 }} + transition={{ duration: 0.15 }} + style={{ + width: '100%', + padding: '0.5rem 0.75rem', + backgroundColor: isSelected ? 'var(--info-3)' : 'transparent', + border: isSelected ? '1px solid var(--info-9)' : '1px solid transparent', + borderRadius: '0.25rem', + cursor: 'pointer', + textAlign: 'left', + fontSize: '0.75rem', + transition: 'all 0.2s' + }} + whileHover={{ + backgroundColor: isSelected ? 'var(--info-4)' : 'var(--gray-3)', + borderColor: isSelected ? 'var(--info-9)' : 'var(--gray-6)' + }} + whileTap={{ scale: 0.98 }} + > + + {item} + + + ); + })} + + + )} + + + + ); + })} + + + ); +} diff --git a/apps/web/src/components/dao-shell.tsx b/apps/web/src/components/dao-shell.tsx index a9f1501..b8c1ecc 100644 --- a/apps/web/src/components/dao-shell.tsx +++ b/apps/web/src/components/dao-shell.tsx @@ -19,6 +19,8 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { type ReactNode } from 'react'; +import { DashboardFooter } from '@/components/dashboard/dashboard-footer'; +import { NetworkIndicator } from '@/components/network-indicator'; import { ProposalDraftIndicator } from '@/components/proposal/proposal-draft-indicator'; import { Callout } from '@/components/ui'; import { WalletControls } from '@/components/wallet-controls'; @@ -134,12 +136,7 @@ export function DaoShell({ children }: { children: ReactNode }) {
- {/* -
-
- */} +
@@ -218,10 +215,7 @@ export function DaoShell({ children }: { children: ReactNode }) { ) : null} -
- {currentNetwork.tokenDescription} - Network status: {session.status || 'Ready'} -
+ ); diff --git a/apps/web/src/components/dashboard/dashboard-footer.tsx b/apps/web/src/components/dashboard/dashboard-footer.tsx new file mode 100644 index 0000000..bc706a8 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-footer.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link'; + +export function DashboardFooter() { + return ( +
+ Built for transparent, community-owned coordination. +
+ + Privacy + + + Terms + + + Disclaimer + +
+
+ ); +} diff --git a/apps/web/src/components/dashboard/dashboard-header.tsx b/apps/web/src/components/dashboard/dashboard-header.tsx new file mode 100644 index 0000000..d2d69ed --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-header.tsx @@ -0,0 +1,32 @@ +import { Menu } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; + +import { WalletControls } from '@/components/wallet-controls'; + +interface DashboardHeaderProps { + showMenuButton?: boolean; + onMenuClick?: () => void; +} + +export function DashboardHeader({ showMenuButton = false, onMenuClick }: DashboardHeaderProps) { + return ( +
+ {showMenuButton ? ( + + ) : null} + + +
+

Stellar DAOs

+

Your governance home

+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/dashboard/dashboard-shell.tsx b/apps/web/src/components/dashboard/dashboard-shell.tsx index 0c9b381..af78c82 100644 --- a/apps/web/src/components/dashboard/dashboard-shell.tsx +++ b/apps/web/src/components/dashboard/dashboard-shell.tsx @@ -1,19 +1,17 @@ 'use client'; -import { Menu } from 'lucide-react'; -import Image from 'next/image'; -import Link from 'next/link'; import { useState } from 'react'; import { DaoDirectory } from '@/components/dao-directory'; import { MarketplaceComingSoon } from '@/components/marketplace/marketplace-coming-soon'; import { Callout } from '@/components/ui'; -import { WalletControls } from '@/components/wallet-controls'; import type { DaoConfig } from '@/lib/dao-db'; import { useDashboardData } from '@/lib/goldsky-queries'; import { useDaoSessionStore } from '@/stores/dao-session-store'; import { DashboardFeed } from './dashboard-feed'; +import { DashboardFooter } from './dashboard-footer'; +import { DashboardHeader } from './dashboard-header'; import { DashboardSidebar } from './dashboard-sidebar'; import { DashboardTabs } from './dashboard-tabs'; import { DashboardWelcome } from './dashboard-welcome'; @@ -30,28 +28,7 @@ export function DashboardShell({ daos, loadError }: { daos: DaoConfig[]; loadErr Skip to content
-
- {!isNewcomer ? ( - - ) : null} - - -
-

Stellar DAOs

-

Your governance home

-
- -
- -
-
+ setSidebarOpen(true)} /> {isNewcomer ? (
@@ -121,10 +98,7 @@ export function DashboardShell({ daos, loadError }: { daos: DaoConfig[]; loadErr
)} -
- Built for transparent, community-owned coordination. - Stellar network directory -
+ ); diff --git a/apps/web/src/components/network-indicator.tsx b/apps/web/src/components/network-indicator.tsx new file mode 100644 index 0000000..99bb489 --- /dev/null +++ b/apps/web/src/components/network-indicator.tsx @@ -0,0 +1,17 @@ +interface NetworkIndicatorProps { + networkLabel: string; + isConnected?: boolean; +} + +export function NetworkIndicator({ networkLabel, isConnected = true }: NetworkIndicatorProps) { + if (!isConnected) { + return null; + } + + return ( +
+
+ ); +} diff --git a/apps/web/src/components/ui/FallbackImage.tsx b/apps/web/src/components/ui/FallbackImage.tsx new file mode 100644 index 0000000..907f453 --- /dev/null +++ b/apps/web/src/components/ui/FallbackImage.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { getFetchableUrls } from '@/lib/ipfs-client'; + +type FallbackImageProps = Omit, 'src' | 'onError'> & { + src?: string | null; + errorFallbackSrc: string; +}; + +export function FallbackImage({ src, errorFallbackSrc, ...props }: FallbackImageProps) { + const urls = useMemo(() => getFetchableUrls(src) ?? [], [src]); + const [currentIndex, setCurrentIndex] = useState(0); + const [hasExhaustedSources, setHasExhaustedSources] = useState(urls.length === 0); + const [isLoading, setIsLoading] = useState(urls.length > 0); + + const imageSrc = hasExhaustedSources ? errorFallbackSrc : urls[currentIndex] || errorFallbackSrc; + + return ( + // eslint-disable-next-line @next/next/no-img-element + {props.alt setIsLoading(false)} + onError={() => { + if (currentIndex < urls.length - 1) { + setIsLoading(true); + setCurrentIndex((index) => index + 1); + } else { + setIsLoading(false); + setHasExhaustedSources(true); + } + }} + /> + ); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index c029ae4..8150d52 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -3,6 +3,7 @@ export { Button } from './button'; export { Callout } from './callout'; export { Card } from './card'; export { CopyIconButton } from './copy-icon-button'; +export { FallbackImage } from './FallbackImage'; export { Field, FieldHelperText, FieldLabel } from './field'; export { IconLinkButton } from './icon-link-button'; export { Input } from './input'; diff --git a/apps/web/src/hooks/useArtworkPreview.ts b/apps/web/src/hooks/useArtworkPreview.ts new file mode 100644 index 0000000..8545d88 --- /dev/null +++ b/apps/web/src/hooks/useArtworkPreview.ts @@ -0,0 +1,251 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { getFetchableUrls } from '@/lib/ipfs-client'; +import { ArtworkProperty, ArtworkSource } from '@/stores/create-dao-store'; + +export interface LayerImage { + name: string; + url?: string; + error?: string; +} + +interface UseArtworkPreviewOptions { + source: Extract; + orderedLayers: ArtworkProperty[]; + canvasRef: React.RefObject; + canvasSize?: number; + timeoutMs?: number; +} + +interface UseArtworkPreviewResult { + layerImages: LayerImage[]; + loadingProgress: number; + error: string | null; + isLoading: boolean; + hasErrors: boolean; + hasImages: boolean; + reload: () => void; +} + +function loadImage(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + const timeoutId = window.setTimeout(() => { + image.src = ''; + reject(new Error('Image load timed out')); + }, timeoutMs); + + image.onload = () => { + window.clearTimeout(timeoutId); + resolve(); + }; + image.onerror = () => { + window.clearTimeout(timeoutId); + reject(new Error('Failed to load image')); + }; + image.src = url; + }); +} + +/** + * Hook to manage artwork preview canvas rendering. + * + * Handles: + * - Loading layer images from gateway URLs with fallback + * - Compositing layers on canvas in order + * - Progress tracking and error handling + * - Automatic reload capability + * + * @param options Configuration for the preview + * @returns Preview state and control functions + */ +export function useArtworkPreview({ + source, + orderedLayers, + canvasRef, + canvasSize = 400, + timeoutMs = 15000 +}: UseArtworkPreviewOptions): UseArtworkPreviewResult { + const [layerImages, setLayerImages] = useState([]); + const [loadingProgress, setLoadingProgress] = useState(0); + const [error, setError] = useState(null); + + /** + * Build layer URL from source properties + */ + const buildLayerUrl = useCallback( + (property: ArtworkProperty, extension: string): string => { + if (!source.baseUri || !property.name || property.items.length === 0) { + return ''; + } + + const itemName = property.items[0]; + const cleanBase = source.baseUri.replace(/\/$/, ''); + + // Handle different base URI formats + if (cleanBase.startsWith('ipfs://')) { + const cid = cleanBase.replace('ipfs://', ''); + const gateway = process.env.NEXT_PUBLIC_PINATA_GATEWAY || 'nouns-builder.mypinata.cloud'; + const gatewayUrl = `https://${gateway}/ipfs/${cid}`; + return `${gatewayUrl}/${property.name}/${itemName}${extension}`; + } + + if (cleanBase.startsWith('https://')) { + return `${cleanBase}/${property.name}/${itemName}${extension}`; + } + + return `${cleanBase}/${property.name}/${itemName}${extension}`; + }, + [source.baseUri] + ); + + /** + * Load all layer images + */ + const loadLayers = useCallback(async () => { + if (!source || !orderedLayers.length) { + setLayerImages([]); + return; + } + + const extension = source.extension || '.png'; + const layers: LayerImage[] = []; + + try { + setError(null); + setLoadingProgress(0); + + for (let i = 0; i < orderedLayers.length; i++) { + const property = orderedLayers[i]; + const url = buildLayerUrl(property, extension); + + if (!url) { + layers.push({ name: property.name, error: 'Invalid URL' }); + continue; + } + + try { + const urls = getFetchableUrls(url); + if (!urls?.length) throw new Error('Invalid image URL'); + + let loadedUrl: string | undefined; + let lastError: Error | undefined; + for (const candidate of urls) { + try { + await loadImage(candidate, timeoutMs); + loadedUrl = candidate; + break; + } catch (err) { + lastError = err instanceof Error ? err : new Error('Failed to load image'); + } + } + + if (!loadedUrl) throw lastError ?? new Error('All image gateways failed'); + layers.push({ name: property.name, url: loadedUrl }); + } catch (err) { + layers.push({ + name: property.name, + error: err instanceof Error ? err.message : 'Unknown error' + }); + } + + setLoadingProgress(Math.round(((i + 1) / orderedLayers.length) * 100)); + } + + setLayerImages(layers); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load artwork'); + setLayerImages([]); + } + }, [source, orderedLayers, buildLayerUrl, timeoutMs]); + + /** + * Render canvas with composited layers + */ + const performCanvasRender = useCallback(async (canvas: HTMLCanvasElement, layers: LayerImage[], size: number) => { + const ctx = canvas.getContext('2d'); + if (!ctx || layers.length === 0) return; + + // Set canvas size + canvas.width = size; + canvas.height = size; + + // Clear canvas with light background + ctx.fillStyle = '#f5f5f5'; + ctx.fillRect(0, 0, size, size); + + // Composite layers (bottom to top) + for (const layer of layers) { + if (!layer.url) continue; + + try { + const img = new Image(); + + await new Promise((resolve, reject) => { + img.onload = () => { + // Draw image centered, maintaining aspect ratio + const scale = Math.min(size / img.width, size / img.height); + const x = (size - img.width * scale) / 2; + const y = (size - img.height * scale) / 2; + + ctx.drawImage(img, x, y, img.width * scale, img.height * scale); + resolve(); + }; + + img.onerror = () => reject(new Error('Failed to draw image')); + + img.src = layer.url!; + }); + } catch (err) { + console.warn(`Failed to render layer ${layer.name}:`, err); + } + } + }, []); + + // Load layers when source or ordering changes + useEffect(() => { + let isMounted = true; + + (async () => { + if (isMounted) { + await loadLayers(); + } + })(); + + return () => { + isMounted = false; + }; + }, [loadLayers]); + + // Render canvas when layer images change + useEffect(() => { + let isMounted = true; + + (async () => { + if (isMounted && canvasRef.current && layerImages.length > 0) { + await performCanvasRender(canvasRef.current, layerImages, canvasSize); + } + })(); + + return () => { + isMounted = false; + }; + }, [layerImages, canvasSize, performCanvasRender, canvasRef]); + + // Determine state + const isLoading = layerImages.length === 0 && orderedLayers.length > 0 && !error; + const hasErrors = layerImages.some((l) => l.error); + const hasImages = layerImages.some((l) => l.url); + + return { + layerImages, + loadingProgress, + error, + isLoading, + hasErrors, + hasImages, + reload: loadLayers + }; +} diff --git a/apps/web/src/hooks/useFallbackSrc.ts b/apps/web/src/hooks/useFallbackSrc.ts new file mode 100644 index 0000000..21585c2 --- /dev/null +++ b/apps/web/src/hooks/useFallbackSrc.ts @@ -0,0 +1,130 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { loadImageWithFallback } from '@/lib/image-loader'; + +interface UseFallbackSrcOptions { + src: string; + onLoadStart?: () => void; + onLoadSuccess?: () => void; + onLoadError?: (error: string) => void; +} + +interface UseFallbackSrcResult { + dataSrc: string | null; + isLoading: boolean; + error: string | null; + retry: () => void; +} + +/** + * Hook to load an image with IPFS gateway fallback. + * + * Converts an image into a data URL using the fallback gateway mechanism. + * Useful for displaying IPFS images reliably when gateways may fail. + * + * @param options Configuration for the fallback + * @returns State containing data URL or error + */ +export function useFallbackSrc({ + src, + onLoadStart, + onLoadSuccess, + onLoadError +}: UseFallbackSrcOptions): UseFallbackSrcResult { + const [dataSrc, setDataSrc] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + (async () => { + if (!src) return; + + try { + onLoadStart?.(); + if (isMounted) setIsLoading(true); + if (isMounted) setError(null); + + // Use the fallback mechanism to load the image + const blob = await loadImageWithFallback(src, 15000); + + if (!isMounted) return; + + // Convert blob to data URL + const reader = new FileReader(); + reader.onload = () => { + if (isMounted) { + const dataUrl = reader.result as string; + setDataSrc(dataUrl); + onLoadSuccess?.(); + } + }; + + reader.onerror = () => { + if (isMounted) { + const errorMsg = 'Failed to read blob as data URL'; + setError(errorMsg); + onLoadError?.(errorMsg); + } + }; + + reader.readAsDataURL(blob); + } catch (err) { + if (isMounted) { + const errorMsg = err instanceof Error ? err.message : 'Failed to load image'; + setError(errorMsg); + onLoadError?.(errorMsg); + } + } finally { + if (isMounted) setIsLoading(false); + } + })(); + + return () => { + isMounted = false; + }; + }, [src, onLoadStart, onLoadSuccess, onLoadError]); + + const retryLoad = useCallback(async () => { + if (!src) return; + + try { + onLoadStart?.(); + setIsLoading(true); + setError(null); + + const blob = await loadImageWithFallback(src, 15000); + + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + setDataSrc(dataUrl); + onLoadSuccess?.(); + }; + + reader.onerror = () => { + const errorMsg = 'Failed to read blob as data URL'; + setError(errorMsg); + onLoadError?.(errorMsg); + }; + + reader.readAsDataURL(blob); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to load image'; + setError(errorMsg); + onLoadError?.(errorMsg); + } finally { + setIsLoading(false); + } + }, [src, onLoadStart, onLoadSuccess, onLoadError]); + + return { + dataSrc, + isLoading, + error, + retry: retryLoad + }; +} diff --git a/apps/web/src/lib/ai-image-generation.ts b/apps/web/src/lib/ai-image-generation.ts new file mode 100644 index 0000000..f52c787 --- /dev/null +++ b/apps/web/src/lib/ai-image-generation.ts @@ -0,0 +1,176 @@ +import { z } from 'zod'; +// TODO: @ai-sdk/gateway API has changed. Need to implement proper image generation +// using the current version's API or use a different provider +const generateImage = async (_options: any) => { + throw new Error('Image generation is not yet implemented. Please configure @ai-sdk/gateway properly.'); +}; + +/** + * Input validation schema for DAO image generation. + * Ensures all user inputs are properly sanitized before use. + */ +export const GenerateDaoImageInputSchema = z.object({ + name: z.string().trim().min(1, 'DAO name is required').max(100, 'DAO name must be 100 characters or less'), + description: z + .string() + .trim() + .min(1, 'Description is required') + .max(500, 'Description must be 500 characters or less'), + artDirection: z.string().trim().max(600, 'Art direction must be 600 characters or less').optional().default(''), + stylePreset: z.enum(['modern', 'vintage', 'abstract', 'minimal', 'vibrant']).optional().default('modern') +}); + +export type GenerateDaoImageInput = z.infer; + +/** + * Generated image candidate returned from the AI provider. + * Contains temporary URLs and metadata for browser selection. + */ +export interface GeneratedImageCandidate { + id: string; + temporaryUrl: string; + expiresAt: Date; + model: string; + revisedPrompt?: string; +} + +/** + * Constructs a product-owned prompt from user inputs. + * Treats user text as data, not as prompt instructions. + * Ensures composition, safety, and style requirements. + */ +function buildPrompt(input: GenerateDaoImageInput): string { + const styleGuides: Record = { + modern: 'Modern, clean, and professional aesthetic', + vintage: 'Vintage-inspired with retro elements', + abstract: 'Abstract and conceptual design', + minimal: 'Minimalist with essential elements only', + vibrant: 'Vibrant colors and dynamic energy' + }; + + const styleDescription = styleGuides[input.stylePreset]; + const artDirectionSegment = input.artDirection ? `\n\nAdditional direction: ${input.artDirection}` : ''; + + // Product-owned prompt template + return ` +Create a square (1:1) artwork for a decentralized organization called "${input.name}". + +Organization description: ${input.description} + +Style: ${styleDescription} + +Requirements: +- Square composition (1:1 aspect ratio) +- Professional, recognizable imagery suitable for organizational branding +- No text, logos, or watermarks unless explicitly requested +- Neutral to warm background color +- Suitable for use as an NFT collection identity image +- High quality, detailed, and visually striking + +${artDirectionSegment} + +Generate a single, cohesive image that captures the essence and values of this organization. +`.trim(); +} + +/** + * Generates DAO identity image candidates using Vercel AI Gateway. + * Returns temporary URLs for user selection before permanent storage. + */ +export async function generateDaoImageCandidates( + input: GenerateDaoImageInput, + options?: { + abortSignal?: AbortSignal; + batchSize?: number; + } +): Promise { + // Validate and sanitize input + const validatedInput = GenerateDaoImageInputSchema.parse(input); + + // Get configuration from environment + const apiKey = process.env.AI_GATEWAY_API_KEY; + const model = process.env.IMAGE_MODEL || 'openai:dall-e-3'; + const batchSize = options?.batchSize || parseInt(process.env.GENERATION_BATCH_SIZE || '4', 10); + + if (!apiKey) { + throw new Error('AI_GATEWAY_API_KEY is not configured'); + } + + const prompt = buildPrompt(validatedInput); + + try { + // Generate candidates sequentially to avoid quota issues + const candidates: GeneratedImageCandidate[] = []; + + for (let i = 0; i < batchSize; i++) { + try { + const image = await generateImage({ + model, + prompt, + // Note: Vercel AI Gateway returns different response structures + // depending on the model. This handles the common case. + // Adjust based on actual provider response structure. + messages: [], + abortSignal: options?.abortSignal + }); + + // Normalize provider output + // The exact structure depends on the provider and model + candidates.push({ + id: `candidate-${Date.now()}-${i}`, + temporaryUrl: (image as any).url || '', + expiresAt: new Date(Date.now() + 1 * 60 * 60 * 1000), // 1 hour + model, + revisedPrompt: (image as any).revisedPrompt + }); + } catch (error) { + // Log individual candidate failures but continue generating others + console.error(`Failed to generate candidate ${i + 1}:`, error); + // Continue to next candidate + } + } + + if (candidates.length === 0) { + throw new Error('Failed to generate any image candidates'); + } + + return candidates; + } catch (error) { + if (error instanceof Error) { + // Map provider errors to user-friendly messages + if (error.message.includes('rate_limit')) { + throw new Error('Too many generation requests. Please try again later.'); + } + if (error.message.includes('quota')) { + throw new Error('Generation quota exceeded. Please try again later.'); + } + if (error.message.includes('content_policy')) { + throw new Error('The request was blocked by content policy filters. Please try different inputs.'); + } + if (error.message.includes('timeout')) { + throw new Error('Generation request timed out. Please try again.'); + } + + throw new Error(`Generation failed: ${error.message}`); + } + + throw error; + } +} + +/** + * Validates that a generated candidate is still valid and can be used. + */ +export function validateCandidate(candidate: GeneratedImageCandidate): boolean { + // Check if candidate has expired + if (candidate.expiresAt < new Date()) { + return false; + } + + // Verify required fields + if (!candidate.id || !candidate.temporaryUrl) { + return false; + } + + return true; +} diff --git a/apps/web/src/lib/auth/rate-limit.ts b/apps/web/src/lib/auth/rate-limit.ts index 4bd2b97..b529ffd 100644 --- a/apps/web/src/lib/auth/rate-limit.ts +++ b/apps/web/src/lib/auth/rate-limit.ts @@ -16,8 +16,28 @@ function pruneExpired(now: number) { } } -function getClientKey(request: Request) { - return request.headers.get('x-vercel-forwarded-for')?.trim() || null; +function getClientKey(request: Request): string | null { + // Try Vercel header first (production) + const vercelIp = request.headers.get('x-vercel-forwarded-for')?.trim(); + if (vercelIp) return vercelIp; + + // Fallback to standard proxy headers + const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + if (forwardedFor) return forwardedFor; + + const realIp = request.headers.get('x-real-ip')?.trim(); + if (realIp) return realIp; + + const cloudflareIp = request.headers.get('cf-connecting-ip')?.trim(); + if (cloudflareIp) return cloudflareIp; + + // Development fallback - allow local dev with static identifier + if (process.env.NODE_ENV === 'development') { + return 'localhost-dev'; + } + + // Fail closed in production if no valid IP found + return null; } export function enforceAuthRateLimit(request: Request, scope: string, limit: number, windowMs = 60_000) { diff --git a/apps/web/src/lib/auth/security.test.ts b/apps/web/src/lib/auth/security.test.ts index 79bcbb6..eff3d8a 100644 --- a/apps/web/src/lib/auth/security.test.ts +++ b/apps/web/src/lib/auth/security.test.ts @@ -17,7 +17,7 @@ describe('authentication safeguards', () => { expect(claimAuthChallenge(nonce, expiresAt)).toEqual({ success: false, reason: 'consumed' }); }); - it('rate-limits authentication requests', async () => { + it('rate-limits authentication requests with x-vercel-forwarded-for', async () => { const scope = `test-${crypto.randomUUID()}`; const request = new Request('http://localhost:3000/api/auth/verify', { headers: { 'x-vercel-forwarded-for': '198.51.100.10' } @@ -28,10 +28,84 @@ describe('authentication safeguards', () => { expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(429); }); - it('fails closed when the trusted client identity is unavailable', () => { - const scope = `missing-identity-${crypto.randomUUID()}`; + it('falls back to x-forwarded-for header', () => { + const scope = `forwarded-${crypto.randomUUID()}`; + const request = new Request('http://localhost:3000/api/auth/verify', { + headers: { 'x-forwarded-for': '203.0.113.42, 198.51.100.50' } + }); + + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(429); + }); + + it('falls back to x-real-ip header', () => { + const scope = `realip-${crypto.randomUUID()}`; + const request = new Request('http://localhost:3000/api/auth/verify', { + headers: { 'x-real-ip': '192.0.2.100' } + }); + + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(429); + }); + + it('falls back to cf-connecting-ip header', () => { + const scope = `cloudflare-${crypto.randomUUID()}`; + const request = new Request('http://localhost:3000/api/auth/verify', { + headers: { 'cf-connecting-ip': '198.51.100.77' } + }); + + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(429); + }); + + it('respects header priority (x-vercel-forwarded-for > x-forwarded-for > x-real-ip > cf-connecting-ip)', () => { + const scope = `priority-${crypto.randomUUID()}`; + const request = new Request('http://localhost:3000/api/auth/verify', { + headers: { + 'x-vercel-forwarded-for': '198.51.100.10', + 'x-forwarded-for': '203.0.113.42', + 'x-real-ip': '192.0.2.100', + 'cf-connecting-ip': '198.51.100.77' + } + }); + + // Should use x-vercel-forwarded-for + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + // If it was using a different header, this would not hit rate limit + expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(429); + }); + + it('fails closed in production when no IP headers are present', () => { + const scope = `missing-identity-prod-${crypto.randomUUID()}`; + const request = new Request('http://localhost:3000/api/auth/verify'); + const nodeEnv = process.env.NODE_ENV; + + try { + // Temporarily set to non-development to test production behavior + Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', configurable: true }); + expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(503); + } finally { + Object.defineProperty(process.env, 'NODE_ENV', { value: nodeEnv, configurable: true }); + } + }); + + it('allows local development with static localhost identifier when no headers present', () => { + const scope = `dev-localhost-${crypto.randomUUID()}`; const request = new Request('http://localhost:3000/api/auth/verify'); + const nodeEnv = process.env.NODE_ENV; - expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(503); + try { + // Set to development to test fallback behavior + Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', configurable: true }); + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect(enforceAuthRateLimit(request, scope, 2)).toBeNull(); + expect((enforceAuthRateLimit(request, scope, 2) as Response).status).toBe(429); + } finally { + Object.defineProperty(process.env, 'NODE_ENV', { value: nodeEnv, configurable: true }); + } }); }); diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts index 31de653..e71499e 100644 --- a/apps/web/src/lib/auth/server.ts +++ b/apps/web/src/lib/auth/server.ts @@ -1,6 +1,7 @@ import { Keypair } from '@stellar/stellar-sdk'; import { getIronSession, type SessionOptions } from 'iron-session'; import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; import { getNetworkConfig, type NetworkName } from '@/config/networks'; @@ -137,8 +138,8 @@ export async function requireAuthenticatedSession() { export function authErrorResponse(error: unknown) { if (error instanceof AuthError) { const status = error.code === 'UNAUTHENTICATED' ? 401 : 422; - return Response.json({ code: error.code, message: error.message }, { status }); + return NextResponse.json({ code: error.code, message: error.message }, { status }); } - return Response.json({ message: 'Authentication service unavailable.' }, { status: 500 }); + return NextResponse.json({ message: 'Authentication service unavailable.' }, { status: 500 }); } diff --git a/apps/web/src/lib/dao-deployment-validation.ts b/apps/web/src/lib/dao-deployment-validation.ts new file mode 100644 index 0000000..64b558e --- /dev/null +++ b/apps/web/src/lib/dao-deployment-validation.ts @@ -0,0 +1,152 @@ +import { ArtworkProperty, CreateDaoStore } from '@/stores/create-dao-store'; + +/** + * Deployment preflight validation. + * Ensures all required fields are set before wallet signing. + */ +export interface DeploymentValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Validates that all required fields are set for deployment. + */ +export function validateDeploymentReady(state: CreateDaoStore): DeploymentValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Basic Info validation + if (!state.basicInfo.tokenName?.trim()) { + errors.push('DAO name is required'); + } + if (!state.basicInfo.tokenSymbol?.trim()) { + errors.push('Token symbol is required'); + } + if (!state.basicInfo.description?.trim()) { + errors.push('Description is required'); + } + + // DAO Image Source validation + if (!state.daoImageSource || state.daoImageSource.kind === 'legacy-unconfirmed') { + errors.push('DAO identity image must be selected or generated'); + } + if (state.daoImageSource && 'gatewayUrl' in state.daoImageSource && !state.daoImageSource.gatewayUrl) { + errors.push('DAO image gateway URL is missing'); + } + + // Contract Image validation + if (!state.basicInfo.contractImage?.trim()) { + errors.push('Contract image URL is missing'); + } + if (!state.basicInfo.contractImage?.startsWith('http')) { + errors.push('Contract image must be an HTTPS URL'); + } + + // Artwork Source validation + if (!state.artworkSource || state.artworkSource.kind === 'legacy-unconfirmed') { + errors.push('Artwork source must be selected (starter collection or uploaded directory)'); + } + + // IPFS validation + if (!state.artwork.ipfs.baseUri?.trim()) { + errors.push('IPFS base URI is required'); + } + if (!state.artwork.ipfs.extension?.trim()) { + errors.push('File extension is required'); + } + + // Properties validation + if (!state.artwork.properties || state.artwork.properties.length === 0) { + errors.push('At least one artwork property is required'); + } else { + // Validate each property + for (let i = 0; i < state.artwork.properties.length; i++) { + const property = state.artwork.properties[i]; + if (!property.name?.trim()) { + errors.push(`Property ${i + 1}: name is required`); + } + if (!property.items || property.items.length === 0) { + errors.push(`Property "${property.name}": at least one item is required`); + } + } + + // Check for duplicate property names + const names = state.artwork.properties.map((p: ArtworkProperty) => p.name.toLowerCase()); + const duplicates = names.filter((name: string, index: number) => names.indexOf(name) !== index); + if (duplicates.length > 0) { + errors.push(`Duplicate property names: ${Array.from(new Set(duplicates)).join(', ')}`); + } + } + + // Founders validation (optional but warn if high vote threshold with no founders) + if (state.founders.length === 0 && state.governance.proposalThresholdBps < 100) { + warnings.push('No founders allocated. Ensure governance parameters are appropriate.'); + } + + // Auction validation + if (state.auction.enabled && !state.auction.paymentAsset?.trim()) { + errors.push('Payment asset is required when auctions are enabled'); + } + + // Launch admin validation + if (!state.launchAdmin?.trim()) { + errors.push('Launch admin address is required'); + } + + return { + valid: errors.length === 0, + errors, + warnings + }; +} + +/** + * Validates that image sources are properly resolved. + */ +export function validateImageSources(state: CreateDaoStore): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // DAO Image Source + const daoImageSource = state.daoImageSource; + if (!daoImageSource || daoImageSource.kind === 'legacy-unconfirmed') { + errors.push('DAO image source not selected'); + } else { + if (daoImageSource.kind === 'generated' || daoImageSource.kind === 'uploaded') { + if (!daoImageSource.ipfsUri?.startsWith('ipfs://')) { + errors.push('DAO image IPFS URI is invalid'); + } + if (!daoImageSource.gatewayUrl?.startsWith('http')) { + errors.push('DAO image gateway URL is invalid'); + } + } else if (daoImageSource.kind === 'default' || daoImageSource.kind === 'url') { + if (!daoImageSource.gatewayUrl?.startsWith('http')) { + errors.push('DAO image URL is invalid'); + } + } + } + + // Artwork Source + const artworkSource = state.artworkSource; + if (!artworkSource || artworkSource.kind === 'legacy-unconfirmed') { + errors.push('Artwork source not selected'); + } else { + if (artworkSource.kind === 'uploaded') { + if (!artworkSource.baseUri?.startsWith('ipfs://') && !artworkSource.baseUri?.startsWith('http')) { + errors.push('Artwork base URI is invalid'); + } + if (!artworkSource.extension || !['.png', '.webp'].includes(artworkSource.extension)) { + errors.push('Artwork extension is invalid'); + } + if (!artworkSource.properties || artworkSource.properties.length === 0) { + errors.push('Artwork properties are missing'); + } + } + } + + return { + valid: errors.length === 0, + errors + }; +} diff --git a/apps/web/src/lib/file-hash.ts b/apps/web/src/lib/file-hash.ts new file mode 100644 index 0000000..5d5fc9b --- /dev/null +++ b/apps/web/src/lib/file-hash.ts @@ -0,0 +1,49 @@ +/** + * File hash utility for deduplicating artwork uploads. + * Creates a SHA256 hash based on file metadata (not content) to quickly + * identify when the same set of files has been uploaded before. + */ + +/** + * Hash file metadata to create a fingerprint for the file set + * Hashing metadata rather than content avoids expensive file reads + */ +export async function hashFiles(files: File[]): Promise { + // Create a deterministic string representation of file metadata + const fileMetadata = files.map((file) => ({ + name: file.name, + size: file.size, + lastModified: file.lastModified, + type: file.type + })); + + const metadataString = JSON.stringify(fileMetadata); + const encoder = new TextEncoder(); + const data = encoder.encode(metadataString); + + // Use Web Crypto API for SHA256 hashing (available in browsers and Node 15+) + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + // Convert to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + return `0x${hashHex}`; +} + +/** + * Quick synchronous hash for cases where async isn't needed + * Uses a simple checksum instead of crypto (faster but less collision-resistant) + */ +export function quickHashFiles(files: File[]): string { + let hash = 5381; + for (const file of files) { + for (let i = 0; i < file.name.length; i++) { + hash = (hash << 5) + hash + file.name.charCodeAt(i); + hash = hash & hash; // Convert to 32bit integer + } + hash = (hash << 5) + hash + file.size; + hash = (hash << 5) + hash + file.lastModified; + } + return `0x${(hash >>> 0).toString(16)}`; +} diff --git a/apps/web/src/lib/image-loader.ts b/apps/web/src/lib/image-loader.ts new file mode 100644 index 0000000..b109ab2 --- /dev/null +++ b/apps/web/src/lib/image-loader.ts @@ -0,0 +1,107 @@ +import { getFetchableUrls } from '@/lib/ipfs-client'; + +/** + * Load an image with automatic gateway fallback. + * Tries multiple gateways sequentially if one fails. + * + * @param uri - IPFS URI or HTTP URL + * @param timeoutMs - Timeout per gateway in milliseconds (default: 10000) + * @returns Promise resolving to the image Blob + * @throws Error if all gateways fail or URI is invalid + */ +export async function loadImageWithFallback(uri: string, timeoutMs: number = 10000): Promise { + const result = await loadImageUrlWithFallback(uri, timeoutMs); + return result.blob; +} + +/** + * Load an image and return the gateway URL that succeeded. + */ +export async function loadImageUrlWithFallback( + uri: string, + timeoutMs: number = 10000 +): Promise<{ blob: Blob; url: string }> { + const urls = getFetchableUrls(uri); + if (!urls?.length) { + throw new Error(`Invalid image URI: ${uri}`); + } + + let lastError: Error | undefined; + + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + + // Validate it's an image + if (!blob.type.startsWith('image/')) { + throw new Error('Invalid image type'); + } + + return { blob, url }; + } catch (err) { + lastError = err instanceof Error ? err : new Error('Unknown error'); + console.warn(`Gateway ${i + 1}/${urls.length} failed (${url.replace(/^https?:\/\//, '')}): ${lastError.message}`); + // Continue to next gateway + } + } + + throw new Error(`All ${urls.length} gateways failed. Last error: ${lastError?.message}`); +} + +/** + * Load multiple images with fallback, with concurrency control. + * Useful for loading multiple layers at once. + * + * @param uris - Array of image URIs + * @param concurrency - Number of concurrent requests (default: 4) + * @param timeoutMs - Timeout per gateway in milliseconds (default: 10000) + * @returns Promise resolving to array of Blobs with errors + */ +export async function loadImagesWithFallback( + uris: string[], + concurrency: number = 4, + timeoutMs: number = 10000 +): Promise<(Blob | Error)[]> { + const results: (Blob | Error)[] = []; + const pending: Promise[] = []; + + for (let i = 0; i < uris.length; i++) { + const uri = uris[i]; + + const load = async () => { + try { + const blob = await loadImageWithFallback(uri, timeoutMs); + results[i] = blob; + } catch (err) { + results[i] = err instanceof Error ? err : new Error('Unknown error'); + } + }; + + pending.push(load()); + + // Maintain concurrency limit + if (pending.length >= concurrency) { + await Promise.race(pending); + pending.splice( + pending.findIndex((p) => p === load()), + 1 + ); + } + } + + // Wait for all remaining + await Promise.all(pending); + + return results; +} diff --git a/apps/web/src/lib/ipfs-client.ts b/apps/web/src/lib/ipfs-client.ts new file mode 100644 index 0000000..865730b --- /dev/null +++ b/apps/web/src/lib/ipfs-client.ts @@ -0,0 +1,66 @@ +'use client'; + +/** + * Client-side IPFS utilities for the browser. + * Does not include server-only functions like DNS validation. + */ + +const DEFAULT_PINATA_GATEWAY = 'nouns-builder.mypinata.cloud'; + +export const IPFS_GATEWAYS = [ + process.env.NEXT_PUBLIC_PINATA_GATEWAY || DEFAULT_PINATA_GATEWAY, + 'ipfs.io', + 'magic.decentralized-content.com', + 'dweb.link', + 'gateway.pinata.cloud', + 'w3s.link', + 'ipfs.decentralized-content.com' +].map((gateway) => `https://${gateway.replace(/^https?:\/\//, '').replace(/\/$/, '')}`); + +const CID_PATTERN = /^(Qm[1-9A-HJ-NP-Za-km-z]{44}|ba[A-Za-z0-9]{50,})$/; + +/** + * Normalize an IPFS URI to ipfs:// format + */ +function normalizeIpfsUri(uri: string): string | undefined { + const value = uri.replace(/"/g, ''); + + if (value.startsWith('ipfs://')) return value; + if (CID_PATTERN.test(value)) return `ipfs://${value}`; + + try { + const parsed = new URL(value); + const marker = '/ipfs/'; + const markerIndex = parsed.pathname.indexOf(marker); + if (markerIndex === -1) return undefined; + + const path = `${parsed.pathname.slice(markerIndex + marker.length)}${parsed.search}${parsed.hash}`; + return `ipfs://${path}`; + } catch { + return undefined; + } +} + +/** + * Get gateway URLs for an IPFS URI + */ +export function ipfsGatewayUrls(uri: string): string[] | undefined { + const normalized = normalizeIpfsUri(uri); + if (!normalized) return undefined; + + const path = normalized.slice('ipfs://'.length); + return IPFS_GATEWAYS.map((gateway) => `${gateway}/ipfs/${path}`); +} + +/** + * Get fetchable URLs for any URI (IPFS or HTTP) + */ +export function getFetchableUrls(uri: string | null | undefined): string[] | undefined { + if (!uri || typeof uri !== 'string') return undefined; + + const ipfsUrls = ipfsGatewayUrls(uri); + if (ipfsUrls) return ipfsUrls; + + if (/^https?:\/\//.test(uri)) return [uri]; + return undefined; +} diff --git a/apps/web/src/lib/pinata-service.ts b/apps/web/src/lib/pinata-service.ts new file mode 100644 index 0000000..f88a13d --- /dev/null +++ b/apps/web/src/lib/pinata-service.ts @@ -0,0 +1,282 @@ +import { cidToUrls, getPreferredGatewayHost, UploadType } from './pinata-upload'; + +/** + * Custom error classes for Pinata operations + */ +export class PinataError extends Error { + constructor( + message: string, + public code?: string, + public status?: number + ) { + super(message); + this.name = 'PinataError'; + } +} + +export class InvalidRequestError extends PinataError { + constructor(message: string) { + super(message, 'INVALID_REQUEST', 400); + this.name = 'InvalidRequestError'; + } +} + +export class AuthenticationError extends PinataError { + constructor(message: string = 'Authentication failed') { + super(message, 'AUTH_FAILED', 401); + this.name = 'AuthenticationError'; + } +} + +export class RateLimitError extends PinataError { + constructor(public retryAfter: number = 60) { + super(`Rate limit exceeded. Retry after ${retryAfter}s`, 'RATE_LIMITED', 429); + this.name = 'RateLimitError'; + } +} + +export class NotFoundError extends PinataError { + constructor(message: string = 'Resource not found') { + super(message, 'NOT_FOUND', 404); + this.name = 'NotFoundError'; + } +} + +export class BackendFailedError extends PinataError { + constructor(message: string = 'Pinata service error') { + super(message, 'BACKEND_FAILED', 500); + this.name = 'BackendFailedError'; + } +} + +/** + * Pinata API service for file uploads and IPFS pinning + */ +export class PinataService { + private jwt: string; + private apiUrl = 'https://api.pinata.cloud'; + private uploadUrl = 'https://uploads.pinata.cloud'; + + constructor() { + const jwt = process.env.PINATA_JWT; + if (!jwt) { + throw new Error('PINATA_JWT environment variable is required'); + } + this.jwt = jwt; + } + + /** + * Creates a signed upload URL from Pinata for direct browser uploads + * The URL expires after 30 minutes + */ + async createSignedUploadUrl(uploadType: UploadType): Promise { + try { + const uploadPolicy = + uploadType === 'dao-image' + ? { + max_file_size: 5 * 1024 * 1024, + allow_mime_types: ['image/png', 'image/jpeg', 'image/webp'] + } + : { + max_file_size: 100 * 1024 * 1024, + allow_mime_types: ['directory'] + }; + + const response = await fetch(`${this.uploadUrl}/v3/files/sign`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.jwt}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + expires: 30, + date: Math.floor(Date.now() / 1000), + ...uploadPolicy + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + this.handleApiError(response.status, error); + } + + const data = await response.json(); + const signedUrl = data.data?.signedUrl ?? data.data; + if (typeof signedUrl !== 'string' || !signedUrl) { + throw new BackendFailedError('Signed URL not returned from Pinata'); + } + + return signedUrl; + } catch (error) { + if (error instanceof PinataError) throw error; + throw new BackendFailedError(error instanceof Error ? error.message : 'Failed to create signed upload URL'); + } + } + + /** + * Verifies a CID exists on Pinata by pinning it through the public files API. + * Pinata's v3 file lookup uses a file ID, not a CID. + */ + async verifyCid(cid: string): Promise<{ size: number; name?: string }> { + try { + const response = await fetch(`${this.apiUrl}/v3/files/public/pin_by_cid`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.jwt}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ cid }) + }); + + if (!response.ok) { + if (response.status === 404) { + throw new NotFoundError(`CID ${cid} not found on Pinata`); + } + const error = await response.json().catch(() => ({})); + this.handleApiError(response.status, error); + } + + await response.json().catch(() => undefined); + return { size: 0 }; + } catch (error) { + if (error instanceof PinataError) throw error; + throw new BackendFailedError(error instanceof Error ? error.message : 'Failed to verify CID'); + } + } + + /** + * Pins a CID to IPFS for redundancy + * Ensures the content is replicated across Pinata's infrastructure + */ + async pinCidToIPFS(cid: string, name?: string): Promise { + try { + const response = await fetch(`${this.apiUrl}/v3/files/public/pin_by_cid`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.jwt}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ cid, ...(name ? { name } : {}) }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + this.handleApiError(response.status, error); + } + + // Response indicates pin was added to queue or already pinned + await response.json(); + } catch (error) { + if (error instanceof PinataError) throw error; + throw new BackendFailedError(error instanceof Error ? error.message : 'Failed to pin CID to IPFS'); + } + } + + /** + * Generates a single-use JWT for directory uploads to the legacy Pinata endpoint + * The JWT has restricted permissions (pinFileToIPFS only) and expires after one use + */ + async generateUploadJwt(): Promise { + try { + const response = await fetch(`${this.apiUrl}/users/generateApiKey`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.jwt}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + keyName: 'Single Use Upload JWT', + maxUses: 1, // Single use only + permissions: { + endpoints: { + pinning: { + pinFileToIPFS: true, + // All other endpoints disabled for security + pinByHash: false, + pinJSONToIPFS: false, + pinJobs: false, + unpin: false, + userPinnedDataTotal: false + } + } + } + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + this.handleApiError(response.status, error); + } + + const data = await response.json(); + if (!data.JWT) { + throw new BackendFailedError('JWT not returned from Pinata'); + } + + return data.JWT; + } catch (error) { + if (error instanceof PinataError) throw error; + throw new BackendFailedError(error instanceof Error ? error.message : 'Failed to generate upload JWT'); + } + } + + /** + * Gets the gateway URL for a CID, respecting the preferred gateway host + */ + getCidGatewayUrl(cid: string): string { + const { gatewayUrl } = cidToUrls(cid, getPreferredGatewayHost()); + return gatewayUrl; + } + + /** + * Handles API errors and throws appropriate error types + */ + private handleApiError(status: number, errorData: any): never { + const message = errorData?.error?.message || errorData?.message || 'Unknown error'; + + if (status === 401 || status === 403) { + throw new AuthenticationError(message); + } + + if (status === 429) { + const retryAfter = parseInt((errorData?.retryAfter || errorData?.['retry-after'] || '60') as string, 10) || 60; + throw new RateLimitError(retryAfter); + } + + if (status === 400) { + throw new InvalidRequestError(message); + } + + if (status === 404) { + throw new NotFoundError(message); + } + + if (status >= 500) { + throw new BackendFailedError(message); + } + + throw new PinataError(message, `HTTP_${status}`, status); + } +} + +/** + * Singleton instance of PinataService + */ +let pinataServiceInstance: PinataService | null = null; + +/** + * Gets or creates the Pinata service instance + */ +export function getPinataService(): PinataService { + if (!pinataServiceInstance) { + pinataServiceInstance = new PinataService(); + } + return pinataServiceInstance; +} + +/** + * For testing purposes: reset the service instance + */ +export function resetPinataService(): void { + pinataServiceInstance = null; +} diff --git a/apps/web/src/lib/pinata-upload-auth.test.ts b/apps/web/src/lib/pinata-upload-auth.test.ts new file mode 100644 index 0000000..00310cc --- /dev/null +++ b/apps/web/src/lib/pinata-upload-auth.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createUploadAuthorization } from './pinata-upload'; +import { getAuthorization, storeAuthorization } from './pinata-upload-auth'; + +const originalSecret = process.env.IRON_PASSWORD; + +afterEach(() => { + if (originalSecret === undefined) delete process.env.IRON_PASSWORD; + else process.env.IRON_PASSWORD = originalSecret; +}); + +describe('upload authorization tokens', () => { + it('can be verified without the in-memory cache', () => { + process.env.IRON_PASSWORD = 'test-upload-secret'; + + const authorization = createUploadAuthorization('dao-image', 'https://uploads.example/sign'); + const uploadId = storeAuthorization(authorization.uploadId, authorization); + + const cached = getAuthorization(uploadId); + expect(cached?.uploadId).toBe(uploadId); + + const reloaded = getAuthorization(uploadId); + expect(reloaded).toMatchObject({ + uploadId, + uploadType: 'dao-image', + expectedMimes: ['image/png', 'image/jpeg', 'image/webp'], + maxBytes: 5 * 1024 * 1024 + }); + }); + + it('rejects tampered authorization tokens', () => { + process.env.IRON_PASSWORD = 'test-upload-secret'; + + const authorization = createUploadAuthorization('dao-image', 'https://uploads.example/sign'); + const uploadId = storeAuthorization(authorization.uploadId, authorization); + + expect(getAuthorization(`${uploadId}tampered`)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/pinata-upload-auth.ts b/apps/web/src/lib/pinata-upload-auth.ts new file mode 100644 index 0000000..39af947 --- /dev/null +++ b/apps/web/src/lib/pinata-upload-auth.ts @@ -0,0 +1,118 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import type { UploadAuthorization } from './pinata-upload'; + +/** + * Warm-process cache for upload permissions. The signed token is the source of truth, + * so completion also works when requests land on different server instances. + */ +const authorizationStore = new Map(); + +function getSigningSecret(): string { + const secret = process.env.IRON_PASSWORD; + if (!secret) throw new Error('IRON_PASSWORD is required for upload authorization signing'); + return secret; +} + +function sign(payload: string): string { + return createHmac('sha256', getSigningSecret()).update(payload).digest('base64url'); +} + +function encodeAuthorization(authorization: UploadAuthorization): string { + const payload = Buffer.from( + JSON.stringify({ + uploadType: authorization.uploadType, + expectedMimes: authorization.expectedMimes, + maxBytes: authorization.maxBytes, + expiresAt: authorization.expiresAt.toISOString() + }) + ).toString('base64url'); + + return `${payload}.${sign(payload)}`; +} + +/** + * Store an authorization and clean up expired ones + */ +export function storeAuthorization(_uploadId: string, authorization: UploadAuthorization): string { + // Clean up expired authorizations + const now = Date.now(); + for (const [id, record] of authorizationStore.entries()) { + if (record.expiresAt < now) { + authorizationStore.delete(id); + } + } + + const token = encodeAuthorization(authorization); + const storedAuthorization = { + ...authorization, + uploadId: token + }; + + authorizationStore.set(token, { + authorization: storedAuthorization, + expiresAt: authorization.expiresAt.getTime() + }); + + return token; +} + +/** + * Retrieve a stored authorization + */ +export function getAuthorization(uploadId: string): UploadAuthorization | null { + const record = authorizationStore.get(uploadId); + if (record) { + if (record.expiresAt < Date.now()) { + authorizationStore.delete(uploadId); + return null; + } + + return record.authorization; + } + + const [payload, providedSignature] = uploadId.split('.'); + if (!payload || !providedSignature) return null; + + const expectedSignature = sign(payload); + const provided = Buffer.from(providedSignature); + const expected = Buffer.from(expectedSignature); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null; + + let parsed: { + uploadType: UploadAuthorization['uploadType']; + expectedMimes: string[]; + maxBytes: number; + expiresAt: string; + }; + + try { + parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + } catch { + return null; + } + + const expiresAt = new Date(parsed.expiresAt); + if ( + !Number.isFinite(expiresAt.getTime()) || + !['dao-image', 'artwork-directory'].includes(parsed.uploadType) || + !Array.isArray(parsed.expectedMimes) || + parsed.expectedMimes.length === 0 || + !Number.isFinite(parsed.maxBytes) + ) { + return null; + } + + if (expiresAt.getTime() < Date.now()) { + return null; + } + + return { + uploadId, + signedUrl: '', + expiresAt, + uploadType: parsed.uploadType, + expectedMimes: parsed.expectedMimes, + maxBytes: parsed.maxBytes + }; +} diff --git a/apps/web/src/lib/pinata-upload.test.ts b/apps/web/src/lib/pinata-upload.test.ts new file mode 100644 index 0000000..e34592b --- /dev/null +++ b/apps/web/src/lib/pinata-upload.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + cidToUrls, + createUploadAuthorization, + getPreferredGatewayHost, + normalizeIpfsCid, + validateUploadCompletion +} from './pinata-upload'; + +const originalGateway = process.env.NEXT_PUBLIC_PINATA_GATEWAY; + +afterEach(() => { + if (originalGateway === undefined) { + delete process.env.NEXT_PUBLIC_PINATA_GATEWAY; + } else { + process.env.NEXT_PUBLIC_PINATA_GATEWAY = originalGateway; + } +}); + +describe('IPFS upload URLs', () => { + it('normalizes bare and ipfs-prefixed CIDs', () => { + expect(normalizeIpfsCid('bafybeigdyrzt3testcid')).toBe('bafybeigdyrzt3testcid'); + expect(normalizeIpfsCid('ipfs://bafybeigdyrzt3testcid')).toBe('bafybeigdyrzt3testcid'); + expect(normalizeIpfsCid('https://example.com/image.png')).toBeNull(); + expect(normalizeIpfsCid('ipfs://bafytest/image.png')).toBeNull(); + }); + + it('uses the configured gateway for finalized URLs', () => { + process.env.NEXT_PUBLIC_PINATA_GATEWAY = 'custom.example.com'; + + expect(getPreferredGatewayHost()).toBe('custom.example.com'); + expect(cidToUrls('bafytest', getPreferredGatewayHost())).toEqual({ + ipfsUri: 'ipfs://bafytest', + gatewayUrl: 'https://custom.example.com/ipfs/bafytest' + }); + }); + + it('falls back to the default gateway when none is configured', () => { + delete process.env.NEXT_PUBLIC_PINATA_GATEWAY; + + expect(cidToUrls('bafytest')).toEqual({ + ipfsUri: 'ipfs://bafytest', + gatewayUrl: 'https://nouns-builder.mypinata.cloud/ipfs/bafytest' + }); + }); +}); + +describe('upload completion validation', () => { + it('accepts JPEG files for DAO image uploads', () => { + const authorization = createUploadAuthorization('dao-image', 'https://uploads.example/sign'); + + expect( + validateUploadCompletion( + { + uploadId: authorization.uploadId, + cid: 'bafytest', + filename: 'dao.jpg', + mimeType: 'image/jpeg', + sizeBytes: 1024, + uploadType: 'dao-image' + }, + authorization + ) + ).toEqual({ valid: true }); + }); + + it('rejects MIME types outside the DAO image policy', () => { + const authorization = createUploadAuthorization('dao-image', 'https://uploads.example/sign'); + + const result = validateUploadCompletion( + { + uploadId: authorization.uploadId, + cid: 'bafytest', + filename: 'dao.gif', + mimeType: 'image/gif', + sizeBytes: 1024, + uploadType: 'dao-image' + }, + authorization + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain('image/jpeg'); + }); +}); diff --git a/apps/web/src/lib/pinata-upload.ts b/apps/web/src/lib/pinata-upload.ts new file mode 100644 index 0000000..f930df0 --- /dev/null +++ b/apps/web/src/lib/pinata-upload.ts @@ -0,0 +1,342 @@ +import { z } from 'zod'; + +/** + * Canonical IPFS gateway URLs - fallback order + * The first gateway is tried first, then others in order. + * Matches the list used in the render API. + */ +const IPFS_GATEWAY_HOSTS = [ + 'nouns-builder.mypinata.cloud', + 'gateway.pinata.cloud', + 'ipfs.io', + 'magic.decentralized-content.com', + 'dweb.link', + 'w3s.link', + 'nft.storage', + 'cf-ipfs.com' +]; + +/** + * Upload policy configuration for different asset types. + * Defines MIME allowlist, size limits, dimension constraints, and path validation. + */ +export const UPLOAD_POLICIES = { + 'dao-image': { + allowedMimes: ['image/png', 'image/jpeg', 'image/webp'], + maxBytes: 5 * 1024 * 1024, // 5MB + maxDimensionPixels: 4000, + minDimensionPixels: 128, + requiresSquare: true, + description: 'DAO identity image (PNG, JPEG, or WebP)' + }, + 'artwork-directory': { + allowedMimes: ['image/png', 'image/jpeg', 'image/webp'], + maxBytesPerFile: 2 * 1024 * 1024, // 2MB per file + maxTotalBytes: 100 * 1024 * 1024, // 100MB total + maxDimensionPixels: 4000, + minDimensionPixels: 64, + requiresSquare: true, + maxFilesPerDirectory: 1000, + description: 'Token artwork directory (PNG, JPEG, or WebP)' + } +} as const; + +export type UploadType = keyof typeof UPLOAD_POLICIES; + +/** + * Validates that an upload type is supported. + */ +export function isValidUploadType(type: unknown): type is UploadType { + return typeof type === 'string' && type in UPLOAD_POLICIES; +} + +/** + * Upload authorization record. + * Created server-side and returned to browser for direct upload. + */ +export interface UploadAuthorization { + uploadId: string; + signedUrl: string; + expiresAt: Date; + uploadType: UploadType; + expectedMimes: string[]; + maxBytes: number; +} + +/** + * Resolved upload result after server-side completion verification. + */ +export interface UploadResult { + cid: string; + ipfsUri: string; + gatewayUrl: string; + mimeType: string; + sizeBytes: number; + filename: string; +} + +/** + * Converts an IPFS CID to both canonical and gateway URL forms. + */ +export function cidToUrls(cid: string, preferredGatewayHost?: string): { ipfsUri: string; gatewayUrl: string } { + const ipfsUri = `ipfs://${cid}`; + + // Use provided gateway or first in fallback list + const gatewayHost = preferredGatewayHost || IPFS_GATEWAY_HOSTS[0]; + const gatewayUrl = `https://${gatewayHost}/ipfs/${cid}`; + + return { ipfsUri, gatewayUrl }; +} + +/** + * Normalizes a bare CID or ipfs:// CID entered by a user. + * The resolved gateway is still checked by the caller before it is accepted. + */ +export function normalizeIpfsCid(value: string): string | null { + const trimmed = value.trim(); + const cid = trimmed.startsWith('ipfs://') ? trimmed.slice('ipfs://'.length) : trimmed; + + if (cid.length < 10 || cid.length > 128 || !/^[a-zA-Z0-9]+$/.test(cid)) { + return null; + } + + return cid; +} + +/** + * Gets the preferred IPFS gateway host. + * Falls back to hardcoded list if environment variable is not set. + */ +export function getPreferredGatewayHost(): string { + const envHost = process.env.NEXT_PUBLIC_PINATA_GATEWAY; + if (envHost && IPFS_GATEWAY_HOSTS.includes(envHost)) { + return envHost; + } + + // Also try the first entry even if not in the hardcoded list + // (in case user has a custom gateway) + if (envHost) { + return envHost; + } + + return IPFS_GATEWAY_HOSTS[0]; +} + +/** + * Validates image dimensions and aspect ratio. + */ +export function validateImageDimensions( + width: number, + height: number, + policy: (typeof UPLOAD_POLICIES)[UploadType] +): { valid: boolean; error?: string } { + if (width < policy.minDimensionPixels || height < policy.minDimensionPixels) { + return { + valid: false, + error: `Image dimensions must be at least ${policy.minDimensionPixels}x${policy.minDimensionPixels}` + }; + } + + if (width > policy.maxDimensionPixels || height > policy.maxDimensionPixels) { + return { + valid: false, + error: `Image dimensions cannot exceed ${policy.maxDimensionPixels}x${policy.maxDimensionPixels}` + }; + } + + if (policy.requiresSquare && width !== height) { + return { + valid: false, + error: `Image must be square (width: ${width}px, height: ${height}px)` + }; + } + + return { valid: true }; +} + +/** + * Validates MIME type against policy. + */ +export function validateMimeType(mimeType: string, uploadType: UploadType): { valid: boolean; error?: string } { + const policy = UPLOAD_POLICIES[uploadType]; + if (uploadType === 'artwork-directory' && mimeType === 'directory') { + return { valid: true }; + } + const allowedMimes: readonly string[] = 'allowedMimes' in policy ? policy.allowedMimes : []; + + if (!allowedMimes.includes(mimeType)) { + return { + valid: false, + error: `Invalid file type. Allowed: ${allowedMimes.join(', ')}. Received: ${mimeType}` + }; + } + + return { valid: true }; +} + +/** + * Validates file size against policy. + */ +export function validateFileSize(sizeBytes: number, uploadType: UploadType): { valid: boolean; error?: string } { + const policy = UPLOAD_POLICIES[uploadType]; + + if (uploadType === 'artwork-directory' && 'maxTotalBytes' in policy && sizeBytes > policy.maxTotalBytes) { + const maxMB = Math.round(policy.maxTotalBytes / 1024 / 1024); + return { + valid: false, + error: `Directory exceeds maximum size of ${maxMB}MB` + }; + } + + // Check per-file limit + if ('maxBytesPerFile' in policy && sizeBytes > policy.maxBytesPerFile) { + const maxMB = Math.round(policy.maxBytesPerFile / 1024 / 1024); + return { + valid: false, + error: `File exceeds maximum size of ${maxMB}MB` + }; + } + + // Check single-file limit (for DAO image) + if ('maxBytes' in policy && sizeBytes > policy.maxBytes) { + const maxMB = Math.round(policy.maxBytes / 1024 / 1024); + return { + valid: false, + error: `File exceeds maximum size of ${maxMB}MB` + }; + } + + return { valid: true }; +} + +/** + * Validates a complete upload against policy. + */ +export function validateUpload( + uploadType: UploadType, + mimeType: string, + sizeBytes: number, + width?: number, + height?: number +): { valid: boolean; error?: string } { + // Validate MIME type + const mimeValidation = validateMimeType(mimeType, uploadType); + if (!mimeValidation.valid) { + return mimeValidation; + } + + // Validate file size + const sizeValidation = validateFileSize(sizeBytes, uploadType); + if (!sizeValidation.valid) { + return sizeValidation; + } + + // Validate dimensions if provided + if (width !== undefined && height !== undefined) { + const policy = UPLOAD_POLICIES[uploadType]; + const dimensionValidation = validateImageDimensions(width, height, policy); + if (!dimensionValidation.valid) { + return dimensionValidation; + } + } + + return { valid: true }; +} + +/** + * Schema for upload completion verification. + * Server-side validation of upload metadata. + */ +export const UploadCompletionSchema = z.object({ + uploadId: z.string(), + cid: z.string(), + filename: z.string(), + mimeType: z.string(), + sizeBytes: z.number().int().positive(), + uploadType: z.enum(['dao-image', 'artwork-directory']) +}); + +export type UploadCompletion = z.infer; + +/** + * Validates upload completion data. + * Ensures the uploaded content matches the authorization. + */ +export function validateUploadCompletion( + completion: unknown, + authorization: UploadAuthorization +): { valid: boolean; error?: string } { + const parseResult = UploadCompletionSchema.safeParse(completion); + if (!parseResult.success) { + return { + valid: false, + error: `Invalid upload completion data: ${parseResult.error.message}` + }; + } + + const data = parseResult.data; + + // Verify upload ID matches + if (data.uploadId !== authorization.uploadId) { + return { + valid: false, + error: 'Upload ID mismatch' + }; + } + + // Verify upload type matches + if (data.uploadType !== authorization.uploadType) { + return { + valid: false, + error: 'Upload type mismatch' + }; + } + + // Verify MIME type matches one of the authorized formats + if (!authorization.expectedMimes.includes(data.mimeType)) { + return { + valid: false, + error: `MIME type mismatch. Expected one of ${authorization.expectedMimes.join(', ')}, got ${data.mimeType}` + }; + } + + // Verify size is within limits + if (data.sizeBytes > authorization.maxBytes) { + return { + valid: false, + error: `File size exceeds authorized limit of ${authorization.maxBytes} bytes` + }; + } + + return { valid: true }; +} + +/** + * Generates a unique upload ID for tracking authorization and completion. + */ +export function generateUploadId(): string { + return `upload-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; +} + +/** + * Creates an upload authorization that will be given to the browser. + * The signed URL is a single-use, expiring authorization from Pinata. + */ +export function createUploadAuthorization( + uploadType: UploadType, + signedUrl: string, + expirationSeconds: number = 3600 +): UploadAuthorization { + const policy = UPLOAD_POLICIES[uploadType]; + const expectedMimes = 'allowedMimes' in policy ? Array.from(policy.allowedMimes) : ['image/png']; + const maxBytes = 'maxBytes' in policy ? policy.maxBytes : policy.maxBytesPerFile; + + return { + uploadId: generateUploadId(), + signedUrl, + expiresAt: new Date(Date.now() + expirationSeconds * 1000), + uploadType, + expectedMimes, + maxBytes + }; +} diff --git a/apps/web/src/lib/starter-collections.ts b/apps/web/src/lib/starter-collections.ts new file mode 100644 index 0000000..b676fe3 --- /dev/null +++ b/apps/web/src/lib/starter-collections.ts @@ -0,0 +1,98 @@ +import { ArtworkProperty } from '@/stores/create-dao-store'; + +/** + * Starter collection metadata. + * These are pre-configured, validated collections ready for deployment. + */ +export interface StarterCollection { + id: string; + name: string; + description: string; + baseUri: string; + extension: '.png' | '.webp'; + properties: ArtworkProperty[]; + previewTokenIds: number[]; + license: string; + attribution: string; +} + +/** + * Registry of available starter collections. + * Each collection is hosted on IPFS and compatible with the renderer. + */ +export const STARTER_COLLECTIONS: StarterCollection[] = [ + { + id: 'nouns-builder-demo', + name: 'Builder Demo Collection', + description: 'A colorful, whimsical collection inspired by Nouns DAO. Perfect for testing and learning.', + baseUri: 'ipfs://bafybeihcsfjvnjmzivm4gxgt75zwajtfxumyxd7j6ibvloykpg4sx47uca/', + extension: '.png', + properties: [ + { + name: '0-backgrounds', + items: ['bg-cool', 'bg-warm'] + }, + { + name: '1-bodies', + items: ['body-rust', 'body-blue-sky', 'body-darkbrown'] + }, + { + name: '2-accessories', + items: ['accessory-txt-cc2', 'accessory-txt-ico', 'accessory-flash'] + }, + { + name: '3-heads', + items: ['head-hotdog', 'head-ufo', 'head-goldcoin'] + }, + { + name: '4-glasses', + items: ['glasses-square-teal', 'glasses-square-guava', 'glasses-square-black-rgb'] + } + ], + previewTokenIds: [0, 1, 2, 3, 4], + license: 'CC0', + attribution: 'Inspired by Nouns DAO artwork' + } +]; + +/** + * Find a starter collection by ID. + */ +export function getStarterCollection(id: string): StarterCollection | undefined { + return STARTER_COLLECTIONS.find((c) => c.id === id); +} + +/** + * Get all available starter collections. + */ +export function getAvailableCollections(): StarterCollection[] { + return STARTER_COLLECTIONS; +} + +/** + * Validate that a collection ID is valid. + */ +export function isValidCollectionId(id: string): boolean { + return STARTER_COLLECTIONS.some((c) => c.id === id); +} + +/** + * Generate a preview URL for a token in a starter collection. + * Used to show randomized previews in the UI. + */ +export function getTokenPreviewUrl( + daoId: string, + collectionId: string, + tokenId: number, + rendererBase: string = 'https://builder-stellar-web.vercel.app/api/render/' +): string { + return `${rendererBase}${daoId}/${tokenId}`; +} + +/** + * Get a random token ID from a collection's preview list. + */ +export function getRandomPreviewTokenId(collection: StarterCollection): number { + const idx = Math.floor(Math.random() * collection.previewTokenIds.length); + return collection.previewTokenIds[idx]; +} diff --git a/apps/web/src/lib/upload-cache.ts b/apps/web/src/lib/upload-cache.ts new file mode 100644 index 0000000..b9c042d --- /dev/null +++ b/apps/web/src/lib/upload-cache.ts @@ -0,0 +1,89 @@ +/** + * Client-side localStorage cache for artwork directory uploads. + * Caches the IPFS CID for uploaded file sets to avoid re-uploading + * identical directories. + */ + +const CACHE_PREFIX = 'BUILDER/IPFSUploadCache'; +const EXPIRY_PREFIX = 'BUILDER/IPFSUploadCacheExpiry'; +const CACHE_DURATION_HOURS = 24; + +export interface CachedUpload { + cid: string; + uri: string; + timestamp: number; +} + +/** + * Get a cached upload by file hash + * Returns null if not found or expired + */ +export function getCachedUpload(fileHash: string): CachedUpload | null { + try { + const cached = localStorage.getItem(`${CACHE_PREFIX}/${fileHash}`); + if (!cached) return null; + + const expiry = localStorage.getItem(`${EXPIRY_PREFIX}/${fileHash}`); + if (expiry && Date.now() > parseInt(expiry, 10)) { + // Cache expired, clean up + localStorage.removeItem(`${CACHE_PREFIX}/${fileHash}`); + localStorage.removeItem(`${EXPIRY_PREFIX}/${fileHash}`); + return null; + } + + const uploadData = JSON.parse(cached) as CachedUpload; + return uploadData; + } catch (error) { + console.warn('[upload-cache] Failed to retrieve cached upload:', error); + return null; + } +} + +/** + * Cache an upload result + */ +export function cacheUpload(fileHash: string, cid: string): void { + try { + const uploadData: CachedUpload = { + cid, + uri: `ipfs://${cid}`, + timestamp: Date.now() + }; + + localStorage.setItem(`${CACHE_PREFIX}/${fileHash}`, JSON.stringify(uploadData)); + + // Set expiry + const expiryTime = Date.now() + CACHE_DURATION_HOURS * 60 * 60 * 1000; + localStorage.setItem(`${EXPIRY_PREFIX}/${fileHash}`, expiryTime.toString()); + } catch (error) { + console.warn('[upload-cache] Failed to cache upload:', error); + } +} + +/** + * Clear all cached uploads + */ +export function clearAllCaches(): void { + try { + const keys = Object.keys(localStorage); + keys.forEach((key) => { + if (key.startsWith(CACHE_PREFIX) || key.startsWith(EXPIRY_PREFIX)) { + localStorage.removeItem(key); + } + }); + } catch (error) { + console.warn('[upload-cache] Failed to clear caches:', error); + } +} + +/** + * Clear specific cached upload + */ +export function clearCachedUpload(fileHash: string): void { + try { + localStorage.removeItem(`${CACHE_PREFIX}/${fileHash}`); + localStorage.removeItem(`${EXPIRY_PREFIX}/${fileHash}`); + } catch (error) { + console.warn('[upload-cache] Failed to clear cached upload:', error); + } +} diff --git a/apps/web/src/lib/validation.ts b/apps/web/src/lib/validation.ts index 25a1484..ee939ec 100644 --- a/apps/web/src/lib/validation.ts +++ b/apps/web/src/lib/validation.ts @@ -145,3 +145,87 @@ export function isValidTokenSymbol(symbol: string): boolean { // Only alphanumeric characters return /^[A-Z0-9]+$/.test(symbol); } + +/** + * Artwork upload validation constants (based on Pinata legacy endpoint requirements) + */ +export const ARTWORK_VALIDATION = { + // Minimum image dimensions (staging uses 600px) + MIN_IMAGE_DIMENSION: 600, + // Maximum aggregate size for directory uploads (200MB like Pinata legacy) + MAX_AGGREGATE_SIZE_BYTES: 200 * 1024 * 1024, + // Maximum file size for single images (2MB) + MAX_SINGLE_FILE_SIZE_BYTES: 2 * 1024 * 1024, + // Maximum number of traits per collection + MAX_TRAITS: 16, + // Allowed MIME types for artwork + ALLOWED_MIME_TYPES: ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp'] +}; + +/** + * Validate image dimensions + * SVG images are exempt from dimension checks (they scale) + */ +export function validateImageDimensions( + width: number | undefined, + height: number | undefined, + mimeType: string +): { valid: boolean; error?: string } { + // SVG images don't need dimension validation + if (mimeType === 'image/svg+xml') { + return { valid: true }; + } + + // For raster images, require square dimensions + if (!width || !height) { + return { valid: false, error: 'Image dimensions could not be determined' }; + } + + if (width !== height) { + return { valid: false, error: 'Image must be square (width = height)' }; + } + + if (width < ARTWORK_VALIDATION.MIN_IMAGE_DIMENSION || height < ARTWORK_VALIDATION.MIN_IMAGE_DIMENSION) { + return { + valid: false, + error: `Image must be at least ${ARTWORK_VALIDATION.MIN_IMAGE_DIMENSION}x${ARTWORK_VALIDATION.MIN_IMAGE_DIMENSION}px (current: ${width}x${height}px)` + }; + } + + return { valid: true }; +} + +/** + * Validate that all files have the same MIME type + */ +export function validateMimeTypeConsistency(mimeTypes: string[]): { valid: boolean; error?: string } { + if (mimeTypes.length === 0) { + return { valid: true }; + } + + const uniqueMimeTypes = new Set(mimeTypes); + if (uniqueMimeTypes.size > 1) { + return { + valid: false, + error: `All files must have the same MIME type. Found: ${Array.from(uniqueMimeTypes).join(', ')}` + }; + } + + return { valid: true }; +} + +/** + * Validate aggregate file size + */ +export function validateAggregateFileSize(totalBytes: number): { valid: boolean; error?: string } { + if (totalBytes > ARTWORK_VALIDATION.MAX_AGGREGATE_SIZE_BYTES) { + const maxMB = ARTWORK_VALIDATION.MAX_AGGREGATE_SIZE_BYTES / (1024 * 1024); + const totalMB = totalBytes / (1024 * 1024); + return { + valid: false, + error: `Total file size exceeds ${maxMB}MB limit (current: ${totalMB.toFixed(2)}MB)` + }; + } + + return { valid: true }; +} diff --git a/apps/web/src/stores/create-dao-store.ts b/apps/web/src/stores/create-dao-store.ts index 8efeda8..b43c94b 100644 --- a/apps/web/src/stores/create-dao-store.ts +++ b/apps/web/src/stores/create-dao-store.ts @@ -7,6 +7,9 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { formatStroops } from '@/lib/auction-values'; +export const DEFAULT_DAO_IMAGE_URL = 'https://builder-stellar-web.vercel.app/images/dao-logo.png'; +export const LOCAL_DEFAULT_DAO_IMAGE_URL = '/images/dao-logo.png'; + /** * Artwork property with items */ @@ -15,6 +18,39 @@ export type ArtworkProperty = { items: string[]; }; +/** + * DAO identity image source + * Can be generated, uploaded, or the default Builder logo + */ +export type DaoImageSource = + | { kind: 'generated'; gatewayUrl: string; ipfsUri: string; prompt: string; model: string } + | { kind: 'uploaded'; gatewayUrl: string; ipfsUri: string; filename: string } + | { kind: 'url'; gatewayUrl: string } + | { kind: 'default'; gatewayUrl: string } + | { kind: 'legacy-unconfirmed' }; // Backwards compatibility + +/** + * Token artwork source + * Can be a starter collection or uploaded directory + */ +export type ArtworkSource = + | { + kind: 'starter'; + starterId: string; + baseUri: string; + extension: '.png' | '.webp'; + properties: ArtworkProperty[]; + gatewayUrl: string; + } + | { + kind: 'uploaded'; + baseUri: string; + extension: '.png' | '.webp'; + properties: ArtworkProperty[]; + gatewayUrl: string; + } + | { kind: 'legacy-unconfirmed' }; // Backwards compatibility + /** * Basic information about the DAO */ @@ -78,6 +114,8 @@ type CreateDaoState = { governance: GovernanceConfig; founders: FounderAllocation[]; launchAdmin: string; + daoImageSource?: DaoImageSource; + artworkSource?: ArtworkSource; busy: boolean; formMessage: string; validationErrors: Record; @@ -94,6 +132,12 @@ type CreateDaoActions = { updateGovernance: (patch: Partial) => void; updateLaunchAdmin: (address: string) => void; + // Image source management + setDaoImageSource: (source: DaoImageSource) => void; + clearDaoImageSource: () => void; + setArtworkSource: (source: ArtworkSource) => void; + clearArtworkSource: () => void; + // Artwork property management addArtworkProperty: () => void; removeArtworkProperty: (index: number) => void; @@ -121,7 +165,7 @@ type CreateDaoActions = { reset: () => void; }; -type CreateDaoStore = CreateDaoState & CreateDaoActions; +export type CreateDaoStore = CreateDaoState & CreateDaoActions; const initialState: CreateDaoState = { basicInfo: { @@ -130,7 +174,7 @@ const initialState: CreateDaoState = { tokenUri: 'https://builder-stellar-web.vercel.app/api/dao/{daoId}/token/', projectUri: 'https://test-dao-stellar-web.vercel.app', description: '', - contractImage: 'https://builder-stellar-web.vercel.app/images/dao-logo.png', + contractImage: DEFAULT_DAO_IMAGE_URL, rendererBase: 'https://builder-stellar-web.vercel.app/api/render/' }, artwork: { @@ -217,6 +261,29 @@ export const useCreateDaoStore = create()( updateLaunchAdmin: (address) => set({ launchAdmin: address }), + // Image source management + setDaoImageSource: (source) => set({ daoImageSource: source }), + clearDaoImageSource: () => set({ daoImageSource: undefined }), + setArtworkSource: (source) => { + set(() => { + // When setting artwork source, update both artworkSource and artwork properties + if (source.kind === 'starter' || source.kind === 'uploaded') { + return { + artworkSource: source, + artwork: { + ipfs: { + baseUri: source.baseUri, + extension: source.extension + }, + properties: source.properties + } + }; + } + return { artworkSource: source }; + }); + }, + clearArtworkSource: () => set({ artworkSource: undefined }), + // Artwork property management addArtworkProperty: () => set((state) => { @@ -324,7 +391,7 @@ export const useCreateDaoStore = create()( }), { name: 'dao.create-dao.v1', - version: 2, + version: 3, storage, skipHydration: true, partialize: (state) => ({ @@ -333,16 +400,33 @@ export const useCreateDaoStore = create()( auction: state.auction, governance: state.governance, founders: state.founders, - launchAdmin: state.launchAdmin + launchAdmin: state.launchAdmin, + daoImageSource: state.daoImageSource, + artworkSource: state.artworkSource }), migrate: (persistedState, version) => { const persisted = persistedState as Partial; + + // Version 0 -> 1: Format reserve price if (version === 0 && persisted.auction?.reservePrice) { const reservePrice = persisted.auction.reservePrice; if (/^\d+$/.test(reservePrice)) { persisted.auction = { ...persisted.auction, reservePrice: formatStroops(reservePrice) }; } } + + // Version 1-2 -> 3: Mark legacy artwork as unconfirmed + // If daoImageSource is not set, mark it as legacy-unconfirmed + // If artworkSource is not set, mark it as legacy-unconfirmed + if (version < 3) { + if (!persisted.daoImageSource) { + persisted.daoImageSource = { kind: 'legacy-unconfirmed' }; + } + if (!persisted.artworkSource) { + persisted.artworkSource = { kind: 'legacy-unconfirmed' }; + } + } + return persisted as CreateDaoState; }, merge: (persistedState, currentState) => { diff --git a/docs/ARTWORK_PLAYGROUND_INTEGRATION.md b/docs/ARTWORK_PLAYGROUND_INTEGRATION.md new file mode 100644 index 0000000..f19bd3f --- /dev/null +++ b/docs/ARTWORK_PLAYGROUND_INTEGRATION.md @@ -0,0 +1,328 @@ +# Artwork Playground Integration + +## Overview + +This document describes the complete integration of the Artwork Playground feature into the DAO creation workflow. The Playground allows users to reorder artwork layers and preview the composite result before finalizing their DAO configuration. + +## Architecture + +### Components Created + +1. **ArtworkPlayground** (`apps/web/src/components/create-dao/ArtworkPlayground.tsx`) + - Main orchestrator component with side-by-side layout + - Manages layer state and completion flow + - Renders LayerOrdering (left) and ArtworkPreviewCanvas (right) + +2. **LayerOrdering** (`apps/web/src/components/create-dao/LayerOrdering.tsx`) + - Drag-and-drop interface for reordering layers + - Pointer events API for cross-device support (mouse, touch, pen) + - Keyboard navigation (Arrow Up/Down) + - Move Up/Down buttons and Remove layer functionality + - Visual indicators (Top layer, Base layer, Layer #N) + +3. **ArtworkPreviewCanvas** (`apps/web/src/components/create-dao/ArtworkPreviewCanvas.tsx`) + - Canvas-based real-time composite rendering + - Loads images from IPFS gateway URLs + - Bottom-to-top layer compositing + - Loading progress and error handling + - Layer status display + +### Integration Points + +**Modified Component:** +- **ArtworkSourceStep** (`apps/web/src/components/create-dao/ArtworkSourceStep.tsx`) + - Added `playgroundMode` state + - Modified `handleUploadComplete` to trigger playground after upload + - Added `handlePlaygroundComplete` to save reordered properties + - Added `handlePlaygroundBack` for navigation + - Conditional rendering of playground + +## Data Flow + +### 1. Upload Phase +``` +User uploads directory + ↓ +ArtworkDirectoryUpload validates and processes + ↓ +Creates ArtworkSource object with: + - kind: 'uploaded' + - baseUri: IPFS CID + - extension: file extension + - properties: [{name, items[]}] array + ↓ +Calls handleUploadComplete(source) +``` + +### 2. Playground Phase +``` +ArtworkSourceStep receives source + ↓ +Sets playgroundMode = true + ↓ +Renders ArtworkPlayground component + ↓ +User interacts: + - LayerOrdering: drag/drop, move buttons, keyboard + - ArtworkPreviewCanvas: real-time preview updates + ↓ +User clicks Complete + ↓ +ArtworkPlayground calls onComplete(updatedSource) + ↓ +ArtworkSourceStep receives updated source with reordered properties +``` + +### 3. Store Update Phase +``` +handlePlaygroundComplete(finalSource) + ↓ +setArtworkSource(finalSource) + ↓ +Zustand store updated: + - artworkSource: complete source object + - artwork.properties: reordered properties array + - artwork.ipfs: baseUri + extension +``` + +### 4. Validation and Review Phase +``` +User clicks "Save and continue" in ArtworkStep section + ↓ +create/page.tsx calls markSectionReviewed('artwork') + ↓ +Validates artwork using sectionSchemas.artwork + ↓ +Marks section as reviewed + ↓ +Opens next section (Auction) +``` + +### 5. Deployment Phase +``` +User completes all sections and clicks "Create DAO" + ↓ +create/page.tsx calls handleSubmit() + ↓ +Validates full form with createDaoSchema + ↓ +Calls deployDao(validation.data) + ↓ +useDaoDeployment hook orchestrates deployment: + +Step 1: Predict addresses (no signature) +Step 2: Create DAO contracts +Step 3: Accept token ownership +Step 4: Add artwork properties ← Key step +Step 5: Mint founder allocations +Step 6: Finalize DAO +Step 7: Wait for indexing +``` + +### 6. Artwork Transformation Phase (Step 4) +``` +addProperties(metadataAddress, formData) + ↓ +formDataToCreationParams(formData, deployer, nonce) + ↓ +Transforms artwork: + formData.artwork.properties.forEach((property, propertyIndex) => { + propertyNames.push(property.name); + property.items.forEach((itemName) => { + artworkItems.push({ + property_id: propertyIndex, ← Index determines stacking order + name: itemName, + is_new_property: false + }); + }); + }); + ↓ +Returns DaoCreationParams with: + - artwork_property_names: string[] + - artwork_items: ArtworkItem[] (with property_id by index) + - artwork_ipfs: {baseUri, extension} + ↓ +Calls metadataClient.add_properties({ + names: params.artwork_property_names, + items: params.artwork_items, + ipfs_group: params.artwork_ipfs +}) + ↓ +Metadata contract stores properties with correct ordering +``` + +## Critical Insight: Order Preservation + +**The order of the properties array directly determines layer stacking:** + +1. User reorders layers in Playground: `['Background', 'Body', 'Eyes', 'Hat']` +2. This order is saved to `artwork.properties` in the store +3. `formDataToCreationParams` iterates properties in order +4. Each property gets `property_id` equal to its index: `0, 1, 2, 3` +5. Metadata contract stores these IDs +6. Render API uses property IDs to composite layers: property 0 → bottom, property 3 → top + +**Therefore:** The Playground's layer ordering is correctly reflected in the final on-chain metadata and token rendering. + +## User Experience Flow + +``` +Upload artwork directory + ↓ +[Playground appears automatically] + ↓ +Reorder layers using: + - Drag and drop handles + - Move Up/Down buttons + - Keyboard navigation (Arrow keys) + ↓ +See real-time preview of composite artwork + ↓ +Click "Complete" when satisfied + ↓ +[Returns to ArtworkSourceStep] + ↓ +Continue with DAO creation wizard + ↓ +Review all sections + ↓ +Deploy DAO with correct artwork configuration +``` + +## Technical Details + +### Layer URL Construction +```typescript +const buildLayerUrl = (baseUri: string, property: ArtworkProperty, extension: string): string => { + const itemName = property.items[0]; // Use first item as preview + const cleanBase = baseUri.replace(/\/$/, ''); + const gatewayUrl = getGatewayUrl(cleanBase); + return `${gatewayUrl}/${property.name}/${itemName}${extension}`; +}; +``` + +### Canvas Compositing +```typescript +// Load all images as Blobs +const layers = await Promise.all(orderedLayers.map(loadLayerImage)); + +// Render bottom-to-top +for (const layer of layers) { + const url = URL.createObjectURL(layer.blob); + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, x, y, width, height); + URL.revokeObjectURL(url); + }; + img.src = url; +} +``` + +### Drag-and-Drop Implementation +- Uses pointer events (not mouse events) for better device support +- Captures pointer to track movement outside element bounds +- Computes insertion points based on row metrics +- Visual feedback with drag overlay and insertion indicators +- Supports keyboard navigation as fallback + +### State Management +- Local state in LayerOrdering for drag operations (refs for performance) +- Local state in ArtworkPlayground for ordered layers +- Global state in Zustand store for final artwork configuration +- Validation state in create-dao-store for form errors + +## Validation + +### Artwork Schema +From `create-dao-schema.ts`: +```typescript +artwork: z.object({ + properties: z.array(z.object({ + name: z.string().min(1), + items: z.array(z.string()).min(1) + })).min(1), + ipfs: z.object({ + baseUri: z.string().min(1), + extension: z.string().min(1) + }) +}) +``` + +**Validation ensures:** +- At least one property exists +- Each property has a name +- Each property has at least one item +- IPFS configuration is complete + +## Error Handling + +### Upload Phase +- File type validation (PNG, SVG, WebP) +- Directory structure validation +- Duplicate name detection +- IPFS upload errors + +### Playground Phase +- Image loading errors (with retry) +- Canvas rendering errors +- Invalid layer configurations + +### Deployment Phase +- Transaction signing failures +- Network errors +- Contract execution errors +- Each step has rollback/retry capability + +## Testing Recommendations + +### Manual Testing +1. **Upload Flow**: Upload valid artwork directory, verify Playground appears +2. **Layer Reordering**: Test drag-and-drop, move buttons, keyboard navigation +3. **Preview Rendering**: Verify preview updates correctly on reorder +4. **Completion**: Click Complete, verify returns to source step with correct order +5. **Form Integration**: Save artwork section, verify validation passes +6. **Deployment**: Deploy DAO, verify artwork appears correctly on token page + +### Automated Testing (Future) +- Component unit tests for LayerOrdering drag logic +- Integration tests for Playground → Store data flow +- E2E tests for complete upload → deploy flow +- Canvas rendering tests with mock images + +## Future Enhancements + +### Potential Improvements +1. **Batch Layer Operations**: Select multiple layers for bulk reordering +2. **Layer Preview Thumbnails**: Show small image previews in layer list +3. **Undo/Redo**: Support for reverting layer changes +4. **Preset Orderings**: Save/load common layer arrangements +5. **Advanced Preview**: Zoom, pan, multiple trait combinations +6. **Performance**: Virtual scrolling for large layer counts +7. **Accessibility**: Enhanced screen reader support, ARIA labels + +### Known Limitations +1. Preview shows only first item from each property +2. Canvas size is fixed (could support responsive sizing) +3. No validation of layer visual quality/composition +4. Memory usage grows with large image files + +## References + +### Source Code +- `/apps/web/src/components/create-dao/ArtworkPlayground.tsx` +- `/apps/web/src/components/create-dao/LayerOrdering.tsx` +- `/apps/web/src/components/create-dao/ArtworkPreviewCanvas.tsx` +- `/apps/web/src/components/create-dao/ArtworkSourceStep.tsx` +- `/apps/web/src/app/create/page.tsx` +- `/apps/web/src/lib/use-dao-deployment.ts` +- `/apps/web/src/lib/dao-creation-params.ts` + +### Related Documentation +- `DAO_CREATION_ARTWORK_PLAN.md` - Original implementation plan +- Metadata contract bindings +- Manager contract bindings +- IPFS integration documentation + +## Conclusion + +The Artwork Playground is fully integrated into the DAO creation workflow. Layer ordering changes made by users are correctly propagated through the application state, validated, transformed for contract compatibility, and deployed to the blockchain. The order of layers in the Playground directly corresponds to the rendering order in the final token artwork. diff --git a/docs/DAO_CREATION_ARTWORK_PLAN.md b/docs/DAO_CREATION_ARTWORK_PLAN.md new file mode 100644 index 0000000..7f905af --- /dev/null +++ b/docs/DAO_CREATION_ARTWORK_PLAN.md @@ -0,0 +1,402 @@ +# Stellar Render API Codebase Exploration + +## 1. Metadata Renderer Contract Location & Requirements + +### Contract Bindings Package +**Location:** `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/packages/metadata-bindings/` + +### Contract Client Interface +**File:** `packages/metadata-bindings/src/client.ts` + +The metadata contract client provides these key methods: + +- `get_settings()` - Returns metadata settings including contract image, description, project URI, and renderer base +- `get_properties()` - Returns array of all Property objects +- `get_ipfs_data()` - Returns array of IpfsGroup objects +- `get_attributes({ token_id })` - Returns artwork selections for a minted token +- `add_properties({ names, items, ipfs_group })` - Add new artwork properties and items +- `initialize()` - Initialize contract with initial properties and items + +### Core Type Definitions +**File:** `packages/metadata-bindings/src/types.ts` + +```typescript +// Item in a property +interface Item { + name: string; // Item name + reference_slot: number; // Index into IPFS groups array +} + +// Property (trait/category) +interface Property { + items: Array; // Array of items in this property + name: string; // Property name +} + +// Settings +interface Settings { + contract_image: string; // Collection image URL + description: string; // Collection description + project_uri: string; // DAO project URL + renderer_base: string; // Base URL for token rendering + token: string; // Associated token contract address +} + +// IPFS storage configuration +interface IpfsGroup { + base_uri: string; // Base IPFS URI or gateway URL + extension: string; // File extension (e.g., ".png") +} + +// Item parameter for adding properties +interface ItemParam { + is_new_property: boolean; // Whether this starts a new property + name: string; // Item name + property_id: number; // Which property this belongs to +} +``` + +### Contract Requirements for Artwork +- **Maximum Properties:** 16 (enforced by limit `MAX_LAYERS = 16` in render API) +- **Item Structure:** Each item references a property and has a unique IPFS group slot +- **IPFS Groups:** Multiple IPFS configurations can exist, each with base URI and extension + +## 2. Current Artwork Structure + +### Frontend Artwork Configuration +**Location:** `apps/web/src/stores/create-dao-store.ts` + +```typescript +// Artwork configuration stored in Zustand +export type ArtworkProperty = { + name: string; // Property name (e.g., "0-backgrounds") + items: string[]; // Array of item names (e.g., ["bg-cool", "bg-warm"]) +}; + +type ArtworkConfig = { + ipfs: { + baseUri: string; // Base IPFS URI or gateway URL + extension: string; // File extension (e.g., ".png") + }; + properties: ArtworkProperty[]; // Array of properties +}; +``` + +### Artwork Property Validation +**File:** `apps/web/src/lib/validation.ts` + +```typescript +// Property validation requirements: +// - Property name must not be empty +// - Property must have at least one item +// - No empty items allowed +// - Item names must be non-empty strings +// - Maximum 16 properties + +export function validateArtworkProperty(property: { name: string; items: string[] }): string | null +export function hasDuplicates(array: T[], keyFn: (item: T) => string): boolean +``` + +### Artwork UI Component +**Location:** `apps/web/src/components/create-dao/ArtworkStep.tsx` + +- Accepts IPFS base URI (ipfs:// or HTTPS gateway URL) +- Accepts file extension (e.g., ".png") +- Manages up to 16 properties +- Each property has a name and array of items +- Supports adding/removing properties and items + +## 3. IPFS Gateway URL Handling + +### IPFS Gateway Configuration +**File:** `apps/web/src/lib/ipfs-gateway.ts` + +```typescript +// Default gateway +const DEFAULT_PINATA_GATEWAY = 'nouns-builder.mypinata.cloud'; + +// Configured gateways (in priority order) +export const IPFS_GATEWAYS = [ + process.env.NEXT_PUBLIC_PINATA_GATEWAY || DEFAULT_PINATA_GATEWAY, // Custom/default + 'ipfs.io', + 'magic.decentralized-content.com', + 'dweb.link', + 'gateway.pinata.cloud', + 'w3s.link', + 'ipfs.decentralized-content.com' +].map((gateway) => `https://${gateway.replace(/^https?:\/\//, '').replace(/\/$/, '')}`); +``` + +### URL Processing Functions + +**`assertSafeRemoteUrl(value: string, allowIpfsGateway = false)`** +- Validates URL uses HTTPS +- Rejects URLs with credentials +- Checks hostname is in approved IPFS gateways list (if `allowIpfsGateway = true`) +- Performs DNS lookup and validates resolved IP addresses are public (not private) +- Returns array of resolved IP addresses + +**`normalizeIpfsUri(uri: string)`** +- Accepts `ipfs://` URIs, CIDs, or gateway URLs +- Converts all to `ipfs://` format +- Pattern: `/^(Qm[1-9A-HJ-NP-Za-km-z]{44}|ba[A-Za-z0-9]{50,})$/` for CID validation + +**`ipfsGatewayUrls(uri: string)`** +- Takes normalized IPFS URI +- Returns URLs for all configured gateways with the IPFS path +- Format: `${gateway}/ipfs/${path}` + +**`getFetchableUrls(uri: string)`** +- Returns array of URLs to try for fetching +- Supports IPFS URIs (converts to gateway URLs) and direct HTTPS URLs + +### Token Metadata Resolution +**File:** `apps/web/src/lib/onchain-token-metadata.ts` + +```typescript +export type ResolvedArtwork = { + property: string; // Property name + item: string; // Item name + url: string; // Full URL to artwork file +}; + +export type OnchainTokenMetadata = { + name: string; + description: string; + image: string; // Token image URL + attributes: Array<{ trait_type: string; value: string }>; + artwork: ResolvedArtwork[]; // Array of resolved artwork layers +}; + +// URL construction logic +function joinUrl(base: string, property: string, item: string, extension: string) { + return `${base.replace(/\/$/, '')}/${property}/${item}${extension}`; +} +``` + +## 4. Render API Implementation + +### Render Endpoint +**Location:** `apps/web/src/app/api/render/[daoId]/[tokenId]/route.ts` + +**Key Constants:** +```typescript +const REQUEST_TIMEOUT_MS = 45_000; // 45 second timeout +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB per layer +const MAX_TOTAL_IMAGE_BYTES = 32 * 1024 * 1024; // 32 MB total +const MAX_LAYERS = 16; // Max artwork layers +const MAX_CONCURRENT_LAYER_FETCHES = 4; // Parallel fetch limit +const MAX_REDIRECTS = 3; // HTTP redirect limit +const MAX_INPUT_PIXELS = 16_777_216; // 4096x4096 +const SIZE = 1080; // Output size +const ALLOWED_IMAGE_FORMATS = new Set(['png', 'jpeg', 'webp']); +``` + +**Rendering Process:** +1. Parse token ID from URL +2. Get DAO config by daoId (token contract address) +3. Resolve on-chain metadata for token +4. Extract artwork layers (array of URLs) +5. Fetch each layer with: + - DNS pinning for IPFS gateways + - Undici Agent with custom lookup function + - Redirect handling (up to 3 redirects) + - Size validation per layer +6. Composite layers using Sharp: + - Base layer resized to 1080x1080 + - Overlay layers composited on top + - Output as WebP with quality=85 +7. Cache headers: `public, s-maxage=300, stale-while-revalidate=600` +8. Fallback: Return SVG "Artwork unavailable" on error + +**DNS Pinning Implementation:** +- Resolves hostname to IP address +- Creates Undici Agent with custom lookup function +- Forces all requests through resolved IP +- Prevents TOCTOU attacks on gateway resolution + +## 5. Environment Variables + +### Application Configuration +**File:** `apps/web/.env.example` + +``` +# Multi-Tenant Configuration +NEXT_PUBLIC_DEPLOYMENT_ID=manager:CONTRACT_ADDRESS + +# Network Selection +NEXT_PUBLIC_NETWORK=testnet|public|local + +# Database +APP_DATABASE_URL=postgres://user:pass@host/db?sslmode=require + +# Server Configuration +APP_URL=http://localhost:3000 +IRON_PASSWORD=<32+ char secret> + +# WalletConnect +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= + +# SEP-10 Auth +STELLAR_WEB_AUTH_SECRET= +STELLAR_HOME_DOMAIN=localhost +STELLAR_WEB_AUTH_DOMAIN=localhost +AUTH_APP_NAME=Stellar DAOs +``` + +### Custom IPFS Gateway +``` +NEXT_PUBLIC_PINATA_GATEWAY=nouns-builder.mypinata.cloud +``` +- Optional override for default Pinata gateway +- Falls back to `nouns-builder.mypinata.cloud` if not set +- Other gateways tried as fallbacks if this gateway fails + +### Legacy Configuration (for reference) +**File:** `.env.example` + +``` +NEXT_PUBLIC_DAO_NETWORK=testnet +NEXT_PUBLIC_DAO_LABEL=builder +MANAGER_DEPLOYMENT_FILE=deploys/builder-testnet-manager.json +APP_DATABASE_URL=postgres://... +``` + +## 6. DAO & Token Configuration + +### DAO Configuration Lookup +**File:** `apps/web/src/lib/dao-config.ts` + +```typescript +export type DaoNetworkConfig = { + name: NetworkName; + label: string; + rpcUrl: string; + passphrase: string; + tokenName: string; + tokenSymbol: string; + tokenDescription: string; + adminAddress: string; + tokenContractId: string; + metadataContractId: string; // Key for resolving artwork + governorContractId: string; + treasuryContractId: string; + auctionContractId: string; + auctionEnabled: boolean | null; +}; + +// Fetched from database (Goldsky) for each DAO +export async function getDaoNetworkConfigById(daoId: string): Promise +``` + +### Token Configuration +**File:** `apps/web/src/lib/token-config.ts` + +- Default token name: `'Token'` +- Default token symbol: `'TKN'` +- Default description: `'Decentralized Autonomous Organization Token'` +- Actual values come from DaoNetworkConfig (database) + +## 7. DAO Creation Parameters + +### Form Data to Contract Parameters +**File:** `apps/web/src/lib/dao-creation-params.ts` + +```typescript +export type CreateDaoFormData = { + basicInfo: { + tokenName: string; + tokenSymbol: string; + tokenUri: string; + projectUri: string; + description: string; + contractImage: string; + rendererBase: string; // Base URL for render API + }; + artwork: { + ipfs: { + baseUri: string; + extension: string; + }; + properties: ArtworkProperty[]; + }; + // ... other configs +}; + +// Transformation to contract parameters +function formDataToCreationParams( + formData: CreateDaoFormData, + deployer: string, + nonce: bigint +): DaoCreationParams { + // Artwork items are flattened: + // - propertyNames: string[] (one per property) + // - artworkItems: ArtworkItem[] (one per item across all properties) + // - artworkIpfs: { base_uri, extension } +} +``` + +## 8. Key Integration Points + +### Metadata Contract Calls (in `onchain-token-metadata.ts`) +1. `metadata.get_settings()` - Get collection metadata +2. `metadata.get_properties()` - Get all properties and items +3. `metadata.get_ipfs_data()` - Get IPFS configurations +4. `metadata.get_attributes({ token_id })` - Get token's artwork selections + +### Artwork URL Construction +``` +URL = ${ipfsGroup.base_uri}/${property.name}/${item.name}${ipfsGroup.extension} +``` + +### Example Artwork Metadata +``` +Property: "0-backgrounds" +Items: ["bg-cool", "bg-warm"] +IPFS Base: "ipfs://bafybeihcsfjvnjmzivm4gxgt75zwajtfxumyxd7j6ibvloykpg4sx47uca/" +Extension: ".png" + +Resulting URLs: +- ipfs://bafybeihcsfjvnjmzivm4gxgt75zwajtfxumyxd7j6ibvloykpg4sx47uca/0-backgrounds/bg-cool.png +- ipfs://bafybeihcsfjvnjmzivm4gxgt75zwajtfxumyxd7j6ibvloykpg4sx47uca/0-backgrounds/bg-warm.png +``` + +## 9. Safety & Security Features + +### IPFS Gateway Security +- DNS resolution with private IP filtering (IPv4 & IPv6) +- Private ranges blocked: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc. +- HTTPS-only enforcement +- No credential URLs allowed + +### Render API Security +- DNS pinning via Undici custom lookup +- Size limits per layer and total +- Input pixel limits (prevent decompression bombs) +- Redirect limit enforcement (max 3) +- Format whitelist: png, jpeg, webp only +- Fallback SVG on any error (no crash) + +### Rate Limiting & Caching +- Per-layer fetch concurrency limited to 4 +- 45-second timeout for entire render +- CDN cache: 5 minutes, stale-while-revalidate 10 minutes + +--- + +## Summary Files & Absolute Paths + +| Component | File Path | +|-----------|-----------| +| Metadata Contract Types | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/packages/metadata-bindings/src/types.ts` | +| Metadata Contract Client | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/packages/metadata-bindings/src/client.ts` | +| IPFS Gateway Utils | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/lib/ipfs-gateway.ts` | +| Metadata Resolution | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/lib/onchain-token-metadata.ts` | +| Render API Endpoint | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/app/api/render/[daoId]/[tokenId]/route.ts` | +| DAO Config | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/lib/dao-config.ts` | +| Artwork UI | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/components/create-dao/ArtworkStep.tsx` | +| Artwork Store | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/stores/create-dao-store.ts` | +| Validation | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/lib/validation.ts` | +| DAO Creation Params | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/src/lib/dao-creation-params.ts` | +| Env Config (Web) | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/apps/web/.env.example` | +| Env Config (Root) | `/Users/dan13ram/code/nouns/stellar-builder-worktrees/render-api/.env.example` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5742e5b..0d15a94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/web: dependencies: + '@ai-sdk/gateway': + specifier: ^4.0.96 + version: 4.0.96(zod@4.6.5) '@builder-stellar/auction-bindings': specifier: workspace:* version: link:../../packages/auction-bindings @@ -44,15 +47,21 @@ importers: '@stellar/stellar-sdk': specifier: ^17.0.1 version: 17.1.0 + ai: + specifier: ^4.0.0 + version: 4.3.19(react@19.3.0)(zod@4.6.5) buffer: specifier: ^6.0.3 version: 6.0.3 + framer-motion: + specifier: ^13.4.4 + version: 13.4.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) iron-session: specifier: ^8.0.4 version: 8.0.4 next: specifier: ^15.4.0 - version: 15.5.25(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) papaparse: specifier: ^5.7.0 version: 5.7.0 @@ -250,6 +259,48 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@ai-sdk/gateway@4.0.96': + resolution: {integrity: sha512-1aniRTzEf9T7uRHdKnBQ36WgEVQswK6wDgkPVsNMl+sPy7otcmrBa2HEurdFBS51s1q3/UXlh8nNcguwU79Hkg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@2.2.8': + resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider-utils@5.0.49': + resolution: {integrity: sha512-T+/H8DCvqJoCqLhltVatS7ffA779Iyk/ZZrgNXkrgv51E0n2Cd6eIdCmtwhCGIKqtbTtwCvTmQdAMv5Z8rOGHQ==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@1.1.3': + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} + engines: {node: '>=18'} + + '@ai-sdk/provider@4.0.18': + resolution: {integrity: sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==} + engines: {node: '>=22'} + + '@ai-sdk/react@1.2.12': + resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + zod: + optional: true + + '@ai-sdk/ui-utils@1.2.11': + resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + '@albedo-link/intent@0.12.0': resolution: {integrity: sha512-UlGBhi0qASDYOjLrOL4484vQ26Ee3zTK2oAgvPMClOs+1XNk3zbs3dECKZv+wqeSI8SkHow8mXLTa16eVh+dQA==} @@ -1186,6 +1237,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@pandacss/config@1.11.5': resolution: {integrity: sha512-SSn4PBN80AQhCESMz8eod1sc329DMqzFUhkReicwyByRAnsTMlAYaw64br4xhrORAmXEsicL52kJ9+Pr9IJ4uA==} engines: {node: '>=20'} @@ -1844,6 +1899,9 @@ packages: '@solana/web3.js@1.99.0': resolution: {integrity: sha512-QZYQ2T1z6xWisoyALPq25i/QZTsRlM02BABtAsfaQ1p8wX4SdTfxrKTRue/ZZqrhNVh5oL7T/DUFiTS9DRgxow==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stellar/freighter-api@6.0.0': resolution: {integrity: sha512-8CTQcKQmTq/wL715ZUzn1x1POpR0eYhYPKEiaeA7AT0WYBOauOGTxfWPFtSidX3ohAlJZP5HFXy1kG29cVjqxw==} @@ -1952,6 +2010,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff-match-patch@1.0.36': + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2162,6 +2223,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -2306,6 +2371,9 @@ packages: '@walletconnect/window-metadata@1.0.1': resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@zag-js/accordion@1.43.3': resolution: {integrity: sha512-J6rGbMPXhYDa2dLgp66DPpwC3OcNAJH71kuGakz7SfCvQWiR9lIyB26G/QdSjJjf0rVARM8rN90e5PxZjYdWjA==} @@ -2601,6 +2669,16 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ai@4.3.19: + resolution: {integrity: sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.23.8 + peerDependenciesMeta: + react: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3042,6 +3120,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -3455,6 +3536,17 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@13.4.4: + resolution: {integrity: sha512-lbsZO95NGbulo6apz70zEt6Vxf/anoRwQI2ECEc/EQYWO5CGbITtl7plDW8P/VXgXw6e8wrbhH4a9i+ygke0Mg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3853,6 +3945,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3868,6 +3963,11 @@ packages: engines: {node: '>=6'} hasBin: true + jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -4133,6 +4233,12 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + motion-dom@13.4.4: + resolution: {integrity: sha512-z2qN3RUABSci4G7cr5aHTFhqPNCWJsEMMfRlzxqtANQsSCIbVJmHvMV288m5x7doEQBTYbRTWbKdztmquCn4Sw==} + + motion-utils@13.3.0: + resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4664,6 +4770,9 @@ packages: resolution: {integrity: sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA==} engines: {node: '>=18.0.0'} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4892,6 +5001,10 @@ packages: thread-stream@3.2.0: resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5037,6 +5150,10 @@ packages: resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} engines: {node: '>=18.17'} + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5415,6 +5532,54 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@ai-sdk/gateway@4.0.96(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.18 + '@ai-sdk/provider-utils': 5.0.49(zod@4.6.5) + '@vercel/oidc': 3.2.0 + zod: 4.6.5 + + '@ai-sdk/provider-utils@2.2.8(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 1.1.3 + nanoid: 3.3.19 + secure-json-parse: 2.7.0 + zod: 4.6.5 + + '@ai-sdk/provider-utils@5.0.49(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.18 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.30.0 + zod: 4.6.5 + + '@ai-sdk/provider@1.1.3': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/provider@4.0.18': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@1.2.12(react@19.3.0)(zod@4.6.5)': + dependencies: + '@ai-sdk/provider-utils': 2.2.8(zod@4.6.5) + '@ai-sdk/ui-utils': 1.2.11(zod@4.6.5) + react: 19.3.0 + swr: 2.5.1(react@19.3.0) + throttleit: 2.1.0 + optionalDependencies: + zod: 4.6.5 + + '@ai-sdk/ui-utils@1.2.11(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.6.5) + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + '@albedo-link/intent@0.12.0': {} '@ark-ui/react@5.39.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': @@ -6431,6 +6596,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api@1.9.0': {} + '@pandacss/config@1.11.5': dependencies: '@pandacss/logger': 1.11.5 @@ -7584,6 +7751,8 @@ snapshots: - typescript - utf-8-validate + '@standard-schema/spec@1.1.0': {} + '@stellar/freighter-api@6.0.0': dependencies: buffer: 6.0.3 @@ -7719,6 +7888,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff-match-patch@1.0.36': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -7918,6 +8089,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vercel/oidc@3.2.0': {} + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -8493,6 +8666,8 @@ snapshots: '@walletconnect/window-getters': 1.0.1 tslib: 1.14.1 + '@workflow/serde@4.1.0': {} + '@zag-js/accordion@1.43.3': dependencies: '@zag-js/anatomy': 1.43.3 @@ -9118,6 +9293,18 @@ snapshots: dependencies: humanize-ms: 1.2.1 + ai@4.3.19(react@19.3.0)(zod@4.6.5): + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.6.5) + '@ai-sdk/react': 1.2.12(react@19.3.0)(zod@4.6.5) + '@ai-sdk/ui-utils': 1.2.11(zod@4.6.5) + '@opentelemetry/api': 1.9.0 + jsondiffpatch: 0.6.0 + zod: 4.6.5 + optionalDependencies: + react: 19.3.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -9553,6 +9740,8 @@ snapshots: detect-libc@2.1.2: {} + diff-match-patch@1.0.5: {} + dijkstrajs@1.0.3: {} doctrine@2.1.0: @@ -10169,6 +10358,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@13.4.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + motion-dom: 13.4.4 + motion-utils: 13.3.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + fresh@2.0.0: {} fs-extra@11.3.2: @@ -10590,6 +10788,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -10600,6 +10800,12 @@ snapshots: json5@2.2.3: {} + jsondiffpatch@0.6.0: + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.6.2 + diff-match-patch: 1.0.5 + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -10826,6 +11032,12 @@ snapshots: minimist@1.2.8: {} + motion-dom@13.4.4: + dependencies: + motion-utils: 13.3.0 + + motion-utils@13.3.0: {} + ms@2.1.3: {} multiformats@9.9.0: {} @@ -10868,7 +11080,7 @@ snapshots: dependencies: content-type: 2.1.0 - next@15.5.25(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@types/node@24.13.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 15.5.25 '@swc/helpers': 0.5.15 @@ -10886,6 +11098,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.25 '@next/swc-win32-arm64-msvc': 15.5.25 '@next/swc-win32-x64-msvc': 15.5.25 + '@opentelemetry/api': 1.9.0 sharp: 0.35.4(@types/node@24.13.4) transitivePeerDependencies: - '@babel/core' @@ -11433,6 +11646,8 @@ snapshots: node-addon-api: 5.1.0 node-gyp-build: 4.8.4 + secure-json-parse@2.7.0: {} + semver@6.3.1: {} semver@7.7.1: {} @@ -11719,6 +11934,8 @@ snapshots: dependencies: real-require: 0.2.0 + throttleit@2.1.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -11861,6 +12078,8 @@ snapshots: undici@6.28.1: {} + undici@7.30.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {}