From 726378df2873edd8fc5215986491ce6cff62f576 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 21:23:49 -0700 Subject: [PATCH] Connect No Paint image moves to AC Braincells and OpenRouter --- lith/server.mjs | 2 + system/backend/account-deletion.mjs | 2 + system/backend/nopaint-billing.mjs | 71 ++++++++++++++++ system/backend/nopaint-openrouter.mjs | 61 +++++++++++++ system/backend/nopaint-provider.mjs | 78 +++++++++++++++++ .../netlify/functions/nopaint-inference.mjs | 83 ++++++++++++++++++ system/tests/account-deletion.test.mjs | 15 ++++ system/tests/nopaint-billing.test.mjs | 46 ++++++++++ system/tests/nopaint-inference.test.mjs | 85 +++++++++++++++++++ system/tests/nopaint-openrouter.test.mjs | 51 +++++++++++ 10 files changed, 494 insertions(+) create mode 100644 system/backend/nopaint-billing.mjs create mode 100644 system/backend/nopaint-openrouter.mjs create mode 100644 system/backend/nopaint-provider.mjs create mode 100644 system/netlify/functions/nopaint-inference.mjs create mode 100644 system/tests/nopaint-billing.test.mjs create mode 100644 system/tests/nopaint-inference.test.mjs create mode 100644 system/tests/nopaint-openrouter.test.mjs diff --git a/lith/server.mjs b/lith/server.mjs index 15eef6c0f9..b202fc4a99 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1504,6 +1504,8 @@ if (!DEV) { const {connect}=await import(pathToFileURL(join(SYSTEM,"backend","database.mjs")).href); const {speechBilling}=await import(pathToFileURL(join(SYSTEM,"backend","whistlegraph-speech-billing.mjs")).href); await speechBilling((await connect()).db).reconcile(); + const {noPaintBilling}=await import(pathToFileURL(join(SYSTEM,"backend","nopaint-billing.mjs")).href); + await noPaintBilling((await connect()).db).reconcile(); const count=await paid.withWallets(w=>paid.reconcilePaidHolds(w)); if(count)console.log("[lith] recovered credit holds:",count); } catch { console.error("[lith] credit reconciliation failed; will retry"); } diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs index 4edda484e4..f1b7b75791 100644 --- a/system/backend/account-deletion.mjs +++ b/system/backend/account-deletion.mjs @@ -142,6 +142,7 @@ const EXPORT = [ // future provider credentials out of this download. const PRIVATE_EXPORT = [ ["oskiewar-fighters", "owner", ["fighter", "acceptedAt", "expiresAt"]], + ["nopaint-move-requests", "user", ["_id", "model", "braincells", "charged", "free", "paid", "status", "startedAt", "finishedAt"]], ["whistlegraph-speech-requests", "user", ["_id", "braincells", "charged", "free", "paid", "status", "startedAt", "finishedAt"]], ["whistlegraph-iap-accounts", "_id", ["createdAt"]], ["whistlegraph-iap-purchases", "user", [ @@ -334,6 +335,7 @@ export async function handleQuarantined(db, handle, now = new Date()) { // [collection, query(sub, ctx)] const DELETE = [ ["whistlegraph-speech-requests", (sub) => ({ user: sub })], + ["nopaint-move-requests", (sub) => ({ user: sub })], ["paintings", (sub) => ({ user: sub })], ["pieces", (sub) => ({ user: sub })], ["moods", (sub) => ({ user: sub })], diff --git a/system/backend/nopaint-billing.mjs b/system/backend/nopaint-billing.mjs new file mode 100644 index 0000000000..15b4c7519f --- /dev/null +++ b/system/backend/nopaint-billing.mjs @@ -0,0 +1,71 @@ +// No Paint uses the existing daily allowance and purchased Braincells wallet. +import {createHash, randomUUID} from 'node:crypto'; +import {DAILY_TOKEN_BUDGET, dayKey} from './ai-budget.mjs'; +import {reserve, settle} from './easel-paid-credits.mjs'; + +export const requestKey = (user, id) => createHash('sha256').update(user+'\0'+id.toLowerCase()).digest('hex'); +const fail = (status, message) => Object.assign(Error(message), {status}); +const collection = 'nopaint-move-requests'; + +export async function ensureNoPaintBillingIndexes(db) { + await db.collection(collection).createIndex({status:1, expiresAt:1}, {name:'pending_recovery'}); + await db.collection(collection).createIndex({user:1}, {name:'account_receipts'}); +} + +export function noPaintBilling(db, {now=()=>new Date(), limit=DAILY_TOKEN_BUDGET}={}) { + const receipts = db.collection(collection), usage = db.collection('ai-usage'); + const wallet = session => ({updateOne:(filter, update, options={}) => + db.collection('ac-credit-wallets').updateOne(filter, update, {...options, session})}); + async function transaction(work) { + const session = db.client.startSession(); + try { return await session.withTransaction(()=>work(session), { + readConcern:{level:'snapshot'}, writeConcern:{w:'majority'}, + }); } finally { await session.endSession(); } + } + async function finish(id, success) { + return transaction(async session => { + const receipt = await receipts.findOne({_id:id}, {session}); + if (!receipt || receipt.status !== 'pending') return false; + if (receipt.hold) { + const settled = await settle(receipt.hold, success ? receipt.paid : 0, wallet(session), {now:now()}); + if (success && !settled) throw fail(503, 'The Braincells reservation expired'); + } + if (!success && receipt.free) await usage.updateOne({_id:receipt.usageId}, {$inc:{tokens:-receipt.free}}, {session}); + if (success) await usage.updateOne({_id:receipt.usageId}, { + $inc:{asks:1}, $set:{last:now(), lastModel:receipt.model}, + }, {session}); + await receipts.updateOne({_id:id, status:'pending'}, {$set:{ + status:success ? 'complete' : 'failed', charged:success ? receipt.braincells : 0, finishedAt:now(), + }}, {session}); + return true; + }); + } + async function reconcile(user) { + let count = 0; + for await (const receipt of receipts.find({status:'pending', expiresAt:{$lte:now()}, ...(user ? {user} : {})})) { + if (await finish(receipt._id, false)) count++; + } + return count; + } + async function begin({user, handle, requestId, hash, braincells, model}) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId || '')) throw fail(400, 'Move ID must be a UUID'); + if (!Number.isSafeInteger(braincells) || braincells < 1) throw fail(400, 'Invalid move price'); + const id = requestKey(user, requestId), at = now(), day = dayKey(at), usageId = handle+':'+day; + const holdId = randomUUID(); + await reconcile(user); + return transaction(async session => { + const previous = await receipts.findOne({_id:id}, {session}); + if (previous) throw fail(409, previous.hash !== hash ? 'Move ID was already used for a different image or settings' : 'Move already submitted; it will not be charged again'); + await usage.updateOne({_id:usageId}, {$setOnInsert:{handle, day, first:at, tokens:0}}, {upsert:true, session}); + const spent = await usage.findOne({_id:usageId}, {session}); + const free = Math.min(braincells, Math.max(0, limit-(Number(spent.tokens)||0))), paid = braincells-free; + const hold = paid ? await reserve(user, paid, wallet(session), {id:holdId, now:at}) : null; + if (paid && !hold) throw fail(402, 'Not enough available Braincells for this move. Choose a local model.'); + if (free) await usage.updateOne({_id:usageId}, {$inc:{tokens:free}}, {session}); + await receipts.insertOne({_id:id, user, hash, model, usageId, day, free, paid, hold, braincells, + status:'pending', startedAt:at, expiresAt:new Date(+at+5*60_000)}, {session}); + return {id, braincells, free, paid}; + }); + } + return {begin, finish, reconcile}; +} diff --git a/system/backend/nopaint-openrouter.mjs b/system/backend/nopaint-openrouter.mjs new file mode 100644 index 0000000000..131068ff77 --- /dev/null +++ b/system/backend/nopaint-openrouter.mjs @@ -0,0 +1,61 @@ +import {createHash} from 'node:crypto'; +import {braincellsFromCost} from './easel-paid-credits.mjs'; + +// Reviewed image-in/image-out profiles. Discovery is broader than this allowlist. +export const profiles = { + 'black-forest-labs/flux.2-klein-4b': {name:'FLUX.2 Klein 4B', options:{aspect_ratio:'1:1', output_format:'png', seed:true}}, + 'google/gemini-3.1-flash-image': {name:'Nano Banana 2', options:{aspect_ratio:'1:1', resolution:'512'}}, + 'openai/gpt-image-1-mini': {name:'GPT Image 1 Mini', options:{aspect_ratio:'1:1', quality:'low'}}, + 'openai/gpt-image-2.5-flare': {name:'GPT Image 2.5 Flare', options:{aspect_ratio:'1:1', quality:'low'}}, +}; + +export function openRouterOffers(configuration='[]') { + let settings; + try { settings=JSON.parse(configuration); } catch { return []; } + if (!Array.isArray(settings)) return []; + const found=new Set(); + return settings.flatMap(entry=>{ + if (!entry || typeof entry!=="object") return []; + const {model,usd}=entry; + const profile=Object.hasOwn(profiles,model)?profiles[model]:null; + if (!profile || !Number.isFinite(usd) || usd<=0 || usd>1 || found.has(model)) return []; + found.add(model); + const braincells=braincellsFromCost(usd); + const quote=createHash('sha256').update(JSON.stringify([model,'openrouter-256-rgb-v1',profile.options,braincells])).digest('hex'); + return [{id:'ac-openrouter:'+model, name:profile.name, model, provider:'openrouter', location:'AC cloud · OpenRouter', + braincells, quote, size:[256,256], previews:false}]; + }); +} + +export function createOpenRouterProvider({key, fetch=globalThis.fetch}) { + return async (input, offer)=>{ + const profile=Object.hasOwn(profiles,offer.model)?profiles[offer.model]:null; + if (!profile) throw Error('Unconfigured image model'); + const amount={'.25':'small','.5':'medium','.75':'large'}[String(input.strength).replace(/^0/,'')]; + const {seed,...options}=profile.options; + const response=await fetch('https://openrouter.ai/api/v1/images', { + method:'POST', redirect:'error', signal:AbortSignal.timeout(180_000), + headers:{Authorization:`Bearer ${key}`, 'Content-Type':'application/json'}, + body:JSON.stringify({model:offer.model, ...options, ...(seed?{seed:input.seed}:{}), n:1, + prompt:`Make exactly one ${amount} abstract painting move on this image: change a texture, color relationship, shape, or spatial arrangement. Preserve most of the existing image. Return the complete updated image. Do not add text, borders, or a depicted scene.`, + input_references:[{type:'image_url',image_url:{url:'data:image/png;base64,'+input.image}}], + }), + }); + if (!response.ok) throw Object.assign(Error(`Image provider unavailable (HTTP ${response.status})`),{status:502}); + // Bound bytes before parsing; never follow a provider-controlled image URL. + let length=0; const chunks=[]; + for await(const chunk of response.body) { + length+=chunk.length; + if(length>24_000_000) throw Error('Image response too large'); + chunks.push(chunk); + } + const result=JSON.parse(Buffer.concat(chunks).toString()), image=result.data?.[0]; + if (!image || typeof image.b64_json!=='string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(image.b64_json) + || (image.media_type && !['image/png','image/jpeg','image/webp'].includes(image.media_type))) throw Error('Invalid image response'); + const {default:sharp}=await import('sharp'); + const output=await sharp(Buffer.from(image.b64_json,'base64'), {limitInputPixels:16_777_216}) + .resize(256,256,{fit:'fill'}).removeAlpha().png().toBuffer(); + return {image:output.toString('base64'), model:offer.model, width:256, height:256, + provider_cost_usd:Number.isFinite(result.usage?.cost)?result.usage.cost:null}; + }; +} diff --git a/system/backend/nopaint-provider.mjs b/system/backend/nopaint-provider.mjs new file mode 100644 index 0000000000..97cfd161e9 --- /dev/null +++ b/system/backend/nopaint-provider.mjs @@ -0,0 +1,78 @@ +import {createHash} from 'node:crypto'; +import {braincellsFromCost} from './easel-paid-credits.mjs'; +import {imageInputBound} from './easel-input-images.mjs'; + +export const MODEL = 'fal-ai/flux-2/klein/4b/edit'; +const fail = (status, message) => Object.assign(Error(message), {status}); +// A server-configured flat tariff, disclosed before submission. No inferred +// megapixel minimum or unverified provider cost is silently billed to users. +export function moveOffer(usd) { + if (!Number.isFinite(usd) || usd <= 0 || usd > 1) return null; + const braincells = braincellsFromCost(usd); + const quote = createHash('sha256').update(JSON.stringify([MODEL, '256-rgb-v1', braincells])).digest('hex'); + return {id:'ac-klein', name:'FLUX.2 Klein 4B', model:MODEL, location:'AC cloud', + braincells, quote, size:[256,256], previews:false}; +} +export function moveInput(body) { + if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(400, 'Expected a move'); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(body.requestId || '')) throw fail(400, 'Move ID must be a UUID'); + if (![.25,.5,.75].includes(body.strength) || !Number.isInteger(body.seed) || body.seed < 0 || body.seed >= 2**31) throw fail(400, 'Invalid move settings'); + try { imageInputBound({messages:[{type:'image',source:{type:'base64',media_type:'image/png',data:body.image}}]}); } + catch { throw fail(400, 'Expected a complete PNG image'); } + const png = Buffer.from(body.image,'base64'); + if (png.readUInt32BE(16)!==256 || png.readUInt32BE(20)!==256 || png[24]!==8 || ![2,6].includes(png[25])) throw fail(400, 'Expected a 256×256 RGB PNG'); + const hash = createHash('sha256').update(png).update(JSON.stringify([body.seed,body.strength,body.quote])).digest('hex'); + return {...body, hash}; +} + +export function createNoPaintProvider({key, fetch=globalThis.fetch, sleep=ms=>new Promise(r=>setTimeout(r,ms))}) { + return async input => { + const signal = AbortSignal.timeout(180_000); + let handle; + const request = async (url, method='GET', body) => { + const target = new URL(url); + if (target.origin !== 'https://queue.fal.run' || target.username || target.password) throw fail(502, 'Unexpected provider URL'); + const response = await fetch(url, {method, redirect:'error', signal, + headers:{Authorization:`Key ${key}`, 'Content-Type':'application/json'}, + ...(body ? {body:JSON.stringify(body)} : {})}); + if (!response.ok) throw fail(502, `Image provider unavailable (HTTP ${response.status})`); + return response.json(); + }; + try { + const amount = {'.25':'small', '.5':'medium', '.75':'large'}[String(input.strength).replace(/^0/,'')]; + handle = await request('https://queue.fal.run/'+MODEL, 'POST', { + prompt:`Make exactly one ${amount} abstract painting move on this image: change a texture, color relationship, shape, or spatial arrangement. Preserve most of the existing image. Return the complete updated image. Do not add text, borders, or a depicted scene.`, + image_urls:['data:image/png;base64,'+input.image], image_size:{width:256,height:256}, + seed:input.seed, num_images:1, num_inference_steps:4, output_format:'png', + sync_mode:true, enable_safety_checker:true, + }); + while (true) { + signal.throwIfAborted(); + const state = await request(handle.status_url); + if (state.status === 'COMPLETED') { + if (state.error) throw fail(502, 'Image generation failed'); + break; + } + if (!['IN_QUEUE','IN_PROGRESS'].includes(state.status)) throw fail(502, 'Unexpected provider status'); + await sleep(500); + } + const result = await request(handle.response_url), image = result.images?.[0]?.url; + // sync_mode keeps image downloads and arbitrary external URLs out of this gateway. + if (typeof image !== 'string' || image.length > 8_000_000 || !/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(image)) throw fail(502, 'Image provider returned an invalid PNG'); + const {default:sharp} = await import('sharp'); + const output = await sharp(Buffer.from(image.split(',')[1],'base64'), {limitInputPixels:4_194_304}) + .resize(256,256,{fit:'fill'}).removeAlpha().png().toBuffer(); + return {image:output.toString('base64'), model:MODEL, width:256, height:256}; + } catch (error) { + if (handle?.cancel_url) { + try { + const url = new URL(handle.cancel_url); + if (url.origin === 'https://queue.fal.run' && !url.username && !url.password) await fetch(url, { + method:'PUT', redirect:'error', headers:{Authorization:`Key ${key}`}, signal:AbortSignal.timeout(5000), + }); + } catch {} + } + throw error; + } + }; +} diff --git a/system/netlify/functions/nopaint-inference.mjs b/system/netlify/functions/nopaint-inference.mjs new file mode 100644 index 0000000000..b19e854470 --- /dev/null +++ b/system/netlify/functions/nopaint-inference.mjs @@ -0,0 +1,83 @@ +import {requestKey, noPaintBilling, ensureNoPaintBillingIndexes} from '../../backend/nopaint-billing.mjs'; +import {moveOffer, moveInput, createNoPaintProvider} from '../../backend/nopaint-provider.mjs'; +import {openRouterOffers, createOpenRouterProvider} from '../../backend/nopaint-openrouter.mjs'; + +const reply = (statusCode, value) => ({statusCode, headers:{ + 'Content-Type':'application/json', 'Cache-Control':'private, no-store', + 'Access-Control-Allow-Origin':'*', 'Access-Control-Allow-Methods':'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers':'Authorization, Content-Type', +}, body:JSON.stringify(value)}); + +export function createHandler({authorize, getHandleOrEmail, billing, generate, offer=null, offers=offer?[offer]:[], enabled=false, now=Date.now}) { + const results = new Map(), pending = new Map(); + return async event => { + if (event.httpMethod === 'OPTIONS') return reply(204, null); + if (event.httpMethod === 'GET') return reply(200, {models:offers.map(item=>({...item,available:enabled}))}); + if (event.httpMethod !== 'POST') return reply(405, {error:'POST only'}); + if (!event.headers?.authorization) return reply(401, {error:'Sign in with AC for remote moves'}); + let user; + try { user = await authorize(event.headers); } catch {} + if (!user?.sub) return reply(401, {error:'Sign in again with AC'}); + if (user.email_verified !== true) return reply(403, {error:'Verify your AC email first'}); + if (!enabled || !offers.length) return reply(503, {error:'AC remote images are not enabled yet'}); + try { + const handle = await getHandleOrEmail(user.sub); + if (typeof handle !== 'string' || !handle.startsWith('@')) return reply(403, {error:'Choose an AC handle first'}); + if (typeof event.body !== 'string' || event.body.length > 710_000) return reply(413, {error:'Image too large'}); + let body; + try { body = JSON.parse(event.body); } catch { return reply(400, {error:'Invalid move'}); } + const input = moveInput(body), id = requestKey(user.sub, input.requestId); + const offer = offers.find(item=>item.quote===input.quote && (!input.model || input.model===item.id)); + if (!offer) return reply(409, {error:'The remote price changed. Refresh before generating.'}); + for (const [key,value] of results) if (value.until <= now()) results.delete(key); + const prior = results.get(id) || pending.get(id); + if (prior && prior.hash !== input.hash) return reply(409, {error:'Move ID already used for different image or settings'}); + if (results.has(id)) return reply(200, prior.value); + if (pending.has(id)) return await prior.promise; + if (pending.size >= 2 || [...pending.values()].some(entry=>entry.user === user.sub)) return reply(429, {error:'A remote move is still processing. Local models remain available.'}); + const work = async () => { + const receipt = await billing.begin({user:user.sub, handle:handle.slice(1), requestId:input.requestId, + hash:input.hash, braincells:offer.braincells, model:offer.model}); + let result; + try { result = await generate(input, offer); } + catch (error) { await billing.finish(receipt.id, false); throw error; } + // No/Paint selects artwork, not billing. Completed remote work is charged + // even if its client stopped waiting. Uncertain or failed work is refunded. + if (!await billing.finish(receipt.id, true)) throw Object.assign(Error('Move reservation expired'), {status:503}); + const value = {...result, handle, billing:{braincells:receipt.braincells, free:receipt.free, paid:receipt.paid}}; + if (results.size >= 32) results.delete(results.keys().next().value); + results.set(id, {hash:input.hash, value, until:now()+60_000}); + return reply(200, value); + }; + const promise = work().finally(()=>pending.delete(id)); + pending.set(id, {user:user.sub, hash:input.hash, promise}); + return await promise; + } catch (error) { + return reply(error.status || 503, {error:error.status ? error.message : 'Remote image generation unavailable'}); + } + }; +} + +let live; +export async function handler(event) { + const falOffer = moveOffer(Number(process.env.NOPAINT_FAL_USD_PER_MOVE)); + const offers = [...(process.env.FAL_KEY && falOffer ? [falOffer] : []), + ...(process.env.OPENROUTER_API_KEY ? openRouterOffers(process.env.NOPAINT_OPENROUTER_OFFERS) : [])]; + const enabled = process.env.NOPAINT_REMOTE_ENABLED === 'true' && offers.length>0; + // Public catalog and a disabled gateway need neither a DB nor provider call. + if (event.httpMethod === 'GET') return reply(200, {models:offers.map(item=>({...item,available:enabled}))}); + if (event.httpMethod === 'OPTIONS') return reply(204, null); + if (!enabled) return reply(503, {error:'AC remote images are not enabled yet'}); + if (!live) live = (async()=>{ + const [{authorize,getHandleOrEmail},{connect}] = await Promise.all([ + import('../../backend/authorization.mjs'), import('../../backend/database.mjs'), + ]); + const {db} = await connect(); + await ensureNoPaintBillingIndexes(db); + return createHandler({authorize, getHandleOrEmail, billing:noPaintBilling(db), offers, enabled, + generate:(input,offer)=> offer.provider==='openrouter' + ? createOpenRouterProvider({key:process.env.OPENROUTER_API_KEY})(input,offer) + : createNoPaintProvider({key:process.env.FAL_KEY})(input)}); + })().catch(error=>{live=null;throw error;}); + try { return await (await live)(event); } catch { return reply(503, {error:'AC remote images unavailable'}); } +} diff --git a/system/tests/account-deletion.test.mjs b/system/tests/account-deletion.test.mjs index 4e98daa623..dbda3311c5 100644 --- a/system/tests/account-deletion.test.mjs +++ b/system/tests/account-deletion.test.mjs @@ -734,3 +734,18 @@ test("speech billing receipts export without audio digests and disappear with th await runDueDeletions(deps,{now:later(GRACE_MS)}); assert.deepEqual(db.all("whistlegraph-speech-requests"),[seed["whistlegraph-speech-requests"][1]]); }); + +test("No Paint billing exports exclude image digests and deletion preserves other accounts", async () => { + const seed = world(); + seed["nopaint-move-requests"] = [ + {_id:"move-mine",user:SUB,model:"test-model",braincells:4000,charged:4000,status:"complete",hash:"private-painting-digest",startedAt:T0}, + {_id:"move-other",user:OTHER,braincells:4000,status:"complete"}, + ]; + const db=fakeDb(seed),deps=fakeDeps(db); + const copy=await exportAccount(deps,{user,now:T0}); + assert.deepEqual(copy.records["nopaint-move-requests"],[{_id:"move-mine",model:"test-model",braincells:4000,charged:4000,status:"complete",startedAt:T0}]); + assert.ok(!JSON.stringify(copy).includes("private-painting-digest")); + await requestDeletion(deps,{user,now:T0}); + await runDueDeletions(deps,{now:later(GRACE_MS)}); + assert.deepEqual(db.all("nopaint-move-requests"),[seed["nopaint-move-requests"][1]]); +}); diff --git a/system/tests/nopaint-billing.test.mjs b/system/tests/nopaint-billing.test.mjs new file mode 100644 index 0000000000..41ee03eb31 --- /dev/null +++ b/system/tests/nopaint-billing.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {noPaintBilling,ensureNoPaintBillingIndexes} from '../backend/nopaint-billing.mjs'; + +// Financial integration tests opt into Mongo and use only randomly named test +// collections. No real account, wallet, artwork or token is read or modified. +test('No Paint reserves free credits then paid credits; settlement, replay and recovery are atomic', + {skip:process.env.NOPAINT_BILLING_INTEGRATION!=='1'}, async()=>{ + const {connect,closePool}=await import('../backend/database.mjs'); + const {db}=await connect(),prefix='test-nopaint-'+randomUUID()+'-',names=new Set(); + const isolated={client:db.client,collection(name){names.add(prefix+name);return db.collection(prefix+name);}}; + let at=new Date('2026-10-05T23:59:00Z'); + const billing=noPaintBilling(isolated,{now:()=>at,limit:60}); + const request=(user='test-user')=>({user,handle:user,requestId:randomUUID(),hash:randomUUID(),braincells:40,model:'test-model'}); + try { + await ensureNoPaintBillingIndexes(isolated); + for(const name of ['ai-usage','ac-credit-wallets','nopaint-move-requests'])await isolated.collection(name).insertOne({_id:'fixture'}); + const wallets=isolated.collection('ac-credit-wallets'),usage=isolated.collection('ai-usage'),receipts=isolated.collection('nopaint-move-requests'); + await wallets.insertOne({_id:'test-user',balance:100}); + const input=request(),first=await billing.begin(input); + assert.equal(first.free,40);assert.equal(first.paid,0); + await assert.rejects(billing.begin(input),{status:409}); + assert.equal(await billing.finish(first.id,true),true); + assert.equal(await billing.finish(first.id,false),false); + const second=await billing.begin(request());assert.equal(second.free,20);assert.equal(second.paid,20); + assert.equal((await wallets.findOne({_id:'test-user'})).balance,80); + await billing.finish(second.id,false); + assert.equal((await wallets.findOne({_id:'test-user'})).balance,100); + assert.equal((await usage.findOne({_id:'test-user:2026-10-05'})).tokens,40); + const third=await billing.begin(request()); + at=new Date('2026-10-06T00:00:00Z'); + await billing.finish(third.id,true); + assert.equal((await wallets.findOne({_id:'test-user'})).daily['2026-10-05'],20); + const abandoned=await billing.begin(request());at=new Date(+at+301000); + assert.equal(await billing.reconcile(),1);assert.equal(await billing.finish(abandoned.id,true),false); + const race=await Promise.allSettled([billing.begin(request('race')),billing.begin(request('race'))]); + assert.equal(race.filter(result=>result.status==='fulfilled').length,1); + assert.equal(race.find(result=>result.status==='rejected').reason.status,402); + const receipt=await receipts.findOne({_id:first.id});assert.equal(receipt.charged,40); + for(const privateField of ['image','token','prompt'])assert.equal(privateField in receipt,false); + } finally { + for(const name of names)await db.collection(name).drop(); + await closePool(); + } + }); diff --git a/system/tests/nopaint-inference.test.mjs b/system/tests/nopaint-inference.test.mjs new file mode 100644 index 0000000000..41f11bc3a9 --- /dev/null +++ b/system/tests/nopaint-inference.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import sharp from 'sharp'; +import {createHandler} from '../netlify/functions/nopaint-inference.mjs'; +import {moveOffer,moveInput,createNoPaintProvider} from '../backend/nopaint-provider.mjs'; + +const image=(await sharp({create:{width:256,height:256,channels:3,background:'#74746f'}}).png().toBuffer()).toString('base64'); +const offer = moveOffer(.01); +const event = (values={}) => ({httpMethod:'POST',headers:{authorization:'Bearer test'},body:JSON.stringify({ + requestId:randomUUID(), image, seed:12, strength:.25, quote:offer.quote, ...values, +})}); +function fixture(overrides={}) { + const calls = {begin:[],finish:[],generate:0}; + const handler = createHandler({authorize:async()=>({sub:'owner',email_verified:true}), + getHandleOrEmail:async()=> '@alice', enabled:true, offer, + billing:{begin:async value=>{calls.begin.push(value);return {id:'receipt',braincells:value.braincells,free:100,paid:value.braincells-100};}, + finish:async (id,ok)=>{calls.finish.push([id,ok]);return true;}}, + generate:async()=>{calls.generate++;return {image,model:offer.model};},...overrides}); + return {handler,calls}; +} +test('cloud tariff shares the existing Braincells conversion and requires explicit configuration',()=>{ + assert.equal(offer.braincells,4000); + assert.equal(moveOffer(undefined),null);assert.equal(moveOffer(NaN),null);assert.equal(moveOffer(-1),null); + assert.notEqual(moveOffer(.02).quote,offer.quote); +}); +test('only bounded complete 256 RGB images and valid settings reach generation',()=>{ + const input=JSON.parse(event().body);assert.ok(moveInput(input).hash); + for(const change of [{image:'garbage'},{seed:-1},{seed:2**31},{strength:1},{requestId:'bad'}]) + assert.throws(()=>moveInput({...input,...change}),{status:400}); + const other=Buffer.from(image,'base64');other.writeUInt32BE(1024,16); + assert.throws(()=>moveInput({...input,image:other.toString('base64')}),{status:400}); +}); +test('identity and price come from the server; replay never charges or generates twice',async()=>{ + const {handler,calls}=fixture(); + const input=event({handle:'@victim',user:'victim',braincells:1}); + const first=await handler(input),second=await handler(input); + assert.equal(first.statusCode,200);assert.deepEqual(second,first); + assert.equal(calls.begin.length,1);assert.equal(calls.generate,1); + assert.equal(calls.begin[0].user,'owner');assert.equal(calls.begin[0].handle,'alice');assert.equal(calls.begin[0].braincells,4000); + const receipt=JSON.parse(first.body);assert.equal(receipt.handle,'@alice');assert.equal(receipt.billing.braincells,4000); + const changed={...input,body:JSON.stringify({...JSON.parse(input.body),seed:13})}; + assert.equal((await handler(changed)).statusCode,409); +}); +test('anonymous, unverified, handleless, disabled and stale-price moves never reserve',async()=>{ + for(const overrides of [{authorize:async()=>null},{authorize:async()=>({sub:'owner',email_verified:false})}, + {getHandleOrEmail:async()=> 'email@example.test'},{enabled:false}]) { + const {handler,calls}=fixture(overrides);assert.ok((await handler(event())).statusCode>=400);assert.equal(calls.begin.length,0); + } + const {handler,calls}=fixture(); + assert.equal((await handler({...event(),headers:{}})).statusCode,401); + assert.equal((await handler(event({quote:'stale'}))).statusCode,409);assert.equal(calls.generate,0); +}); +test('insufficient balance never calls the provider; failure refunds the reservation',async()=>{ + const poor=fixture({billing:{begin:async()=>{throw Object.assign(Error('No balance'),{status:402});}}}); + assert.equal((await poor.handler(event())).statusCode,402);assert.equal(poor.calls.generate,0); + const broken=fixture({generate:async()=>{throw Object.assign(Error('Provider unavailable'),{status:502});}}); + assert.equal((await broken.handler(event())).statusCode,502);assert.deepEqual(broken.calls.finish,[['receipt',false]]); +}); +test('concurrent replay joins the same pending move',async()=>{ + let release;const gate=new Promise(r=>release=r);let generated=0; + const {handler,calls}=fixture({generate:async()=>{generated++;await gate;return {image};}}); + const input=event(),first=handler(input),second=handler(input); + await new Promise(r=>setImmediate(r));release(); + assert.equal((await first).statusCode,200);assert.equal((await second).statusCode,200); + assert.equal(generated,1);assert.equal(calls.begin.length,1); +}); +test('provider adapter submits one complete canvas and uses no retries or arbitrary download URLs',async()=>{ + const calls=[]; + const generate=createNoPaintProvider({key:'test-key',sleep:async()=>{},fetch:async(url,options)=>{ + calls.push([url,options]); + if(options.method==='POST')return Response.json({status_url:'https://queue.fal.run/status',response_url:'https://queue.fal.run/result'}); + if(url.endsWith('/status'))return Response.json({status:'COMPLETED'}); + return Response.json({images:[{url:'data:image/png;base64,'+image}]}); + }}); + const result=await generate(JSON.parse(event().body));assert.equal(result.width,256);assert.equal(result.height,256); + const payload=JSON.parse(calls[0][1].body);assert.equal(payload.image_urls[0],'data:image/png;base64,'+image); + assert.equal(payload.num_images,1);assert.equal(payload.enable_safety_checker,true);assert.match(payload.prompt,/one small/); + assert.equal(calls.filter(([,options])=>options.method==='POST').length,1); +}); +test('provider callback URLs cannot receive credentials on another host',async()=>{ + let calls=0; + const generate=createNoPaintProvider({key:'test-key',fetch:async()=>{calls++;return Response.json({status_url:'https://example.test/status'});}}); + await assert.rejects(generate(JSON.parse(event().body)),{status:502});assert.equal(calls,1); +}); diff --git a/system/tests/nopaint-openrouter.test.mjs b/system/tests/nopaint-openrouter.test.mjs new file mode 100644 index 0000000000..96430e84ef --- /dev/null +++ b/system/tests/nopaint-openrouter.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import sharp from 'sharp'; +import {openRouterOffers, createOpenRouterProvider} from '../backend/nopaint-openrouter.mjs'; +import {createHandler} from '../netlify/functions/nopaint-inference.mjs'; +const image=(await sharp({create:{width:256,height:256,channels:3,background:'#74746f'}}).png().toBuffer()).toString('base64'); +const offers=openRouterOffers(JSON.stringify([ + {model:'black-forest-labs/flux.2-klein-4b',usd:.02}, + {model:'google/gemini-3.1-flash-image',usd:.06}, +])); + +test('only configured reviewed image models receive an explicit Braincells quote',()=>{ + assert.deepEqual(openRouterOffers(),[]); + for(const value of ['bad','null','{}','[null]', '[{"model":"unknown","usd":0.01}]']) assert.deepEqual(openRouterOffers(value),[]); + assert.equal(offers.length,2);assert.notEqual(offers[0].quote,offers[1].quote); + assert.equal(offers[0].braincells,8000);assert.equal(offers[0].previews,false); +}); +test('OpenRouter receives exactly one full input, the selected model and supported size; output returns to 256',async()=>{ + const calls=[]; + const generate=createOpenRouterProvider({key:'test-only',fetch:async(url,options)=>{ + calls.push([url,options]);return Response.json({data:[{b64_json:image,media_type:'image/png'}],usage:{cost:.04}}); + }}); + const output=await generate({image,strength:.25,seed:3},offers[1]); + assert.equal(calls.length,1);assert.equal(calls[0][0],'https://openrouter.ai/api/v1/images'); + assert.equal(calls[0][1].redirect,'error'); + const request=JSON.parse(calls[0][1].body); + assert.equal(request.model,offers[1].model);assert.equal(request.resolution,'512');assert.equal(request.n,1); + assert.equal(request.input_references[0].image_url.url,'data:image/png;base64,'+image); + assert.equal(request.seed,undefined);assert.equal(output.width,256);assert.equal(output.height,256); + assert.equal(output.provider_cost_usd,.04);assert.equal(JSON.stringify(output).includes('test-only'),false); +}); +test('provider errors are not retried; external image URLs are not followed',async()=>{ + for(const response of [()=>new Response('',{status:402}),()=>Response.json({data:[{url:'https://untrusted.test/image.png'}]})]) { + let calls=0; + const generate=createOpenRouterProvider({key:'test-only',fetch:async()=>{calls++;return response();}}); + await assert.rejects(generate({image,strength:.25,seed:3},offers[0]));assert.equal(calls,1); + } +}); +test('gateway selects and bills by server quote; mismatched model never calls provider',async()=>{ + const billed=[],generated=[]; + const handler=createHandler({authorize:async()=>({sub:'owner',email_verified:true}),getHandleOrEmail:async()=>'@owner', + offers,enabled:true,billing:{begin:async r=>{billed.push(r);return {...r,id:'receipt',free:r.braincells,paid:0};},finish:async()=>true}, + generate:async(input,offer)=>{generated.push(offer.model);return {image,model:offer.model};}}); + const event={httpMethod:'POST',headers:{authorization:'Bearer test'},body:JSON.stringify({ + requestId:randomUUID(),image,seed:1,strength:.25,model:offers[1].id,quote:offers[0].quote})}; + assert.equal((await handler(event)).statusCode,409);assert.equal(generated.length,0); + event.body=JSON.stringify({...JSON.parse(event.body),quote:offers[1].quote}); + assert.equal((await handler(event)).statusCode,200);assert.deepEqual(generated,[offers[1].model]); + assert.equal(billed[0].braincells,offers[1].braincells); +}); -- 2.54.0