@hasna/economy 0.2.41 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -573,6 +573,32 @@ function getDbPath() {
573
573
  return process.env["ECONOMY_DB"];
574
574
  return join(getDataDir(), "economy.db");
575
575
  }
576
+ function isSqliteBusyError(error) {
577
+ const candidate = error;
578
+ const code = typeof candidate.code === "string" ? candidate.code : "";
579
+ const message = typeof candidate.message === "string" ? candidate.message : String(error);
580
+ return code === "SQLITE_BUSY" || code === "SQLITE_BUSY_RECOVERY" || /database is locked|SQLITE_BUSY/i.test(message);
581
+ }
582
+ function retryDelayMs(attempt) {
583
+ return Math.min(1000, 50 * 2 ** attempt);
584
+ }
585
+ function withSqliteBusyRetry(operation, context) {
586
+ const maxAttempts = 8;
587
+ let lastError;
588
+ for (let attempt = 0;attempt < maxAttempts; attempt++) {
589
+ try {
590
+ return operation();
591
+ } catch (error) {
592
+ if (!isSqliteBusyError(error))
593
+ throw error;
594
+ lastError = error;
595
+ if (attempt === maxAttempts - 1)
596
+ break;
597
+ Bun.sleepSync(retryDelayMs(attempt));
598
+ }
599
+ }
600
+ throw new Error(`SQLite database is locked after ${maxAttempts} attempts while ${context}. Another economy sync/merge may be recovering the database; retry shortly.`, { cause: lastError });
601
+ }
576
602
  function openDatabase(dbPath, skipSeed = false) {
577
603
  const path = dbPath ?? getDbPath();
578
604
  if (path !== ":memory:") {
@@ -580,11 +606,21 @@ function openDatabase(dbPath, skipSeed = false) {
580
606
  if (dir && !existsSync(dir))
581
607
  mkdirSync(dir, { recursive: true });
582
608
  }
583
- const db = new Database(path);
584
- db.exec("PRAGMA journal_mode = WAL");
585
- db.exec("PRAGMA busy_timeout = 5000");
586
- db.exec("PRAGMA foreign_keys = ON");
587
- initSchema(db);
609
+ const db = withSqliteBusyRetry(() => {
610
+ const opened = new Database(path);
611
+ try {
612
+ opened.exec("PRAGMA busy_timeout = 10000");
613
+ opened.exec("PRAGMA journal_mode = WAL");
614
+ opened.exec("PRAGMA foreign_keys = ON");
615
+ initSchema(opened);
616
+ return opened;
617
+ } catch (error) {
618
+ try {
619
+ opened.close();
620
+ } catch {}
621
+ throw error;
622
+ }
623
+ }, `opening ${path}`);
588
624
  if (!skipSeed) {
589
625
  Promise.resolve().then(() => (init_pricing(), exports_pricing)).then(({ ensurePricingSeeded: ensurePricingSeeded2 }) => ensurePricingSeeded2(db)).catch(() => {});
590
626
  }
@@ -946,36 +982,50 @@ function querySessions(db, filter = {}) {
946
982
  SELECT * FROM sessions ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?
947
983
  `).all(...params, limit, offset);
948
984
  }
949
- function queryTopSessions(db, n = 10, agent) {
985
+ function queryTopSessions(db, n = 10, agent, since) {
986
+ const conditions = [];
987
+ const params = [];
950
988
  if (agent) {
951
- return db.prepare(`SELECT * FROM sessions WHERE agent = ? ORDER BY total_cost_usd DESC LIMIT ?`).all(agent, n);
989
+ conditions.push("agent = ?");
990
+ params.push(agent);
952
991
  }
953
- return db.prepare(`SELECT * FROM sessions ORDER BY total_cost_usd DESC LIMIT ?`).all(n);
992
+ if (since) {
993
+ conditions.push("started_at >= ?");
994
+ params.push(since);
995
+ }
996
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
997
+ return db.prepare(`SELECT * FROM sessions ${where} ORDER BY total_cost_usd DESC LIMIT ?`).all(...params, n);
954
998
  }
955
- function querySummary(db, period, machine, allMachines = false) {
999
+ function querySummary(db, period, machine, allMachines = false, agent) {
956
1000
  const rWhere = periodWhere(period);
957
1001
  const sWhere = sessionPeriodWhere(period);
958
- const machineClause = !allMachines && machine ? ` AND machine_id = '${machine.replace(/'/g, "''")}'` : "";
1002
+ const machineClause = !allMachines && machine ? " AND machine_id = ?" : "";
1003
+ const agentClause = agent ? " AND agent = ?" : "";
1004
+ const params = [];
1005
+ if (!allMachines && machine)
1006
+ params.push(machine);
1007
+ if (agent)
1008
+ params.push(agent);
959
1009
  const r = db.prepare(`
960
1010
  SELECT COALESCE(SUM(cost_usd), 0) as total_usd,
961
1011
  COUNT(*) as requests,
962
1012
  COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
963
- FROM requests WHERE ${rWhere}${machineClause}
964
- `).get();
1013
+ FROM requests WHERE ${rWhere}${machineClause}${agentClause}
1014
+ `).get(...params);
965
1015
  const codexTotals = db.prepare(`
966
1016
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
967
1017
  COALESCE(SUM(total_tokens), 0) as tokens,
968
1018
  COALESCE(SUM(request_count), 0) as requests,
969
1019
  COUNT(*) as sessions
970
1020
  FROM sessions
971
- WHERE ${sWhere}${machineClause}
1021
+ WHERE ${sWhere}${machineClause}${agentClause}
972
1022
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
973
- `).get();
1023
+ `).get(...params);
974
1024
  const requestSessionCount = db.prepare(`
975
1025
  SELECT COUNT(DISTINCT session_id) as sessions
976
1026
  FROM requests
977
- WHERE ${rWhere}${machineClause}
978
- `).get();
1027
+ WHERE ${rWhere}${machineClause}${agentClause}
1028
+ `).get(...params);
979
1029
  return {
980
1030
  total_usd: r.total_usd + codexTotals.cost_usd,
981
1031
  requests: r.requests + codexTotals.requests,
@@ -1797,6 +1847,173 @@ var init_pg_migrations = __esm(() => {
1797
1847
  ];
1798
1848
  });
1799
1849
 
1850
+ // src/db/sync-pg.ts
1851
+ import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads";
1852
+ import { fileURLToPath } from "url";
1853
+ import { dirname, join as join9 } from "path";
1854
+ import { existsSync as existsSync10 } from "fs";
1855
+ import { translateSql, translateParams } from "@hasna/cloud";
1856
+ function translateSqliteDates(sql) {
1857
+ let s = sql;
1858
+ const isoDate = (expr) => `to_char(${expr}, 'YYYY-MM-DD')`;
1859
+ s = s.replace(/DATE\('now',\s*'weekday 0',\s*'-7 days'\)/gi, isoDate("(date_trunc('week', now() + interval '1 day') - interval '1 day')"));
1860
+ s = s.replace(/DATE\('now',\s*'start of month'\)/gi, isoDate("date_trunc('month', now())"));
1861
+ s = s.replace(/DATE\('now',\s*'start of year'\)/gi, isoDate("date_trunc('year', now())"));
1862
+ s = s.replace(/DATE\('now',\s*'(-?\d+)\s*days?'\)/gi, (_m, n) => isoDate(`now() + interval '${n} days'`));
1863
+ s = s.replace(/DATE\('now',\s*(\$\d+)\s*\|\|\s*' days'\)/gi, (_m, p) => isoDate(`now() + ((${p}) || ' days')::interval`));
1864
+ s = s.replace(/DATETIME\('now',\s*(\$\d+)\)/gi, (_m, p) => `(now() + (${p})::interval)`);
1865
+ s = s.replace(/DATETIME\('now',\s*'([^']+)'\)/gi, (_m, lit) => `(now() + interval '${lit}')`);
1866
+ const strftimeMap = { "%H": "HH24", "%Y": "YYYY", "%m": "MM", "%d": "DD", "%M": "MI", "%S": "SS" };
1867
+ s = s.replace(/STRFTIME\('([^']+)',\s*([A-Za-z_][A-Za-z0-9_]*)\)/gi, (_m, fmt, col) => {
1868
+ const pgFmt = Object.entries(strftimeMap).reduce((acc, [k, v]) => acc.split(k).join(v), fmt);
1869
+ return `to_char(CAST("${col}" AS timestamptz), '${pgFmt}')`;
1870
+ });
1871
+ s = s.replace(/\bDATE\('now'\)/gi, isoDate("now()"));
1872
+ s = s.replace(/\bDATETIME\('now'\)/gi, "now()");
1873
+ s = s.replace(/\bDATETIME\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `CAST("${col}" AS timestamptz)`);
1874
+ s = s.replace(/\bDATE\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `substr("${col}", 1, 10)`);
1875
+ return s;
1876
+ }
1877
+ function resolveWorkerPath() {
1878
+ const here = dirname(fileURLToPath(import.meta.url));
1879
+ const candidates = [
1880
+ join9(here, "pg-sync-worker.ts"),
1881
+ join9(here, "pg-sync-worker.js"),
1882
+ join9(here, "..", "db", "pg-sync-worker.js")
1883
+ ];
1884
+ for (const candidate of candidates)
1885
+ if (existsSync10(candidate))
1886
+ return candidate;
1887
+ return candidates[0];
1888
+ }
1889
+
1890
+ class SyncPgAdapter {
1891
+ worker;
1892
+ port;
1893
+ counter = 0;
1894
+ constructor(dsn) {
1895
+ const workerPath = resolveWorkerPath();
1896
+ this.worker = new Worker(workerPath, { workerData: { dsn } });
1897
+ this.worker.on("error", (err) => console.error(JSON.stringify({ evt: "pg_worker_error", message: err.message })));
1898
+ const channel = new MessageChannel;
1899
+ this.port = channel.port1;
1900
+ this.worker.postMessage({ type: "init", port: channel.port2 }, [channel.port2]);
1901
+ }
1902
+ query(sql, params) {
1903
+ const pgSql = translateSqliteDates(translateSql(sql, "pg"));
1904
+ const pgParams = translateParams(params);
1905
+ const id = ++this.counter;
1906
+ const signal = new SharedArrayBuffer(4);
1907
+ const sig = new Int32Array(signal);
1908
+ Atomics.store(sig, 0, 0);
1909
+ this.worker.postMessage({ id, sql: pgSql, params: pgParams, signal });
1910
+ Atomics.wait(sig, 0, 0);
1911
+ let received = receiveMessageOnPort(this.port);
1912
+ for (let attempt = 0;!received && attempt < 1e6; attempt++)
1913
+ received = receiveMessageOnPort(this.port);
1914
+ if (!received)
1915
+ throw new Error("SyncPgAdapter: no reply from worker");
1916
+ const reply = received.message;
1917
+ if (!reply.ok)
1918
+ throw new Error(reply.error);
1919
+ return { rows: reply.rows, rowCount: reply.rowCount };
1920
+ }
1921
+ run(sql, ...params) {
1922
+ const result = this.query(sql, params);
1923
+ return { changes: result.rowCount, lastInsertRowid: result.rows?.[0]?.["id"] ?? 0 };
1924
+ }
1925
+ get(sql, ...params) {
1926
+ return this.query(sql, params).rows[0] ?? null;
1927
+ }
1928
+ all(sql, ...params) {
1929
+ return this.query(sql, params).rows;
1930
+ }
1931
+ exec(sql) {
1932
+ this.query(sql, []);
1933
+ }
1934
+ prepare(sql) {
1935
+ const self = this;
1936
+ return {
1937
+ run: (...params) => self.run(sql, ...params),
1938
+ get: (...params) => self.get(sql, ...params),
1939
+ all: (...params) => self.all(sql, ...params),
1940
+ finalize: () => {}
1941
+ };
1942
+ }
1943
+ transaction(fn) {
1944
+ return fn();
1945
+ }
1946
+ close() {
1947
+ this.worker.terminate();
1948
+ }
1949
+ }
1950
+ var init_sync_pg = () => {};
1951
+
1952
+ // src/db/cloud.ts
1953
+ var exports_cloud = {};
1954
+ __export(exports_cloud, {
1955
+ resolveSigningSecret: () => resolveSigningSecret,
1956
+ openCloudDatabase: () => openCloudDatabase,
1957
+ isCloudMode: () => isCloudMode,
1958
+ getCloudDatabaseUrl: () => getCloudDatabaseUrl2,
1959
+ createCloudPool: () => createCloudPool,
1960
+ cloudMigrations: () => cloudMigrations,
1961
+ authClientFromPool: () => authClientFromPool
1962
+ });
1963
+ import pg from "pg";
1964
+ function getCloudDatabaseUrl2() {
1965
+ return process.env["HASNA_ECONOMY_DATABASE_URL"]?.trim() || process.env["ECONOMY_DATABASE_URL"]?.trim() || process.env["DATABASE_URL"]?.trim() || undefined;
1966
+ }
1967
+ function isCloudMode() {
1968
+ const mode = process.env["HASNA_ECONOMY_STORAGE_MODE"]?.trim().toLowerCase();
1969
+ if (mode === "cloud")
1970
+ return true;
1971
+ if (mode === "local")
1972
+ return false;
1973
+ return Boolean(getCloudDatabaseUrl2());
1974
+ }
1975
+ function resolveSigningSecret() {
1976
+ return process.env["HASNA_ECONOMY_API_SIGNING_KEY"]?.trim() || process.env["HASNA_API_SIGNING_KEY"]?.trim() || process.env["API_KEY_SIGNING_SECRET"]?.trim() || undefined;
1977
+ }
1978
+ function openCloudDatabase(dsn = getCloudDatabaseUrl2()) {
1979
+ if (!dsn) {
1980
+ throw new Error("economy cloud mode requires a Postgres DSN: set HASNA_ECONOMY_DATABASE_URL (or ECONOMY_DATABASE_URL / DATABASE_URL)");
1981
+ }
1982
+ return new SyncPgAdapter(dsn);
1983
+ }
1984
+ function createCloudPool(dsn = getCloudDatabaseUrl2()) {
1985
+ if (!dsn)
1986
+ throw new Error("economy cloud mode requires a Postgres DSN (HASNA_ECONOMY_DATABASE_URL)");
1987
+ const ssl = dsn.includes("sslmode=require") || dsn.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
1988
+ const pool = new pg.Pool({ connectionString: dsn, ssl, max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"), connectionTimeoutMillis: 1e4 });
1989
+ pool.on("error", (err) => {
1990
+ console.error(JSON.stringify({ evt: "pg_pool_error", message: err.message }));
1991
+ });
1992
+ return pool;
1993
+ }
1994
+ function authClientFromPool(pool) {
1995
+ return {
1996
+ async many(sql, params = []) {
1997
+ const res = await pool.query(sql, params);
1998
+ return res.rows;
1999
+ },
2000
+ async get(sql, params = []) {
2001
+ const res = await pool.query(sql, params);
2002
+ return res.rows[0] ?? null;
2003
+ },
2004
+ async execute(sql, params = []) {
2005
+ await pool.query(sql, params);
2006
+ }
2007
+ };
2008
+ }
2009
+ function cloudMigrations() {
2010
+ return PG_MIGRATIONS;
2011
+ }
2012
+ var init_cloud = __esm(() => {
2013
+ init_sync_pg();
2014
+ init_pg_migrations();
2015
+ });
2016
+
1800
2017
  // src/ingest/billing.ts
1801
2018
  var exports_billing = {};
1802
2019
  __export(exports_billing, {
@@ -2093,15 +2310,15 @@ var init_open_projects = __esm(() => {
2093
2310
  });
2094
2311
 
2095
2312
  // src/lib/config.ts
2096
- import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
2097
- import { dirname, join as join9 } from "path";
2313
+ import { existsSync as existsSync11, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
2314
+ import { dirname as dirname2, join as join10 } from "path";
2098
2315
  function getConfigPath() {
2099
- return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join9(getDataDir(), "config.json");
2316
+ return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join10(getDataDir(), "config.json");
2100
2317
  }
2101
2318
  function loadConfig() {
2102
2319
  try {
2103
2320
  const configPath = getConfigPath();
2104
- if (existsSync10(configPath)) {
2321
+ if (existsSync11(configPath)) {
2105
2322
  const raw = readFileSync10(configPath, "utf-8");
2106
2323
  return { ...DEFAULTS, ...JSON.parse(raw) };
2107
2324
  }
@@ -4150,8 +4367,112 @@ function isAuthorizedRequest(req, path) {
4150
4367
 
4151
4368
  // src/server/serve.ts
4152
4369
  import { randomUUID } from "crypto";
4153
- import { existsSync as existsSync11 } from "fs";
4370
+ import { existsSync as existsSync12 } from "fs";
4154
4371
  import { resolve, sep } from "path";
4372
+ init_cloud();
4373
+ import { verifyApiKey, ApiKeyStore } from "@hasna/contracts/auth";
4374
+
4375
+ // src/openapi.ts
4376
+ var openApiSpec = {
4377
+ openapi: "3.0.3",
4378
+ info: {
4379
+ title: "@hasna/economy self-hosted API",
4380
+ version: "0.0.0",
4381
+ description: "AI coding cost tracker \u2014 self-hosted control plane. Cloud mode is PURE REMOTE per Amendment A1: the serve process reads/writes the shared RDS Postgres directly with API-key auth via @hasna/contracts. All /v1 routes require a valid `economy:*` scoped API key (x-api-key or Authorization: Bearer). The foundation probes /health, /ready, /version are open."
4382
+ },
4383
+ servers: [{ url: "https://economy.hasna.xyz", description: "prod" }, { url: "http://localhost:3456", description: "local" }],
4384
+ security: [{ apiKey: [] }, { bearer: [] }],
4385
+ components: {
4386
+ securitySchemes: {
4387
+ apiKey: { type: "apiKey", in: "header", name: "x-api-key" },
4388
+ bearer: { type: "http", scheme: "bearer" }
4389
+ },
4390
+ schemas: {
4391
+ Envelope: { type: "object", properties: { data: {}, meta: { type: "object", additionalProperties: true } }, required: ["data"] },
4392
+ Foundation: { type: "object", properties: { status: { type: "string" }, version: { type: "string" }, mode: { type: "string" }, service: { type: "string" } }, required: ["status", "version", "mode"] },
4393
+ Error: { type: "object", properties: { error: { type: "string" }, message: { type: "string" } }, required: ["error"] },
4394
+ MutationOk: { type: "object", properties: { ok: { type: "boolean" } } },
4395
+ CostSummary: { type: "object", properties: { total_cost_usd: { type: "number" }, total_tokens: { type: "number" }, request_count: { type: "number" }, session_count: { type: "number" }, period: { type: "string" } }, additionalProperties: true },
4396
+ Session: { type: "object", properties: { id: { type: "string" }, agent: { type: "string" }, project_path: { type: "string" }, project_name: { type: "string" }, started_at: { type: "string" }, ended_at: { type: "string", nullable: true }, total_cost_usd: { type: "number" }, total_tokens: { type: "number" }, request_count: { type: "number" } }, additionalProperties: true },
4397
+ ModelBreakdown: { type: "object", additionalProperties: true },
4398
+ ProjectBreakdown: { type: "object", additionalProperties: true },
4399
+ AgentBreakdown: { type: "object", additionalProperties: true },
4400
+ AccountBreakdown: { type: "object", additionalProperties: true },
4401
+ DailyPoint: { type: "object", additionalProperties: true },
4402
+ BudgetStatus: { type: "object", properties: { id: { type: "string" }, limit_usd: { type: "number" }, period: { type: "string" }, spent_usd: { type: "number" } }, additionalProperties: true },
4403
+ CreateBudgetInput: { type: "object", properties: { limit_usd: { type: "number" }, alert_at_percent: { type: "number" }, period: { type: "string", enum: ["daily", "weekly", "monthly", "day", "week", "month"] }, project_path: { type: "string", nullable: true }, agent: { type: "string", nullable: true } }, required: ["limit_usd"] },
4404
+ GoalStatus: { type: "object", additionalProperties: true },
4405
+ CreateGoalInput: { type: "object", properties: { period: { type: "string", enum: ["day", "week", "month", "year"] }, limit_usd: { type: "number" }, project_path: { type: "string", nullable: true }, agent: { type: "string", nullable: true } }, required: ["limit_usd"] },
4406
+ ModelPricing: { type: "object", additionalProperties: true },
4407
+ CreatePricingInput: { type: "object", properties: { model: { type: "string" }, input_per_1m: { type: "number" }, output_per_1m: { type: "number" } }, required: ["model", "input_per_1m", "output_per_1m"] },
4408
+ Subscription: { type: "object", additionalProperties: true },
4409
+ CreateSubscriptionInput: { type: "object", properties: { provider: { type: "string" }, plan: { type: "string" }, monthly_fee_usd: { type: "number" }, included_usage_usd: { type: "number" }, agent: { type: "string", nullable: true } }, required: ["provider", "plan"] },
4410
+ Project: { type: "object", additionalProperties: true },
4411
+ CreateProjectInput: { type: "object", properties: { path: { type: "string" }, name: { type: "string" }, description: { type: "string" }, tags: { type: "array", items: { type: "string" } } }, required: ["path"] },
4412
+ MachineInfo: { type: "object", additionalProperties: true },
4413
+ BillingSummary: { type: "object", additionalProperties: true }
4414
+ }
4415
+ },
4416
+ paths: {
4417
+ "/health": { get: { operationId: "health", summary: "Liveness probe", security: [], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Foundation" } } } } } } },
4418
+ "/ready": { get: { operationId: "ready", summary: "Readiness probe (storage reachable + migrated)", security: [], responses: { "200": { description: "ready", content: { "application/json": { schema: { $ref: "#/components/schemas/Foundation" } } } }, "503": { description: "not ready", content: { "application/json": { schema: { $ref: "#/components/schemas/Foundation" } } } } } } },
4419
+ "/version": { get: { operationId: "version", summary: "Version probe", security: [], responses: { "200": { description: "version", content: { "application/json": { schema: { $ref: "#/components/schemas/Foundation" } } } } } } },
4420
+ "/v1/summary": { get: { operationId: "getSummary", summary: "Cost summary for a period", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/CostSummary" } } } } } } } } },
4421
+ "/v1/sessions": { get: { operationId: "listSessions", summary: "List sessions", parameters: [{ name: "agent", in: "query", schema: { type: "string" } }, { name: "project", in: "query", schema: { type: "string" } }, { name: "search", in: "query", schema: { type: "string" } }, { name: "machine", in: "query", schema: { type: "string" } }, { name: "account", in: "query", schema: { type: "string" } }, { name: "limit", in: "query", schema: { type: "integer" } }, { name: "offset", in: "query", schema: { type: "integer" } }, { name: "since", in: "query", schema: { type: "string" } }, { name: "fields", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/Session" } } } } } } } } } },
4422
+ "/v1/sessions/{id}/requests": { get: { operationId: "getSessionRequests", summary: "Requests for a session", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } }, "404": { description: "not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } } } },
4423
+ "/v1/top": { get: { operationId: "topSessions", summary: "Top sessions by cost", parameters: [{ name: "n", in: "query", schema: { type: "integer" } }, { name: "agent", in: "query", schema: { type: "string" } }, { name: "since", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/Session" } } } } } } } } } },
4424
+ "/v1/models": { get: { operationId: "modelBreakdown", summary: "Cost by model", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/ModelBreakdown" } } } } } } } } } },
4425
+ "/v1/projects": { get: { operationId: "projectBreakdown", summary: "Cost by project", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/ProjectBreakdown" } } } } } } } } } },
4426
+ "/v1/accounts": { get: { operationId: "accountBreakdown", summary: "Cost by account", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/AccountBreakdown" } } } } } } } } } },
4427
+ "/v1/breakdown": { get: { operationId: "breakdown", summary: "Cost breakdown by dimension", parameters: [{ name: "by", in: "query", schema: { type: "string", enum: ["model", "project", "agent", "account"] } }, { name: "period", in: "query", schema: { type: "string" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } },
4428
+ "/v1/daily": { get: { operationId: "daily", summary: "Daily cost points", parameters: [{ name: "days", in: "query", schema: { type: "integer" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/DailyPoint" } } } } } } } } } },
4429
+ "/v1/hourly": { get: { operationId: "hourly", summary: "Hourly cost points", parameters: [{ name: "hours", in: "query", schema: { type: "integer" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } },
4430
+ "/v1/machines": { get: { operationId: "listMachines", summary: "Known machines", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/MachineInfo" } } } } } } } } } },
4431
+ "/v1/fleet": { get: { operationId: "fleet", summary: "Fleet summary", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "machine", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } },
4432
+ "/v1/billing": { get: { operationId: "billing", summary: "Provider billing summary", parameters: [{ name: "period", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/BillingSummary" } } } } } } } } },
4433
+ "/v1/billing/diff": { get: { operationId: "billingDiff", summary: "Billing vs tracked diff", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "threshold", in: "query", schema: { type: "number" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } },
4434
+ "/v1/usage": { get: { operationId: "usage", summary: "Usage snapshots + summary", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "agent", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } },
4435
+ "/v1/savings": { get: { operationId: "savings", summary: "Savings summary", parameters: [{ name: "period", in: "query", schema: { type: "string" } }, { name: "agent", in: "query", schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } },
4436
+ "/v1/budgets": {
4437
+ get: { operationId: "listBudgets", summary: "List budgets", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/BudgetStatus" } } } } } } } } },
4438
+ post: { operationId: "createBudget", summary: "Create a budget", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateBudgetInput" } } } }, responses: { "200": { description: "created", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/BudgetStatus" } } } } } } } }
4439
+ },
4440
+ "/v1/budgets/{id}": { delete: { operationId: "deleteBudget", summary: "Delete a budget", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/MutationOk" } } } } } } },
4441
+ "/v1/goals": {
4442
+ get: { operationId: "listGoals", summary: "List goals", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/GoalStatus" } } } } } } } } },
4443
+ post: { operationId: "createGoal", summary: "Create a goal", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateGoalInput" } } } }, responses: { "200": { description: "created", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/GoalStatus" } } } } } } } }
4444
+ },
4445
+ "/v1/goals/{id}": { delete: { operationId: "deleteGoal", summary: "Delete a goal", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/MutationOk" } } } } } } },
4446
+ "/v1/pricing": {
4447
+ get: { operationId: "listPricing", summary: "List model pricing", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/ModelPricing" } } } } } } } } },
4448
+ post: { operationId: "upsertPricing", summary: "Upsert model pricing", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreatePricingInput" } } } }, responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/ModelPricing" } } } } } } } }
4449
+ },
4450
+ "/v1/pricing/{model}": { delete: { operationId: "deletePricing", summary: "Delete model pricing", parameters: [{ name: "model", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/MutationOk" } } } } } } },
4451
+ "/v1/subscriptions": {
4452
+ get: { operationId: "listSubscriptions", summary: "List subscriptions", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/Subscription" } } } } } } } } },
4453
+ post: { operationId: "upsertSubscription", summary: "Upsert a subscription", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateSubscriptionInput" } } } }, responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { $ref: "#/components/schemas/Subscription" } } } } } } } }
4454
+ },
4455
+ "/v1/subscriptions/{id}": { delete: { operationId: "deleteSubscription", summary: "Delete a subscription", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/MutationOk" } } } } } } },
4456
+ "/v1/project-registry": {
4457
+ get: { operationId: "listProjectRegistry", summary: "List registered projects", responses: { "200": { description: "ok", content: { "application/json": { schema: { type: "object", properties: { data: { type: "array", items: { $ref: "#/components/schemas/Project" } } } } } } } } },
4458
+ post: { operationId: "upsertProject", summary: "Register a project", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateProjectInput" } } } }, responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/MutationOk" } } } } } }
4459
+ },
4460
+ "/v1/project-registry/{id}": { delete: { operationId: "deleteProject", summary: "Delete a registered project", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/MutationOk" } } } } } } },
4461
+ "/v1/sync": { post: { operationId: "sync", summary: "Trigger an ingest sync", requestBody: { required: false, content: { "application/json": { schema: { type: "object", properties: { sources: { type: "string" } } } } } }, responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Envelope" } } } } } } }
4462
+ }
4463
+ };
4464
+
4465
+ // src/server/serve.ts
4466
+ function openApiDocument() {
4467
+ const spec = openApiSpec;
4468
+ return { ...spec, info: { ...spec["info"], version: packageMetadata.version } };
4469
+ }
4470
+ function serveMode() {
4471
+ return isCloudMode() ? "self_hosted" : "local";
4472
+ }
4473
+ function foundationEnvelope(status, extra = {}) {
4474
+ return { status, version: packageMetadata.version, mode: serveMode(), service: "economy", ...extra };
4475
+ }
4155
4476
  var CORS = {
4156
4477
  "Access-Control-Allow-Origin": "*",
4157
4478
  "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
@@ -4217,19 +4538,20 @@ function dashboardPath(root, pathname) {
4217
4538
  const filePath = resolve(rootPath, relativePath);
4218
4539
  return filePath === rootPath || filePath.startsWith(rootPath + sep) ? filePath : null;
4219
4540
  }
4541
+ var FOUNDATION_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz", "/version", "/openapi.json"]);
4220
4542
  function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
4221
4543
  return async function fetch2(req) {
4222
4544
  const url = new URL(req.url);
4223
- if (url.pathname.startsWith("/api") || url.pathname === "/health") {
4545
+ if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
4224
4546
  return apiHandler(req);
4225
4547
  }
4226
- if (existsSync11(dashboardDir)) {
4548
+ if (existsSync12(dashboardDir)) {
4227
4549
  const filePath = dashboardPath(dashboardDir, url.pathname);
4228
- if (filePath && existsSync11(filePath)) {
4550
+ if (filePath && existsSync12(filePath)) {
4229
4551
  return new Response(Bun.file(filePath));
4230
4552
  }
4231
4553
  const indexPath = dashboardPath(dashboardDir, "/");
4232
- if (indexPath && existsSync11(indexPath)) {
4554
+ if (indexPath && existsSync12(indexPath)) {
4233
4555
  return new Response(Bun.file(indexPath));
4234
4556
  }
4235
4557
  }
@@ -4241,15 +4563,34 @@ function applyFields(obj, fields) {
4241
4563
  return obj;
4242
4564
  return Object.fromEntries(fields.map((f) => [f, obj[f] ?? null]));
4243
4565
  }
4244
- function createHandler(db) {
4566
+ function createHandler(db, options = {}) {
4245
4567
  return async function handler(req) {
4246
4568
  const url = new URL(req.url);
4247
- const path = url.pathname;
4569
+ const rawPath = url.pathname;
4570
+ const path = rawPath.startsWith("/v1/") ? "/api" + rawPath.slice(3) : rawPath === "/v1" ? "/api" : rawPath;
4248
4571
  const method = req.method;
4249
4572
  if (method === "OPTIONS")
4250
4573
  return new Response(null, { status: 204, headers: CORS });
4251
- if (!isAuthorizedRequest(req, path))
4574
+ if (method === "GET" && (rawPath === "/health" || rawPath === "/healthz")) {
4575
+ return json(foundationEnvelope("ok"));
4576
+ }
4577
+ if (method === "GET" && (rawPath === "/version" || rawPath === "/v1/version")) {
4578
+ return json(foundationEnvelope("ok"));
4579
+ }
4580
+ if (method === "GET" && (rawPath === "/ready" || rawPath === "/readyz")) {
4581
+ const result = options.readyCheck ? await options.readyCheck() : { ready: true };
4582
+ return json(foundationEnvelope(result.ready ? "ready" : "not_ready", result.detail ? { detail: result.detail } : {}), result.ready ? 200 : 503);
4583
+ }
4584
+ if (method === "GET" && rawPath === "/openapi.json") {
4585
+ return json(openApiDocument());
4586
+ }
4587
+ if (options.authenticator) {
4588
+ const decision = await options.authenticator.authenticate(req.headers, { method, path: rawPath });
4589
+ if (!decision.ok)
4590
+ return json({ error: decision.reason ?? "unauthorized", message: decision.message }, decision.status || 401);
4591
+ } else if (!isAuthorizedRequest(req, path)) {
4252
4592
  return err("Unauthorized", 401);
4593
+ }
4253
4594
  if (path === "/health")
4254
4595
  return ok({ status: "ok", ts: new Date().toISOString() });
4255
4596
  if (path === "/api/summary" && method === "GET") {
@@ -4314,7 +4655,8 @@ function createHandler(db) {
4314
4655
  if (path === "/api/top" && method === "GET") {
4315
4656
  const n = Number(url.searchParams.get("n") ?? 10);
4316
4657
  const agent = url.searchParams.get("agent") ?? undefined;
4317
- return ok(queryTopSessions(db, n, agent));
4658
+ const since = url.searchParams.get("since") ?? undefined;
4659
+ return ok(queryTopSessions(db, n, agent, since));
4318
4660
  }
4319
4661
  if (path === "/api/models" && method === "GET") {
4320
4662
  return ok(queryModelBreakdown(db));
@@ -4601,27 +4943,69 @@ function createHandler(db) {
4601
4943
  return err("Not found", 404);
4602
4944
  };
4603
4945
  }
4946
+ var APP = "economy";
4947
+ function isLocalHost(host) {
4948
+ return ["127.0.0.1", "localhost", "::1"].includes(host);
4949
+ }
4604
4950
  function startServer(port = 3456, options = {}) {
4605
- const db = options.db ?? openDatabase();
4606
- ensurePricingSeeded(db);
4607
- const apiHandler = createHandler(db);
4608
- const hostname2 = options.hostname ?? getServeBindHost();
4951
+ const cloud = options.db ? false : isCloudMode();
4952
+ const hostname2 = options.hostname ?? (cloud ? process.env["ECONOMY_HOST"] ?? "0.0.0.0" : getServeBindHost());
4953
+ const log = options.log ?? console.log;
4954
+ const handlerOptions = {};
4955
+ let db;
4956
+ if (cloud) {
4957
+ db = openCloudDatabase();
4958
+ const pool = createCloudPool();
4959
+ const signingSecret = resolveSigningSecret();
4960
+ if (signingSecret) {
4961
+ const keys = new ApiKeyStore(authClientFromPool(pool));
4962
+ keys.ensureSchema().catch((e) => log(`api_keys ensureSchema: ${e instanceof Error ? e.message : String(e)}`));
4963
+ handlerOptions.authenticator = verifyApiKey({
4964
+ app: APP,
4965
+ signingSecret,
4966
+ isRevoked: keys.statusChecker(),
4967
+ audit: (event) => log(JSON.stringify({ evt: "api_auth", outcome: event.outcome, kid: event.kid, reason: event.reason, path: event.path, status: event.status }))
4968
+ });
4969
+ } else if (!isLocalHost(hostname2)) {
4970
+ throw new Error("economy-serve on a non-local host requires an API signing secret (HASNA_ECONOMY_API_SIGNING_KEY / API_KEY_SIGNING_SECRET)");
4971
+ }
4972
+ handlerOptions.readyCheck = async () => {
4973
+ try {
4974
+ const res = await pool.query("SELECT to_regclass($1) AS reg", ["public.requests"]);
4975
+ if (!res.rows[0]?.reg)
4976
+ return { ready: false, detail: "pending_migrations:requests" };
4977
+ return { ready: true };
4978
+ } catch (error) {
4979
+ return { ready: false, detail: error instanceof Error ? error.message : "storage_unreachable" };
4980
+ }
4981
+ };
4982
+ } else {
4983
+ db = options.db ?? openDatabase();
4984
+ ensurePricingSeeded(db);
4985
+ }
4986
+ const apiHandler = createHandler(db, handlerOptions);
4609
4987
  const server = Bun.serve({
4610
4988
  port,
4611
4989
  hostname: hostname2,
4612
4990
  fetch: createServerFetch(apiHandler, options.dashboardDir)
4613
4991
  });
4614
4992
  const address = `http://${hostname2 === "0.0.0.0" ? "localhost" : hostname2}:${server.port}`;
