diff --git a/lith/server.mjs b/lith/server.mjs index 7b7e3d9b34..2edefb6ef6 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1339,7 +1339,7 @@ app.post("/menuband-logs", // Static files // Checkout capabilities must not be embedded in another site's frame or cached. -app.use('/braincells', (_req, res, next) => { +app.use(['/braincells', '/mint'], (_req, res, next) => { res.set({ 'Content-Security-Policy':"frame-ancestors 'none'", 'X-Frame-Options':'DENY', 'Referrer-Policy':'no-referrer', 'Cache-Control':'no-store' }); next(); diff --git a/oven/bundler.mjs b/oven/bundler.mjs index 4f763c3304..7f8316e44e 100644 --- a/oven/bundler.mjs +++ b/oven/bundler.mjs @@ -505,17 +505,17 @@ async function minifyJS(content, relativePath) { // ─── Dependency discovery ─────────────────────────────────────────── -async function discoverDependencies(acDir, essentialFiles, skipFiles) { +async function discoverDependencies(acDir, essentialFiles, skipFiles, sources = {}) { const discovered = new Set(essentialFiles); const toProcess = [...essentialFiles]; while (toProcess.length > 0) { const file = toProcess.shift(); const fullPath = path.join(acDir, file); - if (!fsSync.existsSync(fullPath)) continue; + if (sources[file] === undefined && !fsSync.existsSync(fullPath)) continue; try { - const content = await fs.readFile(fullPath, "utf8"); + const content = sources[file] ?? await fs.readFile(fullPath, "utf8"); // Exclude `?` from the path char class so cache-bust queries // (`./foo.mjs?v=123`) don't get baked into the resolved VFS key — // otherwise `fs.readFile('lib/foo.mjs?v=123')` fails and the file is @@ -800,7 +800,14 @@ async function packKidLisp({ name: PIECE_NAME_NO_DOLLAR, sources: kidlispSources // ─── JS piece bundle ──────────────────────────────────────────────── -export async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null, brotli = false, noboxart = false, keeplabel = false, forceDaw = false, forceRefresh = false) { +export async function createJSPieceBundleFromSource(pieceName, source, { onProgress = () => {}, density = 2, nocompress = false } = {}) { + if (!/^[a-zA-Z0-9_-]{1,80}$/.test(pieceName)) throw new Error("Invalid piece name"); + if (typeof source !== "string" || !source.trim() || Buffer.byteLength(source) > 500_000) throw new Error("Invalid piece source"); + if (!Number.isInteger(density) || density < 1 || density > 4) throw new Error("Invalid pixel size"); + return createJSPieceBundle(pieceName, onProgress, nocompress, density, false, true, false, false, false, source); +} + +export async function createJSPieceBundle(pieceName, onProgress = () => {}, nocompress = false, density = null, brotli = false, noboxart = false, keeplabel = false, forceDaw = false, forceRefresh = false, source = undefined) { const acDir = AC_SOURCE_DIR; onProgress({ stage: "init", message: `Bundling ${pieceName}...` }); @@ -821,16 +828,16 @@ export async function createJSPieceBundle(pieceName, onProgress = () => {}, noco const piecePath = `disks/${pieceName}.mjs`; const pieceFullPath = path.join(acDir, piecePath); - if (!fsSync.existsSync(pieceFullPath)) { + if (source === undefined && !fsSync.existsSync(pieceFullPath)) { throw new Error(`Piece '${pieceName}' not found at ${piecePath}`); } onProgress({ stage: "piece", message: `Loading ${pieceName}.mjs...` }); - const pieceContent = await fs.readFile(pieceFullPath, "utf8"); + const pieceContent = source ?? await fs.readFile(pieceFullPath, "utf8"); files[piecePath] = { content: rewriteImports(pieceContent, piecePath), binary: false, type: "mjs" }; - const pieceDepFiles = await discoverDependencies(acDir, [piecePath], SKIP_FILES); + const pieceDepFiles = await discoverDependencies(acDir, [piecePath], SKIP_FILES, { [piecePath]: pieceContent }); onProgress({ stage: "deps", message: `Found ${pieceDepFiles.length} dependencies...` }); for (const depFile of pieceDepFiles) { @@ -852,7 +859,7 @@ export async function createJSPieceBundle(pieceName, onProgress = () => {}, noco const boxArtPNG = noboxart ? null : await generateBoxArtPNG(pieceName, null, null, packDate).catch(() => null); - const htmlContent = generateJSPieceHTMLBundle({ pieceName, files, packDate, packTime, gitVersion: GIT_COMMIT, bdfGlyphs, boxArtPNG, keeplabel, forceDaw }); + const htmlContent = generateJSPieceHTMLBundle({ pieceName, files, packDate, packTime, gitVersion: GIT_COMMIT, bdfGlyphs, boxArtPNG, keeplabel, forceDaw, density }); const filename = `${pieceName}-${bundleTimestamp}.html`; const method = nocompress ? "none" : brotli ? "brotli" : "gzip"; @@ -2509,7 +2516,7 @@ function generateHTMLBundle(opts) { } function generateJSPieceHTMLBundle(opts) { - const { pieceName, files, packDate, packTime, gitVersion, bdfGlyphs, boxArtPNG, keeplabel, forceDaw } = opts; + const { pieceName, files, packDate, packTime, gitVersion, bdfGlyphs, boxArtPNG, keeplabel, forceDaw, density } = opts; const boxArtImg = renderBoxArt(pieceName, boxArtPNG); @@ -2522,6 +2529,7 @@ function generateJSPieceHTMLBundle(opts) { @@ -2595,6 +2603,7 @@ function generateJSPieceHTMLBundle(opts) { window.KIDLISP_SUPPRESS_SNAPSHOT_LOGS = true; window.__acKidlispConsoleEnabled = false; window.acSTARTING_PIECE = "${pieceName}"; + ${density && !forceDaw ? `window.acPACK_DENSITY = ${density};` : ""} window.acPACK_PIECE = "${pieceName}"; window.acPACK_DATE = "${packDate}"; window.acPACK_GIT = "${gitVersion}"; diff --git a/system/backend/whistlegraph-mint.mjs b/system/backend/whistlegraph-mint.mjs new file mode 100644 index 0000000000..9c2848b4d6 --- /dev/null +++ b/system/backend/whistlegraph-mint.mjs @@ -0,0 +1,175 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { getPkhfromPk, verifySignature, validateAddress } from '@taquito/utils'; +import { chainJSON } from './tezos-credits.mjs'; + +export const HEN_MINTER = 'KT1Hkg5qeNhfwpKW4fXvq7HGZB9z2EnmCCA9'; +export const HEN_OBJKTS = 'KT1RJ6PbjHpwc3M5rw5s2Nbmefwbuwbdxton'; +export const hash = text => createHash('sha256').update(text).digest('hex'); +export const mintError = (status, message) => Object.assign(new Error(message), { status }); +const publicState = i => ({ id:i._id, code:i.code, version:i.version, sourceHash:i.sourceHash, density:i.density, aspect:i.aspect, + title:i.title, description:i.description, editions:i.editions, royalties:i.royalties, handle:i.handle, + status:i.status, sender:i.sender, artifactUri:i.artifactUri, coverUri:i.coverUri, metadataUri:i.metadataUri, + artifactHash:i.artifactHash, bytes:i.bytes, operationHash:i.operationHash, tokenId:i.tokenId, + error:i.error, network:'mainnet', contract:HEN_OBJKTS }); + +export function mintPayload(i) { + const text = `Tezos Signed Message: Mint Whistlegraph artwork\nhttps://aesthetic.computer\nMint: ${i._id}\nAccount: @${i.handle}\nPiece: ${i.code} v${i.version}\nSource: ${i.sourceHash}\nNonce: ${i.nonce}`; + const bytes = Buffer.from(text); + return '0501' + bytes.length.toString(16).padStart(8, '0') + bytes.toString('hex'); +} +export function verifyMintWallet(i, proof) { + try { + return /^tz[123]/.test(proof.address) && validateAddress(proof.address) === 3 && + getPkhfromPk(proof.publicKey) === proof.address && verifySignature(mintPayload(i), proof.publicKey, proof.signature); + } catch { return false; } +} +export function mintMetadata(i) { + return { name:i.title, description:i.description, tags:['whistlegraph', 'aesthetic-computer', 'interactive', 'generative'], + symbol:'OBJKT', artifactUri:i.artifactUri, displayUri:i.coverUri, thumbnailUri:i.coverUri, + creators:[i.sender], formats:[{ uri:i.artifactUri, mimeType:'text/html' }], decimals:0, + isBooleanAmount:false, shouldPreferSymbol:false, date:new Date(i.createdAt).toISOString(), + whistlegraph:{ code:i.code, version:i.version, sourceHash:i.sourceHash, artifactHash:i.artifactHash, + pixelSize:i.density, previewAspect:i.aspect, mintId:i._id, packVersion:i.packVersion } }; +} +export function mintOperation(i) { + return { kind:'transaction', destination:HEN_MINTER, amount:'0', parameters:{ entrypoint:'mint_OBJKT', + value:{ prim:'Pair', args:[{ prim:'Pair', args:[{ string:i.sender }, { int:String(i.editions) }] }, + { prim:'Pair', args:[{ bytes:Buffer.from(i.metadataUri).toString('hex') }, { int:String(i.royalties) }] }] } } }; +} +export function matchingMint(i, tx) { + const p = tx.parameter?.value; + return tx.status === 'applied' && tx.target?.address === HEN_MINTER && tx.sender?.address === i.sender && + tx.nonce == null && (!tx.initiator || tx.initiator.address === i.sender) && String(tx.amount) === '0' && + tx.parameter?.entrypoint === 'mint_OBJKT' && p?.address === i.sender && String(p.amount) === String(i.editions) && + p.metadata === Buffer.from(i.metadataUri).toString('hex') && String(p.royalties) === String(i.royalties) && + Number.isSafeInteger(tx.id) && Number.isSafeInteger(tx.level) && Number.isSafeInteger(tx.counter) && Date.parse(tx.timestamp) >= +new Date(i.createdAt); +} + +// Persist each step before opening a wallet. A lost browser callback recovers by +// the unique metadata URI, never by an approximate amount or the latest token. +export function whistlegraphMints({ intents, threads, pack, pin, cover, chain = chainJSON, verify = verifyMintWallet, now = () => new Date() }) { + async function lookup(secret) { + if (typeof secret !== 'string' || !/^[a-f0-9]{64}$/.test(secret)) throw mintError(401, 'Invalid mint link'); + const i = await intents.findOne({ _id:hash(secret) }); + if (!i) throw mintError(404, 'Mint not found'); + return i; + } + async function alive(i) { + if (+now() - +new Date(i.createdAt) > 24 * 3600_000) throw mintError(409, 'Mint preview expired. Open a new preview from Whistlegraph.'); + } + return { + async create(user, handle, input) { + if (!/^[a-f0-9]{64}$/.test(input.secret || '')) throw mintError(400, 'Invalid request key'); + const existing = await intents.findOne({ _id:hash(input.secret) }); + if (existing) { + if (existing.user !== user) throw mintError(403, 'Mint belongs to another account'); + return publicState(existing); + } + if (!/^(?:wg|ww)[a-z]{5,12}$/i.test(input.code || '') || !Number.isSafeInteger(input.version) || + !Number.isInteger(input.density) || input.density < 1 || input.density > 4 || + !['2:3','9:16','1:1','4:3','16:9'].includes(input.aspect ?? '2:3') || + typeof input.title !== 'string' || !input.title.trim() || input.title.length > 120 || + typeof input.description !== 'string' || input.description.length > 2000 || + !Number.isInteger(input.editions) || input.editions < 1 || input.editions > 10000 || + !Number.isInteger(input.royalties) || input.royalties < 0 || input.royalties > 250) throw mintError(400, 'Invalid mint settings'); + if (await intents.countDocuments({ user, createdAt:{ $gt:new Date(+now() - 3600_000) } }) >= 5) throw mintError(429, 'Too many previews. Reopen an existing mint.'); + const row = await threads.findOne({ owner:user, codeKey:input.code.toLowerCase() }); + const version = row?.ledger?.versions.find(v => v.id === input.version); + if (!version) throw mintError(404, 'Save this version to your AC account first'); + if (typeof version.source !== 'string' || !version.source.trim() || Buffer.byteLength(version.source) > 500_000 || hash(version.source) !== input.sourceHash) throw mintError(409, 'The saved version differs from the preview. Reopen it before minting.'); + const png = await cover(input.cover); + const i = { _id:hash(input.secret), user, handle:handle.replace(/^@/, ''), code:row.code, version:version.id, + source:version.source, sourceHash:input.sourceHash, density:input.density, aspect:input.aspect ?? '2:3', title:input.title.trim(), + description:input.description.trim(), editions:input.editions, royalties:input.royalties, + cover:png.toString('base64'), nonce:randomUUID(), createdAt:now(), status:'packing' }; + try { await intents.insertOne(i); } catch (e) { + if (e.code !== 11000) throw e; + return this.create(user, handle, input); + } + return publicState(i); + }, + async status(secret) { + const i = await lookup(secret); + return { ...publicState(i), payload:mintPayload(i) }; + }, + async prepare(secret) { + const i = await lookup(secret); + if (i.status !== 'packing') return publicState(i); + await alive(i); + const claimed = await intents.updateOne({ _id:i._id, status:'packing', $or:[{ lease:{ $exists:false } }, { lease:{ $lt:now() } }] }, + { $set:{ lease:new Date(+now() + 10 * 60_000) } }); + if (!claimed.modifiedCount) return publicState(i); + try { + const packed = await pack(i); + const artifactUri = await pin('index.html', 'text/html', Buffer.from(packed.html)); + const coverUri = await pin('cover.png', 'image/png', Buffer.from(i.cover, 'base64')); + await intents.updateOne({ _id:i._id, status:'packing' }, { $set:{ status:'packed', artifactUri, coverUri, + artifactHash:hash(packed.html), bytes:Buffer.byteLength(packed.html), packVersion:packed.version }, $unset:{ source:'', cover:'', lease:'' } }); + } catch (error) { + await intents.updateOne({ _id:i._id, status:'packing' }, { $set:{ status:'failed', error:'Packing failed. Open a new mint preview.' }, $unset:{ source:'', cover:'', lease:'' } }); + throw error; + } + return publicState(await lookup(secret)); + }, + async bind(secret, proof) { + const i = await lookup(secret); + await alive(i); + if (i.sender && i.sender !== proof.address) throw mintError(409, 'This mint belongs to a different wallet'); + if (i.metadataUri) return publicState(i); + if (i.status !== 'packed' && i.status !== 'binding') throw mintError(409, 'Wait for the HTML pack'); + if (!verify(i, proof)) throw mintError(403, 'Wallet signature did not verify'); + await intents.updateOne({ _id:i._id, status:'packed' }, { $set:{ status:'binding', sender:proof.address } }); + const bound = await lookup(secret); + if (bound.sender !== proof.address) throw mintError(409, 'Another wallet claimed this mint'); + const metadataUri = await pin('metadata.json', 'application/json', Buffer.from(JSON.stringify(mintMetadata(bound)))); + await intents.updateOne({ _id:i._id, status:'binding', sender:proof.address }, { $set:{ status:'ready', metadataUri } }); + return publicState(await lookup(secret)); + }, + async begin(secret) { + const i = await lookup(secret); await alive(i); + if (i.status !== 'ready') throw mintError(409, 'A wallet request is already pending. Check the mint before trying again.'); + const changed = await intents.updateOne({ _id:i._id, status:'ready' }, { $set:{ status:'requested', requestedAt:now() } }); + if (!changed.modifiedCount) throw mintError(409, 'A wallet request is already pending'); + return { ...publicState({ ...i, status:'requested' }), operation:mintOperation(i) }; + }, + async cancel(secret) { + const i = await lookup(secret); + if (['requested','confirming','minted'].includes(i.status)) throw mintError(409, 'A wallet request exists. Check that mint before making another.'); + const changed = await intents.updateOne({ _id:i._id, status:i.status }, + { $set:{ status:'cancelled' }, $unset:{ source:'', cover:'' } }); + if (!changed.modifiedCount) throw mintError(409, 'Mint state changed. Check it again.'); + return publicState(await lookup(secret)); + }, + async confirm(secret, operationHash) { + const i = await lookup(secret); + if (i.status === 'minted') return publicState(i); + if (!['requested','confirming'].includes(i.status)) return publicState(i); + if (operationHash && (typeof operationHash !== 'string' || !/^o[1-9A-HJ-NP-Za-km-z]{50}$/.test(operationHash))) throw mintError(400, 'Invalid operation hash'); + const head = await chain('/head'); + if (head.chain !== 'mainnet' || head.chainId !== 'NetXdQprcVkpaWU' || !head.synced || !Number.isSafeInteger(head.level)) throw mintError(503, 'Tezos is still syncing'); + const op = i.operationHash || operationHash; + const query = new URLSearchParams({ sender:i.sender, target:HEN_MINTER, 'parameter.entrypoint':'mint_OBJKT', + 'parameter.metadata':Buffer.from(i.metadataUri).toString('hex'), 'timestamp.ge':new Date(i.createdAt).toISOString(), limit:'100', 'sort.desc':'id' }); + const txs = await chain(op ? `/operations/transactions/${op}` : `/operations/transactions?${query}`); + const tx = txs.find(tx => matchingMint(i, tx)); + if (!tx) { + if (op && txs.length) throw mintError(409, 'That operation is not an applied mint of this artwork'); + return publicState(i); + } + await intents.updateOne({ _id:i._id, status:{ $ne:'minted' } }, { $set:{ status:'confirming', operationHash:tx.hash } }); + if (head.level - tx.level + 1 < 3) return publicState(await lookup(secret)); + const operations = await chain(`/operations/transactions/${tx.hash}`); + if (!operations.length || operations.some(t => t.status !== 'applied')) throw mintError(409, 'The mint operation did not fully apply'); + // A wallet may batch several top-level calls under one operation hash. + // Only transfers caused by this mint's counter identify its token. + const ids = operations.filter(t => t.counter === tx.counter).map(t => t.id).join(','); + const transfers = await chain(`/tokens/transfers?transactionId.in=${ids}&token.contract=${HEN_OBJKTS}&limit=100`); + const minted = transfers.find(t => !t.from && t.to?.address === i.sender && String(t.amount) === String(i.editions) && + t.token?.contract?.address === HEN_OBJKTS && /^\d+$/.test(String(t.token.tokenId))); + if (!minted) return publicState(await lookup(secret)); + await intents.updateOne({ _id:i._id, status:'confirming', operationHash:tx.hash }, + { $set:{ status:'minted', tokenId:String(minted.token.tokenId), mintedAt:now() } }); + return publicState(await lookup(secret)); + }, + }; +} diff --git a/system/backend/whistlegraph-pack-worker.mjs b/system/backend/whistlegraph-pack-worker.mjs new file mode 100644 index 0000000000..188ac1cc3c --- /dev/null +++ b/system/backend/whistlegraph-pack-worker.mjs @@ -0,0 +1,8 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +process.env.AC_SOURCE_DIR = fileURLToPath(new URL('../public/aesthetic.computer', import.meta.url)); +const { createJSPieceBundleFromSource } = await import('../../oven/bundler.mjs'); +const result = await createJSPieceBundleFromSource(`${workerData.code}-v${workerData.version}`, workerData.source, { density:workerData.density }); +const version = execFileSync('git', ['rev-parse', 'HEAD'], { cwd:fileURLToPath(new URL('../../', import.meta.url)), encoding:'utf8' }).trim(); +parentPort.postMessage({ html:result.html, version }); diff --git a/system/backend/whistlegraph-pack.mjs b/system/backend/whistlegraph-pack.mjs new file mode 100644 index 0000000000..8181abec43 --- /dev/null +++ b/system/backend/whistlegraph-pack.mjs @@ -0,0 +1,41 @@ +import { Worker } from 'node:worker_threads'; +import sharp from 'sharp'; +import { mintError } from './whistlegraph-mint.mjs'; + +// One compiler at a time; the HTTP loop remains responsive during minification. +let queue = Promise.resolve(); +export function packWhistlegraph(piece) { + const run = queue.then(() => new Promise((resolve, reject) => { + const worker = new Worker(new URL('./whistlegraph-pack-worker.mjs', import.meta.url), + { workerData:piece, resourceLimits:{ maxOldGenerationSizeMb:384 } }); + const timer = setTimeout(() => { worker.terminate(); reject(new Error('Pack timed out')); }, 150_000); + worker.once('message', result => { clearTimeout(timer); resolve(result); worker.terminate(); }); + worker.once('error', error => { clearTimeout(timer); reject(error); }); + worker.once('exit', code => { clearTimeout(timer); if (code) reject(new Error('Pack worker stopped')); }); + })); + queue = run.catch(() => {}); + return run; +} +export async function normalizeMintCover(encoded) { + if (typeof encoded !== 'string' || encoded.length > 1_400_000 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw mintError(400, 'Invalid artwork cover'); + try { + const input = Buffer.from(encoded, 'base64'); + const image = sharp(input, { limitInputPixels:4_000_000 }); + const info = await image.metadata(); + if (info.format !== 'png' || info.width < 16 || info.height < 16) throw Error('Not a PNG'); + return await image.resize({ width:768, height:768, fit:'inside', withoutEnlargement:true }).png().toBuffer(); + } catch { throw mintError(400, 'The artwork cover could not be read'); } +} +export async function pinMintFile(name, mime, content) { + if (!content.length || content.length > 12_000_000) throw mintError(400, 'Packed artwork is too large'); + const form = new FormData(); + form.append('file', new Blob([content], { type:mime }), name); + const response = await fetch(`${process.env.IPFS_API_URL || 'http://localhost:5001'}/api/v0/add?pin=true&cid-version=0`, + { method:'POST', body:form, signal:AbortSignal.timeout(60_000) }); + if (!response.ok) throw new Error('IPFS pin failed'); + const { Hash:cid } = await response.json(); + if (!/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(cid)) throw new Error('Invalid IPFS response'); + fetch(`${process.env.IPFS_SEEDER_URL || 'http://137.184.237.166:5001'}/api/v0/pin/add?arg=${cid}`, + { method:'POST', signal:AbortSignal.timeout(120_000) }).catch(() => {}); + return `ipfs://${cid}`; +} diff --git a/system/netlify/functions/whistlegraph-mint.mjs b/system/netlify/functions/whistlegraph-mint.mjs new file mode 100644 index 0000000000..3851c5196f --- /dev/null +++ b/system/netlify/functions/whistlegraph-mint.mjs @@ -0,0 +1,62 @@ +import { whistlegraphMints, mintError } from '../../backend/whistlegraph-mint.mjs'; +const headers = { 'Content-Type':'application/json', 'Cache-Control':'no-store', + 'Access-Control-Allow-Origin':'*', 'Access-Control-Allow-Headers':'Authorization, Content-Type', + 'Access-Control-Allow-Methods':'POST, OPTIONS' }; +const reply = (statusCode, body) => ({ statusCode, headers, body:JSON.stringify(body) }); + +export function createHandler({ mints, authorize, handleFor, pilot = false, start = () => {} }) { + return async event => { + if (event.httpMethod === 'OPTIONS') return reply(204, null); + if (event.httpMethod !== 'POST') return reply(405, { error:'POST only' }); + if ((event.body || '').length > 1_500_000) return reply(413, { error:'Request too large' }); + let body; + try { body = JSON.parse(event.body || '{}'); } catch { return reply(400, { error:'Invalid JSON' }); } + if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { error:'Invalid request' }); + try { + const secret = (event.headers?.authorization || event.headers?.Authorization || '').replace(/^Bearer /, ''); + if (body.action === 'create') { + let user; + try { user = await authorize(event.headers || {}); } catch {} + if (!user?.sub) throw mintError(401, 'Sign in to mint your artwork'); + const handle = await handleFor(user.sub); + // First device test; broaden only after wallet + marketplace validation. + if (!pilot || handle !== '@jeffrey') throw mintError(403, 'Whistlegraph minting is being tested'); + const result = await mints.create(user.sub, handle, body); + if (result.status === 'packing') start(body.secret); + return reply(200, result); + } + if (body.action === 'status') { + const result = await mints.status(secret); + if (result.status === 'packing') start(secret); + return reply(200, result); + } + if (body.action === 'bind') return reply(200, await mints.bind(secret, body)); + if (body.action === 'begin') return reply(200, await mints.begin(secret)); + if (body.action === 'cancel') return reply(200, await mints.cancel(secret)); + if (body.action === 'confirm') return reply(200, await mints.confirm(secret, body.operationHash)); + return reply(400, { error:'Unknown action' }); + } catch (error) { + if (!error.status) console.error('Whistlegraph mint failed:', error.name); + return reply(error.status || 503, { error:error.status ? error.message : 'Mint preparation is unavailable. Reopen this preview to check again.' }); + } + }; +} +const running = new Map(); +export async function handler(event) { + if (event.httpMethod !== 'POST' || event.body?.length > 1_500_000) return createHandler({})(event); + const [{ connect }, { authorize, getHandleOrEmail }, { packWhistlegraph, normalizeMintCover, pinMintFile }] = await Promise.all([ + import('../../backend/database.mjs'), import('../../backend/authorization.mjs'), import('../../backend/whistlegraph-pack.mjs'), + ]); + const connection = await connect(); + const mints = whistlegraphMints({ intents:connection.db.collection('whistlegraph-mints'), + threads:connection.db.collection('walkieware-threads'), pack:packWhistlegraph, cover:normalizeMintCover, pin:pinMintFile }); + const start = secret => { + if (running.has(secret) || running.size >= 2) return; + const job = mints.prepare(secret).catch(error => console.error('Whistlegraph pack failed:', error.name)) + .finally(() => running.delete(secret)); + running.set(secret, job); + }; + try { return await createHandler({ mints, authorize, handleFor:getHandleOrEmail, + pilot:process.env.WHISTLEGRAPH_MINT_PILOT === 'true', start })(event); } + finally { await connection.disconnect(); } +} diff --git a/system/public/aesthetic.computer/lib/usb.mjs b/system/public/aesthetic.computer/lib/usb.mjs index 712cee5e32..ee4c4ae9b6 100644 --- a/system/public/aesthetic.computer/lib/usb.mjs +++ b/system/public/aesthetic.computer/lib/usb.mjs @@ -32,6 +32,8 @@ const listUsbDevices = async () => { }; function initialize() { + // Standalone art runs in marketplace sandboxes without USB permission. + if (globalThis.acPACK_MODE) return; // Check if WebUSB is supported and available in this context if (typeof navigator !== 'undefined' && navigator.usb && navigator.usb.getDevices && typeof navigator.usb.getDevices === 'function') { @@ -54,4 +56,3 @@ function initialize() { } export { initialize, connectToUsb }; - diff --git a/system/public/mint/index.html b/system/public/mint/index.html new file mode 100644 index 0000000000..1951dd68c6 --- /dev/null +++ b/system/public/mint/index.html @@ -0,0 +1,26 @@ + + + + + + +Mint Whistlegraph + +
+

Mint on HEN

+

+ +

+

+ + + + +

Preparing the HTML pack…

+ +

Minting publishes this version permanently. Your wallet shows the network fee. The artwork will be public.

+ Return to Whistlegraph +
+ + + diff --git a/system/public/mint/mint.css b/system/public/mint/mint.css new file mode 100644 index 0000000000..1c98fa5b6b --- /dev/null +++ b/system/public/mint/mint.css @@ -0,0 +1,17 @@ +:root { color-scheme:light dark; font:17px system-ui,sans-serif; } +body { margin:0; background:Canvas; color:CanvasText; } +main { max-width:600px; margin:auto; padding:20px 20px 40px; } +h1 { font-size:26px; margin:0 0 12px; } +p { line-height:1.4; overflow-wrap:anywhere; } +iframe { display:block; border:0; width:min(100%, 253px); aspect-ratio:2/3; margin:auto; background:#222; } +iframe[data-aspect="9:16"] { width:min(100%, 214px); aspect-ratio:9/16; } +iframe[data-aspect="1:1"] { width:min(100%, 380px); aspect-ratio:1; } +iframe[data-aspect="4:3"] { width:100%; aspect-ratio:4/3; } +iframe[data-aspect="16:9"] { width:100%; aspect-ratio:16/9; } +button { display:block; width:100%; min-height:50px; margin:16px 0; font:inherit; border-radius:12px; border:1px solid GrayText; } +button:disabled { opacity:.5; } +label { display:block; margin:18px 0; } +input { width:20px; height:20px; vertical-align:middle; } +[hidden] { display:none !important; } +a { color:LinkText; } +#wallet { font:13px ui-monospace,monospace; } diff --git a/system/public/mint/mint.mjs b/system/public/mint/mint.mjs new file mode 100644 index 0000000000..f92f8a6469 --- /dev/null +++ b/system/public/mint/mint.mjs @@ -0,0 +1,103 @@ +const $ = id => document.getElementById(id); +const fragment = location.hash.slice(1); +const secret = /^[a-f0-9]{64}$/.test(fragment) ? fragment : sessionStorage.getItem('whistlegraph-mint'); +if (/^[a-f0-9]{64}$/.test(fragment)) { + sessionStorage.setItem('whistlegraph-mint', fragment); + history.replaceState(null, '', location.pathname); +} +let client, intent, busy = false, polling = false; +const say = text => { $('status').textContent = text; }; +async function api(action, body = {}) { + const response = await fetch('/api/whistlegraph-mint', { method:'POST', + headers:{ 'Content-Type':'application/json', Authorization:`Bearer ${secret}` }, + body:JSON.stringify({ action, ...body }), signal:AbortSignal.timeout(90_000) }); + const state = await response.json(); + if (!response.ok) throw Error(state.error || 'Could not check this mint'); + return state; +} +function show(state) { + intent = { ...intent, ...state }; + $('title').textContent = intent.title; + $('piece').textContent = `${intent.code} · version ${intent.version} · @${intent.handle}`; + $('art').dataset.aspect = intent.aspect || '2:3'; + $('settings').textContent = `${intent.editions} edition${intent.editions === 1 ? '' : 's'} · ${intent.royalties / 10}% royalties · Tezos mainnet`; + if (/^ipfs:\/\/Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(intent.artifactUri || '')) { + const src = 'https://ipfs.io/ipfs/' + intent.artifactUri.slice(7); + if ($('art').src !== src) $('art').src = src; + $('art').hidden = false; + } + $('wallet').textContent = intent.sender || ''; + $('connect').hidden = !['packed','binding'].includes(intent.status); + $('review').hidden = intent.status !== 'ready'; + $('mint').hidden = intent.status !== 'ready'; + $('mint').disabled = busy || !$('checked').checked; + $('check').hidden = !['requested','confirming'].includes(intent.status); + if (intent.status === 'packing') say('Preparing the HTML pack…'); + if (intent.status === 'packed') say('Play the packed version, then connect your wallet.'); + if (intent.status === 'binding') say('Reconnect the same wallet to finish preparing metadata.'); + if (intent.status === 'ready') say('Review this version, then approve minting in your wallet.'); + if (intent.status === 'requested') say('A wallet request is pending. After approving or rejecting it, check here before starting another mint.'); + if (intent.status === 'confirming') say('Mint seen. Waiting for three Tezos confirmations…'); + if (intent.status === 'failed') say(intent.error); + if (intent.status === 'cancelled') say('This preview was discarded. Return to Whistlegraph.'); + if (intent.status === 'minted') { + say(`Minted OBJKT #${intent.tokenId}.`); + if (/^\d+$/.test(intent.tokenId)) { $('token').href = `https://teia.art/objkt/${intent.tokenId}`; $('token').hidden = false; } + } +} +$('checked').onchange = () => { $('mint').disabled = busy || !$('checked').checked; }; +async function wallet() { + if (!client) client = new window.beacon.DAppClient({ name:'Whistlegraph', network:{ type:'mainnet' }, + enableMetrics:false, appUrl:'https://aesthetic.computer/mint/' }); + let account = await client.getActiveAccount(); + if (account && account.network?.type !== 'mainnet') { await client.clearActiveAccount(); account = null; } + if (!account) { await client.requestPermissions({ scopes:['sign','operation_request'] }); account = await client.getActiveAccount(); } + if (!account || account.network?.type !== 'mainnet') throw Error('Choose a Tezos mainnet wallet.'); + return account; +} +async function action(work) { + if (busy) return; + busy = true; + for (const id of ['connect','mint','check']) $(id).disabled = true; + try { await work(); } catch (error) { say(error.message || 'Wallet request interrupted. Check the mint before trying again.'); } + finally { + busy = false; + $('connect').disabled = false; $('check').disabled = false; $('mint').disabled = !$('checked').checked; + } +} +$('connect').onclick = () => action(async () => { + const account = await wallet(); + say('Approve the artwork message in your wallet. This does not mint or move tez.'); + const signed = await client.requestSignPayload({ signingType:'micheline', payload:intent.payload, sourceAddress:account.address }); + show(await api('bind', { address:account.address, publicKey:account.publicKey, signature:signed.signature })); +}); +$('mint').onclick = () => action(async () => { + if (!$('checked').checked || intent.status !== 'ready') return; + const account = await wallet(); + if (account.address !== intent.sender) throw Error('Select the wallet shown on this mint.'); + const begun = await api('begin'); + show(begun); + // The server fixes the reviewed metadata and all contract arguments. No + // automatic retry: a suspended iOS callback might already have minted. + const result = await client.requestOperation({ operationDetails:[begun.operation] }); + sessionStorage.setItem(`whistlegraph-mint-op:${intent.id}`, result.transactionHash); + show(await api('confirm', { operationHash:result.transactionHash })); +}); +async function refresh() { + if (polling || busy) return; + polling = true; + try { + show(await api('status')); + if (['requested','confirming'].includes(intent.status)) { + const operationHash = sessionStorage.getItem(`whistlegraph-mint-op:${intent.id}`); + show(await api('confirm', operationHash ? { operationHash } : {})); + } + } finally { polling = false; } +} +$('check').onclick = () => refresh().catch(error => say(error.message)); +document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh().catch(error => say(error.message)); }); +setInterval(() => { if (!document.hidden && intent && !['minted','failed'].includes(intent.status)) refresh().catch(error => say(error.message)); }, 6000); +try { + if (!secret) throw Error('Open Mint on HEN from your piece in Whistlegraph.'); + await refresh(); +} catch (error) { say(error.message); } diff --git a/system/tests/whistlegraph-mint-browser.mjs b/system/tests/whistlegraph-mint-browser.mjs new file mode 100644 index 0000000000..21454a8884 --- /dev/null +++ b/system/tests/whistlegraph-mint-browser.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {readFile} from 'node:fs/promises'; +import {createRequire} from 'node:module'; +const require=createRequire(new URL('../../oven/package.json',import.meta.url)); +const puppeteer=require('puppeteer'); +const address='tz1inPpZMzFUv5mkmqDMEC8sYxmEq53vxRhw'; +let state={id:'test',code:'wgDefen',version:7,handle:'jeffrey',title:'Spinning tree',editions:1,royalties:150,status:'packed',payload:'0501',artifactUri:'ipfs://Qm'+'a'.repeat(44)}; +const calls=[]; +const server=createServer(async(req,res)=>{ + if(req.url==='/api/whistlegraph-mint') { + let text='';for await(const chunk of req)text+=chunk;const body=JSON.parse(text);calls.push(body.action); + assert.equal(req.headers.authorization,'Bearer '+'a'.repeat(64)); + if(body.action==='bind')state={...state,status:'ready',sender:body.address}; + if(body.action==='begin'){assert.equal(state.status,'ready');state={...state,status:'requested',operation:{kind:'transaction',destination:'minter',amount:'0'}};} + res.setHeader('Content-Type','application/json');res.end(JSON.stringify(state));return; + } + if(req.url==='/braincells/vendor/beacon-sdk.min.js') { + res.setHeader('Content-Type','text/javascript');res.end(`window.walletRequests=0;window.beacon={DAppClient:class{async getActiveAccount(){return {address:'${address}',publicKey:'key',network:{type:'mainnet'}}}async requestSignPayload(){return {signature:'valid'}}async requestOperation(){window.walletRequests++;throw Error('Suspended wallet callback')}}};`);return; + } + try { + const file=req.url==='/mint/'?'index.html':req.url.split('/').pop(); + if(!['index.html','mint.css','mint.mjs'].includes(file))throw Error(); + res.setHeader('Content-Type',file.endsWith('.mjs')?'text/javascript':file.endsWith('.css')?'text/css':'text/html'); + res.end(await readFile(new URL('../public/mint/'+file,import.meta.url))); + }catch{res.writeHead(404).end();} +}); +await new Promise(r=>server.listen(0,'127.0.0.1',r)); +const base='http://127.0.0.1:'+server.address().port; +const browser=await puppeteer.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); +try { + const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.setViewport({width:430,height:900,deviceScaleFactor:1}); + await page.setRequestInterception(true);page.on('request',req=>{ + if(req.url().startsWith('https://ipfs.io/ipfs/'))req.respond({status:200,contentType:'text/html',body:'Packed artwork'}); + else if(req.url().startsWith(base))req.continue();else req.abort(); + }); + await page.goto(base+'/mint/#'+'a'.repeat(64));await page.waitForSelector('#connect:not([hidden])'); + assert.equal(new URL(page.url()).hash,''); + const frame=page.frames().find(f=>f.url().startsWith('https://ipfs.io')); + assert.equal(await frame.evaluate(()=>{try{void top.localStorage;return false;}catch{return true;}}),true); + await page.click('#connect');await page.waitForSelector('#mint:not([hidden])'); + assert.equal(await page.$eval('#mint',e=>e.disabled),true); + await page.click('#checked');assert.equal(await page.$eval('#mint',e=>e.disabled),false); + await page.screenshot({path:'/tmp/whistlegraph-mint-review.png'}); + await page.click('#mint');await page.waitForFunction(()=>document.getElementById('status').textContent==='Suspended wallet callback'); + assert.equal(await page.evaluate(()=>window.walletRequests),1);assert.equal(calls.filter(c=>c==='begin').length,1); + await page.reload();await page.waitForSelector('#check:not([hidden])'); + assert.equal(await page.$eval('#mint',e=>e.hidden),true);assert.equal(calls.filter(c=>c==='begin').length,1); + state={...state,status:'minted',tokenId:'900001'};await page.click('#check');await page.waitForSelector('#token:not([hidden])'); + assert.equal(await page.$eval('#token',e=>e.href),'https://teia.art/objkt/900001'); + assert.deepEqual(errors,[]);console.log('PASS: mobile review, sandbox, wallet gating, interrupted callback, no repeat mint, token receipt'); +}finally{await browser.close();await new Promise(r=>server.close(r));} diff --git a/system/tests/whistlegraph-mint.test.mjs b/system/tests/whistlegraph-mint.test.mjs new file mode 100644 index 0000000000..0ed9ba44ca --- /dev/null +++ b/system/tests/whistlegraph-mint.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { InMemorySigner } from '@taquito/signer'; +import { b58cencode, prefix } from '@taquito/utils'; +import sharp from 'sharp'; +import { whistlegraphMints, hash, mintPayload, verifyMintWallet, matchingMint, mintOperation, HEN_MINTER, HEN_OBJKTS } from '../backend/whistlegraph-mint.mjs'; +import { normalizeMintCover } from '../backend/whistlegraph-pack.mjs'; +import { createHandler } from '../netlify/functions/whistlegraph-mint.mjs'; +function collection() { + const docs = new Map(); + const matches = (doc, filter) => Object.entries(filter).every(([key, value]) => { + if (key === '$or') return value.some(f => matches(doc, f)); + if (value && typeof value === 'object' && !(value instanceof Date)) { + if ('$exists' in value) return (doc[key] !== undefined) === value.$exists; + if ('$gt' in value) return doc[key] > value.$gt; + if ('$ne' in value) return doc[key] !== value.$ne; + if ('$lt' in value) return doc[key] < value.$lt; + } + return doc[key] === value; + }); + return { docs, + async findOne(filter) { const value = [...docs.values()].find(doc => matches(doc, filter)); return value ? structuredClone(value) : null; }, + async countDocuments(filter) { return [...docs.values()].filter(doc => matches(doc, filter)).length; }, + async insertOne(doc) { if (docs.has(doc._id)) throw Object.assign(new Error('duplicate'), { code:11000 }); docs.set(doc._id, structuredClone(doc)); }, + async updateOne(filter, update, opts = {}) { + let doc = [...docs.values()].find(doc => matches(doc, filter)); + if (!doc && opts.upsert) { doc = { _id:filter._id, ...structuredClone(update.$setOnInsert) }; docs.set(doc._id, doc); } + if (!doc) return { modifiedCount:0 }; + Object.assign(doc, structuredClone(update.$set || {})); + for (const key of Object.keys(update.$unset || {})) delete doc[key]; + for (const [key, val] of Object.entries(update.$inc || {})) doc[key] = (doc[key] || 0) + val; + for (const [key, val] of Object.entries(update.$addToSet || {})) if (!doc[key].includes(val)) doc[key].push(val); + return { modifiedCount:1 }; + }, + }; +} +const address = 'tz1inPpZMzFUv5mkmqDMEC8sYxmEq53vxRhw'; +const other = 'tz1gkf8EexComFBJvjtT1zdsisdah791KwBE'; +const operationHash = 'o' + 'a'.repeat(50); +const time = new Date('2026-10-05T23:00:00Z'); +async function fixture() { + const intents = collection(), threads = collection(), pins = [], state = { txs:[], transfers:[], packs:0, + head:{ chain:'mainnet', chainId:'NetXdQprcVkpaWU', synced:true, level:102 } }; + const source = 'export function paint({wipe}) { wipe("red"); }'; + await threads.insertOne({ _id:'piece', owner:'user', code:'wgDefen', codeKey:'wgdefen', ledger:{ head:7, versions:[{ id:7, source }] } }); + const mints = whistlegraphMints({ intents, threads, now:() => time, cover:async () => Buffer.from('cover'), + pack:async () => { state.packs++; return { html:'packed', version:'test' }; }, + pin:async (name, mime, bytes) => { pins.push({ name, mime, bytes }); return 'ipfs://Qm' + hash(bytes).slice(0,44); }, + verify:(_i, p) => p.signature === 'valid', + chain:async path => path === '/head' ? state.head : path.startsWith('/tokens/') ? state.transfers : state.txs }); + const input = { secret:'a'.repeat(64), code:'wgDefen', version:7, sourceHash:hash(source), density:1, + title:'Spinning tree', description:'Test', editions:1, royalties:150, cover:'cover' }; + return { mints, intents, threads, pins, state, input, secret:input.secret }; +} +async function ready() { + const f = await fixture(); + await f.mints.create('user', '@test', f.input); + await f.mints.prepare(f.secret); + await f.mints.bind(f.secret, { address, signature:'valid' }); + return f; +} +async function requested() { + const f = await ready(); await f.mints.begin(f.secret); + const i = [...f.intents.docs.values()][0]; + const tx = { id:123, level:100, counter:41, hash:operationHash, status:'applied', sender:{ address }, target:{ address:HEN_MINTER }, amount:0, + timestamp:'2026-10-05T23:00:01Z', parameter:{ entrypoint:'mint_OBJKT', value:{ address, amount:'1', royalties:'150', metadata:Buffer.from(i.metadataUri).toString('hex') } } }; + const transfer = { from:null, to:{ address }, amount:'1', token:{ contract:{ address:HEN_OBJKTS }, tokenId:'900001' } }; + return { ...f, i, tx, transfer }; +} + +test('only the owner can snapshot a saved exact version; a retry preserves its settings', async () => { + const f = await fixture(); + await assert.rejects(f.mints.create('stranger', '@other', f.input), /Save/); + await assert.rejects(f.mints.create('user', '@test', { ...f.input, sourceHash:'f'.repeat(64) }), /differs/); + for (const patch of [{ code:'../../private' }, { editions:0 }, { editions:1.5 }, { royalties:251 }, { density:0 }, { title:'' }]) { + await assert.rejects(f.mints.create('user', '@test', { ...f.input, ...patch }), /Invalid/); + } + const created = await f.mints.create('user', '@test', f.input); + assert.equal(created.version, 7); assert.equal(created.source, undefined); assert.equal(created.cover, undefined); + assert.equal((await f.mints.create('user', '@test', { ...f.input, title:'Changed' })).title, 'Spinning tree'); + await assert.rejects(f.mints.create('stranger', '@other', f.input), /another account/); + assert.equal(f.intents.docs.size, 1); +}); +test('packing is claimed once, removes temporary source/cover, and pins HTML plus cover', async () => { + const f = await fixture(); await f.mints.create('user', '@test', f.input); + await Promise.all([f.mints.prepare(f.secret), f.mints.prepare(f.secret)]); + assert.equal(f.state.packs, 1); + assert.deepEqual(f.pins.map(p => p.mime), ['text/html','image/png']); + const i = [...f.intents.docs.values()][0]; + assert.equal(i.status, 'packed'); assert.equal(i.source, undefined); assert.equal(i.cover, undefined); + assert.equal(i.artifactHash, hash('packed')); +}); +test('real signed ownership proof binds account, source, version and mint nonce', async () => { + const signer = new InMemorySigner(b58cencode(randomBytes(32), prefix.edsk2)); + const i = { _id:'mint', handle:'test', code:'wgDefen', version:7, sourceHash:'hash', nonce:'nonce' }; + const signature = (await signer.sign(mintPayload(i))).prefixSig; + const proof = { address:await signer.publicKeyHash(), publicKey:await signer.publicKey(), signature }; + assert.equal(verifyMintWallet(i, proof), true); + for (const patch of [{ version:8 }, { sourceHash:'changed' }, { handle:'other' }, { _id:'other' }, { nonce:'other' }]) assert.equal(verifyMintWallet({ ...i, ...patch }, proof), false); +}); +test('wallet ownership is required, metadata immutable and mint dispatch happens once', async () => { + const f = await ready(); + await assert.rejects(f.mints.bind(f.secret, { address:other, signature:'valid' }), /different wallet/); + const metadata = JSON.parse(f.pins.find(p => p.mime === 'application/json').bytes); + assert.equal(metadata.whistlegraph.version, 7); assert.equal(metadata.whistlegraph.sourceHash, f.input.sourceHash); + assert.deepEqual(metadata.creators, [address]); + const first = await f.mints.begin(f.secret); + assert.deepEqual(first.operation, mintOperation([...f.intents.docs.values()][0])); + assert.equal(first.operation.destination, HEN_MINTER); assert.equal(first.operation.amount, '0'); + await assert.rejects(f.mints.begin(f.secret), /already pending/); +}); +test('lost callbacks recover the unique applied mint and wait for chain confirmations and token transfer', async () => { + const f = await requested(); + assert.equal((await f.mints.confirm(f.secret)).status, 'requested'); + f.state.txs = [f.tx]; f.state.head.level = 101; + assert.equal((await f.mints.confirm(f.secret)).status, 'confirming'); + f.state.head.level = 102; + assert.equal((await f.mints.confirm(f.secret)).status, 'confirming'); + f.state.transfers = [f.transfer]; + const result = await f.mints.confirm(f.secret); + assert.equal(result.status, 'minted'); assert.equal(result.tokenId, '900001'); assert.equal(result.operationHash, operationHash); + assert.deepEqual(await f.mints.confirm(f.secret), result); +}); +test('backtracking, another wallet or artwork, wrong contract and internal mints cannot produce success', async () => { + const f = await requested(); + for (const patch of [{ status:'backtracked' }, { sender:{ address:other } }, { target:{ address:HEN_OBJKTS } }, { nonce:0 }, { amount:1 }, + { parameter:{ ...f.tx.parameter, value:{ ...f.tx.parameter.value, metadata:'abcd' } } }, + { parameter:{ ...f.tx.parameter, value:{ ...f.tx.parameter.value, amount:'2' } } }]) { + assert.equal(Boolean(matchingMint(f.i, { ...f.tx, ...patch })), false); + f.state.txs = [{ ...f.tx, ...patch }]; + await assert.rejects(f.mints.confirm(f.secret, operationHash), /not an applied mint/); + } + f.state.txs = [f.tx]; f.state.transfers = [{ ...f.transfer, to:{ address:other } }]; + assert.equal((await f.mints.confirm(f.secret)).status, 'confirming'); + f.state.head.chainId = 'other'; await assert.rejects(f.mints.confirm(f.secret), /syncing/); +}); +test('API create requires AC auth and pilot access; malformed capability stays denied', async () => { + const f = await fixture(); + const event = { httpMethod:'POST', body:JSON.stringify({ ...f.input, action:'create' }), headers:{} }; + const handler = options => createHandler({ mints:f.mints, authorize:async () => ({ sub:'user' }), handleFor:async () => '@jeffrey', pilot:true, ...options }); + assert.equal((await handler({ authorize:async () => null })(event)).statusCode, 401); + assert.equal((await handler({ pilot:false })(event)).statusCode, 403); + assert.equal((await handler({ handleFor:async () => '@other' })(event)).statusCode, 403); + assert.equal((await handler({})(event)).statusCode, 200); + assert.equal((await handler({})({ ...event, body:JSON.stringify({ action:'status' }) })).statusCode, 401); +}); +test('cover validation strips metadata, bounds dimensions, and rejects HTML and oversized input', async () => { + const input = await sharp({ create:{ width:1000, height:500, channels:3, background:'red' } }).png().toBuffer(); + const png = await normalizeMintCover(input.toString('base64')); + const info = await sharp(png).metadata(); assert.equal(info.width, 768); assert.equal(info.height, 384); assert.equal(info.exif, undefined); + await assert.rejects(normalizeMintCover(Buffer.from('').toString('base64')), /could not be read/); + await assert.rejects(normalizeMintCover('x'.repeat(1_400_001)), /Invalid/); +}); +test('discard closes an unsigned preview, but never clears a pending wallet request', async () => { + const f = await ready(); + assert.equal((await f.mints.cancel(f.secret)).status, 'cancelled'); + await assert.rejects(f.mints.begin(f.secret), /pending/); + const pending = await requested(); + await assert.rejects(pending.mints.cancel(pending.secret), /wallet request/); + assert.equal((await pending.mints.status(pending.secret)).status, 'requested'); +});