Mint on HEN
+ + + + + + + + +Preparing the HTML pack…
+ View on Teia +Minting publishes this version permanently. Your wallet shows the network fee. The artwork will be public.
+ Return to Whistlegraph +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 @@ + + + + + + +
Preparing the HTML pack…
+ View on Teia +Minting publishes this version permanently. Your wallet shows the network fee. The artwork will be public.
+ Return to Whistlegraph +