import { randomBytes, createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { pathToFileURL } from 'node:url'; import { DEVNET_PROGRAM_ID, isAddress, openDevnet } from '../lib/registry.mjs'; import { appPath, siteOrigin } from '../lib/partners.mjs'; import { authenticateChallenge, checkHolderEligibility, findHolderEligibility, issueChallenge, validateRule } from '../tools/partner-verifier.mjs'; const files = new Map([ ['/', ['gate.html', 'text/html; charset=utf-8']], ['/gate.js', ['gate.js', 'text/javascript; charset=utf-8']], ['/gate.css', ['gate.css', 'text/css; charset=utf-8']], ['/browser-wallets.mjs', ['../lib/browser-wallets.mjs', 'text/javascript; charset=utf-8']], ['/wallet-standard.js', [new URL('wallets.js',import.meta.resolve('@wallet-standard/app')).href,'text/javascript; charset=utf-8']], ]); class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } } function send(res, status, payload) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(payload)); } async function readInput(req) { if (req.headers['content-type']?.split(';')[0] !== 'application/json') throw new HttpError(415, 'Send JSON.'); const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > 4096) throw new HttpError(413, 'Request is too large.'); chunks.push(chunk); } try { const data = JSON.parse(Buffer.concat(chunks).toString()); if (data && typeof data === 'object' && !Array.isArray(data)) return data; } catch {} throw new HttpError(400, 'Invalid request.'); } // One authoritative process for the pilot. Restarting it signs everyone out. function expiringStore() { const entries = new Map(); return { get(id) { const item = entries.get(id); if (item && item.expiresAt > Date.now()) return item; entries.delete(id); }, save(id, item) { if (entries.size >= 2048) { for (const [key, row] of entries) if (row.expiresAt <= Date.now()) entries.delete(key); if (entries.size >= 2048) throw new HttpError(429, 'The pilot is busy. Try again shortly.'); } entries.set(id, item); }, consume(id, sessionId, expiry) { const item = this.get(id); return !!item && item.sessionId === sessionId && item.expiresAt === expiry && entries.delete(id); }, delete(id) { entries.delete(id); }, }; } export function createGate({ origin, signInOrigin=origin, path='/access', rule='wallet', address, minimum, studio, holderMessage }, {check, find}) { if (siteOrigin(origin)!==origin) throw Error('Configure the HTTPS origin of your app.'); const base=appPath(path).replace(/\/$/,''); const local = /^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(signInOrigin); if (!local && signInOrigin !== origin) throw Error('The sign-in origin must match the configured app.'); validateRule(rule,address,minimum); if (studio && !/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(studio) && siteOrigin(studio)!==studio) throw Error('Configure a trusted HTTPS Solbound origin or a loopback origin.'); const instance=createHash('sha256').update(origin+base).digest('hex').slice(0,16); const cookie = (local ? '' : '__Host-') + 'solmate_' + instance; const sessions = expiringStore(), challenges = expiringStore(); const args = { origin, signInOrigin, store: challenges }; const newId = () => randomBytes(32).toString('hex'); const setCookie = (res, value, age) => res.setHeader('Set-Cookie', cookie + '=' + value + '; HttpOnly; SameSite=Strict; Path=/; Max-Age=' + age + (local ? '' : '; Secure')); return createServer(async (req, res) => { res.setHeader('Cache-Control', 'private, no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); try { if (req.headers.host !== new URL(signInOrigin).host) throw new HttpError(403, 'Open the configured partner website.'); const path = new URL(req.url, signInOrigin).pathname; if (req.method === 'GET' && path === base) { res.writeHead(308,{Location:base+'/'}); return res.end(); } if (req.method === 'GET' && path.startsWith(base+'/') && files.has(path.slice(base.length))) { const [name, type] = files.get(path.slice(base.length)); const body = await readFile(new URL(name, import.meta.url)); res.writeHead(200, { 'Content-Type': type }); return res.end(body); } if (req.method === 'GET' && path === base + '/settings') return send(res, 200, { origin, rule, address, minimum, studio }); if (req.method !== 'POST' || !path.startsWith(base + '/api/')) throw new HttpError(404, 'Page unavailable.'); if (req.headers.origin !== signInOrigin) throw new HttpError(403, 'Open the configured partner website.'); const data = await readInput(req); const sessionId = (req.headers.cookie || '').split(';').map(s => s.trim()).find(s => s.startsWith(cookie + '='))?.slice(cookie.length + 1); if (path === base + '/api/sign-out') { sessions.delete(sessionId); setCookie(res, '', 0); return send(res, 200, {}); } if (path === base + '/api/challenge') { if (!isAddress(data.delegate)) throw new HttpError(400, 'Connect an everyday Solana wallet.'); const id = newId(); let challenge; try { challenge = await issueChallenge({ ...args, sessionId: id, delegate: data.delegate }); } catch (e) { if (e instanceof HttpError) throw e; throw new HttpError(400, 'Choose a wallet that can sign a message.'); } sessions.delete(sessionId); setCookie(res, id, 180); return send(res, 200, challenge); } if (path === base + '/api/sign-in') { let who; try { who = await authenticateChallenge({ ...args, sessionId, id: data.id, signature: data.signature }); } catch { throw new HttpError(401, 'Sign-in expired or the signature did not match. Sign in again.'); } const id = newId(); sessions.save(id, { delegate: who, expiresAt: Date.now() + 5 * 60_000 }); setCookie(res, id, 300); return send(res, 200, { delegate: who }); } if (path !== base + '/api/holder') throw new HttpError(404, 'Page unavailable.'); const session = sessions.get(sessionId); if (!session) throw new HttpError(401, 'Sign in with your everyday wallet first.'); const identity = { authenticatedDelegate: session.delegate, origin }; let result; if (data.vault === undefined && data.mint === undefined) { result = await find({ ...identity, rule, address, minimum }); } else { const mint = rule === 'collection' ? data.mint : address; if (!isAddress(data.vault) || (rule!=='wallet' && !isAddress(mint))) throw new HttpError(400, 'Check the public vault address and any required mint.'); result = await check({ ...identity, vault: data.vault, rule, minimum, qualifyingMint: mint, ...(rule === 'collection' ? { collection: address } : {}) }); } if (!result.eligible) throw new HttpError(403, result.reason); return send(res, 200, { content: holderMessage || 'Your delegation has unlocked this page.', vault:result.vault, slot: result.slot }); } catch (e) { send(res, e instanceof HttpError ? e.status : 503, { error: e instanceof HttpError ? e.message : 'Verification is unavailable. Please try again shortly.' }); } }); } export function readPilotSettings(env) { const setting = name => env['SOLBOUND_' + name] ?? env['SOLMATE_' + name]; return { origin:siteOrigin(setting('ORIGIN')), path:appPath(setting('PATH')), rpc:setting('RPC') || undefined, rule:setting('RULE'), address:setting('ADDRESS'), minimum:setting('MINIMUM'), studio:setting('STUDIO'), holderMessage:setting('HOLDER_MESSAGE'), }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const port = Number(process.env.PORT || 4174); const {rpc,...config} = readPilotSettings(process.env); const signInOrigin = process.argv.includes('--local') ? 'http://127.0.0.1:' + port : config.origin; const client = openDevnet(DEVNET_PROGRAM_ID, rpc); const server = createGate({ ...config, signInOrigin }, {check: args => checkHolderEligibility(client, args), find: args => findHolderEligibility(client, args)}); server.listen(port, '127.0.0.1', () => console.log('Solbound devnet integration at ' + signInOrigin + config.path.replace(/\/$/,'') + '/')); }