Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587// keep-update.mjs - Update on-chain metadata for already-minted Keeps// // POST /api/keep-update - Update token metadata on Tezos (streaming SSE)// Requires authentication and admin privileges (for now)//// IMPORTANT: This updates BOTH on-chain token_info AND uploads new off-chain JSON// The "" key must point to updated IPFS JSON for objkt.com to display correctly
import { authorize, hasAdmin } from "../../backend/authorization.mjs";import { connect } from "../../backend/database.mjs";import { loadKidlispPiece } from "../../backend/kidlisp-read.mjs";import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs";import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs";import { stream } from "@netlify/functions";import { TezosToolkit, MichelsonMap } from "@taquito/taquito";import { InMemorySigner } from "@taquito/signer";
const dev = process.env.CONTEXT === "dev";
// Allow self-signed certs in dev modeif (dev) { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";}const NETWORK = process.env.TEZOS_NETWORK || "mainnet";const TZKT_API = NETWORK === "mainnet" ? "https://api.tzkt.io/v1" : `https://api.${NETWORK}.tzkt.io/v1`;const RPC_URL = NETWORK === "mainnet" ? "https://rpc.tzkt.io/mainnet" : "https://rpc.ghostnet.teztnets.com";
// Helper to convert string to bytes (for Tezos metadata)function stringToBytes(str) { return Buffer.from(str, 'utf8').toString('hex');}
// SSE format helperfunction sse(event, data) { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;}
// ─── IPFS Upload (self-hosted Kubo node on lith + oven seeder + public pin) ──// Matches keep-prepare-background.mjs so the on-chain sync path has the same// storage+mirroring guarantees as the prepare pipeline (no Pinata dependency).const IPFS_API = process.env.IPFS_API_URL || "http://localhost:5001";const IPFS_SEEDER_URL = process.env.IPFS_SEEDER_URL || "http://137.184.237.166:5001";const USE_GATEWAY_URLS = process.env.USE_IPFS_GATEWAY_URLS === "true";const IPFS_GATEWAY = process.env.IPFS_GATEWAY || "https://ipfs.aesthetic.computer";
// Public pinning service (IPFS Pinning Service API spec — Filebase, etc.).// Pins the CID we already own, no re-upload, so objkt's indexer and other// gateways have a well-peered secondary to fetch from.const IPFS_PINNING_SERVICE_URL = process.env.IPFS_PINNING_SERVICE_URL || "";const IPFS_PINNING_SERVICE_TOKEN = process.env.IPFS_PINNING_SERVICE_TOKEN || "";
function formatIpfsUri(hash) { return USE_GATEWAY_URLS ? `${IPFS_GATEWAY}/ipfs/${hash}` : `ipfs://${hash}`;}
// Seed content to the oven IPFS node (fire-and-forget for faster gateway propagation)function seedToSecondaryNode(hash) { fetch(`${IPFS_SEEDER_URL}/api/v0/pin/add?arg=${hash}`, { method: "POST", signal: AbortSignal.timeout(120000) }) .then(r => r.ok ? console.log(`🌱 KEEP-UPDATE: seeded ${hash.slice(0, 12)}... to oven`) : null) .catch(() => {}); // Best-effort, don't block pipeline}
// Pin existing CID on a public pinning service (fire-and-forget).function pinToPublicService(hash, name) { if (!IPFS_PINNING_SERVICE_URL || !IPFS_PINNING_SERVICE_TOKEN) return; fetch(`${IPFS_PINNING_SERVICE_URL}/pins`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${IPFS_PINNING_SERVICE_TOKEN}`, }, body: JSON.stringify({ cid: hash, ...(name ? { name } : {}) }), signal: AbortSignal.timeout(30000), }) .then(r => r.ok ? console.log(`📌 KEEP-UPDATE: pinned ${hash.slice(0, 12)}... to public service`) : r.text().then(t => console.warn(`📌 KEEP-UPDATE: public pin ${r.status}: ${t.slice(0, 200)}`))) .catch(() => {}); // Best-effort, don't block pipeline}
// Warm public IPFS gateways so they DHT-fetch and cache the CID, giving// downstream indexers (objkt, tzkt) multiple providers to find. Keeps objkt// from seeing stale metadata when our self-hosted node is the sole provider.const PUBLIC_GATEWAYS = [ "https://ipfs.io", "https://gateway.ipfs.io", "https://dweb.link", "https://nftstorage.link",];function warmPublicGateways(hash) { for (const gw of PUBLIC_GATEWAYS) { fetch(`${gw}/ipfs/${hash}`, { method: "GET", headers: { Range: "bytes=0-0" }, redirect: "follow", signal: AbortSignal.timeout(20000), }).catch(() => {}); // Best-effort, don't block pipeline } console.log(`🔥 KEEP-UPDATE: warming ${hash.slice(0, 12)}... on ${PUBLIC_GATEWAYS.length} public gateways`);}
async function uploadJsonToIPFS(data, name, timeoutMs = 30000) { const content = JSON.stringify(data); const formData = new FormData(); formData.append("file", new Blob([content], { type: "application/json" }), name); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), Math.max(3000, timeoutMs)); try { const res = await fetch(`${IPFS_API}/api/v0/add?pin=true`, { method: "POST", body: formData, signal: controller.signal, }); clearTimeout(timeout); if (!res.ok) throw new Error(`Metadata upload failed: ${res.status}`); const result = await res.json(); seedToSecondaryNode(result.Hash); pinToPublicService(result.Hash, name); warmPublicGateways(result.Hash); return formatIpfsUri(result.Hash); } catch (err) { clearTimeout(timeout); if (err.name === "AbortError") throw new Error(`Metadata upload timed out after ${Math.round(timeoutMs / 1000)}s`); throw err; }}
async function getTezosCredentials() { const { db } = await connect(); const secrets = await db.collection("secrets").findOne({ _id: "tezos-kidlisp" }); if (!secrets) { throw new Error("Tezos KidLisp credentials not found in database"); } return { address: secrets.address, publicKey: secrets.publicKey, privateKey: secrets.privateKey, network: secrets.network, };}
// SSE headersconst headers = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Methods": "POST, OPTIONS",};
export const handler = stream(async (event) => { // Handle CORS preflight if (event.httpMethod === "OPTIONS") { return { statusCode: 200, headers, body: "" }; }
if (event.httpMethod !== "POST") { return { statusCode: 405, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ error: "Method not allowed" }), }; }
// Create readable stream for SSE const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder();
const send = async (eventType, data) => { await writer.write(encoder.encode(sse(eventType, data))); };
let streamClosed = false; const closeStream = async () => { if (!streamClosed) { streamClosed = true; await writer.close(); } };
// Process update (async () => { let database = null; try { // Parse body let body; try { body = JSON.parse(event.body || "{}"); } catch { await send("error", { error: "Invalid JSON body" }); return; }
const { piece, tokenId, artifactUri, thumbnailUri, walletAddress, mode } = body;
if (!piece || tokenId == null || !artifactUri) { await send("error", { error: "Missing required fields: piece, tokenId, artifactUri" }); return; } // mode: "prepare" returns params for client-side wallet signing (preserves artist attribution) // mode: undefined = server-side signing (DEPRECATED - breaks objkt.com "Created" tab) const isPrepareMode = mode === "prepare"; // allowOwnerEdit: Future flag to let token owners sync (with attribution warning) // Contract supports this, but we default to blocking to preserve objkt.com "Created by" const allowOwnerEdit = body.allowOwnerEdit === true;
const pieceName = piece.replace(/^\$/, "");
await send("progress", { stage: "auth", message: "Checking authorization..." });
// Resolve contract address dynamically (respects MongoDB config + env) const CONTRACT_ADDRESS = await getKeepsContractAddress({ network: NETWORK, fallback: LEGACY_KEEPS_CONTRACT }); console.log(`🪙 KEEP-UPDATE: Using contract ${CONTRACT_ADDRESS}`);
// Get piece data from database (Datomic-aware) database = await connect(); const collection = database.db.collection("kidlisp"); const pieceDoc = await loadKidlispPiece(database, pieceName);
if (!pieceDoc) { await send("error", { error: `Piece '$${pieceName}' not found` }); return; }
// Check if wallet is the current on-chain token owner let isOnChainOwner = false; if (walletAddress && tokenId != null) { try { const tokenResponse = await fetch( `${TZKT_API}/tokens/balances?token.contract=${CONTRACT_ADDRESS}&token.tokenId=${tokenId}&balance.gt=0` ); const balances = await tokenResponse.json(); const ownerBalance = balances.find(b => b.account?.address === walletAddress); isOnChainOwner = !!ownerBalance; console.log(`🪙 KEEP-UPDATE: Wallet ${walletAddress} is on-chain owner: ${isOnChainOwner}`); } catch (e) { console.warn("🪙 KEEP-UPDATE: Could not verify on-chain ownership:", e.message); } }
// Fetch the original minter EARLY - we need this for authorization let originalMinter = null; if (tokenId != null) { try { const tzktBase = NETWORK === "mainnet" ? "https://api.tzkt.io" : `https://api.${NETWORK}.tzkt.io`; const tokenUrl = `${tzktBase}/v1/tokens?contract=${CONTRACT_ADDRESS}&tokenId=${tokenId}`; const tokenResponse = await fetch(tokenUrl); if (tokenResponse.ok) { const tokens = await tokenResponse.json(); if (tokens[0]?.firstMinter?.address) { originalMinter = tokens[0].firstMinter.address; console.log(`🪙 KEEP-UPDATE: Original minter from TzKT: ${originalMinter}`); } } } catch (e) { console.warn(`🪙 KEEP-UPDATE: Failed to fetch firstMinter: ${e.message}`); } }
// Check AC auth const user = await authorize(event.headers); // Check if user is the piece owner/creator OR an admin const isAdmin = user ? await hasAdmin(user) : false; const isPieceOwner = user && pieceDoc.user === user.sub; // Check if wallet matches the original minter (CRITICAL for attribution) const isOriginalMinter = walletAddress && originalMinter && walletAddress === originalMinter; // Authorization rules for metadata sync: // 1. Admin: Always allowed (server-side or via AC account) // 2. Original creator/minter: Always allowed (preserves objkt "Created by") // 3. Token owner: Only if allowOwnerEdit flag is set (changes objkt "Created by") // The contract supports owner edits (v3), but we default-block to preserve attribution const canEdit = isAdmin || isOriginalMinter || (isOnChainOwner && allowOwnerEdit); if (!canEdit) { if (isOnChainOwner && !allowOwnerEdit) { await send("error", { error: "Token owners cannot sync metadata (would change objkt.com 'Created by'). Only the original creator can sync. Pass allowOwnerEdit:true to override.", originalMinter: originalMinter, hint: "The contract supports owner edits, but this would reassign the 'Created by' attribution on objkt.com." }); } else if (!user) { await send("error", { error: "Please connect the original creator's wallet to sync metadata" }); } else { await send("error", { error: "Only the original creator can sync metadata to preserve attribution" }); } return; } // Warn if owner is editing (will change attribution) if (isOnChainOwner && !isOriginalMinter && allowOwnerEdit) { console.warn(`🪙 KEEP-UPDATE: Owner ${walletAddress} editing token ${tokenId} - will change objkt attribution!`); await send("progress", { stage: "auth", message: "⚠️ Warning: Owner edit will change objkt.com 'Created by'" }); }
await send("progress", { stage: "auth", message: "✓ Authorized" }); await send("progress", { stage: "load", message: `Loading $${pieceName}...` }); await send("progress", { stage: "load", message: "✓ Piece loaded" }); await send("progress", { stage: "metadata", message: "Building metadata..." });
// Build metadata — match keep-prepare-background.mjs format exactly const tokenName = `$${pieceName}`; const charCount = pieceDoc.source ? pieceDoc.source.length : 0; const tags = ["KidLisp"]; const attributes = [{ name: "Characters", value: String(charCount) }];
await send("progress", { stage: "metadata", message: "✓ Metadata ready" }); await send("progress", { stage: "tezos", message: "Connecting to Tezos..." });
// Set up Tezos client const tezos = new TezosToolkit(RPC_URL); // Only use admin signer for non-prepare mode (deprecated path) let credentials = null; if (!isPrepareMode) { credentials = await getTezosCredentials(); const signer = new InMemorySigner(credentials.privateKey); tezos.setProvider({ signer }); }
await send("progress", { stage: "tezos", message: `✓ Connected to ${NETWORK}` }); await send("progress", { stage: "contract", message: "Loading contract..." });
// Get contract-specific data from database // Use contract-keyed storage: tezos.contracts[CONTRACT_ADDRESS] const contractData = pieceDoc.tezos?.contracts?.[CONTRACT_ADDRESS] || {}; // originalMinter was already fetched during auth check above // Fallback to DB if TzKT lookup failed earlier if (!originalMinter) { originalMinter = contractData.minter || contractData.owner || pieceDoc.tezos?.minter || pieceDoc.tezos?.owner; } // Last resort fallback if (!originalMinter) { originalMinter = credentials?.address; console.warn(`🪙 KEEP-UPDATE: Using kidlisp wallet as fallback minter`); }
// Get the metadataUri from the database (TZIP-16 off-chain JSON) // This is critical - the empty string "" key must point to the metadata JSON // Otherwise objkt won't resolve artist attribution correctly // Try contract-specific first, then legacy flat field let metadataUri = contractData.metadataUri || pieceDoc.tezos?.metadataUri; // If metadataUri not in DB, fetch from on-chain token_metadata bigmap // This preserves the original "" key and prevents breaking objkt attribution if (!metadataUri && tokenId) { try { const tzktBase = NETWORK === "mainnet" ? "https://api.tzkt.io" : `https://api.${NETWORK}.tzkt.io`; const bigmapUrl = `${tzktBase}/v1/contracts/${CONTRACT_ADDRESS}/bigmaps/token_metadata/keys/${tokenId}`; const bigmapResponse = await fetch(bigmapUrl); if (bigmapResponse.ok) { const bigmapData = await bigmapResponse.json(); const emptyKeyHex = bigmapData?.value?.token_info?.[""]; if (emptyKeyHex) { // Decode hex to get the original IPFS URI metadataUri = Buffer.from(emptyKeyHex, 'hex').toString('utf8'); console.log(`🪙 KEEP-UPDATE: Found metadataUri from on-chain: ${metadataUri}`); } } } catch (e) { console.warn(`🪙 KEEP-UPDATE: Failed to fetch metadataUri from TzKT: ${e.message}`); } }
// ═══════════════════════════════════════════════════════════════════ // BUILD AND UPLOAD NEW OFF-CHAIN JSON METADATA // This is CRITICAL - objkt.com reads the "" key to get the full JSON // We must upload updated JSON with new artifactUri/thumbnailUri // ═══════════════════════════════════════════════════════════════════ await send("progress", { stage: "ipfs", message: "Uploading updated metadata to IPFS..." }); // Build complete off-chain metadata JSON (TZIP-21 compliant) const creatorsArray = [originalMinter];
// v4: Preserve 10% royalty to original creator const royalties = { decimals: 4, shares: { [originalMinter]: "1000" // 10% = 1000/10000 basis points } };
const metadataJson = { name: tokenName, description: pieceDoc.source || "A KidLisp piece preserved on Tezos", artifactUri: artifactUri, displayUri: artifactUri, thumbnailUri: thumbnailUri || artifactUri, decimals: 0, symbol: pieceName, creators: creatorsArray, royalties, tags, attributes, formats: [{ uri: artifactUri, mimeType: "text/html", dimensions: { value: "responsive", unit: "viewport" }, }], }; // Upload new metadata JSON to IPFS const newMetadataUri = await uploadJsonToIPFS( metadataJson, `$${pieceName}-metadata-updated.json` ); console.log(`🪙 KEEP-UPDATE: Uploaded new metadata JSON: ${newMetadataUri}`); await send("progress", { stage: "ipfs", message: `✓ Metadata uploaded: ${newMetadataUri.slice(0, 30)}...` });
// Build on-chain token_info — match keep-prepare-background.mjs format const tokenInfo = new MichelsonMap(); tokenInfo.set("", stringToBytes(newMetadataUri)); tokenInfo.set("name", stringToBytes(tokenName)); tokenInfo.set("symbol", stringToBytes(pieceName)); tokenInfo.set("description", stringToBytes(pieceDoc.source || "")); tokenInfo.set("artifactUri", stringToBytes(artifactUri)); tokenInfo.set("displayUri", stringToBytes(artifactUri)); tokenInfo.set("thumbnailUri", stringToBytes(thumbnailUri || artifactUri)); tokenInfo.set("decimals", stringToBytes("0")); tokenInfo.set("creators", stringToBytes(JSON.stringify(creatorsArray))); tokenInfo.set("royalties", stringToBytes(JSON.stringify(royalties))); tokenInfo.set("content_hash", stringToBytes(pieceName)); tokenInfo.set("metadata_uri", stringToBytes(newMetadataUri));
const contract = await tezos.contract.at(CONTRACT_ADDRESS); await send("progress", { stage: "contract", message: "✓ Contract loaded" });
// ═══════════════════════════════════════════════════════════════════ // PREPARE MODE: Return Michelson params for client-side wallet signing // This preserves artist attribution on objkt.com because the original // creator's wallet signs the edit_metadata call, not the admin server. // ═══════════════════════════════════════════════════════════════════ if (isPrepareMode) { await send("progress", { stage: "ready", message: "Ready for wallet signature..." }); // Generate the Michelson params for the contract call const transferParams = contract.methodsObject.edit_metadata({ token_id: parseInt(tokenId), token_info: tokenInfo, }).toTransferParams(); // Update database to clear pendingRebake and store the new metadata URI // (the actual on-chain URIs will be updated after client confirms tx) await collection.updateOne( { code: pieceName }, { $set: { [`tezos.contracts.${CONTRACT_ADDRESS}.pendingMetadataUri`]: newMetadataUri, [`tezos.contracts.${CONTRACT_ADDRESS}.pendingArtifactUri`]: artifactUri, [`tezos.contracts.${CONTRACT_ADDRESS}.pendingThumbnailUri`]: thumbnailUri, }, } ); await send("prepared", { success: true, piece: pieceName, tokenId, contractAddress: CONTRACT_ADDRESS, network: NETWORK, // Send the Michelson-encoded parameters for Beacon wallet michelsonParams: transferParams.parameter, entrypoint: "edit_metadata", artifactUri, thumbnailUri, metadataUri: newMetadataUri, rpcUrl: RPC_URL, }); return; }
// ═══════════════════════════════════════════════════════════════════ // SERVER-SIDE SIGNING (DEPRECATED) // This path breaks artist attribution on objkt.com because the admin // wallet signs the transaction instead of the original creator. // TODO: Remove this once client-side signing is confirmed working. // ═══════════════════════════════════════════════════════════════════ console.warn("🪙 KEEP-UPDATE: Using deprecated server-side signing - this will break objkt attribution!"); await send("progress", { stage: "submit", message: "Submitting transaction..." });
const op = await contract.methodsObject.edit_metadata({ token_id: parseInt(tokenId), token_info: tokenInfo, }).send();
const opHashShort = op.hash.slice(0, 12) + "..."; await send("progress", { stage: "submit", message: `✓ Submitted: ${opHashShort}` }); await send("progress", { stage: "confirm", message: "Waiting for confirmation..." });
await op.confirmation(1);
await send("progress", { stage: "confirm", message: "✓ Confirmed on-chain!" }); await send("progress", { stage: "database", message: "Updating database..." });
// Clear pendingRebake and update on-chain URIs in the record // Use contract-keyed storage: tezos.contracts[CONTRACT_ADDRESS] await collection.updateOne( { code: pieceName }, { $set: { [`tezos.contracts.${CONTRACT_ADDRESS}.artifactUri`]: artifactUri, [`tezos.contracts.${CONTRACT_ADDRESS}.thumbnailUri`]: thumbnailUri, [`tezos.contracts.${CONTRACT_ADDRESS}.metadataUri`]: newMetadataUri, [`tezos.contracts.${CONTRACT_ADDRESS}.lastUpdatedAt`]: new Date(), [`tezos.contracts.${CONTRACT_ADDRESS}.lastUpdateTxHash`]: op.hash, }, $unset: { pendingRebake: "" } } );
await mirrorRecordMint( pieceName, { tokenId: parseInt(tokenId, 10), contractAddress: CONTRACT_ADDRESS, network: NETWORK, txHash: op.hash, artifactUri, thumbnailUri, metadataUri: newMetadataUri, keptAt: new Date(), }, { source: "update_server" }, );
await send("progress", { stage: "database", message: "✓ Database updated" });
const explorerUrl = NETWORK === "mainnet" ? `https://tzkt.io/${op.hash}` : `https://ghostnet.tzkt.io/${op.hash}`;
await send("complete", { success: true, tokenId, opHash: op.hash, artifactUri, thumbnailUri, metadataUri: newMetadataUri, explorerUrl, });
} catch (error) { console.error("🪙 KEEP-UPDATE: Error:", error); let errorMessage = error.message; if (error.message?.includes("METADATA_LOCKED")) { errorMessage = "Token metadata is locked and cannot be updated"; } if (error.message?.includes("FA2_NOT_ADMIN")) { errorMessage = "Not authorized to update this token (FA2_NOT_ADMIN)"; }
try { await send("error", { error: errorMessage }); } catch (e) { // Stream may already be closed } } finally { if (database) { try { await database.disconnect(); } catch (e) { // Ignore disconnect errors } } await closeStream(); } })();
return { statusCode: 200, headers, body: readable };});