{"version":3,"file":"credits.mjs","names":[],"sources":["../../../src/astro/middleware/credits.ts"],"sourcesContent":["/**\n * Credit metering + enforcement middleware (self-contained, child-side).\n *\n * A hosted instance meters and enforces its own hosting credits. When\n * `credits:enforce` is set, each mutating content/media request is charged\n * `credits:price_per_write_micros` against the cached balance\n * (`credits:balance_micros`, kept as `-SUM(_emdash_usage.charge_micros)`), and\n * once the balance reaches zero further mutations are refused with 402. Reads,\n * login, settings and the billing/top-up endpoints stay open so the owner can\n * always sign in, see the bill and add credits to lift the block.\n *\n * The hosting parent only seeds the price, enforcement flag and initial balance\n * at provision time and grants top-ups; it does no ongoing metering. Runs after\n * the auth middleware and never touches non-enforced instances beyond one cheap\n * options read on write requests.\n */\n\nimport { defineMiddleware } from \"astro:middleware\";\nimport { sql } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { after } from \"../../after.js\";\nimport { apiError } from \"../../api/error.js\";\nimport { OptionsRepository } from \"../../database/repositories/options.js\";\n\n/** Write methods that consume resources and should be gated. */\nconst WRITE_METHODS = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n\n/**\n * API path prefixes whose writes are metered + gated. Deliberately narrow:\n * content and media mutations are the ones a suspended site must stop doing.\n * Auth, settings, billing and plugin routes are left open on purpose.\n */\nconst GATED_PREFIXES = [\"/_emdash/api/content/\", \"/_emdash/api/media\"];\n\nexport const onRequest = defineMiddleware(async (context, next) => {\n\tconst { request, locals } = context;\n\tconst url = new URL(request.url);\n\n\tif (!WRITE_METHODS.has(request.method)) return next();\n\tif (!GATED_PREFIXES.some((p) => url.pathname.startsWith(p))) return next();\n\n\tconst db = locals.emdash?.db;\n\tif (!db) return next();\n\n\tlet priceMicros = 0;\n\ttry {\n\t\tconst options = new OptionsRepository(db);\n\t\tconst enforce = await options.getOrDefault<boolean>(\"credits:enforce\", false);\n\t\tif (!enforce) return next();\n\t\tconst balance = await options.getOrDefault<number>(\"credits:balance_micros\", 0);\n\t\tif (Number(balance) <= 0) {\n\t\t\treturn apiError(\n\t\t\t\t\"PAYMENT_REQUIRED\",\n\t\t\t\t\"This site is out of hosting credits. Add credits on the Billing page to resume editing.\",\n\t\t\t\t402,\n\t\t\t);\n\t\t}\n\t\tpriceMicros = Math.round(Number(await options.getOrDefault<number>(\"credits:price_per_write_micros\", 0)));\n\t} catch {\n\t\t// Never let a billing read fault take the site down — fail open.\n\t\treturn next();\n\t}\n\n\tconst response = await next();\n\n\t// Meter a successful mutation: append a self-sourced charge row and refresh\n\t// the cached balance. Deferred so it never blocks the response; the\n\t// enforcement read above still blocks a zero-balance site even if a few\n\t// writes race ahead of the deduction landing.\n\tif (priceMicros > 0 && response.status < 400) {\n\t\tafter(async () => {\n\t\t\ttry {\n\t\t\t\tawait sql`\n\t\t\t\t\tINSERT OR IGNORE INTO _emdash_usage\n\t\t\t\t\t\t(id, ts, day, kind, key, quantity, cost_micros, charge_micros, actor_id, meta, source, ref)\n\t\t\t\t\tVALUES (${`u_self_${ulid()}`}, datetime('now'), date('now'), 'operation', 'write', 1, ${priceMicros}, ${priceMicros}, NULL, NULL, 'self', NULL)\n\t\t\t\t`.execute(db);\n\t\t\t\tconst row = await sql<{ charged: number }>`\n\t\t\t\t\tSELECT COALESCE(SUM(charge_micros), 0) AS charged FROM _emdash_usage\n\t\t\t\t`.execute(db);\n\t\t\t\tconst charged = Number(row.rows[0]?.charged ?? 0);\n\t\t\t\tawait new OptionsRepository(db).set(\"credits:balance_micros\", -charged);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"[credits] self-metering write failed:\", error);\n\t\t\t}\n\t\t});\n\t}\n\n\treturn response;\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAM,gBAAgB,IAAI,IAAI;CAAC;CAAQ;CAAO;CAAS;CAAS,CAAC;;;;;;AAOjE,MAAM,iBAAiB,CAAC,yBAAyB,qBAAqB;AAEtE,MAAa,YAAY,iBAAiB,OAAO,SAAS,SAAS;CAClE,MAAM,EAAE,SAAS,WAAW;CAC5B,MAAM,MAAM,IAAI,IAAI,QAAQ,IAAI;AAEhC,KAAI,CAAC,cAAc,IAAI,QAAQ,OAAO,CAAE,QAAO,MAAM;AACrD,KAAI,CAAC,eAAe,MAAM,MAAM,IAAI,SAAS,WAAW,EAAE,CAAC,CAAE,QAAO,MAAM;CAE1E,MAAM,KAAK,OAAO,QAAQ;AAC1B,KAAI,CAAC,GAAI,QAAO,MAAM;CAEtB,IAAI,cAAc;AAClB,KAAI;EACH,MAAM,UAAU,IAAI,kBAAkB,GAAG;AAEzC,MAAI,CADY,MAAM,QAAQ,aAAsB,mBAAmB,MAAM,CAC/D,QAAO,MAAM;EAC3B,MAAM,UAAU,MAAM,QAAQ,aAAqB,0BAA0B,EAAE;AAC/E,MAAI,OAAO,QAAQ,IAAI,EACtB,QAAO,SACN,oBACA,2FACA,IACA;AAEF,gBAAc,KAAK,MAAM,OAAO,MAAM,QAAQ,aAAqB,kCAAkC,EAAE,CAAC,CAAC;SAClG;AAEP,SAAO,MAAM;;CAGd,MAAM,WAAW,MAAM,MAAM;AAM7B,KAAI,cAAc,KAAK,SAAS,SAAS,IACxC,OAAM,YAAY;AACjB,MAAI;AACH,SAAM,GAAG;;;eAGE,UAAU,MAAM,GAAG,2DAA2D,YAAY,IAAI,YAAY;MACnH,QAAQ,GAAG;GACb,MAAM,MAAM,MAAM,GAAwB;;MAExC,QAAQ,GAAG;GACb,MAAM,UAAU,OAAO,IAAI,KAAK,IAAI,WAAW,EAAE;AACjD,SAAM,IAAI,kBAAkB,GAAG,CAAC,IAAI,0BAA0B,CAAC,QAAQ;WAC/D,OAAO;AACf,WAAQ,MAAM,yCAAyC,MAAM;;GAE7D;AAGH,QAAO;EACN"}