@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.
- package/CHANGELOG.md +8 -0
- package/dist/cli/commands/extras.d.ts.map +1 -1
- package/dist/cli/index.js +705 -87
- package/dist/db/cloud.d.ts +28 -0
- package/dist/db/cloud.d.ts.map +1 -0
- package/dist/db/database.d.ts +4 -3
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-sync-worker.d.ts +2 -0
- package/dist/db/pg-sync-worker.d.ts.map +1 -0
- package/dist/db/sync-pg.d.ts +23 -0
- package/dist/db/sync-pg.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +299 -49
- package/dist/lib/fleet-sync.d.ts +61 -0
- package/dist/lib/fleet-sync.d.ts.map +1 -0
- package/dist/mcp/index.js +66 -16
- package/dist/openapi.d.ts +3 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/otel/index.js +41 -5
- package/dist/server/index.js +456 -44
- package/dist/server/pg-sync-worker.js +31 -0
- package/dist/server/serve.d.ts +31 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.js +2746 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +7 -4
package/dist/server/index.js
CHANGED
|
@@ -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 =
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
989
|
+
conditions.push("agent = ?");
|
|
990
|
+
params.push(agent);
|
|
952
991
|
}
|
|
953
|
-
|
|
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 ?
|
|
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
|
|
2097
|
-
import { dirname, join as
|
|
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"] ??
|
|
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 (
|
|
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
|
|
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
|
|
4545
|
+
if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
|
|
4224
4546
|
return apiHandler(req);
|
|
4225
4547
|
}
|
|
4226
|
-
if (
|
|
4548
|
+
if (existsSync12(dashboardDir)) {
|
|
4227
4549
|
const filePath = dashboardPath(dashboardDir, url.pathname);
|
|
4228
|
-
if (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 &&
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
4606
|
-
|
|
4607
|
-
const
|
|
4608
|
-
const
|
|
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
|
-
|
|
4652
|
-
|
|
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 (
|
|
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
|
-
|
|
4661
|
-
} catch (error) {
|
|
4662
|
-
|
|
4663
|
-
|
|
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
|
}
|