// Everything a backend needs from sub3: who the user is (the JWT from /login), what they may use // (GET /v1/access) and which credit packs they paid for (GET /v1/orders). Copy this file into any Node backend. import { createRemoteJWKSet, jwtVerify } from 'jose'; import { creditOrder, ordersSeen, problem, setOrdersSeen } from './ledger.mjs'; // GET a sub3 API path. A 404 keeps its status and code. A 429, a 5xx or a network failure becomes 503: // the answer is unknown, which must never be treated as "not paid". export async function sub3(config, path, params = {}) { const url = new URL(path, config.api); url.search = new URLSearchParams(params); let response; try { response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); } catch { throw problem(503, 'sub3 could not be reached; try again'); } const body = await response.json().catch(() => ({})); if (response.ok) return body; if (response.status === 404) throw Object.assign(problem(404, body.error), { code: body.code }); if (response.status === 429 || response.status >= 500) throw problem(503, 'sub3 could not answer right now; try again'); throw problem(500, `sub3 rejected ${path}: ${body.error}`); // a 400 is a bug in this app } const keys = new Map(); const INVALID = new Set([ 'ERR_JWT_EXPIRED', 'ERR_JWT_CLAIM_VALIDATION_FAILED', 'ERR_JWT_INVALID', 'ERR_JWS_INVALID', 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', 'ERR_JWKS_NO_MATCHING_KEY', 'ERR_JOSE_ALG_NOT_ALLOWED' ]); // Verifies the token from sub3's /login and returns the user's address (lowercase). A bad token is a 401 // (sign in again); failing to fetch sub3's keys is a 503 (keep the user signed in and retry). export async function verifySession(config, token) { if (!keys.has(config.api)) keys.set(config.api, createRemoteJWKSet(new URL('/.well-known/jwks.json', config.api))); try { const { payload } = await jwtVerify(token, keys.get(config.api), { algorithms: ['EdDSA'], issuer: config.site, audience: config.origin }); if (!/^0x[0-9a-fA-F]{40}$/.test(payload.sub ?? '')) throw problem(401, 'Invalid session'); return payload.sub.toLowerCase(); } catch (error) { if (error.status === 401 || INVALID.has(error.code)) { // Say why in your logs: a wrong "aud" means APP_ORIGIN is not the page's exact origin. console.warn(`session rejected: ${error.code ?? ''} ${error.claim ?? ''} ${error.message}`); throw problem(401, 'Sign in again'); } throw problem(503, 'Could not check your session; try again'); } } const cache = new Map(); // GET /v1/access: the one entitlement check. Cached for 30 seconds per user; pass { fresh: true } after a // checkout. A cached answer is also dropped when the membership or its allowance cycle has ended. export async function getAccess(config, user, { fresh = false } = {}) { const now = Math.floor(Date.now() / 1000); const key = `${config.api} ${config.product} ${user}`; const hit = cache.get(key); const m = hit?.access.membership; const ended = m?.active && (m.end <= now || (m.cycle && m.cycle.end <= now)); if (hit && !fresh && !ended && Date.now() - hit.at < 30_000) return hit.access; const access = await sub3(config, '/v1/access', { product: config.product, user }); if (access.user?.toLowerCase() !== user || access.product !== config.product) throw problem(503, 'Unexpected answer from sub3'); if (cache.size > 10_000) cache.clear(); cache.set(key, { at: Date.now(), access }); return access; } const networks = new Map(); async function getNetwork(config) { if (!networks.has(config.api)) networks.set(config.api, await sub3(config, '/v1/network')); return networks.get(config.api); } // Credits a confirmed pack order to `user` exactly once. Anything else (a tier, a lifetime item, an // unconfirmed payment) adds nothing. Returns true if credits were added now. export async function creditPack(config, db, user, order) { if (order.kind !== 'pack' || !order.confirmed) return false; const { chainId, contract } = await getNetwork(config); if ( order.product !== config.product || order.user.toLowerCase() !== user || order.chainId !== chainId || order.contract.toLowerCase() !== contract.toLowerCase() ) throw problem(403, 'This order is not for this product and account'); return creditOrder(db, order); } // Finds every order with `user` as beneficiary (GET /v1/orders, oldest first) and credits its packs once. // Recovers payments whose checkout result never arrived. Unconfirmed orders are picked up on a later run. export async function syncPacks(config, db, user) { let next = ordersSeen(db, user); let offset = next; let pending = 0; for (let pages = 0; pages < 10; pages++) { const page = await sub3(config, '/v1/orders', { product: config.product, user, offset, limit: 100 }); for (const [i, order] of page.orders.entries()) { if (!order.confirmed) pending++; else await creditPack(config, db, user, order); if (order.confirmed && next === offset + i) next++; } offset = Number(page.nextOffset); if (!page.orders.length || offset >= Number(page.total)) break; } setOrdersSeen(db, user, next); return { pending }; } // After a checkout result: never trust it, look the order up. Returns { pending: true } until the payment // is confirmed (ask the browser to retry). Refreshes the cached access either way. export async function afterCheckout(config, db, user, { payer, orderId }) { if (!/^0x[0-9a-fA-F]{40}$/.test(payer) || !/^0x[0-9a-fA-F]{64}$/.test(orderId)) throw problem(400, 'Invalid order'); const order = await sub3(config, `/v1/orders/${payer}/${orderId}`).catch((error) => { if (error.status === 404) return null; // not visible yet: retry, never ask the user to pay again throw error; }); if (order && (order.user.toLowerCase() !== user || order.product !== config.product)) throw problem(403, 'This order is not for this product and account'); if (order?.confirmed) await creditPack(config, db, user, order); await getAccess(config, user, { fresh: true }); return { pending: !order?.confirmed }; }