4615
- const log = options.log ?? console.log;
4616
4993
  log(`economy-serve listening on ${address}`);
4617
4994
  return server;
4618
4995
  }
4619
4996
 
4620
4997
  // src/server/index.ts
4621
4998
  function printHelp() {
4622
- console.log(`Usage: economy-serve [options]
4999
+ console.log(`Usage: economy-serve [command] [options]
4623
5000
 
4624
5001
  REST API server for ${packageMetadata.name}
5002
+ Foundation probes: GET /health, /ready, /version -> { status, version, mode }
5003
+ Versioned API: /v1/* (API-key auth via @hasna/contracts in cloud mode)
5004
+
5005
+ Commands:
5006
+ (default) start the HTTP server
5007
+ migrate apply the cloud Postgres schema + api_keys, then exit
5008
+ version print { status, version, mode }
4625
5009
 
4626
5010
  Options:
4627
5011
  -p, --port <port> Port to bind (default: ECONOMY_PORT or 3456)
@@ -4647,18 +5031,46 @@ function parsePort(raw, label) {
4647
5031
  }
4648
5032
  return value;
4649
5033
  }
5034
+ async function runMigrate() {
5035
+ const { getCloudDatabaseUrl: getCloudDatabaseUrl3, createCloudPool: createCloudPool2, authClientFromPool: authClientFromPool2, cloudMigrations: cloudMigrations2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
5036
+ const { applyPgMigrations } = await import("@hasna/cloud");
5037
+ const { ApiKeyStore: ApiKeyStore2 } = await import("@hasna/contracts/auth");
5038
+ const dsn = getCloudDatabaseUrl3();
5039
+ if (!dsn)
5040
+ throw new Error("migrate requires a Postgres DSN: set HASNA_ECONOMY_DATABASE_URL (or ECONOMY_DATABASE_URL / DATABASE_URL)");
5041
+ const result = await applyPgMigrations(dsn, cloudMigrations2(), "economy");
5042
+ const pool = createCloudPool2(dsn);
5043
+ try {
5044
+ await new ApiKeyStore2(authClientFromPool2(pool)).ensureSchema();
5045
+ } finally {
5046
+ await pool.end().catch(() => {});
5047
+ }
5048
+ console.log(JSON.stringify({ evt: "migrate", service: result.service, applied: result.applied, alreadyApplied: result.alreadyApplied, errors: result.errors, api_keys: "ensured" }));
5049
+ if (result.errors.length)
5050
+ process.exit(1);
5051
+ }
4650
5052
  var args = process.argv.slice(2);
4651
- if (args.includes("--help") || args.includes("-h")) {
4652
- printHelp();
5053
+ var sub = args[0];
5054
+ if (sub === "version") {
5055
+ console.log(JSON.stringify({ status: "ok", version: packageMetadata.version, mode: process.env["HASNA_ECONOMY_STORAGE_MODE"] === "cloud" ? "self_hosted" : "local" }));
4653
5056
  process.exit(0);
4654
5057
  }
4655
- if (args.includes("--version") || args.includes("-V")) {
5058
+ if (sub === "migrate") {
5059
+ runMigrate().catch((error) => {
5060
+ console.error(error instanceof Error ? error.message : String(error));
5061
+ process.exit(1);
5062
+ });
5063
+ } else if (args.includes("--help") || args.includes("-h")) {
5064
+ printHelp();
5065
+ process.exit(0);
5066
+ } else if (args.includes("--version") || args.includes("-V")) {
4656
5067
  console.log(packageMetadata.version);
4657
5068
  process.exit(0);
4658
- }
4659
- try {
4660
- startServer(resolvePort(args));
4661
- } catch (error) {
4662
- console.error(error instanceof Error ? error.message : String(error));
4663
- process.exit(1);
5069
+ } else {
5070
+ try {
5071
+ startServer(resolvePort(args));
5072
+ } catch (error) {
5073
+ console.error(error instanceof Error ? error.message : String(error));
5074
+ process.exit(1);
5075
+ }
4664
5076
  }