@trazum/cli 1.42.0 → 1.44.0

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/src/i18n/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CannotTellReason, EvalVerdict, Locale, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome } from '@trazum/core';
1
+ import type { CannotTellReason, EvalVerdict, Locale, NotJudgeable, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome, WatchGate } from '@trazum/core';
2
2
 
3
3
  /**
4
4
  * The CLI's own message catalogue.
@@ -1072,6 +1072,49 @@ export interface CliMessages {
1072
1072
  wrote(path: string): string;
1073
1073
  };
1074
1074
 
1075
+ /**
1076
+ * `trazum serve` — the answer given before the call is sent.
1077
+ *
1078
+ * The copy states the two things a reader must not have to infer: that this
1079
+ * listens on loopback and nowhere else, and that the measured half is read
1080
+ * once rather than being current to the second.
1081
+ */
1082
+ serve: {
1083
+ listening(where: string): string;
1084
+ /** Why there is no --host, and why that is not an omission. */
1085
+ loopbackOnly(): string;
1086
+ measuredFrom(usd: string): string;
1087
+ nothingMeasured(dir: string): string;
1088
+ noBudget(): string;
1089
+ badPort(value: string): string;
1090
+ };
1091
+
1092
+ /**
1093
+ * `trazum watch` — the gates, evaluated as the money moves.
1094
+ *
1095
+ * The copy here carries the rule that makes an alert at 3am trustworthy: a
1096
+ * crossing is measured, never projected, and a period too short to judge is
1097
+ * said to be rather than passed.
1098
+ */
1099
+ watch: {
1100
+ /** Watching with no threshold configured is a green light nobody earned. */
1101
+ noThresholds(): string;
1102
+ nothingToWatch(dir: string): string;
1103
+ intervalTooTight(): string;
1104
+ badWebhook(reason: 'invalid-url' | 'credentials-in-url' | 'insecure-scheme'): string;
1105
+ /** A measured crossing. `day` names the afternoon when the gate is a day gate. */
1106
+ crossed(gate: WatchGate, measured: string, limit: string, day: string | null): string;
1107
+ /** Still over the limit, and already reported — quiet, but not clean. */
1108
+ stillOver(gate: WatchGate, measured: string, limit: string, day: string | null): string;
1109
+ /** Neither a pass nor a failure: this cannot be judged yet, and why. */
1110
+ notJudgeable(gate: WatchGate, reason: NotJudgeable, covered: string | null): string;
1111
+ /** The stretch nobody was watching, named rather than implied away. */
1112
+ gap(from: string, to: string): string;
1113
+ allWithin(gates: string): string;
1114
+ webhookFailed(status: string): string;
1115
+ watching(minutes: string): string;
1116
+ };
1117
+
1075
1118
  /**
1076
1119
  * `trazum store` — the measurements kept on disk.
1077
1120
  *
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  normalizeAnthropicUsage,
21
21
  normalizeOpenAIUsage,
22
22
  bucketsFromRecords,
23
+ evaluateWatch,
24
+ firedKey,
23
25
  pruneRecords,
24
26
  recordsFromBuckets,
25
27
  storeInventory,
@@ -146,6 +148,14 @@ import {
146
148
  import type { Revision } from './git.js';
147
149
  import { fetchProviderUsage } from './connect.js';
148
150
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
151
+ import { DEFAULT_PORT, buildServer, listen } from './serve.js';
152
+ import {
153
+ WATCH_STATE_VERSION,
154
+ checkWebhook,
155
+ postWebhook,
156
+ readWatchState,
157
+ writeWatchState,
158
+ } from './watch-run.js';
149
159
  import { detectLocale, getCliMessages } from './i18n/index.js';
150
160
  import {
151
161
  MAX_SUMMARY_CHARS,
@@ -196,6 +206,10 @@ const VALUE_FLAGS = new Set([
196
206
  'min-usd',
197
207
  'payload',
198
208
  'keep',
209
+ 'interval',
210
+ 'webhook',
211
+ 'port',
212
+ 'socket',
199
213
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
200
214
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
201
215
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -501,6 +515,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
501
515
  history: ['store', 'json', 'markdown-out'],
502
516
  connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
503
517
  store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
518
+ watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
519
+ serve: ['port', 'socket', 'pricing', 'pricing-live'],
504
520
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
505
521
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
506
522
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2282,6 +2298,285 @@ function parseWhen(
2282
2298
  throw new Error(t.profile.badWhen(flag, value));
2283
2299
  }
2284
2300
 
2301
+ /**
2302
+ * `trazum serve` — the answer in milliseconds.
2303
+ *
2304
+ * The measured position is read once at start rather than per request: the
2305
+ * whole promise is a single-digit-millisecond answer, and a file read in the
2306
+ * hot path cannot make it. That staleness is real, so every answer carries the
2307
+ * window its measurement covers instead of implying it is current to the
2308
+ * second.
2309
+ */
2310
+ async function commandServe(
2311
+ args: Args,
2312
+ config: TrazumConfig,
2313
+ pricing: PricingCatalogue,
2314
+ t: CliMessages,
2315
+ ): Promise<void> {
2316
+ const root = process.cwd();
2317
+ const limitUsd = config.spend?.maxUsd;
2318
+
2319
+ const { resolved } = await readStore(root);
2320
+ const measured = resolved.records.length > 0;
2321
+ /**
2322
+ * The window the measurement covers, carried into every answer.
2323
+ *
2324
+ * The position is read once at start, so a caller has to be able to see how
2325
+ * old it is. A null window here would let a figure from last month read as
2326
+ * current, which is the staleness this endpoint is otherwise honest about.
2327
+ */
2328
+ const window = measured
2329
+ ? {
2330
+ fromMs: Math.min(...resolved.records.map((record) => record.fromMs)),
2331
+ toMs: Math.max(...resolved.records.map((record) => record.toMs)),
2332
+ }
2333
+ : null;
2334
+ const report = bucketedProfile(
2335
+ {
2336
+ provider: 'store',
2337
+ granularity: 'bucketed',
2338
+ buckets: bucketsFromRecords(resolved.records),
2339
+ window,
2340
+ gaps: [],
2341
+ unavailable: [],
2342
+ },
2343
+ { catalogue: pricing },
2344
+ );
2345
+
2346
+ const server = buildServer({
2347
+ catalogue: pricing,
2348
+ position: () => ({
2349
+ consumedUsd: measured ? report.total.totalUsd : undefined,
2350
+ limitUsd,
2351
+ window: report.span,
2352
+ }),
2353
+ });
2354
+
2355
+ const socket = stringFlag(args, 'socket');
2356
+ const portRaw = stringFlag(args, 'port');
2357
+ const port = portRaw === undefined ? DEFAULT_PORT : Number(portRaw);
2358
+ if (socket === undefined && (!Number.isInteger(port) || port < 0 || port > 65_535)) {
2359
+ throw new Error(t.serve.badPort(String(portRaw)));
2360
+ }
2361
+
2362
+ const where = await listen(server, socket !== undefined ? { socket } : { port });
2363
+ console.log(c.bold(t.serve.listening(where)));
2364
+ console.log(` ${c.dim(wrap(t.serve.loopbackOnly(), 74, ' '))}`);
2365
+ console.log(
2366
+ ` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(report.total.totalUsd)) : t.serve.nothingMeasured(STORE_DIR), 74, ' '))}`,
2367
+ );
2368
+ if (limitUsd === undefined) {
2369
+ console.log(` ${c.dim(wrap(t.serve.noBudget(), 74, ' '))}`);
2370
+ }
2371
+ }
2372
+
2373
+ /**
2374
+ * `trazum watch` — the afternoon it happened, said that afternoon.
2375
+ *
2376
+ * One cycle is the primitive: measure, keep, evaluate, emit, remember. The
2377
+ * loop is that cycle in a timer, so a cron entry and a foreground watcher run
2378
+ * exactly the same code and the tests exercise the thing that ships.
2379
+ *
2380
+ * Three transports, all boring on purpose: a non-zero exit code so cron mails
2381
+ * it, a JSON event on stdout so any pipeline can read it, and a webhook for
2382
+ * the operator who already has somewhere for alerts to go. No hosted service
2383
+ * and no account.
2384
+ */
2385
+ async function commandWatch(
2386
+ args: Args,
2387
+ config: TrazumConfig,
2388
+ pricing: PricingCatalogue,
2389
+ t: CliMessages,
2390
+ ): Promise<void> {
2391
+ const root = process.cwd();
2392
+ const asJson = boolFlag(args, 'json');
2393
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2394
+ const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
2395
+
2396
+ const thresholds = {
2397
+ maxUsd: config.spend?.maxUsd,
2398
+ maxDayUsd: config.spend?.maxDayUsd,
2399
+ maxCacheLossUsd: config.spend?.maxCacheLossUsd,
2400
+ };
2401
+ if (
2402
+ thresholds.maxUsd === undefined &&
2403
+ thresholds.maxDayUsd === undefined &&
2404
+ thresholds.maxCacheLossUsd === undefined
2405
+ ) {
2406
+ throw new Error(t.watch.noThresholds());
2407
+ }
2408
+
2409
+ /**
2410
+ * A webhook is a new outbound surface, so it is checked before anything is
2411
+ * sent: credentials in a URL end up in logs and shell history, and an alert
2412
+ * carrying spend figures over plain http across a network is a leak the
2413
+ * operator did not ask for. Loopback is the exception, because pointing a
2414
+ * watcher at your own alerting daemon is the ordinary case.
2415
+ */
2416
+ const webhookRaw = stringFlag(args, 'webhook');
2417
+ let webhook: URL | null = null;
2418
+ if (webhookRaw !== undefined) {
2419
+ const checked = checkWebhook(webhookRaw);
2420
+ if (!checked.ok) throw new Error(t.watch.badWebhook(checked.reason));
2421
+ webhook = checked.url;
2422
+ }
2423
+
2424
+ const intervalRaw = stringFlag(args, 'interval');
2425
+ const once = boolFlag(args, 'once') || intervalRaw === undefined;
2426
+ let intervalMs = 0;
2427
+ if (!once) {
2428
+ const match = /^(\d+)(m|h)$/.exec(intervalRaw!);
2429
+ const amount = match === null ? NaN : Number(match[1]);
2430
+ intervalMs = match?.[2] === 'h' ? amount * 3_600_000 : amount * 60_000;
2431
+ // Usage APIs are rate limited, and a tight loop is a way to get somebody's
2432
+ // key throttled by a tool that was supposed to save them money.
2433
+ if (!Number.isFinite(intervalMs) || intervalMs < 5 * 60_000) {
2434
+ throw new Error(t.watch.intervalTooTight());
2435
+ }
2436
+ }
2437
+
2438
+ const cycle = async (): Promise<number> => {
2439
+ const state = await readWatchState(root);
2440
+ const nowMs = Date.now();
2441
+
2442
+ /**
2443
+ * Where the measurements come from: a saved payload when one is named
2444
+ * (which is how this is tested and how an air-gapped run works), and the
2445
+ * store otherwise. A cycle that found nothing to measure says so — a
2446
+ * watcher over nothing is a green light nobody earned.
2447
+ */
2448
+ const payloadPath = stringFlag(args, 'payload');
2449
+ let pull;
2450
+ if (payloadPath !== undefined) {
2451
+ pull = normalizeAnthropicUsage(JSON.parse(await readFile(payloadPath, 'utf8')));
2452
+ } else {
2453
+ const { resolved } = await readStore(root);
2454
+ if (resolved.records.length === 0) throw new Error(t.watch.nothingToWatch(STORE_DIR));
2455
+ pull = {
2456
+ provider: 'store',
2457
+ granularity: 'bucketed' as const,
2458
+ buckets: bucketsFromRecords(resolved.records),
2459
+ window: null,
2460
+ gaps: [],
2461
+ unavailable: [],
2462
+ };
2463
+ }
2464
+
2465
+ const report = bucketedProfile(pull, { catalogue: pricing });
2466
+ const cache = bucketedCacheEconomics(report);
2467
+ const result = evaluateWatch({
2468
+ report,
2469
+ thresholds,
2470
+ cacheDeltaUsd: cache.verdict === 'no-cache' ? undefined : cache.deltaUsd,
2471
+ nowMs,
2472
+ lastCoveredToMs: state?.lastCoveredToMs ?? undefined,
2473
+ alreadyFired: new Set(Object.keys(state?.fired ?? {})),
2474
+ });
2475
+
2476
+ if (asJson) {
2477
+ console.log(JSON.stringify({ schemaVersion: 1, firedAtMs: nowMs, ...result }, null, 2));
2478
+ } else {
2479
+ if (result.gap !== null) {
2480
+ console.log(c.yellow(wrap(t.watch.gap(day(result.gap.fromMs), day(result.gap.toMs)), 76, ' ')));
2481
+ }
2482
+ for (const crossing of result.crossings) {
2483
+ console.log(
2484
+ c.red(
2485
+ wrap(
2486
+ t.watch.crossed(
2487
+ crossing.gate,
2488
+ formatUsd(crossing.measuredUsd),
2489
+ formatUsd(crossing.limitUsd),
2490
+ crossing.day,
2491
+ ),
2492
+ 76,
2493
+ ' ',
2494
+ ),
2495
+ ),
2496
+ );
2497
+ }
2498
+ for (const abstention of result.abstentions) {
2499
+ console.log(
2500
+ c.dim(
2501
+ wrap(
2502
+ t.watch.notJudgeable(
2503
+ abstention.gate,
2504
+ abstention.reason,
2505
+ abstention.detail === null
2506
+ ? null
2507
+ : `${Math.round((abstention.detail.coveredMs / abstention.detail.neededMs) * 100)}%`,
2508
+ ),
2509
+ 76,
2510
+ ' ',
2511
+ ),
2512
+ ),
2513
+ );
2514
+ }
2515
+ for (const still of result.suppressed) {
2516
+ console.log(
2517
+ c.yellow(
2518
+ wrap(
2519
+ t.watch.stillOver(
2520
+ still.gate,
2521
+ formatUsd(still.measuredUsd),
2522
+ formatUsd(still.limitUsd),
2523
+ still.day,
2524
+ ),
2525
+ 76,
2526
+ ' ',
2527
+ ),
2528
+ ),
2529
+ );
2530
+ }
2531
+ if (
2532
+ result.crossings.length === 0 &&
2533
+ result.suppressed.length === 0 &&
2534
+ result.abstentions.length === 0
2535
+ ) {
2536
+ console.log(c.green(wrap(t.watch.allWithin(n(Object.keys(thresholds).filter((k) => thresholds[k as keyof typeof thresholds] !== undefined).length)), 76, ' ')));
2537
+ }
2538
+ }
2539
+
2540
+ if (webhook !== null && result.crossings.length > 0) {
2541
+ const sent = await postWebhook(webhook, {
2542
+ schemaVersion: 1,
2543
+ firedAtMs: nowMs,
2544
+ crossings: result.crossings,
2545
+ });
2546
+ if (!sent.ok) {
2547
+ // Reported and swallowed: the exit code and the event already carried
2548
+ // the crossing, and losing those because a receiver is down would make
2549
+ // the quietest failure the loudest one.
2550
+ console.error(c.yellow(t.watch.webhookFailed(sent.status === null ? sent.error ?? '' : String(sent.status))));
2551
+ }
2552
+ }
2553
+
2554
+ const fired = { ...(state?.fired ?? {}) };
2555
+ for (const crossing of result.crossings) fired[firedKey(crossing.gate, crossing.day)] = nowMs;
2556
+ await writeWatchState(root, {
2557
+ v: WATCH_STATE_VERSION,
2558
+ lastCycleMs: nowMs,
2559
+ lastCoveredToMs: report.span?.toMs ?? state?.lastCoveredToMs ?? null,
2560
+ fired,
2561
+ });
2562
+
2563
+ return result.crossings.length + result.suppressed.length;
2564
+ };
2565
+
2566
+ const crossed = await cycle();
2567
+ // Still over is still a failure: only the alert was already sent.
2568
+ if (crossed > 0) process.exitCode = 1;
2569
+ if (once) return;
2570
+
2571
+ console.log(c.dim(t.watch.watching(String(Math.round(intervalMs / 60_000)))));
2572
+ // The loop is the cycle in a timer and nothing more, so the primitive above
2573
+ // is the only thing that ever needs testing.
2574
+ for (;;) {
2575
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2576
+ await cycle();
2577
+ }
2578
+ }
2579
+
2285
2580
  /**
2286
2581
  * `trazum store` — what is kept, and what a prune would take.
2287
2582
  *
@@ -6866,6 +7161,12 @@ async function main(): Promise<void> {
6866
7161
  case 'store':
6867
7162
  await commandStore(args, config, pricing, t);
6868
7163
  break;
7164
+ case 'watch':
7165
+ await commandWatch(args, config, pricing, t);
7166
+ break;
7167
+ case 'serve':
7168
+ await commandServe(args, config, pricing, t);
7169
+ break;
6869
7170
  case 'route':
6870
7171
  await commandRoute(args, pricing, t);
6871
7172
  break;
package/src/serve.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The endpoint that answers before the call is sent.
3
+ *
4
+ * **Loopback only, and the address is not a flag.** A cost oracle listening on
5
+ * a network interface is an attack surface with a very small upside: it holds
6
+ * a company's spend, its model mix and its budgets, and it answers anybody who
7
+ * asks. `checkedEndpoint` has guarded Trazum's *outbound* requests since 1.14
8
+ * on the principle that a caller selects an endpoint rather than naming one;
9
+ * this is the inbound counterpart, and it is enforced the same way — by there
10
+ * being no way to say otherwise. `127.0.0.1` is compiled in. A Unix socket is
11
+ * offered for callers that would rather not use a port at all.
12
+ *
13
+ * **No auth, on purpose.** Anything reachable only from the machine it runs on
14
+ * is already behind the operating system's own boundary, and a token checked
15
+ * over loopback is theatre: whoever can reach the socket can read the token
16
+ * out of the process that holds it. The honest posture is a surface small
17
+ * enough not to need one.
18
+ *
19
+ * **It degrades rather than failing.** With no store and no budget the
20
+ * endpoint still prices the call from the bundled catalogue and says the
21
+ * budget half is unknown. Offline is a mode, not an error, and an oracle that
22
+ * refuses to speak when half its inputs are missing is an oracle nobody wires
23
+ * into a hot path.
24
+ */
25
+
26
+ import { createServer } from 'node:http';
27
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
28
+ import { answerCost } from '@trazum/core';
29
+ import type { CostAnswer, PricingCatalogue } from '@trazum/core';
30
+
31
+ /** Compiled in. See the module note: this is the inbound SSRF posture. */
32
+ export const BIND_HOST = '127.0.0.1';
33
+
34
+ export const DEFAULT_PORT = 7317;
35
+
36
+ /**
37
+ * Bodies larger than this are refused unread.
38
+ *
39
+ * A prompt is text and text is unbounded; a hot-path oracle that will buffer
40
+ * whatever it is handed is a memory exhaustion away from taking the caller
41
+ * down with it — and the caller was asking how to spend *less*.
42
+ */
43
+ export const MAX_BODY_BYTES = 1_000_000;
44
+
45
+ export interface ServeContext {
46
+ catalogue: PricingCatalogue;
47
+ /**
48
+ * Measured spend and the budget it is judged against, read once at start
49
+ * and refreshed by the caller.
50
+ *
51
+ * Read once because the whole promise here is single-digit milliseconds,
52
+ * and a file read in the request path cannot make that promise. The staleness
53
+ * is a real cost, so the answer carries the window its measurement covers
54
+ * rather than implying it is current to the second.
55
+ */
56
+ position: () => { consumedUsd?: number; limitUsd?: number; window?: { fromMs: number; toMs: number } | null };
57
+ }
58
+
59
+ async function readBody(request: IncomingMessage): Promise<string> {
60
+ const chunks: Buffer[] = [];
61
+ let size = 0;
62
+ for await (const chunk of request) {
63
+ size += chunk.length;
64
+ if (size > MAX_BODY_BYTES) throw new Error('body too large');
65
+ chunks.push(chunk as Buffer);
66
+ }
67
+ return Buffer.concat(chunks).toString('utf8');
68
+ }
69
+
70
+ const send = (response: ServerResponse, status: number, body: unknown): void => {
71
+ const text = JSON.stringify(body);
72
+ response.writeHead(status, {
73
+ 'content-type': 'application/json',
74
+ 'content-length': Buffer.byteLength(text),
75
+ // Nothing here is for a browser to read across origins, and saying so
76
+ // costs one header.
77
+ 'cache-control': 'no-store',
78
+ });
79
+ response.end(text);
80
+ };
81
+
82
+ export function buildServer(context: ServeContext): Server {
83
+ return createServer((request, response) => {
84
+ void (async () => {
85
+ const url = new URL(request.url ?? '/', `http://${BIND_HOST}`);
86
+
87
+ if (request.method === 'GET' && url.pathname === '/health') {
88
+ send(response, 200, { ok: true, schemaVersion: 1 });
89
+ return;
90
+ }
91
+
92
+ if (request.method !== 'POST' || url.pathname !== '/cost') {
93
+ send(response, 404, {
94
+ error: 'not-found',
95
+ detail: 'POST /cost, or GET /health.',
96
+ });
97
+ return;
98
+ }
99
+
100
+ let payload: Record<string, unknown>;
101
+ try {
102
+ const raw = await readBody(request);
103
+ payload = raw.trim() === '' ? {} : (JSON.parse(raw) as Record<string, unknown>);
104
+ } catch (error) {
105
+ send(response, 400, {
106
+ error: 'bad-request',
107
+ detail: error instanceof Error && error.message === 'body too large'
108
+ ? `A request body may be at most ${MAX_BODY_BYTES} bytes.`
109
+ : 'The body must be JSON.',
110
+ });
111
+ return;
112
+ }
113
+
114
+ const position = context.position();
115
+ const answer: CostAnswer = answerCost(
116
+ {
117
+ model: typeof payload.model === 'string' ? payload.model : undefined,
118
+ inputTokens: typeof payload.inputTokens === 'number' ? payload.inputTokens : undefined,
119
+ outputTokens: typeof payload.outputTokens === 'number' ? payload.outputTokens : undefined,
120
+ basis: payload.basis === 'heuristic' ? 'heuristic' : 'token-count',
121
+ ...position,
122
+ },
123
+ { catalogue: context.catalogue },
124
+ );
125
+ send(response, 200, answer);
126
+ })();
127
+ });
128
+ }
129
+
130
+ export interface ListenTarget {
131
+ /** A Unix socket path, when the caller would rather not use a port. */
132
+ socket?: string;
133
+ port?: number;
134
+ }
135
+
136
+ export function listen(server: Server, target: ListenTarget): Promise<string> {
137
+ return new Promise((resolve, reject) => {
138
+ server.once('error', reject);
139
+ if (target.socket !== undefined) {
140
+ server.listen(target.socket, () => resolve(target.socket!));
141
+ return;
142
+ }
143
+ // The host is not a parameter. See the module note.
144
+ server.listen(target.port ?? DEFAULT_PORT, BIND_HOST, () => {
145
+ const address = server.address();
146
+ resolve(typeof address === 'object' && address !== null ? `${BIND_HOST}:${address.port}` : String(address));
147
+ });
148
+ });
149
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * One cycle of watching, and the state that survives a restart.
3
+ *
4
+ * `--once` is the primitive: pull the window, keep it, evaluate the gates,
5
+ * emit what crossed, save state. A cron entry runs exactly that, and so does
6
+ * every test. The foreground loop is this function in a timer, so there is one
7
+ * code path and no daemon-only behaviour that nobody exercises.
8
+ *
9
+ * **The state file is what makes a restart honest.** Without it a resumed
10
+ * watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
11
+ * it was watching the whole time (a claim it cannot make). With it, the
12
+ * crossing stays quiet and the unwatched stretch gets named once.
13
+ */
14
+
15
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
+ import { dirname, join } from 'node:path';
17
+ import { SAFE_FETCH_INIT } from '@trazum/core/node';
18
+ import type { WatchCrossing } from '@trazum/core';
19
+
20
+ export const WATCH_STATE_FILE = '.trazum/watch.json';
21
+
22
+ export const WATCH_STATE_VERSION = 1;
23
+
24
+ export interface WatchState {
25
+ v: number;
26
+ /** When the last cycle ran, so a long silence can be told from a first run. */
27
+ lastCycleMs: number;
28
+ /** How far the measurements reached, for the coverage gap. */
29
+ lastCoveredToMs: number | null;
30
+ /** Gate keys already alerted on, so a restart is not amnesia. */
31
+ fired: Record<string, number>;
32
+ }
33
+
34
+ export async function readWatchState(root: string): Promise<WatchState | null> {
35
+ try {
36
+ const parsed = JSON.parse(await readFile(join(root, WATCH_STATE_FILE), 'utf8')) as WatchState;
37
+ if (parsed?.v !== WATCH_STATE_VERSION) return null;
38
+ return parsed;
39
+ } catch {
40
+ // No state, or state this version cannot read: a first cycle either way,
41
+ // which is a state the caller reports rather than an error.
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export async function writeWatchState(root: string, state: WatchState): Promise<void> {
47
+ const path = join(root, WATCH_STATE_FILE);
48
+ await mkdir(dirname(path), { recursive: true });
49
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
50
+ }
51
+
52
+ /**
53
+ * Whether a webhook URL is one this tool will post to.
54
+ *
55
+ * **This is not the SSRF case and the difference matters.** `checkedEndpoint`
56
+ * exists because a *request body* must never name a host: an anonymous caller
57
+ * pointing a shared server at an internal address is somebody else's machine
58
+ * reaching somewhere it was never meant to. Here the URL is in the operator's
59
+ * own config, on their own machine, and pointing it at their own alerting
60
+ * daemon on loopback is the ordinary case rather than the attack.
61
+ *
62
+ * So loopback is allowed and plain http is allowed *only* there, while two
63
+ * rules stay absolute: no credentials embedded in the URL, because a URL ends
64
+ * up in logs and shell history; and https everywhere else, because an alert
65
+ * carries spend figures across a network.
66
+ */
67
+ export type WebhookRejection = 'invalid-url' | 'credentials-in-url' | 'insecure-scheme';
68
+
69
+ export function checkWebhook(raw: string): { ok: true; url: URL } | { ok: false; reason: WebhookRejection } {
70
+ let url: URL;
71
+ try {
72
+ url = new URL(raw);
73
+ } catch {
74
+ return { ok: false, reason: 'invalid-url' };
75
+ }
76
+ if (url.username !== '' || url.password !== '') {
77
+ return { ok: false, reason: 'credentials-in-url' };
78
+ }
79
+ const loopback =
80
+ url.hostname === 'localhost' ||
81
+ url.hostname === '127.0.0.1' ||
82
+ url.hostname === '[::1]' ||
83
+ url.hostname === '::1';
84
+ if (url.protocol === 'https:') return { ok: true, url };
85
+ if (url.protocol === 'http:' && loopback) return { ok: true, url };
86
+ return { ok: false, reason: 'insecure-scheme' };
87
+ }
88
+
89
+ /**
90
+ * The alert payload.
91
+ *
92
+ * Figures and gate names, never prompt text — the store has never held any and
93
+ * neither does this. Every crossing carries its own provenance, so a receiver
94
+ * that fans these into a dashboard cannot lose track of what kind of number it
95
+ * is holding.
96
+ */
97
+ export interface WatchAlert {
98
+ schemaVersion: 1;
99
+ firedAtMs: number;
100
+ crossings: WatchCrossing[];
101
+ }
102
+
103
+ export async function postWebhook(
104
+ url: URL,
105
+ alert: WatchAlert,
106
+ fetchImpl: typeof fetch = fetch,
107
+ ): Promise<{ ok: boolean; status: number | null; error: string | null }> {
108
+ try {
109
+ const response = await fetchImpl(url.toString(), {
110
+ ...SAFE_FETCH_INIT,
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify(alert),
114
+ signal: AbortSignal.timeout(10_000),
115
+ });
116
+ return { ok: response.ok, status: response.status, error: null };
117
+ } catch (error) {
118
+ /**
119
+ * A webhook that will not deliver must not take the alert down with it.
120
+ * The exit code and the stdout event have already carried the crossing;
121
+ * losing those because a receiver is down would make the quietest failure
122
+ * the loudest one.
123
+ */
124
+ return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
125
+ }
126
+ }