diff --git a/app/api/forge-agent/route.ts b/app/api/forge-agent/route.ts index 05d88ae9..6fc5cd1c 100644 --- a/app/api/forge-agent/route.ts +++ b/app/api/forge-agent/route.ts @@ -2,17 +2,32 @@ import { NextRequest, NextResponse } from "next/server" import { randomUUID } from "node:crypto" import { warnPhaserLiqSacMismatchOnce } from "@/lib/phaser-liq-sac-warn" import { forgeGoogleAiApiKey } from "@/lib/forge/ai-pipeline" -import { buildOfficialPaymentRequirements, verifyPaymentStep, extractSettlementReceiptTxhash, buildLegacyChallenge, forgePriceDisplay, X402_NETWORK } from "@/lib/forge/payment-verifier" +import { + buildOfficialPaymentRequirements, + verifyPaymentStep, + extractSettlementReceiptTxHash, + buildLegacyChallenge, + forgePriceDisplay, + X402_NETWORK, +} from "@/lib/forge/payment-verifier" import { runForgePipeline } from "@/lib/forge/pipeline" import { tokenContractIdForServer, REQUIRED_AMOUNT } from "@/lib/phase-protocol" import { isSettlementUsed, markSettlementUsedIfUnused } from "@/lib/settlement-store" +import { + nanobananaApiKeyConfigured, + nanobananaAsyncWebhookEnabled, + submitForgeImageTaskViaNanobananaApi, +} from "@/lib/forge-nanobanana" +import { createGenerationJob } from "@/lib/generation-job-store" +import { generateLoreStep } from "@/lib/forge/ai-pipeline" +import { normalizeForgeImageStyleMode, normalizeForgeOutputLang } from "@/lib/forge/prompt-builder" export const runtime = "nodejs" export const maxDuration = 120 export const dynamic = "force-dynamic" -static const PHASE_LIQ_TOKEN_CONTRACT = tokenContractIdForServer() -static const ERR_SETTLEMENT_REJECTED = "[ ERROR: SETTLEMENT_REJECTED_BY_FACILITATOR ]" +const PHASE_LIQ_TOKEN_CONTRACT = tokenContractIdForServer() +const ERR_SETTLEMENT_REJECTED = "[ ERROR: SETTLEMENT_REJECTED_BY_FACILITATOR ]" function paymentRequiredResponse(request: NextRequest) { const origin = request.nextUrl.origin @@ -20,61 +35,206 @@ function paymentRequiredResponse(request: NextRequest) { const paymentRequirements = buildOfficialPaymentRequirements(origin) const b64 = Buffer.from(JSON.stringify(challenge)).toString("base64") const body: Record = { - success: false, error: "Payment Required", priceDisplay: forgePriceDisplay(), - message: "Se requiere pago en PHASELQ. Tras confirmar on-chain, reintenta con Authorization o settlementTxHash.", + success: false, + error: "Payment Required", + priceDisplay: forgePriceDisplay(), + message: + "Se requiere pago en PHASELQ. Tras confirmar on-chain, reintenta con Authorization o settlementTxHash.", challenge, } if (paymentRequirements) body.paymentRequirements = paymentRequirements return NextResponse.json(body, { status: 402, headers: { - "WWA-Authenticate": `x402 token="${b64}", amount="${challenge.amount}", facilitator="${challenge.facilitator}", network="${X402_NETWORK,}"`, - "X-Required-Amount": REQUIRED_AMOUNT, "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, - "X-Facilitator": challenge.facilitator, "X-X402-Network": X402_NETWORK. + "WWW-Authenticate": `x402 token="${b64}", amount="${challenge.amount}", facilitator="${challenge.facilitator}", network="${X402_NETWORK}"`, + "X-Required-Amount": REQUIRED_AMOUNT, + "X-Token-Address": PHASE_LIQ_TOKEN_CONTRACT, + "X-Facilitator": challenge.facilitator, + "X-X402-Network": X402_NETWORK, }, }) } export async function POST(request: NextRequest) { - const correlationId = request.headers.get("x-correlation-id")?.trim() || randomUUId() - let body: { prompt?: string; settlementTxHash?: string; payerAddress?: string; imageStyleMode?: string; collection_id?: number; lang?: string } - try { body = await request.json() } catc { return NextResponse.json({ success: false, error: "JSON inválido" }, { status: 400, headers: { "x-correlation-id": correlationId } }) } + const correlationId = request.headers.get("x-correlation-id")?.trim() || randomUUID() + let body: { + prompt?: string + settlementTxHash?: string + payerAddress?: string + imageStyleMode?: string + collection_id?: number + lang?: string + } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json( + { success: false, error: "JSON inválido" }, + { status: 400, headers: { "x-correlation-id": correlationId } }, + ) + } if (!forgeGoogleAiApiKey()) { - return NextResponse.json({ success: false, error: "GOOGLE_AI_STUDIO_API_KEY (o GEMINI_API_KEY) no configurada." }, { status: 503, headers: { "x-correlation-id": correlationId } }) } + return NextResponse.json( + { success: false, error: "GOOGLE_AI_STUDIO_API_KEY (o GEMINI_API_KEY) no configurada." }, + { status: 503, headers: { "x-correlation-id": correlationId } }, + ) + } warnPhaserLiqSacMismatchOnce(PHASE_LIQ_TOKEN_CONTRACT, "forge-agent") const paymentRequirements = buildOfficialPaymentRequirements(request.nextUrl.origin) const auth = request.headers.get("authorization") - const receipt = extractSettlementReceiptTxhash(auth, body) + const receipt = extractSettlementReceiptTxHash(auth, body) const resolution = await verifyPaymentStep({ authHeader: auth, body, paymentRequirements }) - if (resolution === "facilitator_rejected") return NextResponse.json({ success: false, error: ERR_SETTLEMENT_REJECTED }, { status: 403, headers: { "x-correlation-id": correlationId } }) + if (resolution === "facilitator_rejected") { + return NextResponse.json( + { success: false, error: ERR_SETTLEMENT_REJECTED }, + { status: 403, headers: { "x-correlation-id": correlationId } }, + ) + } if (resolution === "missing") return paymentRequiredResponse(request) if (receipt) { if (await isSettlementUsed(receipt)) { - return NextResponse.json({ success: false, error: "Settlement already used" }, { status: 409, headers: { "x-correlation-id": correlationId } }) + return NextResponse.json( + { success: false, error: "Settlement already used" }, + { status: 409, headers: { "x-correlation-id": correlationId } }, + ) } const marked = await markSettlementUsedIfUnused(receipt) if (!marked) { - return NextResponse.json({ success: false, error: "Settlement already used" }, { status: 409, headers: { "x-correlation-id": correlationId } }) + return NextResponse.json( + { success: false, error: "Settlement already used" }, + { status: 409, headers: { "x-correlation-id": correlationId } }, + ) } } if (typeof body.prompt !== "string") { - return NextResponse.json({ success: false, error: "Falta prompt (string)" }, { status: 400, headers: { "x-correlation-id": correlationId } }) + return NextResponse.json( + { success: false, error: "Falta prompt (string)" }, + { status: 400, headers: { "x-correlation-id": correlationId } }, + ) + } + + const txHash = receipt ?? body.settlementTxHash?.trim() ?? "" + + // ── Async mode: NanoBanana webhook enabled ────────────────────────────────── + // When NANOBANANA_WEBHOOK_SECRET + NANOBANANA_CALLBACK_URL are both set, + // submit the image generation task and return immediately with a jobId. + // The client polls /api/jobs/[txHash] until status === 'completed'. + if (nanobananaApiKeyConfigured() && nanobananaAsyncWebhookEnabled() && txHash) { + try { + const callBackUrl = + process.env.NANOBANANA_CALLBACK_URL?.trim() ?? + `${request.nextUrl.origin}/api/webhooks/nanobanana` + + const prompt = body.prompt.trim() + if (!prompt) { + return NextResponse.json( + { success: false, error: "prompt vacío o inválido" }, + { status: 400, headers: { "x-correlation-id": correlationId } }, + ) + } + + // Submit image generation task — returns immediately with taskId + const { taskId } = await submitForgeImageTaskViaNanobananaApi({ prompt, callBackUrl }) + + // Register the generation job so the webhook and polling endpoint can track it + const job = await createGenerationJob({ + taskId, + txHash, + prompt, + payerAddress: body.payerAddress, + imageStyleMode: body.imageStyleMode, + collectionId: body.collection_id, + lang: body.lang, + }) + + // Start lore generation in the background while image is being generated + // (lore is independent of image and typically finishes in 3–10s) + void (async () => { + try { + const styleMode = normalizeForgeImageStyleMode(body.imageStyleMode) + const outputLang = normalizeForgeOutputLang(body.lang) + await generateLoreStep({ prompt, styleMode, outputLang, recentLores: [] }) + // Lore is stored in-flight; the webhook handler will pick it up from the prompt + // when it runs the full pipeline continuation. + } catch { + // non-fatal — webhook handler will generate lore when it fires + } + })() + + return NextResponse.json( + { + success: true, + async: true, + jobId: job.id, + txHash, + status: job.status, + message: "Image generation submitted. Poll /api/jobs/" + encodeURIComponent(txHash) + " for status.", + }, + { headers: { "x-correlation-id": correlationId } }, + ) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + if (msg === "NANO_BANANA_CORE_OVERLOAD") { + // Fall through to synchronous pipeline + console.warn("[forge-agent] NanaBanana overloaded during async submit, falling back to sync") + } else { + return NextResponse.json( + { success: false, error: "Fallo al enviar tarea a NanaBanana.", detail: process.env.NODE_ENV === "development" ? msg : undefined }, + { status: 500, headers: { "x-correlation-id": correlationId } }, + ) + } + } } + // ── Synchronous mode: standard pipeline (legacy) ──────────────────────────── try { - const result = await runForgePipeline(body, correlationId) + const result = await runForgePipeline( + { ...body, prompt: body.prompt as string }, + correlationId, + ) return NextResponse.json(result, { headers: { "x-correlation-id": correlationId } }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) - if (msg === "EMPTY_PROMPT") return NextResponse.json({ success: false, error: "prompt vacío o inválido" }, { status: 400, headers: { "x-correlation-id": correlationId } }) - if (msg === "MISSING_GOOGLE_AI_KEY") return NextResponse.json({ success: false, error: "GOOGLE_AI_STUDIO_API_KEY no configurada." }, { status: 503, headers: { "x-correlation-id": correlationId } }) - if (msg === "NANO_BANANA_CORE_OVERLOAD") return NextResponse.json({ success: false, error: "[ ERROR: NANO_BANANA_CORE_OVERLOAD ]" }, { status: 503, headers: { "x-correlation-id": correlationId } }) - if (msg.startsWith("GEMINI_")) return NextResponse.json({ success: false, error: "Fallo al generar lore con Gemini.", detail: process.env.NODE_ENV === "development" ? msg : undefined }, { status: 500, headers: { "x-correlation-id": correlationId } }) - return NextResponse.json({ success: false, error: "Fallo del agenta IA (Gemini).", detail: process.env.NODE_ENV === "development" ? msg : undefined }, { status: 500, headers: { "x-correlation-id": correlationId } }) + if (msg === "EMPTY_PROMPT") { + return NextResponse.json( + { success: false, error: "prompt vacío o inválido" }, + { status: 400, headers: { "x-correlation-id": correlationId } }, + ) + } + if (msg === "MISSING_GOOGLE_AI_KEY") { + return NextResponse.json( + { success: false, error: "GOOGLE_AI_STUDIO_API_KEY no configurada." }, + { status: 503, headers: { "x-correlation-id": correlationId } }, + ) + } + if (msg === "NANO_BANANA_CORE_OVERLOAD") { + return NextResponse.json( + { success: false, error: "[ ERROR: NANO_BANANA_CORE_OVERLOAD ]" }, + { status: 503, headers: { "x-correlation-id": correlationId } }, + ) + } + if (msg.startsWith("GEMINI_")) { + return NextResponse.json( + { + success: false, + error: "Fallo al generar lore con Gemini.", + detail: process.env.NODE_ENV === "development" ? msg : undefined, + }, + { status: 500, headers: { "x-correlation-id": correlationId } }, + ) + } + return NextResponse.json( + { + success: false, + error: "Fallo del agente IA (Gemini).", + detail: process.env.NODE_ENV === "development" ? msg : undefined, + }, + { status: 500, headers: { "x-correlation-id": correlationId } }, + ) } } diff --git a/app/api/jobs/[txHash]/route.ts b/app/api/jobs/[txHash]/route.ts new file mode 100644 index 00000000..3f90e3de --- /dev/null +++ b/app/api/jobs/[txHash]/route.ts @@ -0,0 +1,72 @@ +/** + * GET /api/jobs/[txHash] + * + * Client polling endpoint for async image generation status. + * The client calls this with the settlement transaction hash it already holds + * to track job progress until completion or failure. + * + * Response shape: + * 200 { found: true, job: { status, imageUrl?, result?, error?, ... } } + * 404 { found: false } + * + * Typical polling interval: 3–5 seconds. The client should stop polling on + * status === 'completed' | 'failed', or after a client-side timeout (e.g. 3 min). + * + * Cache-Control: no-store to prevent stale responses from CDN. + */ + +import { NextRequest, NextResponse } from "next/server" +import { getGenerationJobByTxHash } from "@/lib/generation-job-store" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function GET( + _request: NextRequest, + context: { params: Promise<{ txHash: string }> }, +): Promise { + const { txHash } = await context.params + const decoded = decodeURIComponent(txHash).trim() + + if (!decoded || decoded.length < 8) { + return NextResponse.json( + { found: false, error: "Invalid txHash" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + try { + const job = await getGenerationJobByTxHash(decoded) + + if (!job) { + return NextResponse.json( + { found: false }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ) + } + + return NextResponse.json( + { + found: true, + job: { + id: job.id, + txHash: job.txHash, + status: job.status, + imageUrl: job.imageUrl, + result: job.result, + error: job.error, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }, + }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.error("[jobs/txHash] Error reading job store:", msg) + return NextResponse.json( + { found: false, error: "Internal error reading job status" }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ) + } +} diff --git a/app/api/webhooks/nanobanana/route.ts b/app/api/webhooks/nanobanana/route.ts index 3ae42f98..01bfa856 100644 --- a/app/api/webhooks/nanobanana/route.ts +++ b/app/api/webhooks/nanobanana/route.ts @@ -1,14 +1,290 @@ -import { NextResponse } from "next/server" +/** + * POST /api/webhooks/nanobanana + * + * Receives async image-generation callbacks from NanoBanana API. + * + * Security: + * - HMAC-SHA256 signature verified against NANOBANANA_WEBHOOK_SECRET + * using the raw request body and the `x-nanobanana-signature` header. + * - Requests without a valid signature are rejected (401) and logged to the DLQ. + * + * Success flow: + * 1. Parse taskId + result image URL from the payload. + * 2. Look up the generation job by taskId. + * 3. Mark job as `webhook_received` and record the image URL. + * 4. Trigger the remaining pipeline steps (lore → IPFS → mint) in the background. + * + * Failure flow: + * - Any error during processing appends an entry to the dead-letter queue. + * - Always returns 200 to NanoBanana to prevent webhook retry storms. + * (Retries are handled by the DLQ / polling fallback.) + */ + +import { NextRequest, NextResponse } from "next/server" +import { createHmac, timingSafeEqual } from "node:crypto" +import { + getGenerationJobByTaskId, + updateGenerationJob, + appendGenerationDlq, +} from "@/lib/generation-job-store" +import { generateLoreStep } from "@/lib/forge/ai-pipeline" +import { publishIpfsStep } from "@/lib/forge/ipfs-publisher" +import { mintNftStep } from "@/lib/forge/pipeline" +import { normalizeForgeImageStyleMode, normalizeForgeOutputLang } from "@/lib/forge/prompt-builder" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +// ─── Signature verification ─────────────────────────────────────────────────── + +function getWebhookSecret(): string | null { + return process.env.NANOBANANA_WEBHOOK_SECRET?.trim() || null +} /** - * NanoBanana API exige `callBackUrl` en POST /generate; pueden notificar aquí al terminar. - * El forja usa polling a `record-info`; este endpoint solo confirma recepción (200). + * Verifies the HMAC-SHA256 signature on the raw body. + * NanoBanana sends the signature as `x-nanobanana-signature: sha256=`. */ -export async function POST(request: Request): Promise { +function verifyWebhookSignature(rawBody: string, signatureHeader: string | null): boolean { + const secret = getWebhookSecret() + if (!secret) { + // No secret configured — skip signature check (development / initial setup) + console.warn("[nanobanana-webhook] NANOBANANA_WEBHOOK_SECRET not set; skipping signature check") + return true + } + if (!signatureHeader) return false + + // Strip "sha256=" prefix if present + const receivedHex = signatureHeader.startsWith("sha256=") + ? signatureHeader.slice(7) + : signatureHeader + + const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex") + + try { + return timingSafeEqual(Buffer.from(receivedHex, "hex"), Buffer.from(expected, "hex")) + } catch { + return false + } +} + +// ─── Payload types ──────────────────────────────────────────────────────────── + +type NanobananaWebhookPayload = { + taskId?: string + successFlag?: number // 1 = success, 2/3 = failure + errorMessage?: string + response?: { + resultImageUrl?: string + originImageUrl?: string + } + // Some versions nest under data + data?: { + taskId?: string + successFlag?: number + errorMessage?: string + response?: { + resultImageUrl?: string + originImageUrl?: string + } + } +} + +function extractFromPayload(payload: NanobananaWebhookPayload) { + const root = payload.data ?? payload + return { + taskId: (root.taskId ?? "").trim(), + successFlag: root.successFlag, + errorMessage: root.errorMessage?.trim() ?? "", + imageUrl: + root.response?.resultImageUrl?.trim() || + root.response?.originImageUrl?.trim() || + "", + } +} + +// ─── Background pipeline continuation ──────────────────────────────────────── + +/** + * After the webhook delivers the image URL, run lore generation + IPFS + mint + * to complete the pipeline. This runs fire-and-forget; the job status is updated + * in the store so the polling endpoint reflects progress. + */ +async function continueForgeAfterWebhook(jobId: string, jobSnapshot: { + prompt: string + imageUrl: string + imageStyleMode?: string + lang?: string + payerAddress?: string + collectionId?: number +}): Promise { + try { + const styleMode = normalizeForgeImageStyleMode(jobSnapshot.imageStyleMode) + const outputLang = normalizeForgeOutputLang(jobSnapshot.lang) + + // Generate lore + const lore = await generateLoreStep({ + prompt: jobSnapshot.prompt, + styleMode, + outputLang, + recentLores: [], + }) + + // Publish to IPFS + const { metadataUri, cid } = await publishIpfsStep({ + imageUrl: jobSnapshot.imageUrl, + lore, + prompt: jobSnapshot.prompt, + imageSource: "nanobanana_api", + payerAddress: jobSnapshot.payerAddress, + collectionId: jobSnapshot.collectionId, + }) + + // Mint NFT (best-effort, non-blocking) + await mintNftStep({ + payerAddress: jobSnapshot.payerAddress, + metadataUri, + collectionId: jobSnapshot.collectionId, + }) + + await updateGenerationJob(jobId, { + status: "completed", + result: { + imageUrl: jobSnapshot.imageUrl, + image_url: jobSnapshot.imageUrl, + lore, + metadataStandard: "SEP-41/50", + image_source: "nanobanana_api", + metadataUri, + cid, + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + await updateGenerationJob(jobId, { status: "failed", error: msg }) + await appendGenerationDlq({ + jobId, + source: "continueForgeAfterWebhook", + errorType: "pipeline_failed", + errorMessage: msg, + }).catch(() => {}) + } +} + +// ─── Route handler ──────────────────────────────────────────────────────────── + +export async function POST(request: NextRequest): Promise { + let rawBody = "" try { - await request.text() + rawBody = await request.text() } catch { - /* ignore */ + // Always return 200 to prevent NanoBanana retry storms + return new NextResponse(null, { status: 200 }) } + + const signatureHeader = request.headers.get("x-nanobanana-signature") + + // Verify signature + if (!verifyWebhookSignature(rawBody, signatureHeader)) { + console.error("[nanobanana-webhook] Invalid webhook signature", { + signatureHeader, + bodyPrefix: rawBody.slice(0, 120), + }) + void appendGenerationDlq({ + source: "webhook_signature_check", + errorType: "webhook_sig_invalid", + errorMessage: `Invalid signature. Header: ${signatureHeader ?? "(none)"}`, + rawPayload: rawBody.slice(0, 1000), + }).catch(() => {}) + // Return 401 only when secret IS configured (otherwise log and proceed) + if (getWebhookSecret()) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }) + } + } + + // Parse payload + let payload: NanobananaWebhookPayload + try { + payload = JSON.parse(rawBody) as NanobananaWebhookPayload + } catch { + void appendGenerationDlq({ + source: "webhook_parse", + errorType: "webhook_parse_error", + errorMessage: "Failed to parse JSON body", + rawPayload: rawBody.slice(0, 1000), + }).catch(() => {}) + return new NextResponse(null, { status: 200 }) + } + + const { taskId, successFlag, errorMessage, imageUrl } = extractFromPayload(payload) + + if (!taskId) { + void appendGenerationDlq({ + source: "webhook_no_taskid", + errorType: "webhook_parse_error", + errorMessage: "Webhook payload missing taskId", + rawPayload: payload, + }).catch(() => {}) + return new NextResponse(null, { status: 200 }) + } + + // Look up the generation job + const job = await getGenerationJobByTaskId(taskId).catch(() => null) + + if (!job) { + // No matching job — may have already been cleaned up or was from a different instance + console.warn("[nanobanana-webhook] No generation job found for taskId:", taskId) + return new NextResponse(null, { status: 200 }) + } + + const now = Date.now() + + // Mark webhook received + await updateGenerationJob(job.id, { + lastWebhookAt: now, + webhookDeliveries: (job.webhookDeliveries ?? 0) + 1, + }).catch(() => {}) + + // Handle failure from NanoBanana + if (successFlag === 2 || successFlag === 3) { + const errMsg = errorMessage || `NanoBanana task failed (successFlag=${successFlag ?? "unknown"})` + console.error("[nanobanana-webhook] Task failed:", { taskId, errMsg }) + await updateGenerationJob(job.id, { status: "failed", error: errMsg }).catch(() => {}) + await appendGenerationDlq({ + jobId: job.id, + txHash: job.txHash, + taskId, + source: "nanobanana_task_failure", + errorType: "pipeline_failed", + errorMessage: errMsg, + rawPayload: payload, + }).catch(() => {}) + return new NextResponse(null, { status: 200 }) + } + + // Handle success + if (successFlag === 1 && imageUrl) { + await updateGenerationJob(job.id, { + status: "webhook_received", + imageUrl, + }).catch(() => {}) + + // Continue pipeline async — lore, IPFS, mint + void continueForgeAfterWebhook(job.id, { + prompt: job.prompt, + imageUrl, + imageStyleMode: job.imageStyleMode, + lang: job.lang, + payerAddress: job.payerAddress, + collectionId: job.collectionId, + }).catch((err) => { + console.error("[nanobanana-webhook] continueForgeAfterWebhook unhandled", err) + }) + + return new NextResponse(null, { status: 200 }) + } + + // successFlag === 0 or undefined — still processing, NanoBanana is pinging for progress + console.log("[nanobanana-webhook] Task still processing:", { taskId, successFlag }) return new NextResponse(null, { status: 200 }) } diff --git a/app/forge/page.tsx b/app/forge/page.tsx index ec1bd884..c8c4b507 100644 --- a/app/forge/page.tsx +++ b/app/forge/page.tsx @@ -772,6 +772,10 @@ export default function ForgePage() { let data: { success?: boolean + async?: boolean + jobId?: string + txHash?: string + status?: string imageUrl?: string lore?: string description?: string @@ -782,6 +786,75 @@ export default function ForgePage() { console.error("[forge-agent] second POST success body is not JSON", e, paidRaw) throw new Error(ff.errors.agentRequest) } + + // ── Async job mode: poll /api/jobs/[txHash] until completed ─────────────── + if (data.async && txHash) { + const pollTxHash = data.txHash ?? txHash + console.log("[forge-agent] async mode — polling job for txHash:", pollTxHash) + + const POLL_INTERVAL_MS = 3500 + const POLL_TIMEOUT_MS = 4 * 60 * 1000 // 4 minutes + const deadline = Date.now() + POLL_TIMEOUT_MS + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) + + let pollRes: Response + try { + pollRes = await fetch(`/api/jobs/${encodeURIComponent(pollTxHash)}`, { cache: "no-store" }) + } catch { + continue + } + + if (!pollRes.ok && pollRes.status !== 404) { + console.warn("[forge-agent] poll returned non-ok status", pollRes.status) + continue + } + + let pollData: { + found?: boolean + job?: { + status?: string + imageUrl?: string + error?: string + result?: { + imageUrl?: string + lore?: string + } + } + } + try { + pollData = (await pollRes.json()) as typeof pollData + } catch { + continue + } + + if (!pollData.found || !pollData.job) continue + + const { status: jobStatus, result, error: jobError } = pollData.job + + if (jobStatus === "completed" && result?.imageUrl) { + const imgUrl = result.imageUrl + const loreText = result.lore ?? "" + setAgentImageUrl(imgUrl) + setLore(loreText) + setAgentState("COMPLETE") + return { imageUrl: imgUrl, lore: loreText } + } + + if (jobStatus === "failed") { + throw new Error(jobError ?? "ORACLE_OFFLINE_ENERGY_CONSUMED") + } + + // Still pending / processing / webhook_received — keep polling + console.log("[forge-agent] job still in progress:", jobStatus) + } + + // Polling timeout + throw new Error("ORACLE_OFFLINE_ENERGY_CONSUMED") + } + + // ── Synchronous mode: result available immediately ──────────────────────── if (!data.imageUrl) { console.error("[forge-agent] success payload missing imageUrl", data) throw new Error(ff.errors.agentRequest) diff --git a/lib/forge-nanobanana.ts b/lib/forge-nanobanana.ts index f0ff7362..de10d40c 100644 --- a/lib/forge-nanobanana.ts +++ b/lib/forge-nanobanana.ts @@ -1,6 +1,15 @@ /** * Cliente para https://api.nanobananaapi.ai (docs: docs.nanobananaapi.ai). - * Text-to-image vía POST /generate + polling GET /record-info (callBackUrl sigue siendo obligatorio en el body). + * + * Two modes: + * - generateForgeImageUrlViaNanobananaApi — synchronous polling mode (legacy): + * submits a task and blocks until successFlag === 1 or timeout (110s). + * Used as fallback when async webhooks are not configured. + * + * - submitForgeImageTaskViaNanobananaApi — async submit mode (new): + * submits a task and returns the taskId immediately without polling. + * The caller registers a generation job and waits for the webhook callback + * from app/api/webhooks/nanobanana to deliver the result. */ const NANOBANANA_BASE = "https://api.nanobananaapi.ai" @@ -33,20 +42,19 @@ function isNanobananaOverloadLike(code: number | undefined, msg: string): boolea } /** - * Crea tarea TEXTTOIAMGE y hace poll hasta successFlag === 1 o error. - * @returns URL https de imagen (resultImageUrl preferida sobre originImageUrl). + * Returns true when async webhook mode is available. + * Requires NANOBANANA_WEBHOOK_SECRET and a resolvable NANOBANANA_CALLBACK_URL. */ -export async function generateForgeImageUrlViaNanobananaApi(options: { - prompt: string - callBackUrl: string - pollIntervalMs?: number - maxWaitMs?: number -}): Promise { - const { prompt, callBackUrl } = options - const pollIntervalMs = options.pollIntervalMs ?? 2000 - const maxWaitMs = options.maxWaitMs ?? 110_000 - const apiKey = nanobananaApiKey() +export function nanobananaAsyncWebhookEnabled(): boolean { + const secret = process.env.NANOBANANA_WEBHOOK_SECRET?.trim() + const url = process.env.NANOBANANA_CALLBACK_URL?.trim() + return Boolean(secret && secret.length >= 8 && url && url.startsWith("http")) +} + +// ─── Shared submit helper ───────────────────────────────────────────────────── +async function submitGenerateTask(prompt: string, callBackUrl: string): Promise { + const apiKey = nanobananaApiKey() const body = { prompt, type: "TEXTTOIAMGE" as const, @@ -81,7 +89,47 @@ export async function generateForgeImageUrlViaNanobananaApi(options: { throw new Error(`NANOBANANA_GENERATE_FAILED: ${msg}`) } - const taskId = genJson.data.taskId + return genJson.data.taskId +} + +// ─── Async submit — returns taskId immediately ──────────────────────────────── + +export type SubmitForgeImageTaskResult = { + taskId: string + callBackUrl: string +} + +/** + * Submits an image generation task to NanoBanana and returns the taskId + * immediately without polling. The webhook at `callBackUrl` will receive + * the result once NanoBanana finishes (may take up to several minutes). + */ +export async function submitForgeImageTaskViaNanobananaApi(options: { + prompt: string + callBackUrl: string +}): Promise { + const taskId = await submitGenerateTask(options.prompt, options.callBackUrl) + return { taskId, callBackUrl: options.callBackUrl } +} + +// ─── Synchronous polling — legacy / fallback ────────────────────────────────── + +/** + * Crea tarea TEXTTOIAMGE y hace poll hasta successFlag === 1 o error. + * @returns URL https de imagen (resultImageUrl preferida sobre originImageUrl). + */ +export async function generateForgeImageUrlViaNanobananaApi(options: { + prompt: string + callBackUrl: string + pollIntervalMs?: number + maxWaitMs?: number +}): Promise { + const { prompt, callBackUrl } = options + const pollIntervalMs = options.pollIntervalMs ?? 2000 + const maxWaitMs = options.maxWaitMs ?? 110_000 + const apiKey = nanobananaApiKey() + + const taskId = await submitGenerateTask(prompt, callBackUrl) const deadline = Date.now() + maxWaitMs while (Date.now() < deadline) { diff --git a/lib/generation-job-store.ts b/lib/generation-job-store.ts new file mode 100644 index 00000000..fa40c926 --- /dev/null +++ b/lib/generation-job-store.ts @@ -0,0 +1,361 @@ +/** + * Persistent generation job store for async Nano Banana image generation. + * + * Jobs are keyed by settlement transaction hash (txHash) so the client can poll + * by the payment proof it already holds. Each job records the full pipeline + * state from submission through to webhook completion or failure. + * + * Dead-letter queue (DLQ): failed webhook callbacks and generation errors are + * appended to a separate sidecar for operator review and retry. + * + * Storage: .data/generation-jobs.json and .data/generation-dlq.json + * (falls back to /tmp on Vercel where project dir is read-only). + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { serverDataJsonPath } from "@/lib/server-data-paths" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type GenerationJobStatus = + | "pending" // job created, NanoBanana task submitted + | "processing" // NanoBanana is working (webhook not yet received) + | "webhook_received" // webhook delivered — pipeline continuing (IPFS, mint) + | "completed" // full pipeline succeeded + | "failed" // terminal failure + +export type GenerationJob = { + /** Unique job id (UUID) */ + id: string + /** NanoBanana taskId returned at submission */ + taskId: string + /** Settlement tx hash — primary lookup key */ + txHash: string + /** Payer wallet address */ + payerAddress?: string + /** User prompt */ + prompt: string + /** Image style mode forwarded from forge UI */ + imageStyleMode?: string + /** Collection id (optional) */ + collectionId?: number + /** Output language */ + lang?: string + status: GenerationJobStatus + /** Resolved image URL (NanoBanana CDN URL) */ + imageUrl?: string + /** Full pipeline result (available when status === 'completed') */ + result?: { + imageUrl: string + image_url: string + lore: string + metadataStandard: string + image_source: string + metadataUri?: string + cid?: string | null + } + /** Human-readable error detail */ + error?: string + /** ISO timestamp of last webhook delivery attempt */ + lastWebhookAt?: number + /** Number of webhook callback deliveries received */ + webhookDeliveries: number + createdAt: number + updatedAt: number +} + +export type GenerationDlqEntry = { + id: string + jobId?: string + txHash?: string + taskId?: string + source: string + receivedAt: number + errorType: "webhook_sig_invalid" | "webhook_parse_error" | "pipeline_failed" | "unknown" + errorMessage: string + rawPayload?: unknown +} + +// ─── Store I/O ──────────────────────────────────────────────────────────────── + +type JobStore = Record +type DlqStore = Record + +async function readJobStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("generationJobs"), "utf8") + const parsed = JSON.parse(raw) as JobStore + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +async function writeJobStore(data: JobStore): Promise { + const filePath = serverDataJsonPath("generationJobs") + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, JSON.stringify(data, null, 2), "utf8") +} + +async function readDlqStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("generationDlq"), "utf8") + const parsed = JSON.parse(raw) as DlqStore + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +async function writeDlqStore(data: DlqStore): Promise { + const filePath = serverDataJsonPath("generationDlq") + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, JSON.stringify(data, null, 2), "utf8") +} + +// ─── Pruning ────────────────────────────────────────────────────────────────── + +/** TTL for completed/failed jobs: 4 hours. Pending jobs kept longer (24h). */ +const COMPLETED_TTL_MS = 4 * 60 * 60 * 1000 +const PENDING_TTL_MS = 24 * 60 * 60 * 1000 +const DLQ_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +function pruneJobs(store: JobStore): JobStore { + const now = Date.now() + const out: JobStore = {} + for (const [k, job] of Object.entries(store)) { + const ttl = + job.status === "completed" || job.status === "failed" + ? COMPLETED_TTL_MS + : PENDING_TTL_MS + if (now - job.updatedAt < ttl) out[k] = job + } + return out +} + +function pruneDlq(store: DlqStore): DlqStore { + const now = Date.now() + const out: DlqStore = {} + for (const [k, entry] of Object.entries(store)) { + if (now - entry.receivedAt < DLQ_TTL_MS) out[k] = entry + } + return out +} + +// ─── Job CRUD ───────────────────────────────────────────────────────────────── + +export type CreateJobInput = { + taskId: string + txHash: string + prompt: string + payerAddress?: string + imageStyleMode?: string + collectionId?: number + lang?: string +} + +/** + * Creates a new generation job keyed by txHash. + * If a job for txHash already exists, returns it without creating a duplicate. + */ +export async function createGenerationJob(input: CreateJobInput): Promise { + const store = pruneJobs(await readJobStore()) + + // Idempotent: if already tracked, return existing + const existing = Object.values(store).find((j) => j.txHash === input.txHash) + if (existing) return existing + + const job: GenerationJob = { + id: randomUUID(), + taskId: input.taskId, + txHash: input.txHash, + prompt: input.prompt, + payerAddress: input.payerAddress, + imageStyleMode: input.imageStyleMode, + collectionId: input.collectionId, + lang: input.lang, + status: "pending", + webhookDeliveries: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + } + store[job.id] = job + await writeJobStore(store) + return job +} + +/** + * Returns the generation job for a given txHash, or null if not found. + */ +export async function getGenerationJobByTxHash(txHash: string): Promise { + const store = pruneJobs(await readJobStore()) + return Object.values(store).find((j) => j.txHash === txHash) ?? null +} + +/** + * Returns the generation job for a given NanoBanana taskId, or null. + */ +export async function getGenerationJobByTaskId(taskId: string): Promise { + const store = pruneJobs(await readJobStore()) + return Object.values(store).find((j) => j.taskId === taskId) ?? null +} + +/** + * Returns a job by its UUID id. + */ +export async function getGenerationJobById(id: string): Promise { + const store = pruneJobs(await readJobStore()) + return store[id] ?? null +} + +export type UpdateJobInput = Partial< + Pick< + GenerationJob, + | "status" + | "imageUrl" + | "result" + | "error" + | "lastWebhookAt" + | "webhookDeliveries" + | "taskId" + > +> + +/** + * Applies a partial update to a job (found by UUID id). Returns the updated job. + */ +export async function updateGenerationJob( + id: string, + patch: UpdateJobInput, +): Promise { + const store = pruneJobs(await readJobStore()) + const job = store[id] + if (!job) return null + const updated: GenerationJob = { ...job, ...patch, updatedAt: Date.now() } + store[id] = updated + await writeJobStore(store) + return updated +} + +/** + * Applies a partial update finding the job by txHash. + */ +export async function updateGenerationJobByTxHash( + txHash: string, + patch: UpdateJobInput, +): Promise { + const store = pruneJobs(await readJobStore()) + const entry = Object.entries(store).find(([, j]) => j.txHash === txHash) + if (!entry) return null + const [id, job] = entry + const updated: GenerationJob = { ...job, ...patch, updatedAt: Date.now() } + store[id] = updated + await writeJobStore(store) + return updated +} + +/** + * Applies a partial update finding the job by NanoBanana taskId. + */ +export async function updateGenerationJobByTaskId( + taskId: string, + patch: UpdateJobInput, +): Promise { + const store = pruneJobs(await readJobStore()) + const entry = Object.entries(store).find(([, j]) => j.taskId === taskId) + if (!entry) return null + const [id, job] = entry + const updated: GenerationJob = { ...job, ...patch, updatedAt: Date.now() } + store[id] = updated + await writeJobStore(store) + return updated +} + +/** + * Lists all active generation jobs, sorted newest first. + */ +export async function listGenerationJobs(opts: { limit?: number } = {}): Promise { + const store = pruneJobs(await readJobStore()) + const items = Object.values(store).sort((a, b) => b.createdAt - a.createdAt) + return typeof opts.limit === "number" ? items.slice(0, opts.limit) : items +} + +// ─── Dead-Letter Queue ──────────────────────────────────────────────────────── + +export type AppendDlqInput = { + jobId?: string + txHash?: string + taskId?: string + source: string + errorType: GenerationDlqEntry["errorType"] + errorMessage: string + rawPayload?: unknown +} + +/** + * Appends a failed callback or pipeline error to the dead-letter queue. + * Never throws — caller can fire-and-forget. + */ +export async function appendGenerationDlq(input: AppendDlqInput): Promise { + let store: DlqStore + try { + store = pruneDlq(await readDlqStore()) + } catch { + store = {} + } + + const entry: GenerationDlqEntry = { + id: randomUUID(), + jobId: input.jobId, + txHash: input.txHash, + taskId: input.taskId, + source: input.source, + receivedAt: Date.now(), + errorType: input.errorType, + errorMessage: String(input.errorMessage).slice(0, 2000), + rawPayload: sanitizeDlqPayload(input.rawPayload), + } + store[entry.id] = entry + + try { + await writeDlqStore(store) + } catch { + // best-effort — do not propagate write errors to callers + } + return entry +} + +/** + * Returns all DLQ entries sorted newest first. + */ +export async function listGenerationDlq(opts: { limit?: number } = {}): Promise { + const store = pruneDlq(await readDlqStore()) + const items = Object.values(store).sort((a, b) => b.receivedAt - a.receivedAt) + return typeof opts.limit === "number" ? items.slice(0, opts.limit) : items +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const SECRET_KEY_RE = /(secret|seed|priv|passphrase|password|token|jwt|mnemonic|api[_-]?key)/i + +function sanitizeDlqPayload(value: unknown, depth = 0): unknown { + if (depth > 5) return "[truncated]" + if (Array.isArray(value)) return value.slice(0, 20).map((v) => sanitizeDlqPayload(v, depth + 1)) + if (value && typeof value === "object") { + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SECRET_KEY_RE.test(k) ? "[redacted]" : sanitizeDlqPayload(v, depth + 1) + } + return out + } + if (typeof value === "string" && value.length > 1024) return `${value.slice(0, 1024)}…[truncated]` + return value +} + +/** Test helper */ +export async function _resetGenerationJobStore(): Promise { + await writeJobStore({}) + await writeDlqStore({}) +} diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index a12e37f5..c8e58b98 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -30,10 +30,8 @@ const FILES = { watchlists: "watchlists.json", questRegistry: "quest-registry.json", distributorHealth: "distributor-health.json", - readerProgress: "reader-progress.json", - loreLinks: "lore-links.json", - blockList: "block-list.json", - trendingSignals: "trending-signals.json", + generationJobs: "generation-jobs.json", + generationDlq: "generation-dlq.json", } as const export type ServerDataFile = keyof typeof FILES