@hasna/economy 0.2.42 → 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/index.js +338 -22
- package/dist/db/cloud.d.ts +28 -0
- package/dist/db/cloud.d.ts.map +1 -0
- 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/openapi.d.ts +3 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/server/index.js +388 -27
- 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/package.json +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this repository are tracked here. This project follows semantic versioning for published npm packages when practical.
|
|
4
4
|
|
|
5
|
+
## @hasna/economy 0.2.43 - 2026-07-06
|
|
6
|
+
|
|
7
|
+
- Added the self_hosted service surface: `economy-serve` foundation probes (`GET /health`, `/ready`, `/version` -> `{ status, version, mode }`) and a versioned `/v1` API covering summaries, sessions, breakdowns, budgets, goals, pricing, subscriptions, billing, and sync.
|
|
8
|
+
- Added API-key authentication for the internet-facing `/v1` surface via `@hasna/contracts/auth` (stateless HMAC verify + revocation check).
|
|
9
|
+
- Added Amendment A1 PURE-REMOTE cloud storage: the serve reads/writes the shared RDS Postgres directly through a worker-backed synchronous PG adapter (no local cache, no sync engine in the service).
|
|
10
|
+
- Added a typed SDK client generated from the serve OpenAPI (`ECONOMY_API_URL` + `ECONOMY_API_KEY`).
|
|
11
|
+
- Added the deploy surface: ARM64/bun `Dockerfile`, `docker-compose.yml`, `hasna.contract.json`, a `migrations/` directory + runner (`economy-serve migrate`).
|
|
12
|
+
|
|
5
13
|
## @hasna/economy 0.2.41 - 2026-06-24
|
|
6
14
|
|
|
7
15
|
- Added root open-source project files for release notes, security reporting, contributing guidance, and conduct expectations.
|
package/dist/cli/index.js
CHANGED
|
@@ -4673,16 +4673,275 @@ var init_watch = __esm(() => {
|
|
|
4673
4673
|
init_notification();
|
|
4674
4674
|
});
|
|
4675
4675
|
|
|
4676
|
+
// src/db/sync-pg.ts
|
|
4677
|
+
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads";
|
|
4678
|
+
import { fileURLToPath } from "url";
|
|
4679
|
+
import { dirname as dirname3, join as join14 } from "path";
|
|
4680
|
+
import { existsSync as existsSync16 } from "fs";
|
|
4681
|
+
import { translateSql, translateParams } from "@hasna/cloud";
|
|
4682
|
+
function translateSqliteDates(sql) {
|
|
4683
|
+
let s = sql;
|
|
4684
|
+
const isoDate = (expr) => `to_char(${expr}, 'YYYY-MM-DD')`;
|
|
4685
|
+
s = s.replace(/DATE\('now',\s*'weekday 0',\s*'-7 days'\)/gi, isoDate("(date_trunc('week', now() + interval '1 day') - interval '1 day')"));
|
|
4686
|
+
s = s.replace(/DATE\('now',\s*'start of month'\)/gi, isoDate("date_trunc('month', now())"));
|
|
4687
|
+
s = s.replace(/DATE\('now',\s*'start of year'\)/gi, isoDate("date_trunc('year', now())"));
|
|
4688
|
+
s = s.replace(/DATE\('now',\s*'(-?\d+)\s*days?'\)/gi, (_m, n) => isoDate(`now() + interval '${n} days'`));
|
|
4689
|
+
s = s.replace(/DATE\('now',\s*(\$\d+)\s*\|\|\s*' days'\)/gi, (_m, p) => isoDate(`now() + ((${p}) || ' days')::interval`));
|
|
4690
|
+
s = s.replace(/DATETIME\('now',\s*(\$\d+)\)/gi, (_m, p) => `(now() + (${p})::interval)`);
|
|
4691
|
+
s = s.replace(/DATETIME\('now',\s*'([^']+)'\)/gi, (_m, lit) => `(now() + interval '${lit}')`);
|
|
4692
|
+
const strftimeMap = { "%H": "HH24", "%Y": "YYYY", "%m": "MM", "%d": "DD", "%M": "MI", "%S": "SS" };
|
|
4693
|
+
s = s.replace(/STRFTIME\('([^']+)',\s*([A-Za-z_][A-Za-z0-9_]*)\)/gi, (_m, fmt4, col) => {
|
|
4694
|
+
const pgFmt = Object.entries(strftimeMap).reduce((acc, [k, v]) => acc.split(k).join(v), fmt4);
|
|
4695
|
+
return `to_char(CAST("${col}" AS timestamptz), '${pgFmt}')`;
|
|
4696
|
+
});
|
|
4697
|
+
s = s.replace(/\bDATE\('now'\)/gi, isoDate("now()"));
|
|
4698
|
+
s = s.replace(/\bDATETIME\('now'\)/gi, "now()");
|
|
4699
|
+
s = s.replace(/\bDATETIME\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `CAST("${col}" AS timestamptz)`);
|
|
4700
|
+
s = s.replace(/\bDATE\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `substr("${col}", 1, 10)`);
|
|
4701
|
+
return s;
|
|
4702
|
+
}
|
|
4703
|
+
function resolveWorkerPath() {
|
|
4704
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
4705
|
+
const candidates = [
|
|
4706
|
+
join14(here, "pg-sync-worker.ts"),
|
|
4707
|
+
join14(here, "pg-sync-worker.js"),
|
|
4708
|
+
join14(here, "..", "db", "pg-sync-worker.js")
|
|
4709
|
+
];
|
|
4710
|
+
for (const candidate of candidates)
|
|
4711
|
+
if (existsSync16(candidate))
|
|
4712
|
+
return candidate;
|
|
4713
|
+
return candidates[0];
|
|
4714
|
+
}
|
|
4715
|
+
|
|
4716
|
+
class SyncPgAdapter {
|
|
4717
|
+
worker;
|
|
4718
|
+
port;
|
|
4719
|
+
counter = 0;
|
|
4720
|
+
constructor(dsn) {
|
|
4721
|
+
const workerPath = resolveWorkerPath();
|
|
4722
|
+
this.worker = new Worker(workerPath, { workerData: { dsn } });
|
|
4723
|
+
this.worker.on("error", (err) => console.error(JSON.stringify({ evt: "pg_worker_error", message: err.message })));
|
|
4724
|
+
const channel = new MessageChannel;
|
|
4725
|
+
this.port = channel.port1;
|
|
4726
|
+
this.worker.postMessage({ type: "init", port: channel.port2 }, [channel.port2]);
|
|
4727
|
+
}
|
|
4728
|
+
query(sql, params) {
|
|
4729
|
+
const pgSql = translateSqliteDates(translateSql(sql, "pg"));
|
|
4730
|
+
const pgParams = translateParams(params);
|
|
4731
|
+
const id = ++this.counter;
|
|
4732
|
+
const signal = new SharedArrayBuffer(4);
|
|
4733
|
+
const sig = new Int32Array(signal);
|
|
4734
|
+
Atomics.store(sig, 0, 0);
|
|
4735
|
+
this.worker.postMessage({ id, sql: pgSql, params: pgParams, signal });
|
|
4736
|
+
Atomics.wait(sig, 0, 0);
|
|
4737
|
+
let received = receiveMessageOnPort(this.port);
|
|
4738
|
+
for (let attempt = 0;!received && attempt < 1e6; attempt++)
|
|
4739
|
+
received = receiveMessageOnPort(this.port);
|
|
4740
|
+
if (!received)
|
|
4741
|
+
throw new Error("SyncPgAdapter: no reply from worker");
|
|
4742
|
+
const reply = received.message;
|
|
4743
|
+
if (!reply.ok)
|
|
4744
|
+
throw new Error(reply.error);
|
|
4745
|
+
return { rows: reply.rows, rowCount: reply.rowCount };
|
|
4746
|
+
}
|
|
4747
|
+
run(sql, ...params) {
|
|
4748
|
+
const result = this.query(sql, params);
|
|
4749
|
+
return { changes: result.rowCount, lastInsertRowid: result.rows?.[0]?.["id"] ?? 0 };
|
|
4750
|
+
}
|
|
4751
|
+
get(sql, ...params) {
|
|
4752
|
+
return this.query(sql, params).rows[0] ?? null;
|
|
4753
|
+
}
|
|
4754
|
+
all(sql, ...params) {
|
|
4755
|
+
return this.query(sql, params).rows;
|
|
4756
|
+
}
|
|
4757
|
+
exec(sql) {
|
|
4758
|
+
this.query(sql, []);
|
|
4759
|
+
}
|
|
4760
|
+
prepare(sql) {
|
|
4761
|
+
const self = this;
|
|
4762
|
+
return {
|
|
4763
|
+
run: (...params) => self.run(sql, ...params),
|
|
4764
|
+
get: (...params) => self.get(sql, ...params),
|
|
4765
|
+
all: (...params) => self.all(sql, ...params),
|
|
4766
|
+
finalize: () => {}
|
|
4767
|
+
};
|
|
4768
|
+
}
|
|
4769
|
+
transaction(fn) {
|
|
4770
|
+
return fn();
|
|
4771
|
+
}
|
|
4772
|
+
close() {
|
|
4773
|
+
this.worker.terminate();
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
var init_sync_pg = () => {};
|
|
4777
|
+
|
|
4778
|
+
// src/db/cloud.ts
|
|
4779
|
+
import pg from "pg";
|
|
4780
|
+
function getCloudDatabaseUrl2() {
|
|
4781
|
+
return process.env["HASNA_ECONOMY_DATABASE_URL"]?.trim() || process.env["ECONOMY_DATABASE_URL"]?.trim() || process.env["DATABASE_URL"]?.trim() || undefined;
|
|
4782
|
+
}
|
|
4783
|
+
function isCloudMode() {
|
|
4784
|
+
const mode = process.env["HASNA_ECONOMY_STORAGE_MODE"]?.trim().toLowerCase();
|
|
4785
|
+
if (mode === "cloud")
|
|
4786
|
+
return true;
|
|
4787
|
+
if (mode === "local")
|
|
4788
|
+
return false;
|
|
4789
|
+
return Boolean(getCloudDatabaseUrl2());
|
|
4790
|
+
}
|
|
4791
|
+
function resolveSigningSecret() {
|
|
4792
|
+
return process.env["HASNA_ECONOMY_API_SIGNING_KEY"]?.trim() || process.env["HASNA_API_SIGNING_KEY"]?.trim() || process.env["API_KEY_SIGNING_SECRET"]?.trim() || undefined;
|
|
4793
|
+
}
|
|
4794
|
+
function openCloudDatabase(dsn = getCloudDatabaseUrl2()) {
|
|
4795
|
+
if (!dsn) {
|
|
4796
|
+
throw new Error("economy cloud mode requires a Postgres DSN: set HASNA_ECONOMY_DATABASE_URL (or ECONOMY_DATABASE_URL / DATABASE_URL)");
|
|
4797
|
+
}
|
|
4798
|
+
return new SyncPgAdapter(dsn);
|
|
4799
|
+
}
|
|
4800
|
+
function createCloudPool(dsn = getCloudDatabaseUrl2()) {
|
|
4801
|
+
if (!dsn)
|
|
4802
|
+
throw new Error("economy cloud mode requires a Postgres DSN (HASNA_ECONOMY_DATABASE_URL)");
|
|
4803
|
+
const ssl = dsn.includes("sslmode=require") || dsn.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
4804
|
+
const pool = new pg.Pool({ connectionString: dsn, ssl, max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"), connectionTimeoutMillis: 1e4 });
|
|
4805
|
+
pool.on("error", (err) => {
|
|
4806
|
+
console.error(JSON.stringify({ evt: "pg_pool_error", message: err.message }));
|
|
4807
|
+
});
|
|
4808
|
+
return pool;
|
|
4809
|
+
}
|
|
4810
|
+
function authClientFromPool(pool) {
|
|
4811
|
+
return {
|
|
4812
|
+
async many(sql, params = []) {
|
|
4813
|
+
const res = await pool.query(sql, params);
|
|
4814
|
+
return res.rows;
|
|
4815
|
+
},
|
|
4816
|
+
async get(sql, params = []) {
|
|
4817
|
+
const res = await pool.query(sql, params);
|
|
4818
|
+
return res.rows[0] ?? null;
|
|
4819
|
+
},
|
|
4820
|
+
async execute(sql, params = []) {
|
|
4821
|
+
await pool.query(sql, params);
|
|
4822
|
+
}
|
|
4823
|
+
};
|
|
4824
|
+
}
|
|
4825
|
+
var init_cloud = __esm(() => {
|
|
4826
|
+
init_sync_pg();
|
|
4827
|
+
init_pg_migrations();
|
|
4828
|
+
});
|
|
4829
|
+
|
|
4830
|
+
// src/openapi.ts
|
|
4831
|
+
var openApiSpec;
|
|
4832
|
+
var init_openapi = __esm(() => {
|
|
4833
|
+
openApiSpec = {
|
|
4834
|
+
openapi: "3.0.3",
|
|
4835
|
+
info: {
|
|
4836
|
+
title: "@hasna/economy self-hosted API",
|
|
4837
|
+
version: "0.0.0",
|
|
4838
|
+
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."
|
|
4839
|
+
},
|
|
4840
|
+
servers: [{ url: "https://economy.hasna.xyz", description: "prod" }, { url: "http://localhost:3456", description: "local" }],
|
|
4841
|
+
security: [{ apiKey: [] }, { bearer: [] }],
|
|
4842
|
+
components: {
|
|
4843
|
+
securitySchemes: {
|
|
4844
|
+
apiKey: { type: "apiKey", in: "header", name: "x-api-key" },
|
|
4845
|
+
bearer: { type: "http", scheme: "bearer" }
|
|
4846
|
+
},
|
|
4847
|
+
schemas: {
|
|
4848
|
+
Envelope: { type: "object", properties: { data: {}, meta: { type: "object", additionalProperties: true } }, required: ["data"] },
|
|
4849
|
+
Foundation: { type: "object", properties: { status: { type: "string" }, version: { type: "string" }, mode: { type: "string" }, service: { type: "string" } }, required: ["status", "version", "mode"] },
|
|
4850
|
+
Error: { type: "object", properties: { error: { type: "string" }, message: { type: "string" } }, required: ["error"] },
|
|
4851
|
+
MutationOk: { type: "object", properties: { ok: { type: "boolean" } } },
|
|
4852
|
+
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 },
|
|
4853
|
+
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 },
|
|
4854
|
+
ModelBreakdown: { type: "object", additionalProperties: true },
|
|
4855
|
+
ProjectBreakdown: { type: "object", additionalProperties: true },
|
|
4856
|
+
AgentBreakdown: { type: "object", additionalProperties: true },
|
|
4857
|
+
AccountBreakdown: { type: "object", additionalProperties: true },
|
|
4858
|
+
DailyPoint: { type: "object", additionalProperties: true },
|
|
4859
|
+
BudgetStatus: { type: "object", properties: { id: { type: "string" }, limit_usd: { type: "number" }, period: { type: "string" }, spent_usd: { type: "number" } }, additionalProperties: true },
|
|
4860
|
+
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"] },
|
|
4861
|
+
GoalStatus: { type: "object", additionalProperties: true },
|
|
4862
|
+
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"] },
|
|
4863
|
+
ModelPricing: { type: "object", additionalProperties: true },
|
|
4864
|
+
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"] },
|
|
4865
|
+
Subscription: { type: "object", additionalProperties: true },
|
|
4866
|
+
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"] },
|
|
4867
|
+
Project: { type: "object", additionalProperties: true },
|
|
4868
|
+
CreateProjectInput: { type: "object", properties: { path: { type: "string" }, name: { type: "string" }, description: { type: "string" }, tags: { type: "array", items: { type: "string" } } }, required: ["path"] },
|
|
4869
|
+
MachineInfo: { type: "object", additionalProperties: true },
|
|
4870
|
+
BillingSummary: { type: "object", additionalProperties: true }
|
|
4871
|
+
}
|
|
4872
|
+
},
|
|
4873
|
+
paths: {
|
|
4874
|
+
"/health": { get: { operationId: "health", summary: "Liveness probe", security: [], responses: { "200": { description: "ok", content: { "application/json": { schema: { $ref: "#/components/schemas/Foundation" } } } } } } },
|
|
4875
|
+
"/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" } } } } } } },
|
|
4876
|
+
"/version": { get: { operationId: "version", summary: "Version probe", security: [], responses: { "200": { description: "version", content: { "application/json": { schema: { $ref: "#/components/schemas/Foundation" } } } } } } },
|
|
4877
|
+
"/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" } } } } } } } } },
|
|
4878
|
+
"/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" } } } } } } } } } },
|
|
4879
|
+
"/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" } } } } } } },
|
|
4880
|
+
"/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" } } } } } } } } } },
|
|
4881
|
+
"/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" } } } } } } } } } },
|
|
4882
|
+
"/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" } } } } } } } } } },
|
|
4883
|
+
"/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" } } } } } } } } } },
|
|
4884
|
+
"/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" } } } } } } },
|
|
4885
|
+
"/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" } } } } } } } } } },
|
|
4886
|
+
"/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" } } } } } } },
|
|
4887
|
+
"/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" } } } } } } } } } },
|
|
4888
|
+
"/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" } } } } } } },
|
|
4889
|
+
"/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" } } } } } } } } },
|
|
4890
|
+
"/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" } } } } } } },
|
|
4891
|
+
"/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" } } } } } } },
|
|
4892
|
+
"/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" } } } } } } },
|
|
4893
|
+
"/v1/budgets": {
|
|
4894
|
+
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" } } } } } } } } },
|
|
4895
|
+
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" } } } } } } } }
|
|
4896
|
+
},
|
|
4897
|
+
"/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" } } } } } } },
|
|
4898
|
+
"/v1/goals": {
|
|
4899
|
+
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" } } } } } } } } },
|
|
4900
|
+
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" } } } } } } } }
|
|
4901
|
+
},
|
|
4902
|
+
"/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" } } } } } } },
|
|
4903
|
+
"/v1/pricing": {
|
|
4904
|
+
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" } } } } } } } } },
|
|
4905
|
+
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" } } } } } } } }
|
|
4906
|
+
},
|
|
4907
|
+
"/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" } } } } } } },
|
|
4908
|
+
"/v1/subscriptions": {
|
|
4909
|
+
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" } } } } } } } } },
|
|
4910
|
+
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" } } } } } } } }
|
|
4911
|
+
},
|
|
4912
|
+
"/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" } } } } } } },
|
|
4913
|
+
"/v1/project-registry": {
|
|
4914
|
+
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" } } } } } } } } },
|
|
4915
|
+
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" } } } } } }
|
|
4916
|
+
},
|
|
4917
|
+
"/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" } } } } } } },
|
|
4918
|
+
"/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" } } } } } } }
|
|
4919
|
+
}
|
|
4920
|
+
};
|
|
4921
|
+
});
|
|
4922
|
+
|
|
4676
4923
|
// src/server/serve.ts
|
|
4677
4924
|
var exports_serve = {};
|
|
4678
4925
|
__export(exports_serve, {
|
|
4679
4926
|
startServer: () => startServer,
|
|
4927
|
+
openApiDocument: () => openApiDocument,
|
|
4680
4928
|
createServerFetch: () => createServerFetch,
|
|
4681
4929
|
createHandler: () => createHandler
|
|
4682
4930
|
});
|
|
4683
4931
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
4684
|
-
import { existsSync as
|
|
4932
|
+
import { existsSync as existsSync17 } from "fs";
|
|
4685
4933
|
import { resolve as resolve2, sep } from "path";
|
|
4934
|
+
import { verifyApiKey, ApiKeyStore } from "@hasna/contracts/auth";
|
|
4935
|
+
function openApiDocument() {
|
|
4936
|
+
const spec = openApiSpec;
|
|
4937
|
+
return { ...spec, info: { ...spec["info"], version: packageMetadata.version } };
|
|
4938
|
+
}
|
|
4939
|
+
function serveMode() {
|
|
4940
|
+
return isCloudMode() ? "self_hosted" : "local";
|
|
4941
|
+
}
|
|
4942
|
+
function foundationEnvelope(status, extra = {}) {
|
|
4943
|
+
return { status, version: packageMetadata.version, mode: serveMode(), service: "economy", ...extra };
|
|
4944
|
+
}
|
|
4686
4945
|
function json(data, status = 200) {
|
|
4687
4946
|
return new Response(JSON.stringify(data), {
|
|
4688
4947
|
status,
|
|
@@ -4743,16 +5002,16 @@ function dashboardPath(root, pathname) {
|
|
|
4743
5002
|
function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
|
|
4744
5003
|
return async function fetch2(req) {
|
|
4745
5004
|
const url = new URL(req.url);
|
|
4746
|
-
if (url.pathname.startsWith("/api") || url.pathname
|
|
5005
|
+
if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
|
|
4747
5006
|
return apiHandler(req);
|
|
4748
5007
|
}
|
|
4749
|
-
if (
|
|
5008
|
+
if (existsSync17(dashboardDir)) {
|
|
4750
5009
|
const filePath = dashboardPath(dashboardDir, url.pathname);
|
|
4751
|
-
if (filePath &&
|
|
5010
|
+
if (filePath && existsSync17(filePath)) {
|
|
4752
5011
|
return new Response(Bun.file(filePath));
|
|
4753
5012
|
}
|
|
4754
5013
|
const indexPath = dashboardPath(dashboardDir, "/");
|
|
4755
|
-
if (indexPath &&
|
|
5014
|
+
if (indexPath && existsSync17(indexPath)) {
|
|
4756
5015
|
return new Response(Bun.file(indexPath));
|
|
4757
5016
|
}
|
|
4758
5017
|
}
|
|
@@ -4764,15 +5023,34 @@ function applyFields(obj, fields) {
|
|
|
4764
5023
|
return obj;
|
|
4765
5024
|
return Object.fromEntries(fields.map((f) => [f, obj[f] ?? null]));
|
|
4766
5025
|
}
|
|
4767
|
-
function createHandler(db) {
|
|
5026
|
+
function createHandler(db, options = {}) {
|
|
4768
5027
|
return async function handler(req) {
|
|
4769
5028
|
const url = new URL(req.url);
|
|
4770
|
-
const
|
|
5029
|
+
const rawPath = url.pathname;
|
|
5030
|
+
const path = rawPath.startsWith("/v1/") ? "/api" + rawPath.slice(3) : rawPath === "/v1" ? "/api" : rawPath;
|
|
4771
5031
|
const method = req.method;
|
|
4772
5032
|
if (method === "OPTIONS")
|
|
4773
5033
|
return new Response(null, { status: 204, headers: CORS });
|
|
4774
|
-
if (
|
|
5034
|
+
if (method === "GET" && (rawPath === "/health" || rawPath === "/healthz")) {
|
|
5035
|
+
return json(foundationEnvelope("ok"));
|
|
5036
|
+
}
|
|
5037
|
+
if (method === "GET" && (rawPath === "/version" || rawPath === "/v1/version")) {
|
|
5038
|
+
return json(foundationEnvelope("ok"));
|
|
5039
|
+
}
|
|
5040
|
+
if (method === "GET" && (rawPath === "/ready" || rawPath === "/readyz")) {
|
|
5041
|
+
const result = options.readyCheck ? await options.readyCheck() : { ready: true };
|
|
5042
|
+
return json(foundationEnvelope(result.ready ? "ready" : "not_ready", result.detail ? { detail: result.detail } : {}), result.ready ? 200 : 503);
|
|
5043
|
+
}
|
|
5044
|
+
if (method === "GET" && rawPath === "/openapi.json") {
|
|
5045
|
+
return json(openApiDocument());
|
|
5046
|
+
}
|
|
5047
|
+
if (options.authenticator) {
|
|
5048
|
+
const decision = await options.authenticator.authenticate(req.headers, { method, path: rawPath });
|
|
5049
|
+
if (!decision.ok)
|
|
5050
|
+
return json({ error: decision.reason ?? "unauthorized", message: decision.message }, decision.status || 401);
|
|
5051
|
+
} else if (!isAuthorizedRequest(req, path)) {
|
|
4775
5052
|
return err("Unauthorized", 401);
|
|
5053
|
+
}
|
|
4776
5054
|
if (path === "/health")
|
|
4777
5055
|
return ok({ status: "ok", ts: new Date().toISOString() });
|
|
4778
5056
|
if (path === "/api/summary" && method === "GET") {
|
|
@@ -5125,22 +5403,56 @@ function createHandler(db) {
|
|
|
5125
5403
|
return err("Not found", 404);
|
|
5126
5404
|
};
|
|
5127
5405
|
}
|
|
5406
|
+
function isLocalHost(host) {
|
|
5407
|
+
return ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
5408
|
+
}
|
|
5128
5409
|
function startServer(port = 3456, options = {}) {
|
|
5129
|
-
const
|
|
5130
|
-
|
|
5131
|
-
const
|
|
5132
|
-
const
|
|
5410
|
+
const cloud = options.db ? false : isCloudMode();
|
|
5411
|
+
const hostname2 = options.hostname ?? (cloud ? process.env["ECONOMY_HOST"] ?? "0.0.0.0" : getServeBindHost());
|
|
5412
|
+
const log = options.log ?? console.log;
|
|
5413
|
+
const handlerOptions = {};
|
|
5414
|
+
let db;
|
|
5415
|
+
if (cloud) {
|
|
5416
|
+
db = openCloudDatabase();
|
|
5417
|
+
const pool = createCloudPool();
|
|
5418
|
+
const signingSecret = resolveSigningSecret();
|
|
5419
|
+
if (signingSecret) {
|
|
5420
|
+
const keys = new ApiKeyStore(authClientFromPool(pool));
|
|
5421
|
+
keys.ensureSchema().catch((e) => log(`api_keys ensureSchema: ${e instanceof Error ? e.message : String(e)}`));
|
|
5422
|
+
handlerOptions.authenticator = verifyApiKey({
|
|
5423
|
+
app: APP,
|
|
5424
|
+
signingSecret,
|
|
5425
|
+
isRevoked: keys.statusChecker(),
|
|
5426
|
+
audit: (event) => log(JSON.stringify({ evt: "api_auth", outcome: event.outcome, kid: event.kid, reason: event.reason, path: event.path, status: event.status }))
|
|
5427
|
+
});
|
|
5428
|
+
} else if (!isLocalHost(hostname2)) {
|
|
5429
|
+
throw new Error("economy-serve on a non-local host requires an API signing secret (HASNA_ECONOMY_API_SIGNING_KEY / API_KEY_SIGNING_SECRET)");
|
|
5430
|
+
}
|
|
5431
|
+
handlerOptions.readyCheck = async () => {
|
|
5432
|
+
try {
|
|
5433
|
+
const res = await pool.query("SELECT to_regclass($1) AS reg", ["public.requests"]);
|
|
5434
|
+
if (!res.rows[0]?.reg)
|
|
5435
|
+
return { ready: false, detail: "pending_migrations:requests" };
|
|
5436
|
+
return { ready: true };
|
|
5437
|
+
} catch (error) {
|
|
5438
|
+
return { ready: false, detail: error instanceof Error ? error.message : "storage_unreachable" };
|
|
5439
|
+
}
|
|
5440
|
+
};
|
|
5441
|
+
} else {
|
|
5442
|
+
db = options.db ?? openDatabase();
|
|
5443
|
+
ensurePricingSeeded(db);
|
|
5444
|
+
}
|
|
5445
|
+
const apiHandler = createHandler(db, handlerOptions);
|
|
5133
5446
|
const server = Bun.serve({
|
|
5134
5447
|
port,
|
|
5135
5448
|
hostname: hostname2,
|
|
5136
5449
|
fetch: createServerFetch(apiHandler, options.dashboardDir)
|
|
5137
5450
|
});
|
|
5138
5451
|
const address = `http://${hostname2 === "0.0.0.0" ? "localhost" : hostname2}:${server.port}`;
|
|
5139
|
-
const log = options.log ?? console.log;
|
|
5140
5452
|
log(`economy-serve listening on ${address}`);
|
|
5141
5453
|
return server;
|
|
5142
5454
|
}
|
|
5143
|
-
var CORS, AGENT_ERROR, SYNC_SOURCES, DEFAULT_DASHBOARD_DIR;
|
|
5455
|
+
var CORS, AGENT_ERROR, SYNC_SOURCES, DEFAULT_DASHBOARD_DIR, FOUNDATION_PATHS, APP = "economy";
|
|
5144
5456
|
var init_serve = __esm(() => {
|
|
5145
5457
|
init_database();
|
|
5146
5458
|
init_pricing();
|
|
@@ -5148,6 +5460,9 @@ var init_serve = __esm(() => {
|
|
|
5148
5460
|
init_sync_all();
|
|
5149
5461
|
init_billing_diff();
|
|
5150
5462
|
init_database();
|
|
5463
|
+
init_package_metadata();
|
|
5464
|
+
init_cloud();
|
|
5465
|
+
init_openapi();
|
|
5151
5466
|
CORS = {
|
|
5152
5467
|
"Access-Control-Allow-Origin": "*",
|
|
5153
5468
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
|
@@ -5156,6 +5471,7 @@ var init_serve = __esm(() => {
|
|
|
5156
5471
|
AGENT_ERROR = `agent must be one of: ${AGENTS.join(", ")}`;
|
|
5157
5472
|
SYNC_SOURCES = ["all", ...AGENTS];
|
|
5158
5473
|
DEFAULT_DASHBOARD_DIR = new URL("../../dashboard/dist", import.meta.url).pathname;
|
|
5474
|
+
FOUNDATION_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz", "/version", "/openapi.json"]);
|
|
5159
5475
|
});
|
|
5160
5476
|
|
|
5161
5477
|
// src/cli/commands/menubar.ts
|
|
@@ -5168,14 +5484,14 @@ __export(exports_menubar, {
|
|
|
5168
5484
|
});
|
|
5169
5485
|
import chalk6 from "chalk";
|
|
5170
5486
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
5171
|
-
import { cpSync, existsSync as
|
|
5487
|
+
import { cpSync, existsSync as existsSync18, mkdirSync as mkdirSync5, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
5172
5488
|
import { tmpdir, arch } from "os";
|
|
5173
|
-
import { join as
|
|
5489
|
+
import { join as join15 } from "path";
|
|
5174
5490
|
function getArch() {
|
|
5175
5491
|
return arch() === "arm64" ? "arm64" : "x86_64";
|
|
5176
5492
|
}
|
|
5177
5493
|
function isInstalled() {
|
|
5178
|
-
return
|
|
5494
|
+
return existsSync18(APP_PATH);
|
|
5179
5495
|
}
|
|
5180
5496
|
function isRunning() {
|
|
5181
5497
|
try {
|
|
@@ -5212,8 +5528,8 @@ async function menubarInstall(opts) {
|
|
|
5212
5528
|
console.error(chalk6.red(`\u2717 Failed to fetch release info: ${e instanceof Error ? e.message : String(e)}`));
|
|
5213
5529
|
process.exit(1);
|
|
5214
5530
|
}
|
|
5215
|
-
const zipPath =
|
|
5216
|
-
const extractDir =
|
|
5531
|
+
const zipPath = join15(tmpdir(), `economy-bar-${cpuArch}.zip`);
|
|
5532
|
+
const extractDir = join15(tmpdir(), "economy-bar-extracted");
|
|
5217
5533
|
console.log(chalk6.cyan(`\u2192 Downloading ${assetUrl}...`));
|
|
5218
5534
|
try {
|
|
5219
5535
|
const res = await fetch(assetUrl, { signal: AbortSignal.timeout(60000) });
|
|
@@ -5233,7 +5549,7 @@ async function menubarInstall(opts) {
|
|
|
5233
5549
|
execFileSync3("unzip", ["-q", zipPath, "-d", extractDir], { stdio: "ignore" });
|
|
5234
5550
|
if (isInstalled())
|
|
5235
5551
|
rmSync2(APP_PATH, { recursive: true, force: true });
|
|
5236
|
-
cpSync(
|
|
5552
|
+
cpSync(join15(extractDir, "Economy Bar.app"), APP_PATH, { recursive: true });
|
|
5237
5553
|
try {
|
|
5238
5554
|
execFileSync3("xattr", ["-rd", "com.apple.quarantine", APP_PATH], { stdio: "ignore" });
|
|
5239
5555
|
} catch {}
|
|
@@ -8924,8 +9240,8 @@ program.command("dashboard").description("Open the web dashboard (auto-starts se
|
|
|
8924
9240
|
if (!serverRunning) {
|
|
8925
9241
|
console.log(chalk7.cyan(`\u2192 Starting economy server on port ${port}...`));
|
|
8926
9242
|
const { spawn } = await import("child_process");
|
|
8927
|
-
const { resolve: resolve3, dirname:
|
|
8928
|
-
const serveScript = resolve3(
|
|
9243
|
+
const { resolve: resolve3, dirname: dirname4 } = await import("path");
|
|
9244
|
+
const serveScript = resolve3(dirname4(process.argv[1]), "..", "server", "index.js");
|
|
8929
9245
|
const child = spawn(process.execPath, [serveScript], {
|
|
8930
9246
|
detached: true,
|
|
8931
9247
|
stdio: "ignore",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DbAdapter } from '@hasna/cloud';
|
|
2
|
+
import type { AuthQueryClient } from '@hasna/contracts/auth';
|
|
3
|
+
import pg from 'pg';
|
|
4
|
+
import type { SqliteAdapter as Database } from '@hasna/cloud';
|
|
5
|
+
/** Resolve the cloud Postgres DSN from the standard env aliases. */
|
|
6
|
+
export declare function getCloudDatabaseUrl(): string | undefined;
|
|
7
|
+
/** True when the serve should run in cloud (RDS-direct) mode. */
|
|
8
|
+
export declare function isCloudMode(): boolean;
|
|
9
|
+
/** HMAC signing secret for API-key verification (server-held, never in a token). */
|
|
10
|
+
export declare function resolveSigningSecret(): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Open a pooled Postgres connection to the shared RDS. Returns a value typed as
|
|
13
|
+
* the SQLite `Database` the query layer expects — safe because `PgAdapter`
|
|
14
|
+
* implements the identical `DbAdapter` surface those functions rely on.
|
|
15
|
+
*/
|
|
16
|
+
export declare function openCloudDatabase(dsn?: string | undefined): Database;
|
|
17
|
+
/** A raw pg Pool for auxiliary async work (API-key store, migrations). */
|
|
18
|
+
export declare function createCloudPool(dsn?: string | undefined): pg.Pool;
|
|
19
|
+
/**
|
|
20
|
+
* Adapt a pg Pool to the `@hasna/contracts/auth` `AuthQueryClient` surface
|
|
21
|
+
* (many/get/execute). This backs the `ApiKeyStore` without pulling in the
|
|
22
|
+
* generic vendored kit — economy's storage is the `@hasna/cloud` PG pool.
|
|
23
|
+
*/
|
|
24
|
+
export declare function authClientFromPool(pool: pg.Pool): AuthQueryClient;
|
|
25
|
+
/** Ordered PG migrations applied by the migration runner (index = version). */
|
|
26
|
+
export declare function cloudMigrations(): string[];
|
|
27
|
+
export type { DbAdapter };
|
|
28
|
+
//# sourceMappingURL=cloud.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["../../src/db/cloud.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,EAAE,MAAM,IAAI,CAAA;AACnB,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,cAAc,CAAA;AAI7D,oEAAoE;AACpE,wBAAgB,mBAAmB,IAAI,MAAM,GAAG,SAAS,CAOxD;AAED,iEAAiE;AACjE,wBAAgB,WAAW,IAAI,OAAO,CAKrC;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,IAAI,MAAM,GAAG,SAAS,CAOzD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,qBAAwB,GAAG,QAAQ,CASvE;AAED,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,GAAG,qBAAwB,GAAG,EAAE,CAAC,IAAI,CAYpE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,GAAG,eAAe,CAcjE;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,IAAI,MAAM,EAAE,CAE1C;AAED,YAAY,EAAE,SAAS,EAAE,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pg-sync-worker.d.ts","sourceRoot":"","sources":["../../src/db/pg-sync-worker.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type DbAdapter, type PreparedStatement, type RunResult } from '@hasna/cloud';
|
|
2
|
+
/**
|
|
3
|
+
* Translate the closed set of SQLite date/time idioms the economy query layer
|
|
4
|
+
* uses into Postgres equivalents. Runs AFTER `translateSql` (so `?` are already
|
|
5
|
+
* `$n`). Timestamp columns are stored as ISO-8601 TEXT in both dialects, so bare
|
|
6
|
+
* `DATE(col)` casts through `timestamptz`. Ordered most-specific first.
|
|
7
|
+
*/
|
|
8
|
+
export declare function translateSqliteDates(sql: string): string;
|
|
9
|
+
export declare class SyncPgAdapter implements DbAdapter {
|
|
10
|
+
private readonly worker;
|
|
11
|
+
private readonly port;
|
|
12
|
+
private counter;
|
|
13
|
+
constructor(dsn: string);
|
|
14
|
+
private query;
|
|
15
|
+
run(sql: string, ...params: unknown[]): RunResult;
|
|
16
|
+
get(sql: string, ...params: unknown[]): unknown;
|
|
17
|
+
all(sql: string, ...params: unknown[]): unknown[];
|
|
18
|
+
exec(sql: string): void;
|
|
19
|
+
prepare(sql: string): PreparedStatement;
|
|
20
|
+
transaction<T>(fn: () => T): T;
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=sync-pg.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-pg.d.ts","sourceRoot":"","sources":["../../src/db/sync-pg.ts"],"names":[],"mappings":"AAeA,OAAO,EAAiC,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAIpH;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiCxD;AAaD,qBAAa,aAAc,YAAW,SAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,OAAO,CAAI;gBAEP,GAAG,EAAE,MAAM;IAYvB,OAAO,CAAC,KAAK;IAsBb,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS;IAIjD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO;IAG/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE;IAGjD,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAGvB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB;IASvC,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC;IAI9B,KAAK,IAAI,IAAI;CAGd"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAuF/C,CAAA;AACD,eAAe,WAAW,CAAA"}
|
package/dist/server/index.js
CHANGED
|
@@ -1847,6 +1847,173 @@ var init_pg_migrations = __esm(() => {
|
|
|
1847
1847
|
];
|
|
1848
1848
|
});
|
|
1849
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
|
+
|
|
1850
2017
|
// src/ingest/billing.ts
|
|
1851
2018
|
var exports_billing = {};
|
|
1852
2019
|
__export(exports_billing, {
|
|
@@ -2143,15 +2310,15 @@ var init_open_projects = __esm(() => {
|
|
|
2143
2310
|
});
|
|
2144
2311
|
|
|
2145
2312
|
// src/lib/config.ts
|
|
2146
|
-
import { existsSync as
|
|
2147
|
-
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";
|
|
2148
2315
|
function getConfigPath() {
|
|
2149
|
-
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ??
|
|
2316
|
+
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join10(getDataDir(), "config.json");
|
|
2150
2317
|
}
|
|
2151
2318
|
function loadConfig() {
|
|
2152
2319
|
try {
|
|
2153
2320
|
const configPath = getConfigPath();
|
|
2154
|
-
if (
|
|
2321
|
+
if (existsSync11(configPath)) {
|
|
2155
2322
|
const raw = readFileSync10(configPath, "utf-8");
|
|
2156
2323
|
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
2157
2324
|
}
|
|
@@ -4200,8 +4367,112 @@ function isAuthorizedRequest(req, path) {
|
|
|
4200
4367
|
|
|
4201
4368
|
// src/server/serve.ts
|
|
4202
4369
|
import { randomUUID } from "crypto";
|
|
4203
|
-
import { existsSync as
|
|
4370
|
+
import { existsSync as existsSync12 } from "fs";
|
|
4204
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
|
+
}
|
|
4205
4476
|
var CORS = {
|
|
4206
4477
|
"Access-Control-Allow-Origin": "*",
|
|
4207
4478
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
|
@@ -4267,19 +4538,20 @@ function dashboardPath(root, pathname) {
|
|
|
4267
4538
|
const filePath = resolve(rootPath, relativePath);
|
|
4268
4539
|
return filePath === rootPath || filePath.startsWith(rootPath + sep) ? filePath : null;
|
|
4269
4540
|
}
|
|
4541
|
+
var FOUNDATION_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz", "/version", "/openapi.json"]);
|
|
4270
4542
|
function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
|
|
4271
4543
|
return async function fetch2(req) {
|
|
4272
4544
|
const url = new URL(req.url);
|
|
4273
|
-
if (url.pathname.startsWith("/api") || url.pathname
|
|
4545
|
+
if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
|
|
4274
4546
|
return apiHandler(req);
|
|
4275
4547
|
}
|
|
4276
|
-
if (
|
|
4548
|
+
if (existsSync12(dashboardDir)) {
|
|
4277
4549
|
const filePath = dashboardPath(dashboardDir, url.pathname);
|
|
4278
|
-
if (filePath &&
|
|
4550
|
+
if (filePath && existsSync12(filePath)) {
|
|
4279
4551
|
return new Response(Bun.file(filePath));
|
|
4280
4552
|
}
|
|
4281
4553
|
const indexPath = dashboardPath(dashboardDir, "/");
|
|
4282
|
-
if (indexPath &&
|
|
4554
|
+
if (indexPath && existsSync12(indexPath)) {
|
|
4283
4555
|
return new Response(Bun.file(indexPath));
|
|
4284
4556
|
}
|
|
4285
4557
|
}
|
|
@@ -4291,15 +4563,34 @@ function applyFields(obj, fields) {
|
|
|
4291
4563
|
return obj;
|
|
4292
4564
|
return Object.fromEntries(fields.map((f) => [f, obj[f] ?? null]));
|
|
4293
4565
|
}
|
|
4294
|
-
function createHandler(db) {
|
|
4566
|
+
function createHandler(db, options = {}) {
|
|
4295
4567
|
return async function handler(req) {
|
|
4296
4568
|
const url = new URL(req.url);
|
|
4297
|
-
const
|
|
4569
|
+
const rawPath = url.pathname;
|
|
4570
|
+
const path = rawPath.startsWith("/v1/") ? "/api" + rawPath.slice(3) : rawPath === "/v1" ? "/api" : rawPath;
|
|
4298
4571
|
const method = req.method;
|
|
4299
4572
|
if (method === "OPTIONS")
|
|
4300
4573
|
return new Response(null, { status: 204, headers: CORS });
|
|
4301
|
-
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)) {
|
|
4302
4592
|
return err("Unauthorized", 401);
|
|
4593
|
+
}
|
|
4303
4594
|
if (path === "/health")
|
|
4304
4595
|
return ok({ status: "ok", ts: new Date().toISOString() });
|
|
4305
4596
|
if (path === "/api/summary" && method === "GET") {
|
|
@@ -4652,27 +4943,69 @@ function createHandler(db) {
|
|
|
4652
4943
|
return err("Not found", 404);
|
|
4653
4944
|
};
|
|
4654
4945
|
}
|
|
4946
|
+
var APP = "economy";
|
|
4947
|
+
function isLocalHost(host) {
|
|
4948
|
+
return ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
4949
|
+
}
|
|
4655
4950
|
function startServer(port = 3456, options = {}) {
|
|
4656
|
-
const
|
|
4657
|
-
|
|
4658
|
-
const
|
|
4659
|
-
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);
|
|
4660
4987
|
const server = Bun.serve({
|
|
4661
4988
|
port,
|
|
4662
4989
|
hostname: hostname2,
|
|
4663
4990
|
fetch: createServerFetch(apiHandler, options.dashboardDir)
|
|
4664
4991
|
});
|
|
4665
4992
|
const address = `http://${hostname2 === "0.0.0.0" ? "localhost" : hostname2}:${server.port}`;
|
|
4666
|
-
const log = options.log ?? console.log;
|
|
4667
4993
|
log(`economy-serve listening on ${address}`);
|
|
4668
4994
|
return server;
|
|
4669
4995
|
}
|
|
4670
4996
|
|
|
4671
4997
|
// src/server/index.ts
|
|
4672
4998
|
function printHelp() {
|
|
4673
|
-
console.log(`Usage: economy-serve [options]
|
|
4999
|
+
console.log(`Usage: economy-serve [command] [options]
|
|
4674
5000
|
|
|
4675
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 }
|
|
4676
5009
|
|
|
4677
5010
|
Options:
|
|
4678
5011
|
-p, --port <port> Port to bind (default: ECONOMY_PORT or 3456)
|
|
@@ -4698,18 +5031,46 @@ function parsePort(raw, label) {
|
|
|
4698
5031
|
}
|
|
4699
5032
|
return value;
|
|
4700
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
|
+
}
|
|
4701
5052
|
var args = process.argv.slice(2);
|
|
4702
|
-
|
|
4703
|
-
|
|
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" }));
|
|
4704
5056
|
process.exit(0);
|
|
4705
5057
|
}
|
|
4706
|
-
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")) {
|
|
4707
5067
|
console.log(packageMetadata.version);
|
|
4708
5068
|
process.exit(0);
|
|
4709
|
-
}
|
|
4710
|
-
try {
|
|
4711
|
-
|
|
4712
|
-
} catch (error) {
|
|
4713
|
-
|
|
4714
|
-
|
|
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
|
+
}
|
|
4715
5076
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/db/pg-sync-worker.ts
|
|
3
|
+
import { parentPort, workerData } from "worker_threads";
|
|
4
|
+
import pg from "pg";
|
|
5
|
+
pg.types.setTypeParser(20, (v) => v === null ? null : Number(v));
|
|
6
|
+
pg.types.setTypeParser(1700, (v) => v === null ? null : Number(v));
|
|
7
|
+
var dsn = workerData?.dsn;
|
|
8
|
+
var ssl = dsn && (dsn.includes("sslmode=require") || dsn.includes("ssl=true")) ? { rejectUnauthorized: false } : undefined;
|
|
9
|
+
var pool = new pg.Pool({
|
|
10
|
+
connectionString: dsn,
|
|
11
|
+
ssl,
|
|
12
|
+
max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"),
|
|
13
|
+
connectionTimeoutMillis: 1e4
|
|
14
|
+
});
|
|
15
|
+
pool.on("error", (err) => {
|
|
16
|
+
console.error(JSON.stringify({ evt: "pg_worker_pool_error", message: err.message }));
|
|
17
|
+
});
|
|
18
|
+
var replyPort = null;
|
|
19
|
+
parentPort?.on("message", (msg) => {
|
|
20
|
+
if (msg.type === "init") {
|
|
21
|
+
replyPort = msg.port;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const { id, sql, params, signal } = msg;
|
|
25
|
+
const sig = new Int32Array(signal);
|
|
26
|
+
pool.query(sql, params).then((res) => ({ id, ok: true, rows: res.rows, rowCount: res.rowCount ?? 0 })).catch((err) => ({ id, ok: false, error: err instanceof Error ? err.message : String(err) })).then((response) => {
|
|
27
|
+
replyPort?.postMessage(response);
|
|
28
|
+
Atomics.store(sig, 0, 1);
|
|
29
|
+
Atomics.notify(sig, 0);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/server/serve.d.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import type { SqliteAdapter as Database } from '@hasna/cloud';
|
|
2
|
+
/** The serve OpenAPI document (source of the generated SDK), version-synced. */
|
|
3
|
+
export declare function openApiDocument(): Record<string, unknown>;
|
|
4
|
+
/**
|
|
5
|
+
* Framework-agnostic API-key verifier shape (matches `@hasna/contracts/auth`
|
|
6
|
+
* `ApiKeyVerifier`). Kept structural so serve.ts has no hard dependency on the
|
|
7
|
+
* auth package; the entry point injects the real verifier.
|
|
8
|
+
*/
|
|
9
|
+
export interface ApiAuthenticator {
|
|
10
|
+
authenticate(headers: Headers, context?: {
|
|
11
|
+
method?: string | null;
|
|
12
|
+
path?: string | null;
|
|
13
|
+
requiredScopes?: readonly string[];
|
|
14
|
+
}): Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
status: number;
|
|
17
|
+
reason?: string;
|
|
18
|
+
message?: string;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
2
21
|
interface StartServerOptions {
|
|
3
22
|
db?: Database;
|
|
4
23
|
dashboardDir?: string;
|
|
@@ -6,7 +25,18 @@ interface StartServerOptions {
|
|
|
6
25
|
log?: (message: string) => void;
|
|
7
26
|
}
|
|
8
27
|
export declare function createServerFetch(apiHandler: (req: Request) => Promise<Response>, dashboardDir?: string): (req: Request) => Promise<Response>;
|
|
9
|
-
export
|
|
28
|
+
export interface HandlerOptions {
|
|
29
|
+
/** API-key verifier (from `@hasna/contracts/auth`). When present, every
|
|
30
|
+
* request outside the open foundation probes must present a valid
|
|
31
|
+
* `economy:*` scoped key. This is the internet-facing auth path. */
|
|
32
|
+
authenticator?: ApiAuthenticator;
|
|
33
|
+
/** Readiness probe proving storage is reachable + migrated. */
|
|
34
|
+
readyCheck?: () => Promise<{
|
|
35
|
+
ready: boolean;
|
|
36
|
+
detail?: string;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
export declare function createHandler(db: Database, options?: HandlerOptions): (req: Request) => Promise<Response>;
|
|
10
40
|
export declare function startServer(port?: number, options?: StartServerOptions): ReturnType<typeof Bun.serve>;
|
|
11
41
|
export {};
|
|
12
42
|
//# sourceMappingURL=serve.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/server/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/server/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,cAAc,CAAA;AAkC7D,gFAAgF;AAChF,wBAAgB,eAAe,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAGzD;AAED;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CACV,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAC7F,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC/E;AAqBD,UAAU,kBAAkB;IAC1B,EAAE,CAAC,EAAE,QAAQ,CAAA;IACb,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAuED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAAE,YAAY,SAAwB,IACzF,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAwB7D;AAQD,MAAM,WAAW,cAAc;IAC7B;;wEAEoE;IACpE,aAAa,CAAC,EAAE,gBAAgB,CAAA;IAChC,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAChE;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,EAAE,OAAO,GAAE,cAAmB,IACxC,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAmZ/D;AAQD,wBAAgB,WAAW,CAAC,IAAI,SAAO,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAAC,OAAO,GAAG,CAAC,KAAK,CAAC,CAkDvG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/economy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.43",
|
|
4
4
|
"description": "AI coding cost tracker — CLI + MCP server + REST API + web dashboard for Claude Code, Codex, Gemini, OpenCode, Cursor, Pi, and Hermes",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"CODE_OF_CONDUCT.md"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build": "cd dashboard && bun run build && cd .. && bun build src/cli/index.ts --outdir dist/cli --target bun --packages external && bun build src/mcp/index.ts --outdir dist/mcp --target bun --packages external && bun build src/server/index.ts --outdir dist/server --target bun --packages external && bun build src/otel/index.ts --outdir dist/otel --target bun --packages external && bun build src/index.ts --outdir dist --target bun --packages external && tsc --emitDeclarationOnly --outDir dist",
|
|
39
|
+
"build": "cd dashboard && bun run build && cd .. && bun build src/cli/index.ts --outdir dist/cli --target bun --packages external && bun build src/mcp/index.ts --outdir dist/mcp --target bun --packages external && bun build src/server/index.ts --outdir dist/server --target bun --packages external && bun build src/db/pg-sync-worker.ts --outdir dist/server --target bun --packages external && bun build src/otel/index.ts --outdir dist/otel --target bun --packages external && bun build src/index.ts --outdir dist --target bun --packages external && tsc --emitDeclarationOnly --outDir dist",
|
|
40
40
|
"build:cli": "bun build src/cli/index.ts --outdir dist/cli --target bun --packages external",
|
|
41
41
|
"build:mcp": "bun build src/mcp/index.ts --outdir dist/mcp --target bun --packages external",
|
|
42
42
|
"build:server": "bun build src/server/index.ts --outdir dist/server --target bun --packages external",
|
|
@@ -71,15 +71,18 @@
|
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@hasna/accounts": "0.1.6",
|
|
73
73
|
"@hasna/cloud": "0.1.24",
|
|
74
|
+
"@hasna/contracts": "0.4.1",
|
|
75
|
+
"@hasna/events": "^0.1.7",
|
|
74
76
|
"@hasna/projects": "0.1.51",
|
|
75
77
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
76
78
|
"chalk": "^5.4.1",
|
|
77
79
|
"commander": "^13.1.0",
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
+
"pg": "8.13.1",
|
|
81
|
+
"zod": "^3.24.2"
|
|
80
82
|
},
|
|
81
83
|
"devDependencies": {
|
|
82
84
|
"@types/bun": "latest",
|
|
85
|
+
"@types/pg": "^8.20.0",
|
|
83
86
|
"bun-types": "latest",
|
|
84
87
|
"typescript": "^5.7.2"
|
|
85
88
|
},
|