/** * Credit metering + enforcement middleware (self-contained, child-side). * * A hosted instance meters and enforces its own hosting credits. When * `credits:enforce` is set, each mutating content/media request is charged * `credits:price_per_write_micros` against the cached balance * (`credits:balance_micros`, kept as `-SUM(_emdash_usage.charge_micros)`), and * once the balance reaches zero further mutations are refused with 402. Reads, * login, settings and the billing/top-up endpoints stay open so the owner can * always sign in, see the bill and add credits to lift the block. * * The hosting parent only seeds the price, enforcement flag and initial balance * at provision time and grants top-ups; it does no ongoing metering. Runs after * the auth middleware and never touches non-enforced instances beyond one cheap * options read on write requests. */ import { defineMiddleware } from "astro:middleware"; import { sql } from "kysely"; import { ulid } from "ulidx"; import { after } from "../../after.js"; import { apiError } from "../../api/error.js"; import { OptionsRepository } from "../../database/repositories/options.js"; /** Write methods that consume resources and should be gated. */ const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** * API path prefixes whose writes are metered + gated. Deliberately narrow: * content and media mutations are the ones a suspended site must stop doing. * Auth, settings, billing and plugin routes are left open on purpose. */ const GATED_PREFIXES = ["/_emdash/api/content/", "/_emdash/api/media"]; export const onRequest = defineMiddleware(async (context, next) => { const { request, locals } = context; const url = new URL(request.url); if (!WRITE_METHODS.has(request.method)) return next(); if (!GATED_PREFIXES.some((p) => url.pathname.startsWith(p))) return next(); const db = locals.emdash?.db; if (!db) return next(); let priceMicros = 0; try { const options = new OptionsRepository(db); const enforce = await options.getOrDefault("credits:enforce", false); if (!enforce) return next(); const balance = await options.getOrDefault("credits:balance_micros", 0); if (Number(balance) <= 0) { return apiError( "PAYMENT_REQUIRED", "This site is out of hosting credits. Add credits on the Billing page to resume editing.", 402, ); } priceMicros = Math.round(Number(await options.getOrDefault("credits:price_per_write_micros", 0))); } catch { // Never let a billing read fault take the site down — fail open. return next(); } const response = await next(); // Meter a successful mutation: append a self-sourced charge row and refresh // the cached balance. Deferred so it never blocks the response; the // enforcement read above still blocks a zero-balance site even if a few // writes race ahead of the deduction landing. if (priceMicros > 0 && response.status < 400) { after(async () => { try { await sql` INSERT OR IGNORE INTO _emdash_usage (id, ts, day, kind, key, quantity, cost_micros, charge_micros, actor_id, meta, source, ref) VALUES (${`u_self_${ulid()}`}, datetime('now'), date('now'), 'operation', 'write', 1, ${priceMicros}, ${priceMicros}, NULL, NULL, 'self', NULL) `.execute(db); const row = await sql<{ charged: number }>` SELECT COALESCE(SUM(charge_micros), 0) AS charged FROM _emdash_usage `.execute(db); const charged = Number(row.rows[0]?.charged ?? 0); await new OptionsRepository(db).set("credits:balance_micros", -charged); } catch (error) { console.error("[credits] self-metering write failed:", error); } }); } return response; });