// The app's routes, shared by the Node server (server.mjs) and the Cloudflare Worker (../cloudflare): sign-in, // a tier with a monthly allowance and an upgrade, a lifetime item, and credit packs. `db` is the ledger // (sqlite.mjs on Node, ledgerOn in a Durable Object); `header(name)` reads a lowercase request header. import { actionCost, allowances, entitlements, premium } from './config.mjs'; import { balances, finish, problem, reserve } from './ledger.mjs'; import { afterCheckout, getAccess, sub3, syncPacks, verifySession } from './sub3.mjs'; export function createApp(config, db) { let catalog; // The user's state for the page: their access, allowance left in this cycle and purchased credits. // `sub3` is sub3's own answer (GET /v1/access), returned so the page can show where each number comes from. async function account(user, { fresh = false } = {}) { const access = await getAccess(config, user, { fresh }); const can = entitlements(access); // balances() replaces the tier's allowance with what is left of it in this cycle. return { user, ...can, ...balances(db, user, can.cycle, can.allowance), sub3: access }; } return async function route({ method, path, header, body }) { // Domain verification: served by your site, it lets sub3 show your logo and a verified ✓ at checkout // (set the merchant's website to this site: node sub3.mjs merchant set --url https://…). if (method === 'GET' && path === '/.well-known/sub3.json') return { merchant: config.product.split('/')[0], logo: '/logo.svg' }; if (method === 'GET' && path === '/api/config') { if (!catalog || Date.now() - catalog.at > 60_000) catalog = { at: Date.now(), product: await sub3(config, `/v1/products/${config.product}`) }; const items = catalog.product.items.filter((item) => item.active); return { site: config.site, environment: config.environment, product: config.product, items, premium, // This app's own rules (config.mjs), shown on the page; sub3 knows nothing about them. rules: { allowances: Object.fromEntries( Object.entries(allowances).map(([tier, n]) => [tier, n.toString()]) ), actionCost: actionCost.toString(), premium } }; } const token = /^Bearer (\S+)$/i.exec(header('authorization') ?? '')?.[1]; if (!token) throw problem(401, 'Sign in first'); const user = await verifySession(config, token); if (method === 'GET' && path === '/api/account') return account(user); if (method !== 'POST') throw problem(404, 'Not found'); if (header('origin') && header('origin') !== config.origin) throw problem(403, 'Wrong origin'); // After sign-in, after a checkout and on "Refresh": re-read sub3 and credit any paid packs. if (path === '/api/refresh') { const { pending } = body.order ? await afterCheckout(config, db, user, body.order) : { pending: false }; const packs = await syncPacks(config, db, user); return { ...(await account(user, { fresh: true })), pending, waiting: packs.pending }; } if (path === '/api/run') { const can = entitlements(await getAccess(config, user)); if (body.premium === true && !can.premium) throw problem( 403, `The premium feature needs a tier of level ${premium.level} or higher, or the lifetime item "${premium.lifetime}"` ); if (typeof body.text !== 'string' || body.text.length > 1000) throw problem(400, 'Send text'); const id = header('idempotency-key'); if (!/^[\w-]{8,100}$/.test(id ?? '')) throw problem(400, 'Send an Idempotency-Key header'); const input = { text: body.text, premium: body.premium === true, fail: body.fail === true }; const op = reserve(db, user, id, input, actionCost, can.cycle, can.allowance); if (!op.fresh) { if (op.state === 'reserved') throw problem(409, 'This action is still running'); return op.result; } // Your paid work goes here. Pass `id` on to providers that accept an idempotency key. if (input.fail) { finish(db, user, id, { error: 'The action failed; nothing was charged' }, { failed: true }); throw problem(422, 'The action failed; nothing was charged'); } const result = { text: input.text.toUpperCase(), id, charged: op.charged }; finish(db, user, id, result); return result; } throw problem(404, 'Not found'); }; }