@hasna/treasury 0.1.2
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/LICENSE +202 -0
- package/README.md +85 -0
- package/dist/adapters/fixtures.d.ts +31 -0
- package/dist/adapters/fixtures.d.ts.map +1 -0
- package/dist/adapters/types.d.ts +42 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/api/index.d.ts +37 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/cli/context.d.ts +9 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/dashboard.d.ts +5 -0
- package/dist/cli/dashboard.d.ts.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4353 -0
- package/dist/cli/namespaces.d.ts +7 -0
- package/dist/cli/namespaces.d.ts.map +1 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/core/app-home.d.ts +9 -0
- package/dist/core/app-home.d.ts.map +1 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/db/audit.d.ts +16 -0
- package/dist/db/audit.d.ts.map +1 -0
- package/dist/db/backup.d.ts +14 -0
- package/dist/db/backup.d.ts.map +1 -0
- package/dist/db/database.d.ts +40 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/migration-plan.d.ts +14 -0
- package/dist/db/migration-plan.d.ts.map +1 -0
- package/dist/db/schema.d.ts +8 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/generated/storage-kit/health.d.ts +20 -0
- package/dist/generated/storage-kit/health.d.ts.map +1 -0
- package/dist/generated/storage-kit/index.d.ts +8 -0
- package/dist/generated/storage-kit/index.d.ts.map +1 -0
- package/dist/generated/storage-kit/migrations.d.ts +48 -0
- package/dist/generated/storage-kit/migrations.d.ts.map +1 -0
- package/dist/generated/storage-kit/mode.d.ts +48 -0
- package/dist/generated/storage-kit/mode.d.ts.map +1 -0
- package/dist/generated/storage-kit/pool.d.ts +34 -0
- package/dist/generated/storage-kit/pool.d.ts.map +1 -0
- package/dist/generated/storage-kit/query.d.ts +36 -0
- package/dist/generated/storage-kit/query.d.ts.map +1 -0
- package/dist/generated/storage-kit/tls.d.ts +26 -0
- package/dist/generated/storage-kit/tls.d.ts.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23773 -0
- package/dist/mcp/compact.d.ts +12 -0
- package/dist/mcp/compact.d.ts.map +1 -0
- package/dist/mcp/http.d.ts +38 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +32 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +6243 -0
- package/dist/mcp/tools/domain.d.ts +10 -0
- package/dist/mcp/tools/domain.d.ts.map +1 -0
- package/dist/mcp/tools/standard.d.ts +4 -0
- package/dist/mcp/tools/standard.d.ts.map +1 -0
- package/dist/mcp/tools/storage.d.ts +4 -0
- package/dist/mcp/tools/storage.d.ts.map +1 -0
- package/dist/release/package-smoke.d.ts +11 -0
- package/dist/release/package-smoke.d.ts.map +1 -0
- package/dist/server/app.d.ts +19 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/auth-constants.d.ts +22 -0
- package/dist/server/auth-constants.d.ts.map +1 -0
- package/dist/server/auth.d.ts +147 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/health.d.ts +23 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2094 -0
- package/dist/server/list-query.d.ts +7 -0
- package/dist/server/list-query.d.ts.map +1 -0
- package/dist/services/authorization-constants.d.ts +14 -0
- package/dist/services/authorization-constants.d.ts.map +1 -0
- package/dist/services/authorization.d.ts +95 -0
- package/dist/services/authorization.d.ts.map +1 -0
- package/dist/services/balances.d.ts +33 -0
- package/dist/services/balances.d.ts.map +1 -0
- package/dist/services/context.d.ts +24 -0
- package/dist/services/context.d.ts.map +1 -0
- package/dist/services/entities.d.ts +14 -0
- package/dist/services/entities.d.ts.map +1 -0
- package/dist/services/forecast.d.ts +10 -0
- package/dist/services/forecast.d.ts.map +1 -0
- package/dist/services/fx.d.ts +33 -0
- package/dist/services/fx.d.ts.map +1 -0
- package/dist/services/ingest.d.ts +19 -0
- package/dist/services/ingest.d.ts.map +1 -0
- package/dist/services/registry.d.ts +33 -0
- package/dist/services/registry.d.ts.map +1 -0
- package/dist/services/runway.d.ts +28 -0
- package/dist/services/runway.d.ts.map +1 -0
- package/dist/services/storage.d.ts +35 -0
- package/dist/services/storage.d.ts.map +1 -0
- package/dist/services/sweeps.d.ts +26 -0
- package/dist/services/sweeps.d.ts.map +1 -0
- package/dist/types/index.d.ts +133 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/docker-compose.yml +53 -0
- package/hasna.contract.json +20 -0
- package/openapi.json +615 -0
- package/package.json +80 -0
|
@@ -0,0 +1,2094 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
|
|
19
|
+
// src/generated/storage-kit/mode.ts
|
|
20
|
+
function normalizeStorageMode(value) {
|
|
21
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
22
|
+
if (normalized === "local")
|
|
23
|
+
return { mode: "local", deprecatedAlias: null };
|
|
24
|
+
if (normalized === "cloud")
|
|
25
|
+
return { mode: "cloud", deprecatedAlias: null };
|
|
26
|
+
if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
|
|
27
|
+
return { mode: "cloud", deprecatedAlias: normalized };
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
30
|
+
}
|
|
31
|
+
function envToken(name) {
|
|
32
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
33
|
+
}
|
|
34
|
+
function storageEnvKeys(name) {
|
|
35
|
+
const token = envToken(name);
|
|
36
|
+
return {
|
|
37
|
+
modeKeys: [`HASNA_${token}_STORAGE_MODE`, `${token}_STORAGE_MODE`],
|
|
38
|
+
databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function firstEnv2(env, keys) {
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const value = env[key]?.trim();
|
|
44
|
+
if (value)
|
|
45
|
+
return { key, value };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function resolveStorageMode2(name, env = process.env) {
|
|
50
|
+
const { modeKeys, databaseUrlKeys } = storageEnvKeys(name);
|
|
51
|
+
const dbHit = firstEnv2(env, databaseUrlKeys);
|
|
52
|
+
const databaseUrlPresent2 = Boolean(dbHit);
|
|
53
|
+
const databaseUrlSource = dbHit ? dbHit.key : null;
|
|
54
|
+
const modeHit = firstEnv2(env, modeKeys);
|
|
55
|
+
if (!modeHit) {
|
|
56
|
+
return {
|
|
57
|
+
mode: "local",
|
|
58
|
+
source: "default",
|
|
59
|
+
deprecatedAlias: null,
|
|
60
|
+
databaseUrlPresent: databaseUrlPresent2,
|
|
61
|
+
databaseUrlSource,
|
|
62
|
+
warning: null
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const { mode, deprecatedAlias } = normalizeStorageMode(modeHit.value);
|
|
66
|
+
const warnings = [];
|
|
67
|
+
if (deprecatedAlias) {
|
|
68
|
+
warnings.push(`Deprecated storage mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Set ${modeKeys[0]}=cloud instead.`);
|
|
69
|
+
}
|
|
70
|
+
if (mode === "cloud" && !databaseUrlPresent2) {
|
|
71
|
+
warnings.push(`cloud mode needs ${databaseUrlKeys[0]} (PURE REMOTE: reads and writes go to cloud Postgres).`);
|
|
72
|
+
}
|
|
73
|
+
if (modeHit.key !== modeKeys[0]) {
|
|
74
|
+
warnings.push(`Using alias env ${modeHit.key}; the canonical key is ${modeKeys[0]}.`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
mode,
|
|
78
|
+
source: modeHit.key,
|
|
79
|
+
deprecatedAlias,
|
|
80
|
+
databaseUrlPresent: databaseUrlPresent2,
|
|
81
|
+
databaseUrlSource,
|
|
82
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function resolveDatabaseUrl2(name, env = process.env) {
|
|
86
|
+
const { databaseUrlKeys } = storageEnvKeys(name);
|
|
87
|
+
const hit = firstEnv2(env, databaseUrlKeys);
|
|
88
|
+
return hit ? hit.value : null;
|
|
89
|
+
}
|
|
90
|
+
var DEPRECATED_STORAGE_MODE_ALIASES;
|
|
91
|
+
var init_mode = __esm(() => {
|
|
92
|
+
DEPRECATED_STORAGE_MODE_ALIASES = [
|
|
93
|
+
"remote",
|
|
94
|
+
"hybrid",
|
|
95
|
+
"self_hosted"
|
|
96
|
+
];
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// src/generated/storage-kit/tls.ts
|
|
100
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
101
|
+
function sslModeFromConnectionString(connectionString) {
|
|
102
|
+
const queryStart = connectionString.indexOf("?");
|
|
103
|
+
const params = new URLSearchParams(queryStart === -1 ? "" : connectionString.slice(queryStart + 1));
|
|
104
|
+
const sslmode = params.get("sslmode")?.trim().toLowerCase();
|
|
105
|
+
if (sslmode) {
|
|
106
|
+
switch (sslmode) {
|
|
107
|
+
case "disable":
|
|
108
|
+
case "prefer":
|
|
109
|
+
case "require":
|
|
110
|
+
case "verify-ca":
|
|
111
|
+
case "verify-full":
|
|
112
|
+
return sslmode;
|
|
113
|
+
case "allow":
|
|
114
|
+
return "prefer";
|
|
115
|
+
default:
|
|
116
|
+
throw new Error(`Unknown sslmode '${sslmode}' in connection string.`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const ssl = params.get("ssl")?.trim().toLowerCase();
|
|
120
|
+
if (ssl && ["1", "true", "yes", "on", "require"].includes(ssl))
|
|
121
|
+
return "require";
|
|
122
|
+
return "disable";
|
|
123
|
+
}
|
|
124
|
+
function loadCaBundle(options) {
|
|
125
|
+
const env = options.env ?? process.env;
|
|
126
|
+
if (options.ca && options.ca.trim())
|
|
127
|
+
return options.ca;
|
|
128
|
+
const path = options.caCertPath ?? env.PGSSLROOTCERT ?? env.NODE_EXTRA_CA_CERTS;
|
|
129
|
+
if (path && path.trim())
|
|
130
|
+
return readFileSync2(path.trim(), "utf8");
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
function resolveTlsConfig(connectionString, options = {}) {
|
|
134
|
+
const mode = sslModeFromConnectionString(connectionString);
|
|
135
|
+
if (mode === "disable" || mode === "prefer") {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const ca = loadCaBundle(options);
|
|
139
|
+
if (mode === "require") {
|
|
140
|
+
return ca ? { rejectUnauthorized: false, ca } : { rejectUnauthorized: false };
|
|
141
|
+
}
|
|
142
|
+
if (!ca) {
|
|
143
|
+
throw new Error(`sslmode=${mode} requires a CA bundle. Set PGSSLROOTCERT (or pass caCertPath/ca) to the ` + `Amazon RDS global bundle: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`);
|
|
144
|
+
}
|
|
145
|
+
return { rejectUnauthorized: true, ca };
|
|
146
|
+
}
|
|
147
|
+
var init_tls = () => {};
|
|
148
|
+
|
|
149
|
+
// src/generated/storage-kit/query.ts
|
|
150
|
+
var exports_query = {};
|
|
151
|
+
__export(exports_query, {
|
|
152
|
+
wrapExecutor: () => wrapExecutor,
|
|
153
|
+
createQueryClient: () => createQueryClient
|
|
154
|
+
});
|
|
155
|
+
function wrapExecutor(executor) {
|
|
156
|
+
return {
|
|
157
|
+
async query(sql, params) {
|
|
158
|
+
const result = await executor.query(sql, params);
|
|
159
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length };
|
|
160
|
+
},
|
|
161
|
+
async many(sql, params) {
|
|
162
|
+
const result = await executor.query(sql, params);
|
|
163
|
+
return result.rows;
|
|
164
|
+
},
|
|
165
|
+
async get(sql, params) {
|
|
166
|
+
const result = await executor.query(sql, params);
|
|
167
|
+
return result.rows[0] ?? null;
|
|
168
|
+
},
|
|
169
|
+
async one(sql, params) {
|
|
170
|
+
const result = await executor.query(sql, params);
|
|
171
|
+
if (result.rows.length !== 1) {
|
|
172
|
+
throw new Error(`Expected exactly one row, got ${result.rows.length}.`);
|
|
173
|
+
}
|
|
174
|
+
return result.rows[0];
|
|
175
|
+
},
|
|
176
|
+
async execute(sql, params) {
|
|
177
|
+
await executor.query(sql, params);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function createQueryClient(pool) {
|
|
182
|
+
const base = wrapExecutor(pool);
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
pool,
|
|
186
|
+
async transaction(fn) {
|
|
187
|
+
const client = await pool.connect();
|
|
188
|
+
try {
|
|
189
|
+
await client.query("BEGIN");
|
|
190
|
+
const result = await fn(wrapExecutor(client));
|
|
191
|
+
await client.query("COMMIT");
|
|
192
|
+
return result;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
try {
|
|
195
|
+
await client.query("ROLLBACK");
|
|
196
|
+
} catch {}
|
|
197
|
+
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
client.release();
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
async close() {
|
|
203
|
+
await pool.end();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/generated/storage-kit/pool.ts
|
|
209
|
+
var exports_pool = {};
|
|
210
|
+
__export(exports_pool, {
|
|
211
|
+
createPgPool: () => createPgPool,
|
|
212
|
+
createCloudPoolFromEnv: () => createCloudPoolFromEnv
|
|
213
|
+
});
|
|
214
|
+
import pg from "pg";
|
|
215
|
+
function createPgPool(options) {
|
|
216
|
+
const ssl = resolveTlsConfig(options.connectionString, {
|
|
217
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
218
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
219
|
+
...options.env !== undefined ? { env: options.env } : {}
|
|
220
|
+
});
|
|
221
|
+
const config = { connectionString: options.connectionString };
|
|
222
|
+
if (ssl !== undefined)
|
|
223
|
+
config.ssl = ssl;
|
|
224
|
+
if (options.max !== undefined)
|
|
225
|
+
config.max = options.max;
|
|
226
|
+
if (options.idleTimeoutMillis !== undefined)
|
|
227
|
+
config.idleTimeoutMillis = options.idleTimeoutMillis;
|
|
228
|
+
if (options.connectionTimeoutMillis !== undefined)
|
|
229
|
+
config.connectionTimeoutMillis = options.connectionTimeoutMillis;
|
|
230
|
+
if (options.applicationName !== undefined)
|
|
231
|
+
config.application_name = options.applicationName;
|
|
232
|
+
return new pg.Pool(config);
|
|
233
|
+
}
|
|
234
|
+
function createCloudPoolFromEnv(appName, options = {}) {
|
|
235
|
+
const env = options.env ?? process.env;
|
|
236
|
+
const resolution = resolveStorageMode2(appName, env);
|
|
237
|
+
if (resolution.mode !== "cloud") {
|
|
238
|
+
throw new Error(`createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`);
|
|
239
|
+
}
|
|
240
|
+
const connectionString = resolveDatabaseUrl2(appName, env);
|
|
241
|
+
if (!connectionString) {
|
|
242
|
+
throw new Error(`cloud mode for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`);
|
|
243
|
+
}
|
|
244
|
+
const pool = createPgPool({
|
|
245
|
+
connectionString,
|
|
246
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
247
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
248
|
+
env,
|
|
249
|
+
...options.max !== undefined ? { max: options.max } : {},
|
|
250
|
+
...options.idleTimeoutMillis !== undefined ? { idleTimeoutMillis: options.idleTimeoutMillis } : {},
|
|
251
|
+
...options.connectionTimeoutMillis !== undefined ? { connectionTimeoutMillis: options.connectionTimeoutMillis } : {},
|
|
252
|
+
...options.applicationName !== undefined ? { applicationName: options.applicationName } : {}
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
client: createQueryClient(pool),
|
|
256
|
+
connectionSource: resolution.databaseUrlSource ?? "unknown"
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
var init_pool = __esm(() => {
|
|
260
|
+
init_mode();
|
|
261
|
+
init_tls();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// src/server/app.ts
|
|
265
|
+
import { Hono } from "hono";
|
|
266
|
+
|
|
267
|
+
// src/server/auth.ts
|
|
268
|
+
import { Buffer } from "buffer";
|
|
269
|
+
import { timingSafeEqual } from "crypto";
|
|
270
|
+
|
|
271
|
+
// src/types/index.ts
|
|
272
|
+
class TreasuryError extends Error {
|
|
273
|
+
static suggestion = "";
|
|
274
|
+
code = "INTERNAL_ERROR";
|
|
275
|
+
constructor(message) {
|
|
276
|
+
super(message);
|
|
277
|
+
this.name = new.target.name;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
class EntityNotFoundError extends TreasuryError {
|
|
282
|
+
static suggestion = "Use `treasury entities list` to find the correct entity_id.";
|
|
283
|
+
code = "ENTITY_NOT_FOUND";
|
|
284
|
+
constructor(id) {
|
|
285
|
+
super(`Entity not found: ${id}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
class SweepNotFoundError extends TreasuryError {
|
|
290
|
+
static suggestion = "Use `treasury sweeps list` to find the correct sweep id.";
|
|
291
|
+
code = "SWEEP_NOT_FOUND";
|
|
292
|
+
constructor(id) {
|
|
293
|
+
super(`Sweep recommendation not found: ${id}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
class ValidationError extends TreasuryError {
|
|
298
|
+
static suggestion = "Check the request fields and retry.";
|
|
299
|
+
code = "VALIDATION_ERROR";
|
|
300
|
+
constructor(message) {
|
|
301
|
+
super(message);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
class PermissionDeniedError extends TreasuryError {
|
|
306
|
+
static suggestion = "Your credential lacks the required scope or entity access.";
|
|
307
|
+
code = "PERMISSION_DENIED";
|
|
308
|
+
constructor(action, resource) {
|
|
309
|
+
super(`Permission denied for '${action}'${resource ? ` on ${resource}` : ""}.`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/services/authorization-constants.ts
|
|
314
|
+
var allActions = ["read", "write", "recommend", "export", "admin"];
|
|
315
|
+
var rolePermissions = {
|
|
316
|
+
system: new Set(allActions),
|
|
317
|
+
owner: new Set(allActions),
|
|
318
|
+
admin: new Set(allActions),
|
|
319
|
+
treasurer: new Set(["read", "write", "recommend", "export"]),
|
|
320
|
+
analyst: new Set(["read", "recommend"]),
|
|
321
|
+
auditor: new Set(["read", "export"]),
|
|
322
|
+
integration: new Set(["read", "write"])
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// src/services/authorization.ts
|
|
326
|
+
var SYSTEM_AUTHORIZATION_CONTEXT = {
|
|
327
|
+
actor_id: "system",
|
|
328
|
+
roles: ["system"],
|
|
329
|
+
bypass: true
|
|
330
|
+
};
|
|
331
|
+
function allowedEntitySet(context) {
|
|
332
|
+
const ids = new Set;
|
|
333
|
+
if (context.entity_id)
|
|
334
|
+
ids.add(context.entity_id);
|
|
335
|
+
for (const id of context.entity_ids ?? [])
|
|
336
|
+
ids.add(id);
|
|
337
|
+
if (context.org_id)
|
|
338
|
+
ids.add(context.org_id);
|
|
339
|
+
for (const id of context.org_ids ?? [])
|
|
340
|
+
ids.add(id);
|
|
341
|
+
return ids;
|
|
342
|
+
}
|
|
343
|
+
function hasEntityAccess(context, entityId) {
|
|
344
|
+
if (context.bypass)
|
|
345
|
+
return true;
|
|
346
|
+
if (!entityId)
|
|
347
|
+
return true;
|
|
348
|
+
return allowedEntitySet(context).has(entityId);
|
|
349
|
+
}
|
|
350
|
+
function hasAllEntityAccess(context, entityIds) {
|
|
351
|
+
return entityIds.every((id) => hasEntityAccess(context, id));
|
|
352
|
+
}
|
|
353
|
+
function roleAllows(role, action) {
|
|
354
|
+
return rolePermissions[role].has(action);
|
|
355
|
+
}
|
|
356
|
+
function authorize(action, context, resource = {}) {
|
|
357
|
+
const principal = context ?? SYSTEM_AUTHORIZATION_CONTEXT;
|
|
358
|
+
if (!hasEntityAccess(principal, resource.entity_id)) {
|
|
359
|
+
throw new PermissionDeniedError(action, resource.resource || resource.entity_id);
|
|
360
|
+
}
|
|
361
|
+
if (resource.entity_ids && !hasAllEntityAccess(principal, resource.entity_ids)) {
|
|
362
|
+
throw new PermissionDeniedError(action, resource.resource || "entity-group");
|
|
363
|
+
}
|
|
364
|
+
if (principal.bypass || principal.roles.some((role) => roleAllows(role, action))) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
throw new PermissionDeniedError(action, resource.resource);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/server/auth-constants.ts
|
|
371
|
+
var apiScopes = [
|
|
372
|
+
"treasury:read",
|
|
373
|
+
"treasury:write",
|
|
374
|
+
"treasury:recommend",
|
|
375
|
+
"treasury:export",
|
|
376
|
+
"treasury:admin",
|
|
377
|
+
"storage:admin"
|
|
378
|
+
];
|
|
379
|
+
var allScopes = [...apiScopes];
|
|
380
|
+
var AUTH_CONSTANTS = {
|
|
381
|
+
apiScopes,
|
|
382
|
+
knownRoles: ["system", "owner", "admin", "treasurer", "analyst", "auditor", "integration"],
|
|
383
|
+
roleScopes: {
|
|
384
|
+
system: allScopes,
|
|
385
|
+
owner: allScopes,
|
|
386
|
+
admin: allScopes,
|
|
387
|
+
treasurer: ["treasury:read", "treasury:write", "treasury:recommend", "treasury:export"],
|
|
388
|
+
analyst: ["treasury:read", "treasury:recommend"],
|
|
389
|
+
auditor: ["treasury:read", "treasury:export"],
|
|
390
|
+
integration: ["treasury:read", "treasury:write"]
|
|
391
|
+
},
|
|
392
|
+
actionScope: {
|
|
393
|
+
read: "treasury:read",
|
|
394
|
+
write: "treasury:write",
|
|
395
|
+
recommend: "treasury:recommend",
|
|
396
|
+
export: "treasury:export",
|
|
397
|
+
admin: "treasury:admin"
|
|
398
|
+
},
|
|
399
|
+
defaultAction: "treasury:admin",
|
|
400
|
+
env: {
|
|
401
|
+
apiKey: ["HASNA_TREASURY_API_KEY", "TREASURY_API_KEY"],
|
|
402
|
+
credentials: ["HASNA_TREASURY_API_CREDENTIALS", "TREASURY_API_CREDENTIALS"]
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// src/server/auth.ts
|
|
407
|
+
var allScopes2 = [...AUTH_CONSTANTS.apiScopes];
|
|
408
|
+
var knownScopes = new Set(allScopes2);
|
|
409
|
+
var knownRoles = new Set(AUTH_CONSTANTS.knownRoles);
|
|
410
|
+
var roleScopes = AUTH_CONSTANTS.roleScopes;
|
|
411
|
+
var actionScope = AUTH_CONSTANTS.actionScope;
|
|
412
|
+
function scopesForRoles(roles) {
|
|
413
|
+
return Array.from(new Set(roles.flatMap((role) => roleScopes[role] || [])));
|
|
414
|
+
}
|
|
415
|
+
function legacyApiKey() {
|
|
416
|
+
for (const name of AUTH_CONSTANTS.env.apiKey) {
|
|
417
|
+
const value = process.env[name]?.trim();
|
|
418
|
+
if (value)
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
function credentialsRaw() {
|
|
424
|
+
for (const name of AUTH_CONSTANTS.env.credentials) {
|
|
425
|
+
const value = process.env[name];
|
|
426
|
+
if (value)
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
function isApiAuthConfigured() {
|
|
432
|
+
return Boolean(legacyApiKey() || credentialsRaw());
|
|
433
|
+
}
|
|
434
|
+
function configuredApiCredentials() {
|
|
435
|
+
const raw = credentialsRaw();
|
|
436
|
+
if (!raw)
|
|
437
|
+
return [];
|
|
438
|
+
let parsed;
|
|
439
|
+
try {
|
|
440
|
+
parsed = JSON.parse(raw);
|
|
441
|
+
} catch {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
445
|
+
return list.filter((cred) => Boolean((cred.token || cred.key) && cred.id));
|
|
446
|
+
}
|
|
447
|
+
function bearerToken(input) {
|
|
448
|
+
const header = typeof input === "object" && input !== null && "headers" in input ? input.headers.get("Authorization") : input;
|
|
449
|
+
const auth = (header || "").trim();
|
|
450
|
+
if (!auth)
|
|
451
|
+
return "";
|
|
452
|
+
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : auth;
|
|
453
|
+
}
|
|
454
|
+
function credentialEntityIds(credential) {
|
|
455
|
+
const ids = new Set;
|
|
456
|
+
if (credential.entity_id)
|
|
457
|
+
ids.add(credential.entity_id);
|
|
458
|
+
for (const id of credential.entity_ids ?? [])
|
|
459
|
+
ids.add(id);
|
|
460
|
+
if (credential.org_id)
|
|
461
|
+
ids.add(credential.org_id);
|
|
462
|
+
for (const id of credential.org_ids ?? [])
|
|
463
|
+
ids.add(id);
|
|
464
|
+
return ids.size > 0 ? [...ids] : undefined;
|
|
465
|
+
}
|
|
466
|
+
function authenticateToken(token) {
|
|
467
|
+
if (!token)
|
|
468
|
+
return null;
|
|
469
|
+
const legacy = legacyApiKey();
|
|
470
|
+
if (legacy && safeEqual(token, legacy)) {
|
|
471
|
+
return {
|
|
472
|
+
actor_id: "legacy-api-key",
|
|
473
|
+
credential_id: "legacy-api-key",
|
|
474
|
+
credential_type: "api_key",
|
|
475
|
+
roles: ["owner"],
|
|
476
|
+
scopes: allScopes2
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
for (const credential of configuredApiCredentials()) {
|
|
480
|
+
const secret = credential.token || credential.key || "";
|
|
481
|
+
if (!safeEqual(token, secret) || credential.revoked || isExpired(credential.expires_at))
|
|
482
|
+
continue;
|
|
483
|
+
const roles = normalizeRoles(credential.roles);
|
|
484
|
+
const scopes = normalizeScopes(credential.scopes) || scopesForRoles(roles);
|
|
485
|
+
const entity_ids = credentialEntityIds(credential);
|
|
486
|
+
return {
|
|
487
|
+
actor_id: credential.actor_id || `${credential.type || "api_key"}:${credential.id}`,
|
|
488
|
+
credential_id: credential.id,
|
|
489
|
+
credential_type: credential.type || "api_key",
|
|
490
|
+
roles,
|
|
491
|
+
scopes,
|
|
492
|
+
...entity_ids ? { entity_ids } : {}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const verify = AUTH_CONSTANTS.verifyToken;
|
|
496
|
+
if (verify && token.split(".").length === 3) {
|
|
497
|
+
try {
|
|
498
|
+
const verified = verify(token);
|
|
499
|
+
return {
|
|
500
|
+
actor_id: verified.identity_id,
|
|
501
|
+
credential_id: verified.jti,
|
|
502
|
+
credential_type: "session",
|
|
503
|
+
roles: [],
|
|
504
|
+
scopes: normalizeScopes(verified.scopes) ?? [],
|
|
505
|
+
...verified.entity_ids ? { entity_ids: verified.entity_ids } : {}
|
|
506
|
+
};
|
|
507
|
+
} catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
function authenticateApiRequest(req) {
|
|
514
|
+
return authenticateToken(bearerToken(req.headers.get("Authorization")));
|
|
515
|
+
}
|
|
516
|
+
function normalizeRoles(roles = ["integration"]) {
|
|
517
|
+
const normalized = roles.filter((role) => knownRoles.has(role));
|
|
518
|
+
return normalized.length > 0 ? Array.from(new Set(normalized)) : ["integration"];
|
|
519
|
+
}
|
|
520
|
+
function normalizeScopes(scopes) {
|
|
521
|
+
if (!scopes)
|
|
522
|
+
return null;
|
|
523
|
+
return Array.from(new Set(scopes.filter((scope) => knownScopes.has(scope))));
|
|
524
|
+
}
|
|
525
|
+
function safeEqual(a, b) {
|
|
526
|
+
const left = Buffer.from(a);
|
|
527
|
+
const right = Buffer.from(b);
|
|
528
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
529
|
+
}
|
|
530
|
+
function isExpired(expiresAt) {
|
|
531
|
+
return Boolean(expiresAt && Date.parse(expiresAt) <= Date.now());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/db/database.ts
|
|
535
|
+
import { Database } from "bun:sqlite";
|
|
536
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
537
|
+
import { dirname } from "path";
|
|
538
|
+
|
|
539
|
+
// src/config.ts
|
|
540
|
+
import { existsSync, readFileSync } from "fs";
|
|
541
|
+
import { homedir } from "os";
|
|
542
|
+
import { join } from "path";
|
|
543
|
+
var APP_NAME = "treasury";
|
|
544
|
+
var ENV_TOKEN = "TREASURY";
|
|
545
|
+
var DEPRECATED_CLOUD_ALIASES = new Set(["remote", "hybrid", "self_hosted"]);
|
|
546
|
+
var MODE_KEYS = [`HASNA_${ENV_TOKEN}_STORAGE_MODE`, `${ENV_TOKEN}_STORAGE_MODE`];
|
|
547
|
+
var DB_URL_KEYS = [`HASNA_${ENV_TOKEN}_DATABASE_URL`, `${ENV_TOKEN}_DATABASE_URL`];
|
|
548
|
+
var DB_URL_FILE_KEYS = [`HASNA_${ENV_TOKEN}_DATABASE_URL_FILE`, `${ENV_TOKEN}_DATABASE_URL_FILE`];
|
|
549
|
+
var DB_PATH_KEYS = [`HASNA_${ENV_TOKEN}_DB_PATH`, `${ENV_TOKEN}_DB_PATH`];
|
|
550
|
+
function firstEnv(env, keys) {
|
|
551
|
+
for (const key of keys) {
|
|
552
|
+
const value = env[key]?.trim();
|
|
553
|
+
if (value)
|
|
554
|
+
return value;
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
function resolveStorageMode(env = process.env) {
|
|
559
|
+
const raw = firstEnv(env, MODE_KEYS);
|
|
560
|
+
const dsnPresent = databaseUrlPresent(env);
|
|
561
|
+
let mode;
|
|
562
|
+
if (!raw) {
|
|
563
|
+
mode = "local";
|
|
564
|
+
} else {
|
|
565
|
+
const normalized = raw.toLowerCase().replace(/-/g, "_");
|
|
566
|
+
if (normalized === "local")
|
|
567
|
+
mode = "local";
|
|
568
|
+
else if (normalized === "cloud" || DEPRECATED_CLOUD_ALIASES.has(normalized))
|
|
569
|
+
mode = "cloud";
|
|
570
|
+
else
|
|
571
|
+
throw new Error(`Unknown storage mode: ${raw}. Use local or cloud.`);
|
|
572
|
+
}
|
|
573
|
+
if (mode === "local" && dsnPresent) {
|
|
574
|
+
throw new Error(`Fail-closed misconfig: a ${ENV_TOKEN} DATABASE_URL is present but storage mode is 'local'. ` + `Set HASNA_${ENV_TOKEN}_STORAGE_MODE=cloud (PURE REMOTE) or unset the DATABASE_URL. ` + `Refusing to silently write money/audit data to SQLite while a cloud DB is configured.`);
|
|
575
|
+
}
|
|
576
|
+
if (mode === "cloud" && !dsnPresent) {
|
|
577
|
+
console.warn(`[treasury] cloud mode needs HASNA_${ENV_TOKEN}_DATABASE_URL (or *_FILE); ` + `PURE REMOTE reads/writes go to cloud Postgres.`);
|
|
578
|
+
}
|
|
579
|
+
return mode;
|
|
580
|
+
}
|
|
581
|
+
function databaseUrlPresent(env = process.env) {
|
|
582
|
+
return firstEnv(env, DB_URL_KEYS) !== undefined || firstEnv(env, DB_URL_FILE_KEYS) !== undefined;
|
|
583
|
+
}
|
|
584
|
+
function resolveDatabaseUrl(env = process.env) {
|
|
585
|
+
const filePath = firstEnv(env, DB_URL_FILE_KEYS);
|
|
586
|
+
if (filePath && existsSync(filePath))
|
|
587
|
+
return readFileSync(filePath, "utf8").trim();
|
|
588
|
+
return firstEnv(env, DB_URL_KEYS) ?? null;
|
|
589
|
+
}
|
|
590
|
+
function scrubDatabaseUrl(env = process.env) {
|
|
591
|
+
for (const key of DB_URL_KEYS)
|
|
592
|
+
delete env[key];
|
|
593
|
+
}
|
|
594
|
+
function defaultSqlitePath() {
|
|
595
|
+
return join(homedir(), ".hasna", APP_NAME, `${APP_NAME}.db`);
|
|
596
|
+
}
|
|
597
|
+
function resolveDbPath(env = process.env) {
|
|
598
|
+
return firstEnv(env, DB_PATH_KEYS) ?? defaultSqlitePath();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/core/app-home.ts
|
|
602
|
+
import { existsSync as existsSync2, mkdirSync } from "fs";
|
|
603
|
+
import { homedir as homedir2 } from "os";
|
|
604
|
+
import { join as join2, resolve } from "path";
|
|
605
|
+
var TREASURY_APP_SUBDIRS = ["config", "data", "exports", "backups", "logs", "tmp"];
|
|
606
|
+
function homeDir() {
|
|
607
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
608
|
+
}
|
|
609
|
+
function getTreasuryAppHome() {
|
|
610
|
+
return resolve(process.env["HASNA_TREASURY_HOME"] ?? process.env["TREASURY_HOME"] ?? join2(homeDir(), ".hasna", APP_NAME));
|
|
611
|
+
}
|
|
612
|
+
function getTreasuryAppDir(name) {
|
|
613
|
+
return join2(getTreasuryAppHome(), name);
|
|
614
|
+
}
|
|
615
|
+
function ensureTreasuryAppHome() {
|
|
616
|
+
const root = getTreasuryAppHome();
|
|
617
|
+
mkdirSync(root, { recursive: true, mode: 448 });
|
|
618
|
+
const dirs = { root };
|
|
619
|
+
for (const name of TREASURY_APP_SUBDIRS) {
|
|
620
|
+
const dir = getTreasuryAppDir(name);
|
|
621
|
+
if (!existsSync2(dir))
|
|
622
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
623
|
+
dirs[name] = dir;
|
|
624
|
+
}
|
|
625
|
+
return dirs;
|
|
626
|
+
}
|
|
627
|
+
function getDefaultTreasuryBackupDir() {
|
|
628
|
+
return getTreasuryAppDir("backups");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/db/backup.ts
|
|
632
|
+
import { chmodSync, copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, statSync, unlinkSync } from "fs";
|
|
633
|
+
import { basename, join as join3, resolve as resolve2 } from "path";
|
|
634
|
+
var RETENTION = 10;
|
|
635
|
+
function backupsDisabled() {
|
|
636
|
+
const raw = process.env["HASNA_TREASURY_BACKUP_BEFORE_MIGRATION"] ?? process.env["TREASURY_BACKUP_BEFORE_MIGRATION"];
|
|
637
|
+
if (!raw)
|
|
638
|
+
return false;
|
|
639
|
+
return ["0", "false", "no", "off"].includes(raw.toLowerCase());
|
|
640
|
+
}
|
|
641
|
+
function backupDirFor(dbPath) {
|
|
642
|
+
const override = process.env["HASNA_TREASURY_BACKUP_DIR"] ?? process.env["TREASURY_BACKUP_DIR"];
|
|
643
|
+
if (override)
|
|
644
|
+
return resolve2(override);
|
|
645
|
+
const resolved = resolve2(dbPath);
|
|
646
|
+
if (resolved === resolve2(getDefaultTreasuryBackupDir(), "..", "data", "treasury.db")) {
|
|
647
|
+
return getDefaultTreasuryBackupDir();
|
|
648
|
+
}
|
|
649
|
+
return getDefaultTreasuryBackupDir();
|
|
650
|
+
}
|
|
651
|
+
function maybeBackupBeforeMigration(dbPath) {
|
|
652
|
+
if (backupsDisabled())
|
|
653
|
+
return { skipped: true, reason: "disabled" };
|
|
654
|
+
if (dbPath === ":memory:")
|
|
655
|
+
return { skipped: true, reason: "memory database" };
|
|
656
|
+
if (!existsSync3(dbPath))
|
|
657
|
+
return { skipped: true, reason: "no existing database" };
|
|
658
|
+
if (statSync(dbPath).size === 0)
|
|
659
|
+
return { skipped: true, reason: "empty database" };
|
|
660
|
+
const dir = backupDirFor(dbPath);
|
|
661
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
662
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
663
|
+
const backupPath = join3(dir, `${basename(dbPath)}-${stamp}-pre-migration.db`);
|
|
664
|
+
copyFileSync(dbPath, backupPath);
|
|
665
|
+
chmodSync(backupPath, 384);
|
|
666
|
+
pruneOldBackups(dir, basename(dbPath));
|
|
667
|
+
return { skipped: false, backup_path: backupPath };
|
|
668
|
+
}
|
|
669
|
+
function pruneOldBackups(dir, dbBase) {
|
|
670
|
+
const prefix = `${dbBase}-`;
|
|
671
|
+
const entries = readdirSync(dir).filter((n) => n.startsWith(prefix) && n.endsWith("-pre-migration.db")).map((n) => ({ name: n, path: join3(dir, n), mtime: statSync(join3(dir, n)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
|
|
672
|
+
for (const stale of entries.slice(RETENTION)) {
|
|
673
|
+
try {
|
|
674
|
+
unlinkSync(stale.path);
|
|
675
|
+
} catch {}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/db/schema.ts
|
|
680
|
+
var SQLITE_SCHEMA = `
|
|
681
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
682
|
+
id INTEGER PRIMARY KEY,
|
|
683
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
687
|
+
entity_id TEXT PRIMARY KEY,
|
|
688
|
+
entity_slug TEXT UNIQUE,
|
|
689
|
+
name TEXT NOT NULL,
|
|
690
|
+
base_currency TEXT NOT NULL,
|
|
691
|
+
created_at TEXT NOT NULL,
|
|
692
|
+
updated_at TEXT NOT NULL
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
|
696
|
+
id TEXT PRIMARY KEY,
|
|
697
|
+
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
698
|
+
account_ref TEXT NOT NULL,
|
|
699
|
+
account_kind TEXT NOT NULL,
|
|
700
|
+
currency TEXT NOT NULL,
|
|
701
|
+
amount_minor INTEGER NOT NULL,
|
|
702
|
+
as_of TEXT NOT NULL,
|
|
703
|
+
source TEXT NOT NULL,
|
|
704
|
+
captured_at TEXT NOT NULL
|
|
705
|
+
);
|
|
706
|
+
CREATE INDEX IF NOT EXISTS idx_balances_entity ON balance_snapshots(entity_id);
|
|
707
|
+
|
|
708
|
+
CREATE TABLE IF NOT EXISTS fx_rates (
|
|
709
|
+
id TEXT PRIMARY KEY,
|
|
710
|
+
base_currency TEXT NOT NULL,
|
|
711
|
+
quote_currency TEXT NOT NULL,
|
|
712
|
+
rate REAL NOT NULL,
|
|
713
|
+
as_of TEXT NOT NULL,
|
|
714
|
+
source TEXT NOT NULL,
|
|
715
|
+
captured_at TEXT NOT NULL
|
|
716
|
+
);
|
|
717
|
+
CREATE INDEX IF NOT EXISTS idx_fx_pair ON fx_rates(base_currency, quote_currency);
|
|
718
|
+
|
|
719
|
+
CREATE TABLE IF NOT EXISTS cost_feeds (
|
|
720
|
+
id TEXT PRIMARY KEY,
|
|
721
|
+
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
722
|
+
currency TEXT NOT NULL,
|
|
723
|
+
monthly_burn_minor INTEGER NOT NULL,
|
|
724
|
+
as_of TEXT NOT NULL,
|
|
725
|
+
source TEXT NOT NULL,
|
|
726
|
+
captured_at TEXT NOT NULL
|
|
727
|
+
);
|
|
728
|
+
CREATE INDEX IF NOT EXISTS idx_cost_entity ON cost_feeds(entity_id);
|
|
729
|
+
|
|
730
|
+
CREATE TABLE IF NOT EXISTS sweep_recommendations (
|
|
731
|
+
id TEXT PRIMARY KEY,
|
|
732
|
+
from_entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
733
|
+
to_entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
734
|
+
currency TEXT NOT NULL,
|
|
735
|
+
amount_minor INTEGER NOT NULL,
|
|
736
|
+
rationale TEXT NOT NULL,
|
|
737
|
+
status TEXT NOT NULL DEFAULT 'recommended',
|
|
738
|
+
created_at TEXT NOT NULL,
|
|
739
|
+
updated_at TEXT NOT NULL
|
|
740
|
+
);
|
|
741
|
+
CREATE INDEX IF NOT EXISTS idx_sweeps_from ON sweep_recommendations(from_entity_id);
|
|
742
|
+
|
|
743
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
744
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
745
|
+
entity_id TEXT,
|
|
746
|
+
actor_id TEXT NOT NULL,
|
|
747
|
+
action TEXT NOT NULL,
|
|
748
|
+
detail TEXT NOT NULL,
|
|
749
|
+
prev_hash TEXT NOT NULL,
|
|
750
|
+
row_hash TEXT NOT NULL,
|
|
751
|
+
created_at TEXT NOT NULL
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
-- Keep the hash chain strictly linear under concurrent writers: each row's
|
|
755
|
+
-- prev_hash is the previous row's row_hash, so it is unique by construction.
|
|
756
|
+
-- A concurrent fork (two appends reading the same prev_hash) then loses the
|
|
757
|
+
-- race with a unique-violation instead of silently forking the chain (\xA74.7).
|
|
758
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_prev_hash ON audit_log(prev_hash);
|
|
759
|
+
|
|
760
|
+
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
|
|
761
|
+
BEFORE UPDATE ON audit_log
|
|
762
|
+
BEGIN
|
|
763
|
+
SELECT RAISE(ABORT, 'audit_log is append-only');
|
|
764
|
+
END;
|
|
765
|
+
|
|
766
|
+
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
|
|
767
|
+
BEFORE DELETE ON audit_log
|
|
768
|
+
BEGIN
|
|
769
|
+
SELECT RAISE(ABORT, 'audit_log is append-only');
|
|
770
|
+
END;
|
|
771
|
+
`;
|
|
772
|
+
function runSqliteMigrations(db) {
|
|
773
|
+
db.run(SQLITE_SCHEMA);
|
|
774
|
+
db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (1)");
|
|
775
|
+
}
|
|
776
|
+
var POSTGRES_MIGRATIONS = [
|
|
777
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
778
|
+
id INTEGER PRIMARY KEY,
|
|
779
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
780
|
+
)`,
|
|
781
|
+
`INSERT INTO schema_migrations (id) VALUES (1) ON CONFLICT DO NOTHING`,
|
|
782
|
+
`CREATE TABLE IF NOT EXISTS entities (
|
|
783
|
+
entity_id TEXT PRIMARY KEY,
|
|
784
|
+
entity_slug TEXT UNIQUE,
|
|
785
|
+
name TEXT NOT NULL,
|
|
786
|
+
base_currency TEXT NOT NULL,
|
|
787
|
+
created_at TEXT NOT NULL,
|
|
788
|
+
updated_at TEXT NOT NULL
|
|
789
|
+
)`,
|
|
790
|
+
`CREATE TABLE IF NOT EXISTS balance_snapshots (
|
|
791
|
+
id TEXT PRIMARY KEY,
|
|
792
|
+
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
793
|
+
account_ref TEXT NOT NULL,
|
|
794
|
+
account_kind TEXT NOT NULL,
|
|
795
|
+
currency TEXT NOT NULL,
|
|
796
|
+
amount_minor BIGINT NOT NULL,
|
|
797
|
+
as_of TEXT NOT NULL,
|
|
798
|
+
source TEXT NOT NULL,
|
|
799
|
+
captured_at TEXT NOT NULL
|
|
800
|
+
)`,
|
|
801
|
+
`CREATE INDEX IF NOT EXISTS idx_balances_entity ON balance_snapshots(entity_id)`,
|
|
802
|
+
`CREATE TABLE IF NOT EXISTS fx_rates (
|
|
803
|
+
id TEXT PRIMARY KEY,
|
|
804
|
+
base_currency TEXT NOT NULL,
|
|
805
|
+
quote_currency TEXT NOT NULL,
|
|
806
|
+
rate DOUBLE PRECISION NOT NULL,
|
|
807
|
+
as_of TEXT NOT NULL,
|
|
808
|
+
source TEXT NOT NULL,
|
|
809
|
+
captured_at TEXT NOT NULL
|
|
810
|
+
)`,
|
|
811
|
+
`CREATE INDEX IF NOT EXISTS idx_fx_pair ON fx_rates(base_currency, quote_currency)`,
|
|
812
|
+
`CREATE TABLE IF NOT EXISTS cost_feeds (
|
|
813
|
+
id TEXT PRIMARY KEY,
|
|
814
|
+
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
815
|
+
currency TEXT NOT NULL,
|
|
816
|
+
monthly_burn_minor BIGINT NOT NULL,
|
|
817
|
+
as_of TEXT NOT NULL,
|
|
818
|
+
source TEXT NOT NULL,
|
|
819
|
+
captured_at TEXT NOT NULL
|
|
820
|
+
)`,
|
|
821
|
+
`CREATE INDEX IF NOT EXISTS idx_cost_entity ON cost_feeds(entity_id)`,
|
|
822
|
+
`CREATE TABLE IF NOT EXISTS sweep_recommendations (
|
|
823
|
+
id TEXT PRIMARY KEY,
|
|
824
|
+
from_entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
825
|
+
to_entity_id TEXT NOT NULL REFERENCES entities(entity_id),
|
|
826
|
+
currency TEXT NOT NULL,
|
|
827
|
+
amount_minor BIGINT NOT NULL,
|
|
828
|
+
rationale TEXT NOT NULL,
|
|
829
|
+
status TEXT NOT NULL DEFAULT 'recommended',
|
|
830
|
+
created_at TEXT NOT NULL,
|
|
831
|
+
updated_at TEXT NOT NULL
|
|
832
|
+
)`,
|
|
833
|
+
`CREATE INDEX IF NOT EXISTS idx_sweeps_from ON sweep_recommendations(from_entity_id)`,
|
|
834
|
+
`CREATE TABLE IF NOT EXISTS audit_log (
|
|
835
|
+
id BIGSERIAL PRIMARY KEY,
|
|
836
|
+
entity_id TEXT,
|
|
837
|
+
actor_id TEXT NOT NULL,
|
|
838
|
+
action TEXT NOT NULL,
|
|
839
|
+
detail TEXT NOT NULL,
|
|
840
|
+
prev_hash TEXT NOT NULL,
|
|
841
|
+
row_hash TEXT NOT NULL,
|
|
842
|
+
created_at TEXT NOT NULL
|
|
843
|
+
)`,
|
|
844
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_prev_hash ON audit_log(prev_hash)`,
|
|
845
|
+
`CREATE OR REPLACE FUNCTION treasury_audit_immutable() RETURNS trigger AS $$
|
|
846
|
+
BEGIN RAISE EXCEPTION 'audit_log is append-only'; END; $$ LANGUAGE plpgsql`,
|
|
847
|
+
`DROP TRIGGER IF EXISTS audit_log_no_mutate ON audit_log`,
|
|
848
|
+
`CREATE TRIGGER audit_log_no_mutate BEFORE UPDATE OR DELETE ON audit_log
|
|
849
|
+
FOR EACH ROW EXECUTE FUNCTION treasury_audit_immutable()`
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
// src/db/database.ts
|
|
853
|
+
class SqliteClient {
|
|
854
|
+
db;
|
|
855
|
+
mode = "local";
|
|
856
|
+
constructor(db) {
|
|
857
|
+
this.db = db;
|
|
858
|
+
}
|
|
859
|
+
async all(sql, params = []) {
|
|
860
|
+
return this.db.query(sql).all(...params);
|
|
861
|
+
}
|
|
862
|
+
async get(sql, params = []) {
|
|
863
|
+
return this.db.query(sql).get(...params) ?? null;
|
|
864
|
+
}
|
|
865
|
+
async run(sql, params = []) {
|
|
866
|
+
this.db.query(sql).run(...params);
|
|
867
|
+
}
|
|
868
|
+
async transaction(fn) {
|
|
869
|
+
this.db.run("BEGIN");
|
|
870
|
+
try {
|
|
871
|
+
const result = await fn(this);
|
|
872
|
+
this.db.run("COMMIT");
|
|
873
|
+
return result;
|
|
874
|
+
} catch (error) {
|
|
875
|
+
try {
|
|
876
|
+
this.db.run("ROLLBACK");
|
|
877
|
+
} catch {}
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async close() {
|
|
882
|
+
this.db.close();
|
|
883
|
+
}
|
|
884
|
+
raw() {
|
|
885
|
+
return this.db;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function toPgSql(sql) {
|
|
889
|
+
let i = 0;
|
|
890
|
+
return sql.replace(/\?/g, () => `$${++i}`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
class PostgresClient {
|
|
894
|
+
client;
|
|
895
|
+
full;
|
|
896
|
+
mode = "cloud";
|
|
897
|
+
constructor(client, full) {
|
|
898
|
+
this.client = client;
|
|
899
|
+
this.full = full;
|
|
900
|
+
}
|
|
901
|
+
async all(sql, params = []) {
|
|
902
|
+
return this.client.many(toPgSql(sql), params);
|
|
903
|
+
}
|
|
904
|
+
async get(sql, params = []) {
|
|
905
|
+
return this.client.get(toPgSql(sql), params);
|
|
906
|
+
}
|
|
907
|
+
async run(sql, params = []) {
|
|
908
|
+
await this.client.execute(toPgSql(sql), params);
|
|
909
|
+
}
|
|
910
|
+
async transaction(fn) {
|
|
911
|
+
if (!this.full)
|
|
912
|
+
throw new Error("Nested transaction is not supported.");
|
|
913
|
+
return this.full.transaction((c) => fn(new PostgresClient(c)));
|
|
914
|
+
}
|
|
915
|
+
async close() {
|
|
916
|
+
await this.full?.close();
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
var _client = null;
|
|
920
|
+
async function openDatabase(opts = {}) {
|
|
921
|
+
if (_client && !opts.fresh && !opts.path && !opts.mode)
|
|
922
|
+
return _client;
|
|
923
|
+
const mode = opts.mode ?? resolveStorageMode();
|
|
924
|
+
const client = opts.path !== undefined ? openLocal(opts.path) : mode === "cloud" ? await openCloud() : openLocal(resolveLocalPath());
|
|
925
|
+
if (!opts.path && !opts.fresh && !opts.mode)
|
|
926
|
+
_client = client;
|
|
927
|
+
return client;
|
|
928
|
+
}
|
|
929
|
+
function resolveLocalPath() {
|
|
930
|
+
ensureTreasuryAppHome();
|
|
931
|
+
return resolveDbPath();
|
|
932
|
+
}
|
|
933
|
+
function openLocal(path) {
|
|
934
|
+
if (path !== ":memory:") {
|
|
935
|
+
mkdirSync3(dirname(path), { recursive: true, mode: 448 });
|
|
936
|
+
maybeBackupBeforeMigration(path);
|
|
937
|
+
}
|
|
938
|
+
const db = new Database(path);
|
|
939
|
+
db.run("PRAGMA journal_mode = WAL;");
|
|
940
|
+
db.run("PRAGMA busy_timeout = 5000;");
|
|
941
|
+
db.run("PRAGMA foreign_keys = ON;");
|
|
942
|
+
runSqliteMigrations(db);
|
|
943
|
+
return new SqliteClient(db);
|
|
944
|
+
}
|
|
945
|
+
async function openCloud() {
|
|
946
|
+
const dsn = resolveDatabaseUrl();
|
|
947
|
+
if (!dsn) {
|
|
948
|
+
throw new Error("cloud storage mode requires HASNA_TREASURY_DATABASE_URL (or *_FILE). " + "PURE REMOTE refuses to fall back to local/in-memory SQLite.");
|
|
949
|
+
}
|
|
950
|
+
if (!/sslmode=verify-full/.test(dsn)) {
|
|
951
|
+
throw new Error("cloud DSN must use sslmode=verify-full (BUILD-SPEC \xA74.8); sslmode=require is forbidden.");
|
|
952
|
+
}
|
|
953
|
+
const { createPgPool: createPgPool2 } = await Promise.resolve().then(() => (init_pool(), exports_pool));
|
|
954
|
+
const { createQueryClient: createQueryClient2 } = await Promise.resolve().then(() => exports_query);
|
|
955
|
+
const pool = createPgPool2({ connectionString: dsn, env: process.env, applicationName: "treasury" });
|
|
956
|
+
const client = createQueryClient2(pool);
|
|
957
|
+
for (const sql of POSTGRES_MIGRATIONS) {
|
|
958
|
+
await client.execute(sql);
|
|
959
|
+
}
|
|
960
|
+
scrubDatabaseUrl();
|
|
961
|
+
return new PostgresClient(client, client);
|
|
962
|
+
}
|
|
963
|
+
function now() {
|
|
964
|
+
return new Date().toISOString();
|
|
965
|
+
}
|
|
966
|
+
function uuid() {
|
|
967
|
+
return crypto.randomUUID();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/services/context.ts
|
|
971
|
+
function contextFromPrincipal(db, principal) {
|
|
972
|
+
return {
|
|
973
|
+
db,
|
|
974
|
+
auth: {
|
|
975
|
+
actor_id: principal.actor_id,
|
|
976
|
+
roles: principal.roles,
|
|
977
|
+
...principal.entity_ids ? { entity_ids: principal.entity_ids } : {},
|
|
978
|
+
...principal.bypass ? { bypass: true } : {}
|
|
979
|
+
},
|
|
980
|
+
scopes: principal.scopes
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function localOwnerContext(db) {
|
|
984
|
+
return {
|
|
985
|
+
db,
|
|
986
|
+
auth: SYSTEM_AUTHORIZATION_CONTEXT,
|
|
987
|
+
scopes: ["treasury:read", "treasury:write", "treasury:recommend", "treasury:export", "treasury:admin", "storage:admin"]
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
function guard(rc, scope, action, entity_id) {
|
|
991
|
+
if (!rc.auth.bypass && !rc.scopes.includes(scope)) {
|
|
992
|
+
throw new PermissionDeniedError(action, `missing scope ${scope}`);
|
|
993
|
+
}
|
|
994
|
+
authorize(action, rc.auth, entity_id ? { entity_id } : {});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/db/audit.ts
|
|
998
|
+
import { createHash } from "crypto";
|
|
999
|
+
var GENESIS = "0".repeat(64);
|
|
1000
|
+
function canonical(fields) {
|
|
1001
|
+
return JSON.stringify([fields.entity_id, fields.actor_id, fields.action, fields.detail, fields.created_at]);
|
|
1002
|
+
}
|
|
1003
|
+
async function appendAudit(db, input) {
|
|
1004
|
+
await db.transaction(async (tx) => {
|
|
1005
|
+
const last = await tx.get("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1");
|
|
1006
|
+
const prev_hash = last?.row_hash ?? GENESIS;
|
|
1007
|
+
const created_at = now();
|
|
1008
|
+
const fields = { entity_id: input.entity_id, actor_id: input.actor_id, action: input.action, detail: input.detail, created_at };
|
|
1009
|
+
const row_hash = createHash("sha256").update(prev_hash + canonical(fields)).digest("hex");
|
|
1010
|
+
await tx.run("INSERT INTO audit_log (entity_id, actor_id, action, detail, prev_hash, row_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [fields.entity_id, fields.actor_id, fields.action, fields.detail, prev_hash, row_hash, created_at]);
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/services/entities.ts
|
|
1015
|
+
async function createEntity(rc, input) {
|
|
1016
|
+
guard(rc, "treasury:admin", "admin");
|
|
1017
|
+
if (!input.name?.trim())
|
|
1018
|
+
throw new ValidationError("name is required.");
|
|
1019
|
+
if (!/^[A-Z]{3}$/.test(input.base_currency ?? ""))
|
|
1020
|
+
throw new ValidationError("base_currency must be an ISO-4217 code (e.g. USD).");
|
|
1021
|
+
const ts = now();
|
|
1022
|
+
const row = {
|
|
1023
|
+
entity_id: uuid(),
|
|
1024
|
+
entity_slug: input.entity_slug?.trim() || null,
|
|
1025
|
+
name: input.name.trim(),
|
|
1026
|
+
base_currency: input.base_currency,
|
|
1027
|
+
created_at: ts,
|
|
1028
|
+
updated_at: ts
|
|
1029
|
+
};
|
|
1030
|
+
await rc.db.run("INSERT INTO entities (entity_id, entity_slug, name, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", [row.entity_id, row.entity_slug, row.name, row.base_currency, row.created_at, row.updated_at]);
|
|
1031
|
+
await appendAudit(rc.db, { entity_id: row.entity_id, actor_id: rc.auth.actor_id, action: "entity.create", detail: `name=${row.name}` });
|
|
1032
|
+
return row;
|
|
1033
|
+
}
|
|
1034
|
+
async function listEntities(rc) {
|
|
1035
|
+
guard(rc, "treasury:read", "read");
|
|
1036
|
+
const rows = await rc.db.all("SELECT * FROM entities ORDER BY name ASC");
|
|
1037
|
+
if (rc.auth.bypass || rc.auth.entity_ids === undefined) {
|
|
1038
|
+
return rc.auth.bypass ? rows : [];
|
|
1039
|
+
}
|
|
1040
|
+
const allowed = new Set(rc.auth.entity_ids);
|
|
1041
|
+
return rows.filter((r) => allowed.has(r.entity_id));
|
|
1042
|
+
}
|
|
1043
|
+
async function getEntity(rc, input) {
|
|
1044
|
+
guard(rc, "treasury:read", "read", input.entity_id);
|
|
1045
|
+
return requireEntity(rc, input.entity_id);
|
|
1046
|
+
}
|
|
1047
|
+
async function requireEntity(rc, entity_id) {
|
|
1048
|
+
const row = await rc.db.get("SELECT * FROM entities WHERE entity_id = ?", [entity_id]);
|
|
1049
|
+
if (!row)
|
|
1050
|
+
throw new EntityNotFoundError(entity_id);
|
|
1051
|
+
return row;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/services/fx.ts
|
|
1055
|
+
async function recordFxRate(rc, input) {
|
|
1056
|
+
guard(rc, "treasury:write", "write");
|
|
1057
|
+
if (!/^[A-Z]{3}$/.test(input.base_currency) || !/^[A-Z]{3}$/.test(input.quote_currency)) {
|
|
1058
|
+
throw new ValidationError("base_currency and quote_currency must be ISO-4217 codes.");
|
|
1059
|
+
}
|
|
1060
|
+
if (!(input.rate > 0))
|
|
1061
|
+
throw new ValidationError("rate must be a positive number.");
|
|
1062
|
+
const captured_at = now();
|
|
1063
|
+
const row = {
|
|
1064
|
+
id: uuid(),
|
|
1065
|
+
base_currency: input.base_currency,
|
|
1066
|
+
quote_currency: input.quote_currency,
|
|
1067
|
+
rate: input.rate,
|
|
1068
|
+
as_of: input.as_of ?? captured_at,
|
|
1069
|
+
source: input.source ?? "manual",
|
|
1070
|
+
captured_at
|
|
1071
|
+
};
|
|
1072
|
+
await rc.db.run("INSERT INTO fx_rates (id, base_currency, quote_currency, rate, as_of, source, captured_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [row.id, row.base_currency, row.quote_currency, row.rate, row.as_of, row.source, row.captured_at]);
|
|
1073
|
+
await appendAudit(rc.db, { entity_id: null, actor_id: rc.auth.actor_id, action: "fx.record", detail: `${row.base_currency}/${row.quote_currency}=${row.rate}` });
|
|
1074
|
+
return row;
|
|
1075
|
+
}
|
|
1076
|
+
async function listFxRates(rc) {
|
|
1077
|
+
guard(rc, "treasury:read", "read");
|
|
1078
|
+
return rc.db.all("SELECT * FROM fx_rates ORDER BY captured_at DESC");
|
|
1079
|
+
}
|
|
1080
|
+
async function convertMinor(db, amountMinor, from, to) {
|
|
1081
|
+
if (from === to)
|
|
1082
|
+
return amountMinor;
|
|
1083
|
+
const direct = await db.get("SELECT rate FROM fx_rates WHERE base_currency = ? AND quote_currency = ? ORDER BY captured_at DESC LIMIT 1", [to, from]);
|
|
1084
|
+
if (direct)
|
|
1085
|
+
return Math.round(amountMinor / direct.rate);
|
|
1086
|
+
const inverse = await db.get("SELECT rate FROM fx_rates WHERE base_currency = ? AND quote_currency = ? ORDER BY captured_at DESC LIMIT 1", [from, to]);
|
|
1087
|
+
if (inverse)
|
|
1088
|
+
return Math.round(amountMinor * inverse.rate);
|
|
1089
|
+
throw new ValidationError(`No FX rate available to convert ${from} -> ${to}. Record a rate first.`);
|
|
1090
|
+
}
|
|
1091
|
+
async function fxExposure(rc, input) {
|
|
1092
|
+
guard(rc, "treasury:read", "read");
|
|
1093
|
+
const base = (input.base ?? "USD").toUpperCase();
|
|
1094
|
+
if (!/^[A-Z]{3}$/.test(base))
|
|
1095
|
+
throw new ValidationError("base must be an ISO-4217 code.");
|
|
1096
|
+
const rows = await visibleBalanceSums(rc);
|
|
1097
|
+
const exposures = [];
|
|
1098
|
+
let totalInBase = 0;
|
|
1099
|
+
for (const { currency, total_minor } of rows) {
|
|
1100
|
+
const in_base_minor = await convertMinor(rc.db, total_minor, currency, base);
|
|
1101
|
+
totalInBase += in_base_minor;
|
|
1102
|
+
exposures.push({ currency, total_minor, in_base_minor });
|
|
1103
|
+
}
|
|
1104
|
+
exposures.sort((a, b) => b.in_base_minor - a.in_base_minor);
|
|
1105
|
+
return { base_currency: base, as_of: now(), total_in_base_minor: totalInBase, exposures };
|
|
1106
|
+
}
|
|
1107
|
+
async function visibleBalanceSums(rc) {
|
|
1108
|
+
const { clause, params } = entityFilter(rc);
|
|
1109
|
+
return rc.db.all(`SELECT currency, SUM(amount_minor) AS total_minor FROM balance_snapshots ${clause} GROUP BY currency`, params);
|
|
1110
|
+
}
|
|
1111
|
+
function entityFilter(rc, column = "entity_id") {
|
|
1112
|
+
if (rc.auth.bypass)
|
|
1113
|
+
return { clause: "", params: [] };
|
|
1114
|
+
const ids = rc.auth.entity_ids ?? [];
|
|
1115
|
+
if (ids.length === 0)
|
|
1116
|
+
return { clause: "WHERE 1=0", params: [] };
|
|
1117
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
1118
|
+
return { clause: `WHERE ${column} IN (${placeholders})`, params: [...ids] };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/services/balances.ts
|
|
1122
|
+
async function recordBalance(rc, input) {
|
|
1123
|
+
guard(rc, "treasury:write", "write", input.entity_id);
|
|
1124
|
+
await requireEntity(rc, input.entity_id);
|
|
1125
|
+
if (!/^[A-Z]{3}$/.test(input.currency))
|
|
1126
|
+
throw new ValidationError("currency must be an ISO-4217 code.");
|
|
1127
|
+
if (!Number.isInteger(input.amount_minor))
|
|
1128
|
+
throw new ValidationError("amount_minor must be an integer (minor units).");
|
|
1129
|
+
if (input.account_kind !== "bank" && input.account_kind !== "wallet")
|
|
1130
|
+
throw new ValidationError("account_kind must be 'bank' or 'wallet'.");
|
|
1131
|
+
const captured_at = now();
|
|
1132
|
+
const row = {
|
|
1133
|
+
id: uuid(),
|
|
1134
|
+
entity_id: input.entity_id,
|
|
1135
|
+
account_ref: input.account_ref,
|
|
1136
|
+
account_kind: input.account_kind,
|
|
1137
|
+
currency: input.currency,
|
|
1138
|
+
amount_minor: input.amount_minor,
|
|
1139
|
+
as_of: input.as_of ?? captured_at,
|
|
1140
|
+
source: input.source ?? "manual",
|
|
1141
|
+
captured_at
|
|
1142
|
+
};
|
|
1143
|
+
await rc.db.run("INSERT INTO balance_snapshots (id, entity_id, account_ref, account_kind, currency, amount_minor, as_of, source, captured_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [row.id, row.entity_id, row.account_ref, row.account_kind, row.currency, row.amount_minor, row.as_of, row.source, row.captured_at]);
|
|
1144
|
+
await appendAudit(rc.db, { entity_id: row.entity_id, actor_id: rc.auth.actor_id, action: "balance.record", detail: `${row.currency} ${row.amount_minor} @ ${row.account_ref}` });
|
|
1145
|
+
return row;
|
|
1146
|
+
}
|
|
1147
|
+
async function listBalances(rc, input) {
|
|
1148
|
+
guard(rc, "treasury:read", "read", input.entity_id);
|
|
1149
|
+
if (input.entity_id) {
|
|
1150
|
+
return rc.db.all("SELECT * FROM balance_snapshots WHERE entity_id = ? ORDER BY captured_at DESC", [input.entity_id]);
|
|
1151
|
+
}
|
|
1152
|
+
const { clause, params } = entityFilter(rc);
|
|
1153
|
+
return rc.db.all(`SELECT * FROM balance_snapshots ${clause} ORDER BY captured_at DESC`, params);
|
|
1154
|
+
}
|
|
1155
|
+
async function consolidatedBalances(rc, input) {
|
|
1156
|
+
guard(rc, "treasury:read", "read");
|
|
1157
|
+
const base = (input.base ?? "USD").toUpperCase();
|
|
1158
|
+
if (!/^[A-Z]{3}$/.test(base))
|
|
1159
|
+
throw new ValidationError("base must be an ISO-4217 code.");
|
|
1160
|
+
const { clause, params } = entityFilter(rc);
|
|
1161
|
+
const rows = await rc.db.all(`SELECT currency, SUM(amount_minor) AS total_minor FROM balance_snapshots ${clause} GROUP BY currency`, params);
|
|
1162
|
+
let total = 0;
|
|
1163
|
+
const by_currency = [];
|
|
1164
|
+
for (const r of rows) {
|
|
1165
|
+
const in_base_minor = await convertMinor(rc.db, r.total_minor, r.currency, base);
|
|
1166
|
+
total += in_base_minor;
|
|
1167
|
+
by_currency.push({ currency: r.currency, total_minor: r.total_minor, in_base_minor });
|
|
1168
|
+
}
|
|
1169
|
+
by_currency.sort((a, b) => b.in_base_minor - a.in_base_minor);
|
|
1170
|
+
return { base_currency: base, as_of: now(), total_in_base_minor: total, by_currency };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/services/runway.ts
|
|
1174
|
+
async function recordCostFeed(rc, input) {
|
|
1175
|
+
guard(rc, "treasury:write", "write", input.entity_id);
|
|
1176
|
+
await requireEntity(rc, input.entity_id);
|
|
1177
|
+
if (!/^[A-Z]{3}$/.test(input.currency))
|
|
1178
|
+
throw new ValidationError("currency must be an ISO-4217 code.");
|
|
1179
|
+
if (!Number.isInteger(input.monthly_burn_minor))
|
|
1180
|
+
throw new ValidationError("monthly_burn_minor must be an integer (minor units).");
|
|
1181
|
+
const captured_at = now();
|
|
1182
|
+
const row = {
|
|
1183
|
+
id: uuid(),
|
|
1184
|
+
entity_id: input.entity_id,
|
|
1185
|
+
currency: input.currency,
|
|
1186
|
+
monthly_burn_minor: input.monthly_burn_minor,
|
|
1187
|
+
as_of: input.as_of ?? captured_at,
|
|
1188
|
+
source: input.source ?? "manual",
|
|
1189
|
+
captured_at
|
|
1190
|
+
};
|
|
1191
|
+
await rc.db.run("INSERT INTO cost_feeds (id, entity_id, currency, monthly_burn_minor, as_of, source, captured_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [row.id, row.entity_id, row.currency, row.monthly_burn_minor, row.as_of, row.source, row.captured_at]);
|
|
1192
|
+
await appendAudit(rc.db, { entity_id: row.entity_id, actor_id: rc.auth.actor_id, action: "cost.record", detail: `${row.currency} ${row.monthly_burn_minor}/mo` });
|
|
1193
|
+
return row;
|
|
1194
|
+
}
|
|
1195
|
+
async function listCostFeeds(rc, input) {
|
|
1196
|
+
guard(rc, "treasury:read", "read", input.entity_id);
|
|
1197
|
+
if (input.entity_id) {
|
|
1198
|
+
return rc.db.all("SELECT * FROM cost_feeds WHERE entity_id = ? ORDER BY captured_at DESC", [input.entity_id]);
|
|
1199
|
+
}
|
|
1200
|
+
const { clause, params } = entityFilter(rc);
|
|
1201
|
+
return rc.db.all(`SELECT * FROM cost_feeds ${clause} ORDER BY captured_at DESC`, params);
|
|
1202
|
+
}
|
|
1203
|
+
async function latestCostFeeds(rc, entity_id) {
|
|
1204
|
+
const rows = entity_id ? await rc.db.all("SELECT * FROM cost_feeds WHERE entity_id = ? ORDER BY captured_at DESC", [entity_id]) : await (async () => {
|
|
1205
|
+
const { clause, params } = entityFilter(rc);
|
|
1206
|
+
return rc.db.all(`SELECT * FROM cost_feeds ${clause} ORDER BY captured_at DESC`, params);
|
|
1207
|
+
})();
|
|
1208
|
+
const seen = new Set;
|
|
1209
|
+
const latest = [];
|
|
1210
|
+
for (const r of rows) {
|
|
1211
|
+
const key = `${r.entity_id}:${r.currency}`;
|
|
1212
|
+
if (seen.has(key))
|
|
1213
|
+
continue;
|
|
1214
|
+
seen.add(key);
|
|
1215
|
+
latest.push(r);
|
|
1216
|
+
}
|
|
1217
|
+
return latest;
|
|
1218
|
+
}
|
|
1219
|
+
async function sumBalancesInBase(rc, base, entity_id) {
|
|
1220
|
+
const rows = entity_id ? await rc.db.all("SELECT currency, SUM(amount_minor) AS total_minor FROM balance_snapshots WHERE entity_id = ? GROUP BY currency", [entity_id]) : await (async () => {
|
|
1221
|
+
const { clause, params } = entityFilter(rc);
|
|
1222
|
+
return rc.db.all(`SELECT currency, SUM(amount_minor) AS total_minor FROM balance_snapshots ${clause} GROUP BY currency`, params);
|
|
1223
|
+
})();
|
|
1224
|
+
let total = 0;
|
|
1225
|
+
for (const r of rows)
|
|
1226
|
+
total += await convertMinor(rc.db, r.total_minor, r.currency, base);
|
|
1227
|
+
return total;
|
|
1228
|
+
}
|
|
1229
|
+
function runwayMonths(cash, burn) {
|
|
1230
|
+
if (burn <= 0)
|
|
1231
|
+
return null;
|
|
1232
|
+
return Math.round(cash / burn * 100) / 100;
|
|
1233
|
+
}
|
|
1234
|
+
async function entityRunway(rc, input) {
|
|
1235
|
+
guard(rc, "treasury:read", "read", input.entity_id);
|
|
1236
|
+
const entity = await requireEntity(rc, input.entity_id);
|
|
1237
|
+
const base = (input.base ?? entity.base_currency).toUpperCase();
|
|
1238
|
+
const cash = await sumBalancesInBase(rc, base, input.entity_id);
|
|
1239
|
+
const feeds = await latestCostFeeds(rc, input.entity_id);
|
|
1240
|
+
let burn = 0;
|
|
1241
|
+
for (const f of feeds)
|
|
1242
|
+
burn += await convertMinor(rc.db, f.monthly_burn_minor, f.currency, base);
|
|
1243
|
+
return {
|
|
1244
|
+
scope: "entity",
|
|
1245
|
+
entity_id: input.entity_id,
|
|
1246
|
+
base_currency: base,
|
|
1247
|
+
cash_in_base_minor: cash,
|
|
1248
|
+
monthly_burn_in_base_minor: burn,
|
|
1249
|
+
runway_months: runwayMonths(cash, burn),
|
|
1250
|
+
as_of: now()
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
async function groupRunway(rc, input) {
|
|
1254
|
+
guard(rc, "treasury:read", "read");
|
|
1255
|
+
const base = (input.base ?? "USD").toUpperCase();
|
|
1256
|
+
if (!/^[A-Z]{3}$/.test(base))
|
|
1257
|
+
throw new ValidationError("base must be an ISO-4217 code.");
|
|
1258
|
+
const cash = await sumBalancesInBase(rc, base);
|
|
1259
|
+
const feeds = await latestCostFeeds(rc);
|
|
1260
|
+
let burn = 0;
|
|
1261
|
+
for (const f of feeds)
|
|
1262
|
+
burn += await convertMinor(rc.db, f.monthly_burn_minor, f.currency, base);
|
|
1263
|
+
return {
|
|
1264
|
+
scope: "group",
|
|
1265
|
+
entity_id: null,
|
|
1266
|
+
base_currency: base,
|
|
1267
|
+
cash_in_base_minor: cash,
|
|
1268
|
+
monthly_burn_in_base_minor: burn,
|
|
1269
|
+
runway_months: runwayMonths(cash, burn),
|
|
1270
|
+
as_of: now()
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// src/services/forecast.ts
|
|
1275
|
+
var MAX_HORIZON = 36;
|
|
1276
|
+
function monthLabel(offset) {
|
|
1277
|
+
const d = new Date;
|
|
1278
|
+
d.setUTCDate(1);
|
|
1279
|
+
d.setUTCMonth(d.getUTCMonth() + offset);
|
|
1280
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
1281
|
+
}
|
|
1282
|
+
function project(starting, burn, horizon) {
|
|
1283
|
+
const points = [];
|
|
1284
|
+
for (let m = 0;m <= horizon; m++) {
|
|
1285
|
+
points.push({ month_index: m, month: monthLabel(m), projected_cash_in_base_minor: starting - burn * m });
|
|
1286
|
+
}
|
|
1287
|
+
return points;
|
|
1288
|
+
}
|
|
1289
|
+
async function cashForecast(rc, input) {
|
|
1290
|
+
guard(rc, "treasury:read", "read", input.entity_id);
|
|
1291
|
+
const horizon = input.horizon_months ?? 6;
|
|
1292
|
+
if (!Number.isInteger(horizon) || horizon < 1 || horizon > MAX_HORIZON) {
|
|
1293
|
+
throw new ValidationError(`horizon_months must be an integer in 1..${MAX_HORIZON}.`);
|
|
1294
|
+
}
|
|
1295
|
+
const runway = input.entity_id ? await entityRunway(rc, { entity_id: input.entity_id, ...input.base ? { base: input.base } : {} }) : await groupRunway(rc, { ...input.base ? { base: input.base } : {} });
|
|
1296
|
+
return {
|
|
1297
|
+
scope: runway.scope,
|
|
1298
|
+
entity_id: runway.entity_id,
|
|
1299
|
+
base_currency: runway.base_currency,
|
|
1300
|
+
horizon_months: horizon,
|
|
1301
|
+
starting_cash_in_base_minor: runway.cash_in_base_minor,
|
|
1302
|
+
monthly_burn_in_base_minor: runway.monthly_burn_in_base_minor,
|
|
1303
|
+
points: project(runway.cash_in_base_minor, runway.monthly_burn_in_base_minor, horizon),
|
|
1304
|
+
as_of: now()
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// src/services/sweeps.ts
|
|
1309
|
+
function hydrate(row) {
|
|
1310
|
+
return { ...row, requires_controls_authorization: true };
|
|
1311
|
+
}
|
|
1312
|
+
async function listSweeps(rc, input = {}) {
|
|
1313
|
+
guard(rc, "treasury:read", "read");
|
|
1314
|
+
const from = entityFilter(rc, "from_entity_id");
|
|
1315
|
+
const to = entityFilter(rc, "to_entity_id");
|
|
1316
|
+
let clause = "";
|
|
1317
|
+
const args = [];
|
|
1318
|
+
if (from.clause && to.clause) {
|
|
1319
|
+
clause = `${from.clause} AND ${to.clause.replace(/^WHERE\s+/, "")}`;
|
|
1320
|
+
args.push(...from.params, ...to.params);
|
|
1321
|
+
}
|
|
1322
|
+
let sql = `SELECT * FROM sweep_recommendations ${clause}`;
|
|
1323
|
+
if (input.status) {
|
|
1324
|
+
sql += clause ? " AND status = ?" : "WHERE status = ?";
|
|
1325
|
+
args.push(input.status);
|
|
1326
|
+
}
|
|
1327
|
+
sql += " ORDER BY created_at DESC";
|
|
1328
|
+
const rows = await rc.db.all(sql, args);
|
|
1329
|
+
return rows.map(hydrate);
|
|
1330
|
+
}
|
|
1331
|
+
async function getSweep(rc, input) {
|
|
1332
|
+
guard(rc, "treasury:read", "read");
|
|
1333
|
+
const row = await rc.db.get("SELECT * FROM sweep_recommendations WHERE id = ?", [input.id]);
|
|
1334
|
+
if (!row)
|
|
1335
|
+
throw new SweepNotFoundError(input.id);
|
|
1336
|
+
authorize("read", rc.auth, { entity_ids: [row.from_entity_id, row.to_entity_id] });
|
|
1337
|
+
return hydrate(row);
|
|
1338
|
+
}
|
|
1339
|
+
async function generateSweeps(rc, input) {
|
|
1340
|
+
guard(rc, "treasury:recommend", "recommend");
|
|
1341
|
+
const base = (input.base ?? "USD").toUpperCase();
|
|
1342
|
+
if (!/^[A-Z]{3}$/.test(base))
|
|
1343
|
+
throw new ValidationError("base must be an ISO-4217 code.");
|
|
1344
|
+
const minRunway = input.min_runway_months ?? 3;
|
|
1345
|
+
const healthy = input.healthy_runway_months ?? 12;
|
|
1346
|
+
if (minRunway <= 0 || healthy <= minRunway)
|
|
1347
|
+
throw new ValidationError("healthy_runway_months must be greater than min_runway_months (> 0).");
|
|
1348
|
+
const entities = await listEntities(rc);
|
|
1349
|
+
const needy = [];
|
|
1350
|
+
const donors = [];
|
|
1351
|
+
for (const e of entities) {
|
|
1352
|
+
const r = await entityRunway(rc, { entity_id: e.entity_id, base });
|
|
1353
|
+
const burn = r.monthly_burn_in_base_minor;
|
|
1354
|
+
const cash = r.cash_in_base_minor;
|
|
1355
|
+
if (burn > 0) {
|
|
1356
|
+
const need = Math.max(0, Math.round(minRunway * burn) - cash);
|
|
1357
|
+
const surplus = Math.max(0, cash - Math.round(healthy * burn));
|
|
1358
|
+
if (need > 0)
|
|
1359
|
+
needy.push({ id: e.entity_id, need });
|
|
1360
|
+
else if (surplus > 0)
|
|
1361
|
+
donors.push({ id: e.entity_id, surplus });
|
|
1362
|
+
} else if (cash > 0) {
|
|
1363
|
+
donors.push({ id: e.entity_id, surplus: cash });
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const { clause, params } = entityFilter(rc, "from_entity_id");
|
|
1367
|
+
await rc.db.run(`DELETE FROM sweep_recommendations ${clause}${clause ? " AND" : "WHERE"} status = 'recommended'`, [...params]);
|
|
1368
|
+
needy.sort((a, b) => b.need - a.need);
|
|
1369
|
+
donors.sort((a, b) => b.surplus - a.surplus);
|
|
1370
|
+
const recs = [];
|
|
1371
|
+
let di = 0;
|
|
1372
|
+
for (const n of needy) {
|
|
1373
|
+
let remaining = n.need;
|
|
1374
|
+
while (remaining > 0 && di < donors.length) {
|
|
1375
|
+
const donor = donors[di];
|
|
1376
|
+
if (donor.surplus <= 0) {
|
|
1377
|
+
di++;
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const amount = Math.min(remaining, donor.surplus);
|
|
1381
|
+
donor.surplus -= amount;
|
|
1382
|
+
remaining -= amount;
|
|
1383
|
+
const ts = now();
|
|
1384
|
+
const rec = {
|
|
1385
|
+
id: uuid(),
|
|
1386
|
+
from_entity_id: donor.id,
|
|
1387
|
+
to_entity_id: n.id,
|
|
1388
|
+
currency: base,
|
|
1389
|
+
amount_minor: amount,
|
|
1390
|
+
rationale: `Lift ${n.id} toward ${input.min_runway_months ?? 3}mo runway from ${donor.id} surplus.`,
|
|
1391
|
+
status: "recommended",
|
|
1392
|
+
requires_controls_authorization: true,
|
|
1393
|
+
created_at: ts,
|
|
1394
|
+
updated_at: ts
|
|
1395
|
+
};
|
|
1396
|
+
await rc.db.run("INSERT INTO sweep_recommendations (id, from_entity_id, to_entity_id, currency, amount_minor, rationale, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [rec.id, rec.from_entity_id, rec.to_entity_id, rec.currency, rec.amount_minor, rec.rationale, rec.status, rec.created_at, rec.updated_at]);
|
|
1397
|
+
await appendAudit(rc.db, { entity_id: donor.id, actor_id: rc.auth.actor_id, action: "sweep.recommend", detail: `${donor.id}->${n.id} ${base} ${amount}` });
|
|
1398
|
+
recs.push(rec);
|
|
1399
|
+
if (donor.surplus <= 0)
|
|
1400
|
+
di++;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return recs;
|
|
1404
|
+
}
|
|
1405
|
+
async function updateSweepStatus(rc, input) {
|
|
1406
|
+
guard(rc, "treasury:recommend", "recommend");
|
|
1407
|
+
if (!["recommended", "acknowledged", "dismissed"].includes(input.status)) {
|
|
1408
|
+
throw new ValidationError("status must be one of: recommended, acknowledged, dismissed.");
|
|
1409
|
+
}
|
|
1410
|
+
const row = await rc.db.get("SELECT * FROM sweep_recommendations WHERE id = ?", [input.id]);
|
|
1411
|
+
if (!row)
|
|
1412
|
+
throw new SweepNotFoundError(input.id);
|
|
1413
|
+
authorize("recommend", rc.auth, { entity_ids: [row.from_entity_id, row.to_entity_id] });
|
|
1414
|
+
const updated_at = now();
|
|
1415
|
+
await rc.db.run("UPDATE sweep_recommendations SET status = ?, updated_at = ? WHERE id = ?", [input.status, updated_at, input.id]);
|
|
1416
|
+
await appendAudit(rc.db, { entity_id: row.from_entity_id, actor_id: rc.auth.actor_id, action: "sweep.status", detail: `${input.id} -> ${input.status}` });
|
|
1417
|
+
return hydrate({ ...row, status: input.status, updated_at });
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/services/ingest.ts
|
|
1421
|
+
async function ingestFromAdapters(rc, adapters) {
|
|
1422
|
+
guard(rc, "treasury:write", "write");
|
|
1423
|
+
const entities = await listEntities(rc);
|
|
1424
|
+
const ids = entities.map((e) => e.entity_id);
|
|
1425
|
+
const rates = await adapters.fx.fetchRates();
|
|
1426
|
+
for (const r of rates) {
|
|
1427
|
+
await recordFxRate(rc, { base_currency: r.base_currency, quote_currency: r.quote_currency, rate: r.rate, as_of: r.as_of, source: adapters.fx.source });
|
|
1428
|
+
}
|
|
1429
|
+
const balances = await adapters.balances.fetchBalances(ids);
|
|
1430
|
+
for (const b of balances) {
|
|
1431
|
+
await recordBalance(rc, { entity_id: b.entity_id, account_ref: b.account_ref, account_kind: b.account_kind, currency: b.currency, amount_minor: b.amount_minor, as_of: b.as_of, source: adapters.balances.source });
|
|
1432
|
+
}
|
|
1433
|
+
const feeds = await adapters.cost.fetchCostFeeds(ids);
|
|
1434
|
+
for (const f of feeds) {
|
|
1435
|
+
await recordCostFeed(rc, { entity_id: f.entity_id, currency: f.currency, monthly_burn_minor: f.monthly_burn_minor, as_of: f.as_of, source: adapters.cost.source });
|
|
1436
|
+
}
|
|
1437
|
+
return {
|
|
1438
|
+
balances_ingested: balances.length,
|
|
1439
|
+
fx_rates_ingested: rates.length,
|
|
1440
|
+
cost_feeds_ingested: feeds.length,
|
|
1441
|
+
sources: { balances: adapters.balances.source, fx: adapters.fx.source, cost: adapters.cost.source }
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/adapters/fixtures.ts
|
|
1446
|
+
var AS_OF = "2026-07-01T00:00:00.000Z";
|
|
1447
|
+
|
|
1448
|
+
class FixtureBalancesAdapter {
|
|
1449
|
+
rows;
|
|
1450
|
+
source = "fixture:banking";
|
|
1451
|
+
constructor(rows) {
|
|
1452
|
+
this.rows = rows;
|
|
1453
|
+
}
|
|
1454
|
+
async fetchBalances(entityIds) {
|
|
1455
|
+
const set = new Set(entityIds);
|
|
1456
|
+
return this.rows.filter((r) => set.has(r.entity_id));
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
class FixtureFxAdapter {
|
|
1461
|
+
rows;
|
|
1462
|
+
source = "fixture:fx";
|
|
1463
|
+
constructor(rows) {
|
|
1464
|
+
this.rows = rows;
|
|
1465
|
+
}
|
|
1466
|
+
async fetchRates() {
|
|
1467
|
+
return [...this.rows];
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
class FixtureCostAdapter {
|
|
1472
|
+
rows;
|
|
1473
|
+
source = "fixture:cost";
|
|
1474
|
+
constructor(rows) {
|
|
1475
|
+
this.rows = rows;
|
|
1476
|
+
}
|
|
1477
|
+
async fetchCostFeeds(entityIds) {
|
|
1478
|
+
const set = new Set(entityIds);
|
|
1479
|
+
return this.rows.filter((r) => set.has(r.entity_id));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function buildFixtureAdapters(usEntityId, roEntityId) {
|
|
1483
|
+
const balances = [
|
|
1484
|
+
{ entity_id: usEntityId, account_ref: "mercury-1010", account_kind: "bank", currency: "USD", amount_minor: 25000000, as_of: AS_OF },
|
|
1485
|
+
{ entity_id: usEntityId, account_ref: "wallet-usdc", account_kind: "wallet", currency: "USD", amount_minor: 3000000, as_of: AS_OF },
|
|
1486
|
+
{ entity_id: roEntityId, account_ref: "bcr-2020", account_kind: "bank", currency: "EUR", amount_minor: 1500000, as_of: AS_OF }
|
|
1487
|
+
];
|
|
1488
|
+
const fx = [
|
|
1489
|
+
{ base_currency: "USD", quote_currency: "EUR", rate: 0.92, as_of: AS_OF }
|
|
1490
|
+
];
|
|
1491
|
+
const cost = [
|
|
1492
|
+
{ entity_id: usEntityId, currency: "USD", monthly_burn_minor: 1e6, as_of: AS_OF },
|
|
1493
|
+
{ entity_id: roEntityId, currency: "EUR", monthly_burn_minor: 2000000, as_of: AS_OF }
|
|
1494
|
+
];
|
|
1495
|
+
return {
|
|
1496
|
+
balances: new FixtureBalancesAdapter(balances),
|
|
1497
|
+
fx: new FixtureFxAdapter(fx),
|
|
1498
|
+
cost: new FixtureCostAdapter(cost)
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/services/registry.ts
|
|
1503
|
+
var R = ["minimal", "standard", "full"];
|
|
1504
|
+
var SF = ["standard", "full"];
|
|
1505
|
+
var F = ["full"];
|
|
1506
|
+
var OPS = [
|
|
1507
|
+
{
|
|
1508
|
+
name: "list_entities",
|
|
1509
|
+
description: "List entities the caller is authorized to see.",
|
|
1510
|
+
scope: "treasury:read",
|
|
1511
|
+
action: "read",
|
|
1512
|
+
mutating: false,
|
|
1513
|
+
http: { method: "GET", path: "/v1/entities" },
|
|
1514
|
+
operationId: "listEntities",
|
|
1515
|
+
cli: ["entities", "list"],
|
|
1516
|
+
fields: [],
|
|
1517
|
+
profiles: R,
|
|
1518
|
+
run: (rc) => listEntities(rc)
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
name: "get_entity",
|
|
1522
|
+
description: "Get a single entity by entity_id.",
|
|
1523
|
+
scope: "treasury:read",
|
|
1524
|
+
action: "read",
|
|
1525
|
+
mutating: false,
|
|
1526
|
+
http: { method: "GET", path: "/v1/entities/:entity_id" },
|
|
1527
|
+
operationId: "getEntity",
|
|
1528
|
+
cli: ["entities", "get"],
|
|
1529
|
+
fields: [{ name: "entity_id", type: "string", required: true, location: "path" }],
|
|
1530
|
+
profiles: R,
|
|
1531
|
+
run: (rc, i) => getEntity(rc, i)
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
name: "create_entity",
|
|
1535
|
+
description: "Create (cache) an entity anchor.",
|
|
1536
|
+
scope: "treasury:admin",
|
|
1537
|
+
action: "admin",
|
|
1538
|
+
mutating: true,
|
|
1539
|
+
http: { method: "POST", path: "/v1/entities" },
|
|
1540
|
+
operationId: "createEntity",
|
|
1541
|
+
cli: ["entities", "create"],
|
|
1542
|
+
fields: [
|
|
1543
|
+
{ name: "name", type: "string", required: true, location: "body" },
|
|
1544
|
+
{ name: "base_currency", type: "string", required: true, location: "body" },
|
|
1545
|
+
{ name: "entity_slug", type: "string", location: "body" }
|
|
1546
|
+
],
|
|
1547
|
+
profiles: R,
|
|
1548
|
+
run: (rc, i) => createEntity(rc, i)
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
name: "list_balances",
|
|
1552
|
+
description: "List cached balance snapshots (optionally for one entity).",
|
|
1553
|
+
scope: "treasury:read",
|
|
1554
|
+
action: "read",
|
|
1555
|
+
mutating: false,
|
|
1556
|
+
http: { method: "GET", path: "/v1/balances" },
|
|
1557
|
+
operationId: "listBalances",
|
|
1558
|
+
cli: ["balances", "list"],
|
|
1559
|
+
fields: [{ name: "entity_id", type: "string", location: "query" }],
|
|
1560
|
+
profiles: R,
|
|
1561
|
+
run: (rc, i) => listBalances(rc, i)
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
name: "record_balance",
|
|
1565
|
+
description: "Cache a balance snapshot with provenance.",
|
|
1566
|
+
scope: "treasury:write",
|
|
1567
|
+
action: "write",
|
|
1568
|
+
mutating: true,
|
|
1569
|
+
http: { method: "POST", path: "/v1/balances" },
|
|
1570
|
+
operationId: "recordBalance",
|
|
1571
|
+
cli: ["balances", "record"],
|
|
1572
|
+
fields: [
|
|
1573
|
+
{ name: "entity_id", type: "string", required: true, location: "body" },
|
|
1574
|
+
{ name: "account_ref", type: "string", required: true, location: "body" },
|
|
1575
|
+
{ name: "account_kind", type: "string", required: true, location: "body" },
|
|
1576
|
+
{ name: "currency", type: "string", required: true, location: "body" },
|
|
1577
|
+
{ name: "amount_minor", type: "int", required: true, location: "body" },
|
|
1578
|
+
{ name: "source", type: "string", location: "body" }
|
|
1579
|
+
],
|
|
1580
|
+
profiles: R,
|
|
1581
|
+
run: (rc, i) => recordBalance(rc, i)
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
name: "consolidated_balances",
|
|
1585
|
+
description: "Consolidated balances across visible entities, converted to a base.",
|
|
1586
|
+
scope: "treasury:read",
|
|
1587
|
+
action: "read",
|
|
1588
|
+
mutating: false,
|
|
1589
|
+
http: { method: "GET", path: "/v1/balances/consolidated" },
|
|
1590
|
+
operationId: "consolidatedBalances",
|
|
1591
|
+
cli: ["balances", "consolidated"],
|
|
1592
|
+
fields: [{ name: "base", type: "string", location: "query" }],
|
|
1593
|
+
profiles: R,
|
|
1594
|
+
run: (rc, i) => consolidatedBalances(rc, i)
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
name: "list_fx_rates",
|
|
1598
|
+
description: "List cached FX rates.",
|
|
1599
|
+
scope: "treasury:read",
|
|
1600
|
+
action: "read",
|
|
1601
|
+
mutating: false,
|
|
1602
|
+
http: { method: "GET", path: "/v1/fx-rates" },
|
|
1603
|
+
operationId: "listFxRates",
|
|
1604
|
+
cli: ["fx", "list"],
|
|
1605
|
+
fields: [],
|
|
1606
|
+
profiles: SF,
|
|
1607
|
+
run: (rc) => listFxRates(rc)
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
name: "record_fx_rate",
|
|
1611
|
+
description: "Cache an FX rate (1 base = rate quote).",
|
|
1612
|
+
scope: "treasury:write",
|
|
1613
|
+
action: "write",
|
|
1614
|
+
mutating: true,
|
|
1615
|
+
http: { method: "POST", path: "/v1/fx-rates" },
|
|
1616
|
+
operationId: "recordFxRate",
|
|
1617
|
+
cli: ["fx", "record"],
|
|
1618
|
+
fields: [
|
|
1619
|
+
{ name: "base_currency", type: "string", required: true, location: "body" },
|
|
1620
|
+
{ name: "quote_currency", type: "string", required: true, location: "body" },
|
|
1621
|
+
{ name: "rate", type: "number", required: true, location: "body" },
|
|
1622
|
+
{ name: "source", type: "string", location: "body" }
|
|
1623
|
+
],
|
|
1624
|
+
profiles: SF,
|
|
1625
|
+
run: (rc, i) => recordFxRate(rc, i)
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
name: "fx_exposure",
|
|
1629
|
+
description: "FX exposure across visible balances, converted to a base.",
|
|
1630
|
+
scope: "treasury:read",
|
|
1631
|
+
action: "read",
|
|
1632
|
+
mutating: false,
|
|
1633
|
+
http: { method: "GET", path: "/v1/exposure" },
|
|
1634
|
+
operationId: "fxExposure",
|
|
1635
|
+
cli: ["fx", "exposure"],
|
|
1636
|
+
fields: [{ name: "base", type: "string", location: "query" }],
|
|
1637
|
+
profiles: SF,
|
|
1638
|
+
run: (rc, i) => fxExposure(rc, i)
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
name: "record_cost_feed",
|
|
1642
|
+
description: "Record an entity's monthly net cash burn.",
|
|
1643
|
+
scope: "treasury:write",
|
|
1644
|
+
action: "write",
|
|
1645
|
+
mutating: true,
|
|
1646
|
+
http: { method: "POST", path: "/v1/cost-feeds" },
|
|
1647
|
+
operationId: "recordCostFeed",
|
|
1648
|
+
cli: ["cost", "record"],
|
|
1649
|
+
fields: [
|
|
1650
|
+
{ name: "entity_id", type: "string", required: true, location: "body" },
|
|
1651
|
+
{ name: "currency", type: "string", required: true, location: "body" },
|
|
1652
|
+
{ name: "monthly_burn_minor", type: "int", required: true, location: "body" },
|
|
1653
|
+
{ name: "source", type: "string", location: "body" }
|
|
1654
|
+
],
|
|
1655
|
+
profiles: SF,
|
|
1656
|
+
run: (rc, i) => recordCostFeed(rc, i)
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
name: "list_cost_feeds",
|
|
1660
|
+
description: "List cached cost feeds (optionally for one entity).",
|
|
1661
|
+
scope: "treasury:read",
|
|
1662
|
+
action: "read",
|
|
1663
|
+
mutating: false,
|
|
1664
|
+
http: { method: "GET", path: "/v1/cost-feeds" },
|
|
1665
|
+
operationId: "listCostFeeds",
|
|
1666
|
+
cli: ["cost", "list"],
|
|
1667
|
+
fields: [{ name: "entity_id", type: "string", location: "query" }],
|
|
1668
|
+
profiles: SF,
|
|
1669
|
+
run: (rc, i) => listCostFeeds(rc, i)
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
name: "entity_runway",
|
|
1673
|
+
description: "Runway (months) for a single entity.",
|
|
1674
|
+
scope: "treasury:read",
|
|
1675
|
+
action: "read",
|
|
1676
|
+
mutating: false,
|
|
1677
|
+
http: { method: "GET", path: "/v1/runway/:entity_id" },
|
|
1678
|
+
operationId: "entityRunway",
|
|
1679
|
+
cli: ["runway", "entity"],
|
|
1680
|
+
fields: [
|
|
1681
|
+
{ name: "entity_id", type: "string", required: true, location: "path" },
|
|
1682
|
+
{ name: "base", type: "string", location: "query" }
|
|
1683
|
+
],
|
|
1684
|
+
profiles: R,
|
|
1685
|
+
run: (rc, i) => entityRunway(rc, i)
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
name: "group_runway",
|
|
1689
|
+
description: "Consolidated group runway (months).",
|
|
1690
|
+
scope: "treasury:read",
|
|
1691
|
+
action: "read",
|
|
1692
|
+
mutating: false,
|
|
1693
|
+
http: { method: "GET", path: "/v1/runway" },
|
|
1694
|
+
operationId: "groupRunway",
|
|
1695
|
+
cli: ["runway", "group"],
|
|
1696
|
+
fields: [{ name: "base", type: "string", location: "query" }],
|
|
1697
|
+
profiles: R,
|
|
1698
|
+
run: (rc, i) => groupRunway(rc, i)
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
name: "cash_forecast",
|
|
1702
|
+
description: "Short-horizon cash projection (entity or group).",
|
|
1703
|
+
scope: "treasury:read",
|
|
1704
|
+
action: "read",
|
|
1705
|
+
mutating: false,
|
|
1706
|
+
http: { method: "GET", path: "/v1/forecast" },
|
|
1707
|
+
operationId: "cashForecast",
|
|
1708
|
+
cli: ["forecast", "run"],
|
|
1709
|
+
fields: [
|
|
1710
|
+
{ name: "entity_id", type: "string", location: "query" },
|
|
1711
|
+
{ name: "base", type: "string", location: "query" },
|
|
1712
|
+
{ name: "horizon_months", type: "int", location: "query" }
|
|
1713
|
+
],
|
|
1714
|
+
profiles: SF,
|
|
1715
|
+
run: (rc, i) => cashForecast(rc, i)
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
name: "list_sweeps",
|
|
1719
|
+
description: "List sweep / intercompany-funding recommendations.",
|
|
1720
|
+
scope: "treasury:read",
|
|
1721
|
+
action: "read",
|
|
1722
|
+
mutating: false,
|
|
1723
|
+
http: { method: "GET", path: "/v1/sweeps" },
|
|
1724
|
+
operationId: "listSweeps",
|
|
1725
|
+
cli: ["sweeps", "list"],
|
|
1726
|
+
fields: [{ name: "status", type: "string", location: "query" }],
|
|
1727
|
+
profiles: SF,
|
|
1728
|
+
run: (rc, i) => listSweeps(rc, i)
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
name: "get_sweep",
|
|
1732
|
+
description: "Get a single sweep recommendation.",
|
|
1733
|
+
scope: "treasury:read",
|
|
1734
|
+
action: "read",
|
|
1735
|
+
mutating: false,
|
|
1736
|
+
http: { method: "GET", path: "/v1/sweeps/:id" },
|
|
1737
|
+
operationId: "getSweep",
|
|
1738
|
+
cli: ["sweeps", "get"],
|
|
1739
|
+
fields: [{ name: "id", type: "string", required: true, location: "path" }],
|
|
1740
|
+
profiles: SF,
|
|
1741
|
+
run: (rc, i) => getSweep(rc, i)
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
name: "generate_sweeps",
|
|
1745
|
+
description: "Generate intercompany-funding recommendations (advisory only).",
|
|
1746
|
+
scope: "treasury:recommend",
|
|
1747
|
+
action: "recommend",
|
|
1748
|
+
mutating: true,
|
|
1749
|
+
http: { method: "POST", path: "/v1/sweeps/generate" },
|
|
1750
|
+
operationId: "generateSweeps",
|
|
1751
|
+
cli: ["sweeps", "generate"],
|
|
1752
|
+
fields: [
|
|
1753
|
+
{ name: "base", type: "string", location: "body" },
|
|
1754
|
+
{ name: "min_runway_months", type: "number", location: "body" },
|
|
1755
|
+
{ name: "healthy_runway_months", type: "number", location: "body" }
|
|
1756
|
+
],
|
|
1757
|
+
profiles: F,
|
|
1758
|
+
run: (rc, i) => generateSweeps(rc, i)
|
|
1759
|
+
},
|
|
1760
|
+
{
|
|
1761
|
+
name: "update_sweep_status",
|
|
1762
|
+
description: "Acknowledge or dismiss a sweep recommendation (advisory only).",
|
|
1763
|
+
scope: "treasury:recommend",
|
|
1764
|
+
action: "recommend",
|
|
1765
|
+
mutating: true,
|
|
1766
|
+
http: { method: "PATCH", path: "/v1/sweeps/:id" },
|
|
1767
|
+
operationId: "updateSweepStatus",
|
|
1768
|
+
cli: ["sweeps", "update"],
|
|
1769
|
+
fields: [
|
|
1770
|
+
{ name: "id", type: "string", required: true, location: "path" },
|
|
1771
|
+
{ name: "status", type: "string", required: true, location: "body" }
|
|
1772
|
+
],
|
|
1773
|
+
profiles: F,
|
|
1774
|
+
run: (rc, i) => updateSweepStatus(rc, i)
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
name: "ingest_fixtures",
|
|
1778
|
+
description: "Ingest balances/FX/cost from the v0 fixture adapters for two demo entities.",
|
|
1779
|
+
scope: "treasury:write",
|
|
1780
|
+
action: "write",
|
|
1781
|
+
mutating: true,
|
|
1782
|
+
http: { method: "POST", path: "/v1/ingest" },
|
|
1783
|
+
operationId: "ingestFixtures",
|
|
1784
|
+
cli: ["ingest", "fixtures"],
|
|
1785
|
+
fields: [
|
|
1786
|
+
{ name: "us_entity_id", type: "string", required: true, location: "body" },
|
|
1787
|
+
{ name: "ro_entity_id", type: "string", required: true, location: "body" }
|
|
1788
|
+
],
|
|
1789
|
+
profiles: F,
|
|
1790
|
+
run: (rc, i) => {
|
|
1791
|
+
const input = i;
|
|
1792
|
+
return ingestFromAdapters(rc, buildFixtureAdapters(input.us_entity_id, input.ro_entity_id));
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
];
|
|
1796
|
+
function coerceField(field, raw) {
|
|
1797
|
+
if (raw === undefined || raw === null || raw === "")
|
|
1798
|
+
return;
|
|
1799
|
+
switch (field.type) {
|
|
1800
|
+
case "int": {
|
|
1801
|
+
const n = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
|
1802
|
+
return Number.isFinite(n) ? n : raw;
|
|
1803
|
+
}
|
|
1804
|
+
case "number": {
|
|
1805
|
+
const n = typeof raw === "number" ? raw : Number.parseFloat(String(raw));
|
|
1806
|
+
return Number.isFinite(n) ? n : raw;
|
|
1807
|
+
}
|
|
1808
|
+
case "bool":
|
|
1809
|
+
return raw === true || raw === "true" || raw === "1";
|
|
1810
|
+
default:
|
|
1811
|
+
return String(raw);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// src/core/errors.ts
|
|
1816
|
+
function normalizeError(error) {
|
|
1817
|
+
if (error instanceof Error) {
|
|
1818
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : "INTERNAL_ERROR";
|
|
1819
|
+
const ctor = error.constructor;
|
|
1820
|
+
const suggestion = typeof ctor.suggestion === "string" ? ctor.suggestion : "";
|
|
1821
|
+
return { code, message: error.message, suggestion };
|
|
1822
|
+
}
|
|
1823
|
+
return { code: "UNKNOWN_ERROR", message: String(error), suggestion: "An unexpected error occurred." };
|
|
1824
|
+
}
|
|
1825
|
+
var STATUS_BY_CODE = {
|
|
1826
|
+
UNAUTHORIZED: 401,
|
|
1827
|
+
PERMISSION_DENIED: 403,
|
|
1828
|
+
ENTITY_NOT_FOUND: 404,
|
|
1829
|
+
SWEEP_NOT_FOUND: 404,
|
|
1830
|
+
VALIDATION_ERROR: 422,
|
|
1831
|
+
INVALID_LIST_QUERY: 400,
|
|
1832
|
+
RATE_LIMITED: 429
|
|
1833
|
+
};
|
|
1834
|
+
function httpStatusForCode(code) {
|
|
1835
|
+
return STATUS_BY_CODE[code] ?? 500;
|
|
1836
|
+
}
|
|
1837
|
+
// package.json
|
|
1838
|
+
var package_default = {
|
|
1839
|
+
name: "@hasna/treasury",
|
|
1840
|
+
version: "0.1.2",
|
|
1841
|
+
description: "Multi-entity cash/treasury cockpit: consolidated balances, FX exposure, per-entity & group runway, sweep/intercompany-funding recommendations, short-horizon cash forecast. CLI + MCP + serve over a Hasna-contract store.",
|
|
1842
|
+
type: "module",
|
|
1843
|
+
main: "dist/index.js",
|
|
1844
|
+
types: "dist/index.d.ts",
|
|
1845
|
+
bin: {
|
|
1846
|
+
treasury: "dist/cli/index.js",
|
|
1847
|
+
"treasury-mcp": "dist/mcp/index.js",
|
|
1848
|
+
"treasury-serve": "dist/server/index.js"
|
|
1849
|
+
},
|
|
1850
|
+
exports: {
|
|
1851
|
+
".": {
|
|
1852
|
+
types: "./dist/index.d.ts",
|
|
1853
|
+
import: "./dist/index.js"
|
|
1854
|
+
},
|
|
1855
|
+
"./sdk": {
|
|
1856
|
+
types: "./dist/index.d.ts",
|
|
1857
|
+
import: "./dist/index.js"
|
|
1858
|
+
}
|
|
1859
|
+
},
|
|
1860
|
+
files: [
|
|
1861
|
+
"dist",
|
|
1862
|
+
"hasna.contract.json",
|
|
1863
|
+
"openapi.json",
|
|
1864
|
+
"docker-compose.yml",
|
|
1865
|
+
"LICENSE",
|
|
1866
|
+
"README.md"
|
|
1867
|
+
],
|
|
1868
|
+
scripts: {
|
|
1869
|
+
build: "bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external hono --external pg && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external pg && bun build src/server/index.ts --outdir dist/server --target bun --external hono --external pg && bun build src/index.ts --outdir dist --target bun --external pg && tsc --emitDeclarationOnly --outDir dist",
|
|
1870
|
+
typecheck: "tsc --noEmit",
|
|
1871
|
+
test: "bun test",
|
|
1872
|
+
conformance: "bunx @hasna/contracts@0.4.1 vendor-kit --check && bunx @hasna/contracts@0.4.1 repo-conformance",
|
|
1873
|
+
verify: "bun run typecheck && bun run test && bun run build && bun run conformance",
|
|
1874
|
+
"openapi:generate": "bun run src/cli/index.tsx openapi generate --out openapi.json",
|
|
1875
|
+
"openapi:check": "bun run src/cli/index.tsx openapi check --path openapi.json",
|
|
1876
|
+
"smoke:package": "bun run src/release/package-smoke.ts",
|
|
1877
|
+
"dev:cli": "bun run src/cli/index.tsx",
|
|
1878
|
+
"dev:mcp": "bun run src/mcp/index.ts --http --port 8890",
|
|
1879
|
+
"dev:serve": "bun run src/server/index.ts",
|
|
1880
|
+
postinstall: `node -e "try{const os=require('os'),fs=require('fs'),p=require('path');const b=p.join(os.homedir(),'.hasna','treasury');for(const d of ['','config','data','exports','backups','logs','tmp']) fs.mkdirSync(p.join(b,d),{recursive:true});}catch(e){}"`
|
|
1881
|
+
},
|
|
1882
|
+
publishConfig: {
|
|
1883
|
+
registry: "https://registry.npmjs.org",
|
|
1884
|
+
access: "public"
|
|
1885
|
+
},
|
|
1886
|
+
repository: {
|
|
1887
|
+
type: "git",
|
|
1888
|
+
url: "git+https://github.com/hasna/treasury.git"
|
|
1889
|
+
},
|
|
1890
|
+
homepage: "https://github.com/hasna/treasury#readme",
|
|
1891
|
+
bugs: {
|
|
1892
|
+
url: "https://github.com/hasna/treasury/issues"
|
|
1893
|
+
},
|
|
1894
|
+
engines: {
|
|
1895
|
+
bun: ">=1.1.0"
|
|
1896
|
+
},
|
|
1897
|
+
author: "Andrei Hasna <andrei@hasna.com>",
|
|
1898
|
+
license: "Apache-2.0",
|
|
1899
|
+
dependencies: {
|
|
1900
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1901
|
+
hono: "^4.6.0",
|
|
1902
|
+
commander: "^13.1.0",
|
|
1903
|
+
ink: "^5.2.0",
|
|
1904
|
+
react: "^18.3.1",
|
|
1905
|
+
chalk: "^5.4.1",
|
|
1906
|
+
zod: "^3.24.2",
|
|
1907
|
+
pg: "^8.13.1"
|
|
1908
|
+
},
|
|
1909
|
+
devDependencies: {
|
|
1910
|
+
"@hasna/contracts": "0.4.1",
|
|
1911
|
+
"@types/pg": "^8.11.10",
|
|
1912
|
+
"@types/bun": "^1.2.4",
|
|
1913
|
+
"@types/react": "^18.3.18",
|
|
1914
|
+
typescript: "^5.7.3",
|
|
1915
|
+
"bun-types": "1.3.14"
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
// src/version.ts
|
|
1920
|
+
var APP_VERSION = package_default.version;
|
|
1921
|
+
|
|
1922
|
+
// src/server/health.ts
|
|
1923
|
+
function versionPayload() {
|
|
1924
|
+
return { status: "ok", version: APP_VERSION, mode: resolveStorageMode() };
|
|
1925
|
+
}
|
|
1926
|
+
function healthPayload() {
|
|
1927
|
+
return { status: "ok", version: APP_VERSION, mode: resolveStorageMode() };
|
|
1928
|
+
}
|
|
1929
|
+
async function readyPayload() {
|
|
1930
|
+
try {
|
|
1931
|
+
const db = await openDatabase();
|
|
1932
|
+
await db.get("SELECT 1 AS ok");
|
|
1933
|
+
return { ok: true, body: { status: "ready" } };
|
|
1934
|
+
} catch {
|
|
1935
|
+
return { ok: false, body: { status: "unavailable" } };
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// src/server/app.ts
|
|
1940
|
+
var rateLimitMap = new Map;
|
|
1941
|
+
var RATE_LIMIT_WINDOW = 60000;
|
|
1942
|
+
function rateLimitMax() {
|
|
1943
|
+
return Number.parseInt(process.env["HASNA_TREASURY_RATE_LIMIT"] || process.env["TREASURY_RATE_LIMIT"] || "120", 10);
|
|
1944
|
+
}
|
|
1945
|
+
function checkRateLimit(ip) {
|
|
1946
|
+
const now2 = Date.now();
|
|
1947
|
+
const entry = rateLimitMap.get(ip);
|
|
1948
|
+
if (!entry || now2 > entry.resetAt) {
|
|
1949
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now2 + RATE_LIMIT_WINDOW });
|
|
1950
|
+
return true;
|
|
1951
|
+
}
|
|
1952
|
+
entry.count++;
|
|
1953
|
+
return entry.count <= rateLimitMax();
|
|
1954
|
+
}
|
|
1955
|
+
function corsOrigins() {
|
|
1956
|
+
const raw = process.env["HASNA_TREASURY_CORS_ORIGINS"] || process.env["TREASURY_CORS_ORIGINS"] || "";
|
|
1957
|
+
return raw.split(",").map((o) => o.trim()).filter(Boolean);
|
|
1958
|
+
}
|
|
1959
|
+
function getBindHost() {
|
|
1960
|
+
return process.env["HASNA_TREASURY_BIND_HOST"] || process.env["TREASURY_BIND_HOST"] || "127.0.0.1";
|
|
1961
|
+
}
|
|
1962
|
+
function isLoopback(host = getBindHost()) {
|
|
1963
|
+
return host === "127.0.0.1" || host === "localhost";
|
|
1964
|
+
}
|
|
1965
|
+
function clientIp(c) {
|
|
1966
|
+
const server = c.env;
|
|
1967
|
+
const peer = server?.requestIP?.(c.req.raw)?.address;
|
|
1968
|
+
if (peer)
|
|
1969
|
+
return peer;
|
|
1970
|
+
const xff = c.req.header("x-forwarded-for");
|
|
1971
|
+
if (xff) {
|
|
1972
|
+
const hops = xff.split(",").map((h) => h.trim()).filter(Boolean);
|
|
1973
|
+
if (hops.length > 0)
|
|
1974
|
+
return `xff:${hops[hops.length - 1]}`;
|
|
1975
|
+
}
|
|
1976
|
+
return "conn";
|
|
1977
|
+
}
|
|
1978
|
+
function buildInput(op, c, body) {
|
|
1979
|
+
const input = {};
|
|
1980
|
+
for (const f of op.fields) {
|
|
1981
|
+
let raw;
|
|
1982
|
+
if (f.location === "path")
|
|
1983
|
+
raw = c.req.param(f.name);
|
|
1984
|
+
else if (f.location === "query")
|
|
1985
|
+
raw = c.req.query(f.name);
|
|
1986
|
+
else
|
|
1987
|
+
raw = body[f.name];
|
|
1988
|
+
const v = coerceField(f, raw);
|
|
1989
|
+
if (v !== undefined)
|
|
1990
|
+
input[f.name] = v;
|
|
1991
|
+
}
|
|
1992
|
+
return input;
|
|
1993
|
+
}
|
|
1994
|
+
function honoPath(op) {
|
|
1995
|
+
return op.http.path;
|
|
1996
|
+
}
|
|
1997
|
+
function createApp() {
|
|
1998
|
+
const app = new Hono;
|
|
1999
|
+
app.use("*", async (c, next) => {
|
|
2000
|
+
const origin = c.req.header("Origin");
|
|
2001
|
+
const allowed = corsOrigins();
|
|
2002
|
+
if (origin && allowed.includes(origin)) {
|
|
2003
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
2004
|
+
c.header("Vary", "Origin");
|
|
2005
|
+
c.header("Access-Control-Allow-Credentials", "true");
|
|
2006
|
+
c.header("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
2007
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
|
|
2008
|
+
}
|
|
2009
|
+
if (c.req.method === "OPTIONS")
|
|
2010
|
+
return c.body(null, 204);
|
|
2011
|
+
const ip = clientIp(c);
|
|
2012
|
+
if (!checkRateLimit(ip)) {
|
|
2013
|
+
return c.json({ code: "RATE_LIMITED", message: "Too many requests", suggestion: "Slow down and retry." }, 429);
|
|
2014
|
+
}
|
|
2015
|
+
await next();
|
|
2016
|
+
});
|
|
2017
|
+
app.get("/health", (c) => c.json(healthPayload()));
|
|
2018
|
+
app.get("/version", (c) => c.json(versionPayload()));
|
|
2019
|
+
app.get("/ready", async (c) => {
|
|
2020
|
+
const { ok, body } = await readyPayload();
|
|
2021
|
+
return c.json(body, ok ? 200 : 503);
|
|
2022
|
+
});
|
|
2023
|
+
for (const op of OPS) {
|
|
2024
|
+
const handler = async (c) => {
|
|
2025
|
+
const authConfigured = isApiAuthConfigured();
|
|
2026
|
+
let principal = null;
|
|
2027
|
+
if (authConfigured) {
|
|
2028
|
+
principal = authenticateApiRequest(c.req.raw);
|
|
2029
|
+
if (!principal) {
|
|
2030
|
+
return c.json({ code: "UNAUTHORIZED", message: "Invalid or missing API credential.", suggestion: "Provide a valid Bearer token." }, 401);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
let body = {};
|
|
2034
|
+
if (op.http.method === "POST" || op.http.method === "PATCH") {
|
|
2035
|
+
try {
|
|
2036
|
+
body = await c.req.json();
|
|
2037
|
+
} catch {
|
|
2038
|
+
body = {};
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
try {
|
|
2042
|
+
const db = await openDatabase();
|
|
2043
|
+
const rc = principal ? contextFromPrincipal(db, principal) : localOwnerContext(db);
|
|
2044
|
+
const result = await op.run(rc, buildInput(op, c, body));
|
|
2045
|
+
return c.json(result);
|
|
2046
|
+
} catch (error) {
|
|
2047
|
+
const env = normalizeError(error);
|
|
2048
|
+
return c.json(env, httpStatusForCode(env.code));
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
const path = honoPath(op);
|
|
2052
|
+
if (op.http.method === "GET")
|
|
2053
|
+
app.get(path, handler);
|
|
2054
|
+
else if (op.http.method === "POST")
|
|
2055
|
+
app.post(path, handler);
|
|
2056
|
+
else if (op.http.method === "PATCH")
|
|
2057
|
+
app.patch(path, handler);
|
|
2058
|
+
else if (op.http.method === "DELETE")
|
|
2059
|
+
app.delete(path, handler);
|
|
2060
|
+
}
|
|
2061
|
+
app.notFound((c) => c.json({ code: "NOT_FOUND", message: `No route: ${c.req.method} ${c.req.path}`, suggestion: "Check the API path." }, 404));
|
|
2062
|
+
return app;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/server/index.ts
|
|
2066
|
+
function getPort() {
|
|
2067
|
+
return Number.parseInt(process.env["HASNA_TREASURY_PORT"] || process.env["TREASURY_PORT"] || "3486", 10);
|
|
2068
|
+
}
|
|
2069
|
+
function assertServeSafety() {
|
|
2070
|
+
const host = getBindHost();
|
|
2071
|
+
const mode = resolveStorageMode();
|
|
2072
|
+
const openOk = isLoopback(host) && mode === "local";
|
|
2073
|
+
if (!openOk && !isApiAuthConfigured()) {
|
|
2074
|
+
throw new Error(`Refusing to start: bind=${host} mode=${mode} requires API credentials. ` + `Set HASNA_TREASURY_API_CREDENTIALS (or HASNA_TREASURY_API_KEY). ` + `Unauthenticated /v1 is only allowed on 127.0.0.1 in local mode.`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
function startServer() {
|
|
2078
|
+
assertServeSafety();
|
|
2079
|
+
const app = createApp();
|
|
2080
|
+
const port = getPort();
|
|
2081
|
+
const hostname = getBindHost();
|
|
2082
|
+
const server = Bun.serve({ port, hostname, fetch: app.fetch });
|
|
2083
|
+
console.log(`treasury-serve v${APP_VERSION} on http://${hostname}:${port} (mode=${resolveStorageMode()})`);
|
|
2084
|
+
console.log(`API auth ${isApiAuthConfigured() ? "enabled" : "disabled (loopback+local dev only)"}`);
|
|
2085
|
+
return server;
|
|
2086
|
+
}
|
|
2087
|
+
if (import.meta.main) {
|
|
2088
|
+
startServer();
|
|
2089
|
+
}
|
|
2090
|
+
export {
|
|
2091
|
+
startServer,
|
|
2092
|
+
getPort,
|
|
2093
|
+
assertServeSafety
|
|
2094
|
+
};
|