@omnicross/daemon 0.1.2 → 0.1.4

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/dist/index.d.cts CHANGED
@@ -1,14 +1,20 @@
1
+ import { OutboundApiServerConfig, Logger, ProviderConfigSource, TransformerService, Transformer, ResolvedTransformerChain, ApiServerSettingsStore, PricingStore, OutboundKeyDb as OutboundKeyDb$1, OutboundKeyDbRow, OutboundKeyPolicy } from '@omnicross/core';
1
2
  import { ApiKeyPoolService } from '@omnicross/core/completion/ApiKeyPoolService';
2
- import { OutboundKeyDb, OutboundApiServer } from '@omnicross/core/outbound-api';
3
+ import { AccountProbeConfig, OutboundKeyDb, VoucherDb, KeySpendReader, OutboundApiServer } from '@omnicross/core/outbound-api';
3
4
  import { ProviderProxy } from '@omnicross/core/provider-proxy';
4
5
  import { UsageRecorder, PricingEngine } from '@omnicross/core/usage';
5
6
  import { SubscriptionCredentialStore, FetchLike, SubscriptionProviderRegistry, SubscriptionAccountService } from '@omnicross/subscriptions';
6
- import { OutboundApiServerConfig, ProviderConfigSource, TransformerService, Transformer, ResolvedTransformerChain, ApiServerSettingsStore, PricingStore, Logger, OutboundKeyDb as OutboundKeyDb$1, OutboundKeyDbRow } from '@omnicross/core';
7
+ import { LoggingConfig, HealthReport } from '@omnicross/contracts/health-logging-types';
8
+ import { ClaudeTokenConfig, CodexTokenConfig, GeminiTokenConfig, AccountTokensConfig, ProxyConfig, SubscriptionAccountSanitized, AccountClientIdentity } from '@omnicross/contracts/account-tokens-types';
9
+ import { OpenCodeGoTokenConfig, SubscriptionProviderId } from '@omnicross/contracts/subscription-types';
10
+ import { SubscriptionAccountHealth } from '@omnicross/core/pipeline/SubscriptionAccountHealth';
11
+ import { fetchUpstream } from '@omnicross/core/pipeline/upstreamFetch';
12
+ import { AuditRecord, AuditConfig } from '@omnicross/contracts/audit-types';
13
+ import { BillingDeliveryStatus, BillingConfig, BillingEvent } from '@omnicross/contracts/billing-types';
7
14
  import http from 'node:http';
8
15
  import { LLMProvider, AgentDefaultModels, GlobalModelParameters } from '@omnicross/contracts/llm-config';
9
16
  import { PricingEntry, PricingEntryInput, PricingResolution } from '@omnicross/contracts/pricing-types';
10
- import { OpenCodeGoTokenConfig, SubscriptionProviderId } from '@omnicross/contracts/subscription-types';
11
- import { ClaudeTokenConfig, CodexTokenConfig, GeminiTokenConfig, AccountTokensConfig, SubscriptionAccountSanitized } from '@omnicross/contracts/account-tokens-types';
17
+ import { WebhookConfig, WebhookEvent } from '@omnicross/contracts/webhook-types';
12
18
 
13
19
  /**
14
20
  * SecretBox.ts — a 32-byte master key wrapped with the tri-state secret rules
@@ -376,6 +382,12 @@ interface DaemonConfig {
376
382
  server?: OutboundApiServerConfig;
377
383
  /** Optional admin-dashboard config (RT3). */
378
384
  admin?: DaemonAdminConfig;
385
+ /**
386
+ * Optional logging config (configurable-logging). Absent ⇒ the zero-regression
387
+ * default (console + all levels + text). `file` is a PLAIN value — NOT a secret,
388
+ * so it is never walked by `decryptConfigSecrets`/`encryptConfigSecrets`.
389
+ */
390
+ logging?: LoggingConfig;
379
391
  }
380
392
  /** Apply defaults to a (possibly absent) admin block: enabled, port 8766,
381
393
  * loopback, no token. NOTE: an EXPLICIT `port: 0` is honored as "bind an
@@ -551,16 +563,27 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
551
563
  /**
552
564
  * @param tokensPath on-disk `tokens.json` location.
553
565
  * @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
554
- * @param fetchImpl injectable HTTP port for the OAuth refresh round-trips
555
- * (oauth design D4). Defaults to the global `fetch` so boot
556
- * is unchanged; tests inject a mock fetch. NOT used by any
557
- * read/write path only by `refresh*Token`.
558
- */
559
- constructor(tokensPath: string, box: SecretBox, fetchImpl?: FetchLike,
566
+ * @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
567
+ * round-trips (oauth design D4). A TEST-injected transport is
568
+ * used verbatim. When ABSENT (production), each refresh uses a
569
+ * proxy-aware {@link fetchUpstream} that threads the
570
+ * `{ providerId, accountId }` ctx (upstream-proxy M1) so a
571
+ * per-account/per-provider proxy is honored on refresh exactly
572
+ * as on relay — refresh egresses from the SAME proxy IP as the
573
+ * account's traffic. NOT used by any read/write path.
574
+ */
575
+ constructor(tokensPath: string, box: SecretBox, fetchImpl?: FetchLike | undefined,
560
576
  /** Injectable external CLI native-store reader (external-cli-sync). */
561
577
  externalCliReader?: ExternalCliReader,
562
578
  /** Injectable external CLI native-store WRITER (marker-gated write-back). */
563
579
  externalCliStore?: ExternalCliStorePort);
580
+ /**
581
+ * The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
582
+ * TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
583
+ * through {@link fetchUpstream} with the account's `{ providerId, accountId }`
584
+ * ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
585
+ */
586
+ buildRefreshFetch(providerId: string, accountId?: string): FetchLike;
564
587
  /**
565
588
  * In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
566
589
  * SINGLE-USE: two concurrent refreshes of one account each spend the same
@@ -580,6 +603,14 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
580
603
  getValidClaudeAccessToken(): Promise<string | null>;
581
604
  /** Current OpenCodeGo static API key, or `null` when none is stored. */
582
605
  getValidOpenCodeGoApiKey(): Promise<string | null>;
606
+ /**
607
+ * DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
608
+ * DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
609
+ * `undefined` for an unknown provider/account or no per-account proxy. Feeds the
610
+ * winning per-account layer of the upstream-proxy resolver. Synchronous like the
611
+ * other hot reads. Never returns token material.
612
+ */
613
+ getAccountProxy(providerId: string, accountId: string): ProxyConfig | undefined;
583
614
  /**
584
615
  * DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
585
616
  * each provider's accounts to the secret-free `SubscriptionAccountSanitized`
@@ -629,6 +660,63 @@ declare class JsonSubscriptionCredentialStore implements SubscriptionCredentialS
629
660
  * failure flags ONLY that account `expired`.
630
661
  */
631
662
  refreshAccountById(provider: 'claude' | 'codex' | 'gemini', id: string): Promise<boolean>;
663
+ /**
664
+ * Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
665
+ * provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
666
+ * (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
667
+ * a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
668
+ * opencodego returns the account's static key. `null` when unknown/expired/
669
+ * tokenless.
670
+ */
671
+ getAccessTokenForAccount(providerId: SubscriptionProviderId, accountId: string): Promise<string | null>;
672
+ /**
673
+ * Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
674
+ * `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
675
+ * → `false` (no refresh affordance).
676
+ */
677
+ refreshAccountToken(providerId: SubscriptionProviderId, accountId: string): Promise<boolean>;
678
+ /**
679
+ * Best-effort record of a selection time onto the account's `lastUsedAt` by id
680
+ * (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
681
+ * an unknown id. The selector throttles the call frequency, so this stays cheap.
682
+ */
683
+ touchAccountLastUsed(providerId: SubscriptionProviderId, accountId: string, iso: string): Promise<void>;
684
+ /**
685
+ * Best-effort write-through of a per-account client `identity`
686
+ * (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
687
+ * whitelisted fingerprint headers; the token mirror is untouched); a no-op for
688
+ * an unknown id. Called by the identity store's persistence port on a first-seen
689
+ * freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
690
+ * store's port wrapper swallows a rejection so the relay hot path is unaffected.
691
+ */
692
+ setAccountIdentity(providerId: SubscriptionProviderId, accountId: string, identity: AccountClientIdentity): Promise<void>;
693
+ /**
694
+ * DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
695
+ * the port). Set one account's scheduling `priority` by id. Secret-free
696
+ * (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
697
+ */
698
+ setAccountPriority(providerId: SubscriptionProviderId, accountId: string, priority: number): Promise<{
699
+ ok: boolean;
700
+ }>;
701
+ /**
702
+ * DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
703
+ * the port). Passing `undefined` clears the override. Write-only password: when
704
+ * the incoming structured proxy omits the password but the account already had
705
+ * one, the current (decrypted) password is preserved — editing host/port never
706
+ * wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
707
+ */
708
+ setAccountProxy(providerId: SubscriptionProviderId, accountId: string, proxy: ProxyConfig | undefined): Promise<{
709
+ ok: boolean;
710
+ }>;
711
+ /**
712
+ * DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
713
+ * model-map, admin write, NOT on the port). Passing `undefined` clears it.
714
+ * Secret-free (model ids only; the mirror invariant is untouched). Rejects an
715
+ * unknown id.
716
+ */
717
+ setAccountSupportedModels(providerId: SubscriptionProviderId, accountId: string, supportedModels: string[] | Record<string, string> | undefined): Promise<{
718
+ ok: boolean;
719
+ }>;
632
720
  /** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
633
721
  private refreshUpstream;
634
722
  /**
@@ -798,6 +886,24 @@ interface SubscriptionTokenWriter {
798
886
  renameAccount(providerId: SubscriptionProviderId, id: string, label: string): Promise<{
799
887
  ok: boolean;
800
888
  }>;
889
+ /** Set one account's scheduling priority (secret-free; rejects an unknown id).
890
+ * subscription-account-scheduling — lets an operator order a pool. */
891
+ setAccountPriority(providerId: SubscriptionProviderId, id: string, priority: number): Promise<{
892
+ ok: boolean;
893
+ }>;
894
+ /** Set (or CLEAR, with `undefined`) one account's per-account proxy override
895
+ * (upstream-proxy). The `proxy.password` is a secret (encrypted at rest, masked
896
+ * in the sanitized view). Rejects an unknown id. */
897
+ setAccountProxy(providerId: SubscriptionProviderId, id: string, proxy: ProxyConfig | undefined): Promise<{
898
+ ok: boolean;
899
+ }>;
900
+ /** Set (or CLEAR, with `undefined`) one account's `supportedModels`
901
+ * (subscription-account-model-map). Secret-free (model ids only). Rejects an
902
+ * unknown id. An array = allow-list (skip-only); an object = allow-list keys +
903
+ * logical→actual remap values. */
904
+ setAccountSupportedModels(providerId: SubscriptionProviderId, id: string, supportedModels: string[] | Record<string, string> | undefined): Promise<{
905
+ ok: boolean;
906
+ }>;
801
907
  listSanitizedAccounts(): Promise<Record<string, SubscriptionAccountSanitized[]>>;
802
908
  refreshClaudeToken(): Promise<boolean>;
803
909
  refreshCodexToken(): Promise<boolean>;
@@ -942,7 +1048,7 @@ interface SubscriptionAccountAppender {
942
1048
  */
943
1049
 
944
1050
  /** The loopback-listener fn (injected so tests need not bind a real port). */
945
- type CodexLoopbackFn = (state: string, timeoutMs?: number) => Promise<string>;
1051
+ type CodexLoopbackFn = (state: string, timeoutMs?: number, signal?: AbortSignal) => Promise<string>;
946
1052
  /** One codex sign-in flow's polled status (NEVER carries a token). */
947
1053
  interface CodexFlowState {
948
1054
  status: 'pending' | 'done' | 'error';
@@ -960,19 +1066,257 @@ declare class CodexOAuthSessionStore {
960
1066
  private readonly ttlMs;
961
1067
  private readonly sessions;
962
1068
  private activeSessionId;
1069
+ private readonly aborters;
963
1070
  constructor(ttlMs?: number);
964
1071
  /** Whether a codex sign-in is currently in flight (port 1455 held). */
965
1072
  isBusy(): boolean;
966
1073
  /** Mint a fresh sessionId, mark it pending + active, return the id. */
967
- begin(): string;
1074
+ begin(): {
1075
+ sessionId: string;
1076
+ signal: AbortSignal;
1077
+ };
968
1078
  /** Settle a flow (done/error) + free the active slot. */
969
1079
  settle(sessionId: string, status: 'done' | 'error', error?: string): void;
1080
+ cancel(sessionId: string): boolean;
970
1081
  /** Read a flow's status (token-free), or null when unknown/expired. */
971
1082
  get(sessionId: string): CodexFlowState | null;
972
1083
  /** Drop expired flows; free the active slot if the active flow expired. */
973
1084
  private sweep;
974
1085
  }
975
1086
 
1087
+ /**
1088
+ * ProbeStrategy — the per-provider two-tier probe plan
1089
+ * (subscription-account-probe #8, design D1).
1090
+ *
1091
+ * A `ProbePlan` says HOW to probe one provider's account, cheapest signal first:
1092
+ * - `{ kind: 'local' }` — no upstream call. The scheduler's free local tier reads
1093
+ * the account's token via the credential store; a missing/expired-unrefreshable
1094
+ * token is a dead account (recorded as a synthesized 401). A provider is
1095
+ * local-only until a cheap authed GET endpoint is VERIFIED (the omnicross
1096
+ * `// UNVERIFIED` convention) — Phase 1 ships codex/gemini/opencodego local-only
1097
+ * (LEAD OQ1: never GUESS a billable/wrong endpoint).
1098
+ * - `{ kind: 'upstream'; url; buildInit(token) }` — a minimal AUTHED GET the
1099
+ * scheduler issues through #3's proxy-aware `fetchUpstream`. Phase 1 wires ONLY
1100
+ * claude → `GET https://api.anthropic.com/v1/models` (a verified free list;
1101
+ * NEVER a billable completion). The scheduler adds the timeout `signal` +
1102
+ * `{ providerId, accountId }` ctx; `buildInit` supplies method + auth header.
1103
+ *
1104
+ * NEVER put a body / max_tokens here — a probe must cost nothing (design D1).
1105
+ *
1106
+ * @module @omnicross/daemon/probe/ProbeStrategy
1107
+ */
1108
+
1109
+ /** How to probe one provider's account (design D1). */
1110
+ type ProbePlan = {
1111
+ kind: 'local';
1112
+ } | {
1113
+ kind: 'upstream';
1114
+ /** The cheap, free, authed GET endpoint. */
1115
+ url: string;
1116
+ /** Build the minimal request init (method + Authorization only — no body). */
1117
+ buildInit(token: string): RequestInit;
1118
+ };
1119
+
1120
+ /**
1121
+ * AccountHealthProbeScheduler — the scheduled ACTIVE account-health probe
1122
+ * (subscription-account-probe #8, design D1–D6).
1123
+ *
1124
+ * #2's health machine is PASSIVE — an account is only found dead when a REAL
1125
+ * request hits it and fails. This scheduler is the active complement: on a plain
1126
+ * `unref()`ed interval (omnicross has no cron dep) it runs a CHEAP per-account
1127
+ * probe and feeds the outcome into #2's EXISTING shared tracker
1128
+ * (`recordUpstreamOutcome`) — inventing no new marking path. Modeled EXACTLY on
1129
+ * `AccountHealthSweeper`: `start()` arms the timer, `dispose()` clears it, a
1130
+ * single-sweep re-entrancy guard prevents overlap.
1131
+ *
1132
+ * TWO-TIER, cheapest-first (design D1):
1133
+ * 1. FREE local — read the account's token via the credential store; no usable
1134
+ * token ⇒ dead ⇒ a synthesized `401` outcome, NO upstream call.
1135
+ * 2. Minimal AUTHED upstream GET (only providers with a VERIFIED cheap endpoint)
1136
+ * via #3's proxy-aware `fetchUpstream` with a short timeout.
1137
+ *
1138
+ * SAFE tracker mapping (LEAD constraint, mirrors #2's bare-429 discipline):
1139
+ * **401/403 → mark; 2xx → clear transient; 429 / 5xx / thrown/timeout → NEVER a
1140
+ * mark** (a probe rate-limit or upstream blip must not blacklist a healthy
1141
+ * account). So only 401/403/2xx are forwarded to the tracker; the rest are
1142
+ * history-only. NEVER a billable probe — the upstream tier is a free authed GET.
1143
+ *
1144
+ * NEVER-STRAND: marking flows through #2, whose ≥2-account gate (in the strategy's
1145
+ * schedulable derivation) keeps a marked SOLE account schedulable — so a probe can
1146
+ * never strand a single-account user. `onlyMultiAccount` (default) additionally
1147
+ * skips single-account providers entirely.
1148
+ *
1149
+ * ZERO REGRESSION: default `enabled:false` ⇒ `start()` never arms + `sweep()`
1150
+ * early-returns ⇒ no probes, no `/health` boolean, byte-identical.
1151
+ *
1152
+ * @module @omnicross/daemon/AccountHealthProbeScheduler
1153
+ */
1154
+
1155
+ /** One rolling probe result (design D4; in-memory, cleared on restart). */
1156
+ interface ProbeRecord {
1157
+ /** Epoch ms of the probe. */
1158
+ ts: number;
1159
+ /** Whether the probe observed a HEALTHY signal (2xx upstream / token-present local). */
1160
+ ok: boolean;
1161
+ /** The HTTP status (`null` = thrown/timeout); absent for a token-present local record. */
1162
+ status?: number | null;
1163
+ /** Upstream round-trip latency (ms); absent for a local-tier record. */
1164
+ latencyMs?: number;
1165
+ /** Which tier produced this record. */
1166
+ tier: 'local' | 'upstream';
1167
+ }
1168
+ /** Per-account probe history for the authed admin surface (names account ids). */
1169
+ interface AccountProbeHistorySnapshot {
1170
+ providerId: string;
1171
+ accountId: string;
1172
+ records: ProbeRecord[];
1173
+ }
1174
+ /**
1175
+ * The read surface the AUTHED admin route consumes (subscription-account-probe,
1176
+ * design D5). Structurally satisfied by {@link AccountHealthProbeScheduler}; typed
1177
+ * narrow so the admin layer carries no scheduler coupling.
1178
+ */
1179
+ interface AccountProbeHistoryReader {
1180
+ getAllHistory(): AccountProbeHistorySnapshot[];
1181
+ }
1182
+ /** The narrow credential-store surface the scheduler reads (#1 seams). */
1183
+ interface ProbeCredentialStore {
1184
+ getFullConfig(): Promise<AccountTokensConfig>;
1185
+ getAccessTokenForAccount(providerId: SubscriptionProviderId, accountId: string): Promise<string | null>;
1186
+ }
1187
+ /** Proxy-aware upstream fetch signature (#3 `fetchUpstream`). */
1188
+ type ProbeFetch = typeof fetchUpstream;
1189
+ /** Injectable test seams (all default to production behavior). */
1190
+ interface ProbeSchedulerOptions {
1191
+ /** Injectable clock (ms). Default `Date.now`. */
1192
+ now?: () => number;
1193
+ /** Injectable proxy-aware fetch (#3). Default `fetchUpstream`. */
1194
+ fetchImpl?: ProbeFetch;
1195
+ /** Injectable inter-probe delay (stagger). Default a real `setTimeout`. */
1196
+ sleep?: (ms: number) => Promise<void>;
1197
+ /** Injectable per-provider probe-plan resolver. Default {@link probePlanFor}. */
1198
+ planFor?: (providerId: string) => ProbePlan;
1199
+ }
1200
+ declare class AccountHealthProbeScheduler implements AccountProbeHistoryReader {
1201
+ private readonly store;
1202
+ private readonly health;
1203
+ private readonly logger;
1204
+ private config;
1205
+ private timer;
1206
+ private sweeping;
1207
+ private readonly history;
1208
+ private readonly now;
1209
+ private readonly fetchImpl;
1210
+ private readonly sleep;
1211
+ private readonly planFor;
1212
+ constructor(store: ProbeCredentialStore, health: SubscriptionAccountHealth, logger: Logger, config: AccountProbeConfig, opts?: ProbeSchedulerOptions);
1213
+ /** Whether probing is enabled by the current config. */
1214
+ get enabled(): boolean;
1215
+ /**
1216
+ * Re-apply config to the live instance (the async `start.ts` loads the persisted
1217
+ * `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
1218
+ */
1219
+ configure(config: AccountProbeConfig): void;
1220
+ /** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
1221
+ start(): void;
1222
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
1223
+ dispose(): void;
1224
+ /**
1225
+ * One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
1226
+ * Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
1227
+ * for tests; never throws.
1228
+ */
1229
+ sweep(): Promise<void>;
1230
+ /**
1231
+ * Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
1232
+ * no upstream); else the upstream tier when a verified endpoint exists. Records
1233
+ * the rolling history entry either way; returns whether the tracker was MARKED.
1234
+ */
1235
+ probeAccount(providerId: SubscriptionProviderId, accountId: string): Promise<{
1236
+ ok: boolean;
1237
+ marked: boolean;
1238
+ }>;
1239
+ /** Per-account rolling history for the authed admin surface (design D5). */
1240
+ getAllHistory(): AccountProbeHistorySnapshot[];
1241
+ /**
1242
+ * The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
1243
+ * probed account is currently unhealthy (per #2's tracker). No ids, no counts —
1244
+ * safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
1245
+ */
1246
+ probedAccountsHealthy(now?: number): boolean;
1247
+ /**
1248
+ * Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
1249
+ * 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
1250
+ * NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
1251
+ */
1252
+ private applyOutcome;
1253
+ /** Append a record, capping the ring at `historySize` (drop oldest). */
1254
+ private record;
1255
+ /** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
1256
+ private readBounded;
1257
+ private key;
1258
+ private parseKey;
1259
+ }
1260
+
1261
+ /**
1262
+ * auditReader — read + filter the date-rotated audit store (request-audit-log,
1263
+ * design D4/D6). Backs the AUTHED admin query only (the records carry IP/UA +
1264
+ * possibly bodies). Reads the relevant `audit-*.jsonl` files, parses defensively
1265
+ * (a torn final line never poisons a query), filters by key id + time window, and
1266
+ * returns NEWEST-FIRST up to a bounded limit.
1267
+ *
1268
+ * @module @omnicross/daemon/audit/auditReader
1269
+ */
1270
+
1271
+ /** Filters for an audit query (all optional). */
1272
+ interface AuditQuery {
1273
+ /** Restrict to one outbound key id. */
1274
+ keyId?: string;
1275
+ /** Inclusive lower bound (epoch ms). */
1276
+ from?: number;
1277
+ /** Inclusive upper bound (epoch ms). */
1278
+ to?: number;
1279
+ /** Max rows (default 200, capped 2000). */
1280
+ limit?: number;
1281
+ }
1282
+
1283
+ /**
1284
+ * auditQueryApi — the AUTHED `GET /admin/api/audit?keyId=&from=&to=&limit=`
1285
+ * handler (request-audit-log, design D6).
1286
+ *
1287
+ * Audit records carry client IP / user-agent (PII) and, when body capture is on,
1288
+ * redacted bodies — so unlike the coarse `/health` boolean they are served ONLY
1289
+ * behind the admin auth gate. This lives in its OWN helper module (the
1290
+ * #4/#8/#10 helper-module convention) so `adminApi.ts` — at its line cap — is not
1291
+ * touched: `AdminServer.dispatch` routes the path here directly, AFTER its auth
1292
+ * gate. NEVER unauthenticated, NEVER surfaced on `/health`.
1293
+ *
1294
+ * SECRET-FREE by construction: it returns exactly the stored records, which never
1295
+ * hold key material / tokens / Authorization (headers are never captured).
1296
+ *
1297
+ * @module @omnicross/daemon/admin/auditQueryApi
1298
+ */
1299
+
1300
+ /** The read surface the AdminServer consumes (bootstrap binds it to the store). */
1301
+ type AuditQueryReader = (query: AuditQuery) => AuditRecord[];
1302
+
1303
+ /**
1304
+ * billingStatusApi — the AUTHED `GET /admin/api/billing-status` handler
1305
+ * (billing-event-stream, design D5/P2).
1306
+ *
1307
+ * Returns the SECRET-FREE aggregate delivery status of the durable billing ledger
1308
+ * (total / delivered / pending counts) so the admin UI can show a delivery
1309
+ * indicator. Lives in its OWN helper module (the #4/#8/#10/#13 convention) so
1310
+ * `adminApi.ts` — at its line cap — is not touched: `AdminServer.dispatch` routes
1311
+ * the path here directly, AFTER its auth gate. Carries no secret and no event
1312
+ * payload — only counts.
1313
+ *
1314
+ * @module @omnicross/daemon/admin/billingStatusApi
1315
+ */
1316
+
1317
+ /** The read surface the AdminServer consumes (bootstrap binds it to the ledger dir). */
1318
+ type BillingStatusReader = () => BillingDeliveryStatus;
1319
+
976
1320
  /**
977
1321
  * autoDisableStore.ts — the daemon's PROCESS-IN-MEMORY auto-disable store.
978
1322
  *
@@ -1114,9 +1458,22 @@ declare class ConfigFileProviderConfigSource implements ProviderConfigSource {
1114
1458
 
1115
1459
  declare class JsonApiServerSettingsStore implements ApiServerSettingsStore {
1116
1460
  private readonly configPath;
1117
- constructor(configPath: string);
1461
+ private readonly box;
1462
+ /**
1463
+ * @param configPath the daemon config.json whose `server` field is backed.
1464
+ * @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
1465
+ * `server.proxy.*` passwords are encrypted-on-`set` /
1466
+ * decrypted-on-`get` (the settings-store path is otherwise not
1467
+ * secret-aware — every OTHER server field is non-secret). Null
1468
+ * ⇒ passthrough (legacy/pure tests unchanged).
1469
+ */
1470
+ constructor(configPath: string, box?: SecretBox | null);
1118
1471
  get<T = unknown>(key: string): Promise<T | undefined>;
1119
1472
  set<T = unknown>(key: string, value: T): Promise<void>;
1473
+ /** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
1474
+ private encryptSecrets;
1475
+ /** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
1476
+ private decryptSecrets;
1120
1477
  /** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
1121
1478
  private readFile;
1122
1479
  }
@@ -1310,6 +1667,18 @@ interface AdminApiDeps {
1310
1667
  readonly llmConfig: ConfigFileProviderConfigSource;
1311
1668
  /** Named outbound-key store. */
1312
1669
  readonly keyDb: OutboundKeyDb;
1670
+ /**
1671
+ * OPTIONAL voucher (redemption-card) store (voucher-redemption #9). When wired,
1672
+ * the `/admin/api/voucher` surface can generate/list/revoke cards. Absent ⇒ the
1673
+ * surface returns 501 (feature not available in this build).
1674
+ */
1675
+ readonly voucherDb?: VoucherDb;
1676
+ /**
1677
+ * OPTIONAL per-key spend reader (outbound-key-policy). When wired, the key list
1678
+ * surfaces each key's OWN accumulated spend (daily/weekly/total) so the admin
1679
+ * can see spend-vs-limit. Leak-safe: only the key's own numbers are exposed.
1680
+ */
1681
+ readonly keySpendReader?: KeySpendReader;
1313
1682
  /** Outbound server settings store (server config persistence). */
1314
1683
  readonly settingsStore: JsonApiServerSettingsStore;
1315
1684
  /** The running outbound server (status + live applyConfig). */
@@ -1433,6 +1802,40 @@ declare function handleAdminApi(req: http.IncomingMessage, res: http.ServerRespo
1433
1802
  interface AdminServerDeps extends AdminApiDeps {
1434
1803
  /** Read the resolved admin config (enabled/port/networkBinding/token). */
1435
1804
  getAdminConfig: () => ResolvedAdminConfig;
1805
+ /**
1806
+ * Build the coarse, secret-free `/health` report (daemon-health-endpoint). A
1807
+ * shared closure over live handles (bootstrap wires the SAME builder into the
1808
+ * outbound server), served UNAUTHENTICATED — before the admin auth gate.
1809
+ */
1810
+ getHealthReport: () => HealthReport;
1811
+ /**
1812
+ * Injected logger (configurable-logging) — the admin listener's OWN lifecycle
1813
+ * lines (bind/refuse/error) route through it so they honor the configured
1814
+ * level / format / file sink.
1815
+ */
1816
+ logger: Logger;
1817
+ /**
1818
+ * OPTIONAL per-account probe-history reader (subscription-account-probe #8,
1819
+ * design D5). When wired (bootstrap → the `AccountHealthProbeScheduler`), the
1820
+ * AUTHED `GET /admin/api/account-probes` returns per-account probe history.
1821
+ * Absent ⇒ the route serves an empty list (byte-safe for embedders/tests that
1822
+ * do not wire it). Read-only + secret-free (ids + status labels, no tokens).
1823
+ */
1824
+ probeHistoryReader?: AccountProbeHistoryReader;
1825
+ /**
1826
+ * OPTIONAL audit query reader (request-audit-log, design D6). When wired
1827
+ * (bootstrap → the date-rotated store), the AUTHED `GET /admin/api/audit`
1828
+ * returns filtered records. Absent ⇒ the route serves an empty list. The
1829
+ * records carry IP/UA/bodies → this route is behind the auth gate ONLY, NEVER
1830
+ * unauthenticated, NEVER on `/health`.
1831
+ */
1832
+ auditReader?: AuditQueryReader;
1833
+ /**
1834
+ * OPTIONAL billing delivery-status reader (billing-event-stream, design D5).
1835
+ * When wired (bootstrap → the ledger dir), the AUTHED `GET /admin/api/billing-status`
1836
+ * returns secret-free total/delivered/pending counts. Absent ⇒ zeroed counts.
1837
+ */
1838
+ billingStatusReader?: BillingStatusReader;
1436
1839
  }
1437
1840
  /** A live status snapshot for the admin listener. */
1438
1841
  interface AdminServerStatus {
@@ -1469,24 +1872,6 @@ declare class AdminServer {
1469
1872
  getStatus(): AdminServerStatus;
1470
1873
  }
1471
1874
 
1472
- /**
1473
- * ConsoleLogger — the daemon's file-less default `Logger` port impl (design D5).
1474
- *
1475
- * A thin `console.*` wrapper. The serving core depends on the `Logger` port
1476
- * (never a host class), so this trivial implementation is the only logger the
1477
- * standalone daemon needs. `error` uses the WIDEST `(message, error?, meta?)`
1478
- * signature so every core call site stays assignable.
1479
- *
1480
- * @module @omnicross/daemon/ports/ConsoleLogger
1481
- */
1482
-
1483
- declare class ConsoleLogger implements Logger {
1484
- info(message: string, meta?: Record<string, unknown> | Error | object): void;
1485
- warn(message: string, meta?: Record<string, unknown> | Error | object): void;
1486
- error(message: string, error?: unknown, meta?: Record<string, unknown> | object): void;
1487
- debug(message: string, meta?: Record<string, unknown> | Error | object): void;
1488
- }
1489
-
1490
1875
  /**
1491
1876
  * JsonOutboundKeyDb — the daemon's file-backed `OutboundKeyDb` port impl
1492
1877
  * (design D3).
@@ -1516,6 +1901,9 @@ declare class JsonOutboundKeyDb implements OutboundKeyDb$1 {
1516
1901
  outboundApiKeysRevoke(id: string): Promise<boolean>;
1517
1902
  outboundApiKeysTouchLastUsed(id: string): Promise<boolean>;
1518
1903
  outboundApiKeysSetEnabled(id: string, enabled: boolean): Promise<boolean>;
1904
+ outboundApiKeysSetMaxConcurrency(id: string, maxConcurrency: number | null): Promise<boolean>;
1905
+ outboundApiKeysSetPolicy(id: string, policy: OutboundKeyPolicy): Promise<boolean>;
1906
+ outboundApiKeysMarkActivated(id: string, activatedAt: number): Promise<boolean>;
1519
1907
  /** Apply `fn` to the row with `id`, persisting when it returns true. */
1520
1908
  private mutateRow;
1521
1909
  /** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
@@ -1523,6 +1911,275 @@ declare class JsonOutboundKeyDb implements OutboundKeyDb$1 {
1523
1911
  private writeRows;
1524
1912
  }
1525
1913
 
1914
+ /**
1915
+ * AccountHealthSweeper — proactive account-health recovery tick
1916
+ * (subscription-account-health, design D6).
1917
+ *
1918
+ * The health tracker (`@omnicross/core` `SubscriptionAccountHealth`) already
1919
+ * self-heals LAZILY: an elapsed cooldown restores an account on the next
1920
+ * `isSchedulable` read, so CORRECTNESS never depends on this sweeper. What the
1921
+ * tick adds is PROACTIVITY for IDLE accounts (no traffic to trigger a lazy read):
1922
+ * - it fires the tracker's recovery SIGNAL (the seam #5 webhooks + #8
1923
+ * health-cron consume — this child only emits it), and
1924
+ * - it optionally nudges a fresh token for a recovered OAuth account so it
1925
+ * resumes instantly instead of paying refresh latency on its first request.
1926
+ *
1927
+ * Modeled EXACTLY on `TokenRefreshScheduler`: `start()` arms an `unref()`ed 60s
1928
+ * timer, `dispose()` clears it, and a single-sweep re-entrancy guard means a
1929
+ * long sweep never overlaps the next tick.
1930
+ *
1931
+ * @module @omnicross/daemon/AccountHealthSweeper
1932
+ */
1933
+
1934
+ declare class AccountHealthSweeper {
1935
+ private readonly store;
1936
+ private readonly health;
1937
+ private readonly logger;
1938
+ private readonly intervalMs;
1939
+ private readonly leadMs;
1940
+ private timer;
1941
+ private sweeping;
1942
+ constructor(store: JsonSubscriptionCredentialStore, health: SubscriptionAccountHealth, logger: Logger, intervalMs?: number, leadMs?: number);
1943
+ /** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
1944
+ start(): void;
1945
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
1946
+ dispose(): void;
1947
+ /**
1948
+ * One sweep: surface accounts that just recovered (emits the recovery signal
1949
+ * through the tracker's hook) and nudge a fresh token for any recovered OAuth
1950
+ * account whose token is near expiry. Exposed for tests. Never throws.
1951
+ */
1952
+ sweep(now?: number): Promise<void>;
1953
+ /** Expiring within the lead window, refreshable, and not already dead. */
1954
+ private needsRefresh;
1955
+ /** Refresh one recovered account by id; failures are logged, never thrown. */
1956
+ private refreshOne;
1957
+ }
1958
+
1959
+ /**
1960
+ * AuditPruneSweeper — the TTL prune for the audit store (request-audit-log,
1961
+ * design D4). Deletes whole `audit-YYYY-MM-DD.jsonl` files whose date is older
1962
+ * than `retentionDays` — a cheap file UNLINK, never a line-level rewrite of a
1963
+ * live file (which jsonl makes awkward). So the store never grows unbounded and
1964
+ * TTL is O(files).
1965
+ *
1966
+ * Modeled on the #8 `AccountHealthProbeScheduler` / `AccountHealthSweeper`:
1967
+ * `start()` arms an `unref()`ed interval, `dispose()` clears it, a single-sweep
1968
+ * re-entrancy guard prevents overlap. A prune ALSO runs once at boot (`start`
1969
+ * fires an immediate sweep). Disabled/zero-retention config ⇒ armed-off ⇒ no-op
1970
+ * (byte-identical zero regression). Never throws.
1971
+ *
1972
+ * @module @omnicross/daemon/audit/AuditPruneSweeper
1973
+ */
1974
+
1975
+ declare class AuditPruneSweeper {
1976
+ private readonly auditDir;
1977
+ private readonly logger;
1978
+ private config;
1979
+ private readonly intervalMs;
1980
+ /** Injectable clock (ms) for deterministic tests. */
1981
+ private readonly now;
1982
+ private timer;
1983
+ private sweeping;
1984
+ constructor(auditDir: string, logger: Logger, config: AuditConfig, intervalMs?: number,
1985
+ /** Injectable clock (ms) for deterministic tests. */
1986
+ now?: () => number);
1987
+ /** Whether pruning is active (audit enabled). */
1988
+ get enabled(): boolean;
1989
+ /** Re-apply config to the live instance (boot + admin PUT hot-reload). */
1990
+ configure(config: AuditConfig): void;
1991
+ /**
1992
+ * Arm the prune interval AND run one prune immediately (boot cleanup). No-op
1993
+ * when audit is disabled (zero regression). Idempotent.
1994
+ */
1995
+ start(): void;
1996
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
1997
+ dispose(): void;
1998
+ /**
1999
+ * One prune: unlink every audit date file strictly OLDER than the retention
2000
+ * cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
2001
+ * for tests; never throws. Returns the number of files removed.
2002
+ */
2003
+ sweep(): Promise<number>;
2004
+ }
2005
+
2006
+ /**
2007
+ * AuditWriter — the daemon's file-backed audit sink (request-audit-log, design
2008
+ * D4/D5). Registered as `@omnicross/core`'s audit sink when audit is enabled; its
2009
+ * {@link record} is what `recordAudit` hands each assembled record to.
2010
+ *
2011
+ * FIRE-AND-FORGET (hard constraint): {@link record} DEFERS the fs append off the
2012
+ * caller's stack (an injectable `defer`, default a zero-delay timer — the
2013
+ * `UsageRecorder` precedent) and returns immediately, so the relay response path
2014
+ * never waits on disk I/O. A write error is swallowed + logged (a failing audit
2015
+ * store must never affect a relay). Each record is appended as ONE JSON line to
2016
+ * `audit/audit-YYYY-MM-DD.jsonl` (the record's LOCAL date), matching the
2017
+ * `usage-events.jsonl` pattern — no new dependency, TTL is a whole-file unlink.
2018
+ *
2019
+ * @module @omnicross/daemon/audit/AuditWriter
2020
+ */
2021
+
2022
+ declare class AuditWriter {
2023
+ private readonly auditDir;
2024
+ private readonly logger;
2025
+ /** Deferral used by `record()` to schedule the append off the caller's path. */
2026
+ private readonly defer;
2027
+ private dirEnsured;
2028
+ constructor(auditDir: string, logger: Logger,
2029
+ /** Deferral used by `record()` to schedule the append off the caller's path. */
2030
+ defer?: (fn: () => void) => void);
2031
+ /**
2032
+ * Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
2033
+ * write happens on the deferred tick. A failure is logged, never thrown.
2034
+ */
2035
+ record(record: AuditRecord): void;
2036
+ /**
2037
+ * Append synchronously — the awaitable form tests use to assert the line landed.
2038
+ * Ensures the `audit/` directory exists on first write (lazy, like the usage
2039
+ * store's lazy file creation).
2040
+ */
2041
+ appendNow(record: AuditRecord): void;
2042
+ }
2043
+
2044
+ /**
2045
+ * BillingPublisher — the daemon's durable-first billing sink (billing-event-stream,
2046
+ * design D2/D4). Registered as `@omnicross/core`'s billing sink when billing is
2047
+ * enabled; its {@link record} is what `publishBillingEvent` hands each assembled
2048
+ * event.
2049
+ *
2050
+ * DURABLE-FIRST (THE key decision, design D2): {@link record}
2051
+ * 1. APPENDS the event as one JSON line to `billing/billing-YYYY-MM-DD.jsonl` —
2052
+ * SYNCHRONOUSLY, BEFORE any delivery attempt. This is the DURABLE source of
2053
+ * truth: once appended, the event is NEVER lost, even if the process crashes
2054
+ * or every delivery attempt fails. A billing ledger is a financial record.
2055
+ * 2. then, only when an `endpoint` is configured, schedules a best-effort POST
2056
+ * OFF the caller's stack (an injectable `defer`, default a zero-delay timer)
2057
+ * so {@link record} RETURNS IMMEDIATELY — a slow/failing endpoint never blocks
2058
+ * the caller (which is already off the relay response path). Ledger-only mode
2059
+ * (no `endpoint`) simply appends — an external tailer consumes the jsonl.
2060
+ * 3. on a POST ack, appends a delivery marker (`delivered-YYYY-MM-DD.jsonl`); on
2061
+ * failure the event stays UNdelivered in the ledger for the retry sweep +
2062
+ * external reconciliation. A delivery failure NEVER drops the event.
2063
+ *
2064
+ * At-least-once: the consumer dedupes on the event `id` (the request id). The
2065
+ * built-in POST optionally signs the body with `X-Omnicross-Billing-Signature:
2066
+ * sha256=<hmac hex>` (node `crypto`, no new dep). The signing `secret` is used
2067
+ * ONLY to sign — it NEVER appears in the payload or a log line. Egress is #3's
2068
+ * proxy-aware `fetchUpstream` (global proxy).
2069
+ *
2070
+ * @module @omnicross/daemon/billing/BillingPublisher
2071
+ */
2072
+
2073
+ /** The minimal `fetch` shape the publisher POSTs through (proxy-aware by default). */
2074
+ type BillingFetch = (url: string, init: RequestInit) => Promise<Response>;
2075
+ /** Constructor knobs (all optional; test seams for fetch/defer/clock). */
2076
+ interface BillingPublisherOptions {
2077
+ /** Egress fn; defaults to #3's proxy-aware `fetchUpstream` (global proxy only). */
2078
+ fetchImpl?: BillingFetch;
2079
+ /** Deferral used by `record()` to schedule the POST off the caller's path. */
2080
+ defer?: (fn: () => void) => void;
2081
+ timeoutMs?: number;
2082
+ /** Clock seam for delivery-marker timestamps (tests fix it). */
2083
+ now?: () => number;
2084
+ }
2085
+ declare class BillingPublisher {
2086
+ private readonly billingDir;
2087
+ private readonly logger;
2088
+ private config;
2089
+ private dirEnsured;
2090
+ private readonly fetchImpl;
2091
+ private readonly defer;
2092
+ private readonly timeoutMs;
2093
+ private readonly now;
2094
+ constructor(billingDir: string, logger: Logger, opts?: BillingPublisherOptions);
2095
+ /** Install/replace the live billing config (endpoint + secret + retry bound). */
2096
+ setConfig(config: BillingConfig | undefined): void;
2097
+ /**
2098
+ * Record one billing event. DURABLE-FIRST: append synchronously (the event is
2099
+ * now on disk, never lost), THEN schedule a best-effort POST off the caller's
2100
+ * stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
2101
+ * NEVER throws — a failing append/POST is logged, never propagated.
2102
+ */
2103
+ record(event: BillingEvent): void;
2104
+ /**
2105
+ * Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
2106
+ * LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
2107
+ * line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
2108
+ */
2109
+ appendNow(event: BillingEvent): void;
2110
+ /**
2111
+ * One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
2112
+ * event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
2113
+ * appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
2114
+ * attempt returns `false` — the event stays UNdelivered in the ledger (never
2115
+ * lost). NEVER rejects. A no-op `false` when no endpoint is configured.
2116
+ */
2117
+ deliverNow(event: BillingEvent): Promise<boolean>;
2118
+ /**
2119
+ * Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
2120
+ * (keyed by the EVENT's date so the reader finds both together). Idempotent at
2121
+ * the reconciliation layer — the reader unions marker ids into a delivered set,
2122
+ * so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
2123
+ */
2124
+ markDelivered(event: BillingEvent): void;
2125
+ private ensureDir;
2126
+ }
2127
+
2128
+ /**
2129
+ * BillingRetrySweeper — the bounded retry + reconciliation sweep for the billing
2130
+ * ledger (billing-event-stream, design D5). Periodically re-POSTs UNdelivered
2131
+ * ledger events (via the publisher's built-in delivery) with a bounded age:
2132
+ * - an undelivered event WITHIN `maxRetryAgeMs` of its timestamp is re-POSTed
2133
+ * (the request id makes the re-POST safe — the consumer dedupes);
2134
+ * - an undelivered event PAST `maxRetryAgeMs` is LEFT in the ledger for external
2135
+ * reconciliation — it is NEVER deleted (a delivery failure must never drop a
2136
+ * billing record; the ledger is a financial record, so there is NO prune here,
2137
+ * unlike the #13 audit TTL);
2138
+ * - a DELIVERED event (has a marker) is never re-sent (delivery-marking prevents
2139
+ * double delivery).
2140
+ *
2141
+ * Modeled on the #8/#13 sweepers: `start()` arms an `unref()`ed interval, a
2142
+ * single-sweep re-entrancy guard prevents overlap, `dispose()` clears it. A sweep
2143
+ * ALSO runs once at boot (`start` fires an immediate sweep). Disabled/ledger-only
2144
+ * (no endpoint) ⇒ armed-off ⇒ no-op. Never throws.
2145
+ *
2146
+ * @module @omnicross/daemon/billing/BillingRetrySweeper
2147
+ */
2148
+
2149
+ declare class BillingRetrySweeper {
2150
+ private readonly billingDir;
2151
+ private readonly publisher;
2152
+ private readonly logger;
2153
+ private config;
2154
+ private readonly intervalMs;
2155
+ /** Injectable clock (ms) for deterministic tests. */
2156
+ private readonly now;
2157
+ private timer;
2158
+ private sweeping;
2159
+ constructor(billingDir: string, publisher: BillingPublisher, logger: Logger, config: BillingConfig, intervalMs?: number,
2160
+ /** Injectable clock (ms) for deterministic tests. */
2161
+ now?: () => number);
2162
+ /** Whether retrying is active: billing enabled AND an endpoint is configured. */
2163
+ get enabled(): boolean;
2164
+ /** Re-apply config to the live instance (boot + admin PUT hot-reload). */
2165
+ configure(config: BillingConfig): void;
2166
+ /**
2167
+ * Arm the retry interval AND run one sweep immediately (boot catch-up for events
2168
+ * that failed to deliver while the daemon was down). No-op when disabled or in
2169
+ * ledger-only mode (no endpoint to POST to). Idempotent.
2170
+ */
2171
+ start(): void;
2172
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
2173
+ dispose(): void;
2174
+ /**
2175
+ * One sweep: re-POST every UNdelivered ledger event still within
2176
+ * `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
2177
+ * deleted). Exposed for tests; never throws. Returns the number of events a
2178
+ * re-POST was attempted for.
2179
+ */
2180
+ sweep(): Promise<number>;
2181
+ }
2182
+
1526
2183
  /**
1527
2184
  * TokenRefreshScheduler — proactive background OAuth token refresh
1528
2185
  * (external-cli-sync).
@@ -1572,6 +2229,96 @@ declare class TokenRefreshScheduler {
1572
2229
  private refreshActive;
1573
2230
  }
1574
2231
 
2232
+ /**
2233
+ * WebhookDispatcher — the daemon-side fire-and-forget webhook sender
2234
+ * (webhook-notifications, design D4/D5/D6).
2235
+ *
2236
+ * Registered as the core emit sink at bootstrap. {@link emit} pushes the event
2237
+ * onto a bounded in-memory queue and RETURNS IMMEDIATELY — it never awaits a
2238
+ * send and never throws, so a slow/failing/throwing destination can NEVER block
2239
+ * or delay a relay request (the HARD contract). An async drain loop then:
2240
+ * - matches each event to every enabled destination whose `events` filter
2241
+ * allows it (absent/empty ⇒ all kinds),
2242
+ * - sends to the matching destinations CONCURRENTLY,
2243
+ * - retries a failed send with bounded exponential backoff up to
2244
+ * `maxAttempts`, then LOGS (via the injected #10 logger) and DROPS it.
2245
+ * The queue is bounded (drop-OLDEST + a one-shot warn) so a runaway source can't
2246
+ * OOM the process.
2247
+ *
2248
+ * Egress is #3's proxy-aware `fetchUpstream` with NO ctx → the GLOBAL proxy only
2249
+ * (webhooks aren't per-account). Signing uses node `crypto` (no new deps):
2250
+ * `custom` → optional `X-Omnicross-Signature: sha256=<hex hmac of body>`;
2251
+ * `feishu` → Feishu's `timestamp` + `sign` (HMAC-SHA256 base64 of
2252
+ * `timestamp\nsecret`) envelope. The destination `secret` is used ONLY to sign;
2253
+ * it is NEVER placed in a payload or a log line.
2254
+ *
2255
+ * @module @omnicross/daemon/webhook/WebhookDispatcher
2256
+ */
2257
+
2258
+ /** The minimal `fetch` shape the dispatcher POSTs through (proxy-aware by default). */
2259
+ type WebhookFetch = (url: string, init: RequestInit) => Promise<Response>;
2260
+ /** Outcome of a single delivery attempt (used by the admin test path). */
2261
+ interface WebhookDeliveryResult {
2262
+ ok: boolean;
2263
+ status?: number;
2264
+ error?: string;
2265
+ }
2266
+ /** Constructor knobs (all optional; test seams for fetch/logger/sleep/clock). */
2267
+ interface WebhookDispatcherOptions {
2268
+ /** Egress fn; defaults to #3's proxy-aware `fetchUpstream` (global proxy only). */
2269
+ fetchImpl?: WebhookFetch;
2270
+ /** Injected #10 logger for drop/debug lines (never logs a secret). */
2271
+ logger?: Logger;
2272
+ maxAttempts?: number;
2273
+ queueMax?: number;
2274
+ timeoutMs?: number;
2275
+ baseBackoffMs?: number;
2276
+ /** Backoff sleep seam (tests inject an instant/fake sleep). */
2277
+ sleep?: (ms: number) => Promise<void>;
2278
+ /** Clock seam for the `test` event `at` + Feishu `timestamp` (tests fix it). */
2279
+ now?: () => number;
2280
+ }
2281
+ declare class WebhookDispatcher {
2282
+ private config;
2283
+ private readonly queue;
2284
+ private draining;
2285
+ private warnedFull;
2286
+ private readonly fetchImpl;
2287
+ private readonly logger;
2288
+ private readonly maxAttempts;
2289
+ private readonly queueMax;
2290
+ private readonly timeoutMs;
2291
+ private readonly baseBackoffMs;
2292
+ private readonly sleep;
2293
+ private readonly now;
2294
+ constructor(opts?: WebhookDispatcherOptions);
2295
+ /** Install/replace the live webhook config (destinations + master switch). */
2296
+ setConfig(config: WebhookConfig | undefined): void;
2297
+ /**
2298
+ * Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
2299
+ * send, NEVER throws — the drain loop does all sending on a side channel. A
2300
+ * full queue drops the OLDEST event (with a one-shot warn) so a runaway source
2301
+ * can't OOM the process.
2302
+ */
2303
+ emit(event: WebhookEvent): void;
2304
+ /** Drain the queue, sending each event to its matching destinations concurrently. */
2305
+ private drain;
2306
+ /** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
2307
+ private matchingDestinations;
2308
+ /** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
2309
+ private sendWithRetry;
2310
+ /** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
2311
+ private sendOnce;
2312
+ /**
2313
+ * ADMIN test path (design D8): deliver a `test` event to ONE destination and
2314
+ * AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
2315
+ * the admin request path (an operator clicking "Test"), NEVER on a relay path,
2316
+ * so awaiting it is safe. Finds the destination regardless of its `enabled`
2317
+ * flag or the master switch (an explicit operator action).
2318
+ */
2319
+ deliverTest(destinationId: string): Promise<WebhookDeliveryResult>;
2320
+ }
2321
+
1575
2322
  /**
1576
2323
  * bootstrap.ts — `buildDaemon` wires `@omnicross/core`'s `ProviderProxy` +
1577
2324
  * `OutboundApiServer` STANDALONE (design D6).
@@ -1636,7 +2383,8 @@ interface DaemonPaths {
1636
2383
  }
1637
2384
  /** The constructed daemon handles the CLI commands operate on. */
1638
2385
  interface Daemon {
1639
- readonly logger: ConsoleLogger;
2386
+ /** The injected `Logger` port (a `ConfigurableLogger` built from `config.logging`). */
2387
+ readonly logger: Logger;
1640
2388
  readonly llmConfig: ConfigFileProviderConfigSource;
1641
2389
  readonly keyDb: JsonOutboundKeyDb;
1642
2390
  readonly settingsStore: JsonApiServerSettingsStore;
@@ -1673,6 +2421,54 @@ interface Daemon {
1673
2421
  * still disposes it in cleanup.
1674
2422
  */
1675
2423
  readonly tokenRefreshScheduler: TokenRefreshScheduler;
2424
+ /**
2425
+ * Proactive account-health recovery sweep (subscription-account-health, D6).
2426
+ * NOT started here — `start.ts` arms it for the resident daemon; disposed in
2427
+ * cleanup. Correctness never depends on it (health self-heals lazily on read).
2428
+ */
2429
+ readonly accountHealthSweeper: AccountHealthSweeper;
2430
+ /**
2431
+ * Scheduled ACTIVE account-health probe (subscription-account-probe #8).
2432
+ * Constructed armed-off with default config (`enabled:false`); `start.ts`
2433
+ * `configure(...)`s it from the persisted `accountProbe` segment and starts it
2434
+ * ONLY when enabled. Disposed in cleanup.
2435
+ */
2436
+ readonly accountHealthProbeScheduler: AccountHealthProbeScheduler;
2437
+ /**
2438
+ * Fire-and-forget webhook sender (webhook-notifications). Wired into the core
2439
+ * emit sink + the #2 health signals by `start.ts`/admin PUT via
2440
+ * `applyWebhookConfig`. INERT until a config enables it (zero regression).
2441
+ */
2442
+ readonly webhookDispatcher: WebhookDispatcher;
2443
+ /**
2444
+ * File-backed audit sink (request-audit-log) — appends each captured record to
2445
+ * `audit/audit-YYYY-MM-DD.jsonl` fire-and-forget. Registered as the core sink
2446
+ * (via the audit runtime slot) by `start.ts`/admin PUT ONLY when the `audit`
2447
+ * segment is enabled. INERT until then (no sink ⇒ capture hook is a no-op).
2448
+ */
2449
+ readonly auditWriter: AuditWriter;
2450
+ /**
2451
+ * TTL prune for the audit store (request-audit-log) — unlinks date files past
2452
+ * `retentionDays`. Armed-off; `start.ts` configures from the persisted `audit`
2453
+ * segment + starts it (running one prune at boot) ONLY when enabled. Disposed
2454
+ * in cleanup.
2455
+ */
2456
+ readonly auditPruneSweeper: AuditPruneSweeper;
2457
+ /**
2458
+ * Durable-first billing publisher (billing-event-stream) — appends each event
2459
+ * to `billing/billing-YYYY-MM-DD.jsonl` FIRST, then best-effort POSTs it.
2460
+ * Registered as the core billing sink (via the billing runtime slot) by
2461
+ * `start.ts`/admin PUT ONLY when the `billing` segment is enabled. INERT until
2462
+ * then (no sink ⇒ `publishBillingEvent` is a no-op).
2463
+ */
2464
+ readonly billingPublisher: BillingPublisher;
2465
+ /**
2466
+ * Bounded retry + reconciliation sweep for the billing ledger
2467
+ * (billing-event-stream) — re-POSTs undelivered events within `maxRetryAgeMs`,
2468
+ * NEVER deletes. Armed-off; `start.ts` configures from the persisted `billing`
2469
+ * segment + starts it ONLY when enabled with an endpoint. Disposed in cleanup.
2470
+ */
2471
+ readonly billingRetrySweeper: BillingRetrySweeper;
1676
2472
  }
1677
2473
  /**
1678
2474
  * Construct the standalone daemon from a loaded config + on-disk paths. Does NOT
@@ -1698,6 +2494,137 @@ declare function buildDaemon(config: DaemonConfig, paths: DaemonPaths): Daemon;
1698
2494
  * via `daemon.adminServer.stop()`. */
1699
2495
  declare function resetDaemonSingletonsForTests(): void;
1700
2496
 
2497
+ /**
2498
+ * health.ts — the pure `/health` report builder (daemon-health-endpoint, D2).
2499
+ *
2500
+ * `buildHealthReport(deps)` returns a COARSE, SECRET-FREE {@link HealthReport}
2501
+ * from cheap SYNCHRONOUS probes. It NEVER hits an upstream, NEVER blocks, and
2502
+ * NEVER embeds a token/email/config-value/record-count — the body is served
2503
+ * UNAUTHENTICATED (before the admin auth gate, and optionally before the outbound
2504
+ * key-auth), so it must expose nothing sensitive.
2505
+ *
2506
+ * Each check is a caller-supplied boolean thunk; a thunk that THROWS collapses to
2507
+ * `false` (a health probe must never crash the process). Status math:
2508
+ * - CRITICAL (`config`, `credentialStore`): a false critical → `error`.
2509
+ * - READINESS (`outboundServer`): the serving-path signal — false → `degraded`.
2510
+ * - INFORMATIONAL (`adminServer`): reported in `checks` but does NOT affect
2511
+ * `status` — a disabled/loopback dashboard must not fail the TRAFFIC-port
2512
+ * probe (the whole point of the outbound secondary mount).
2513
+ * `error` and `degraded` both map to HTTP 503 (see `healthHttpStatus`), so a
2514
+ * probe treats "not fully ready" as not-ready.
2515
+ *
2516
+ * @module @omnicross/daemon/admin/health
2517
+ */
2518
+
2519
+ /** The coarse dependency probes + process-stat seams the builder reads. */
2520
+ interface HealthReportDeps {
2521
+ /** The daemon package version (non-secret; also on the identity header). */
2522
+ version: string;
2523
+ /** CRITICAL: the bootstrap config is present/loaded. */
2524
+ configPresent: () => boolean;
2525
+ /** CRITICAL: the credential store is constructed + its file readable (no decrypt). */
2526
+ credentialStoreReadable: () => boolean;
2527
+ /** Non-critical: the outbound `/v1/*` serving listener is running. */
2528
+ outboundServerRunning: () => boolean;
2529
+ /** Non-critical: the admin listener is running. */
2530
+ adminServerRunning: () => boolean;
2531
+ /**
2532
+ * OPTIONAL coarse account-probe signal (subscription-account-probe #8, design
2533
+ * D5). Returns `true` when no PROBED account is currently unhealthy, `false`
2534
+ * when one is, or `undefined` when probing is DISABLED (→ the check is OMITTED,
2535
+ * keeping the `/health` body byte-identical when the feature is off).
2536
+ * INFORMATIONAL: it never affects `status` (a marked account is an operational
2537
+ * signal, not a daemon-readiness failure). Account-anonymous — no ids/counts.
2538
+ */
2539
+ subscriptionAccountsHealthy?: () => boolean | undefined;
2540
+ /** TEST SEAM: process memory snapshot (defaults to `process.memoryUsage`). */
2541
+ memoryUsage?: () => NodeJS.MemoryUsage;
2542
+ /** TEST SEAM: process uptime seconds (defaults to `process.uptime`). */
2543
+ uptimeSeconds?: () => number;
2544
+ /** TEST SEAM: wall clock ms (defaults to `Date.now`). */
2545
+ now?: () => number;
2546
+ }
2547
+ /** Build the coarse, secret-free health report (design D2). */
2548
+ declare function buildHealthReport(deps: HealthReportDeps): HealthReport;
2549
+
2550
+ /**
2551
+ * ConfigurableLogger — a `Logger` port impl with level / format / file sink
2552
+ * (configurable-logging, design D3). Supersedes `ConsoleLogger` as the injected
2553
+ * daemon logger.
2554
+ *
2555
+ * - LEVEL: numeric severity `error(0) < warn(1) < info(2) < debug(3)`; a message
2556
+ * whose level is BELOW the configured threshold (higher ordinal) is dropped.
2557
+ * Default threshold = `debug` (prints everything).
2558
+ * - FORMAT: `text` (the legacy `console.*(message, meta)` shape) | `json` (one
2559
+ * structured line `{ ts, level, msg, ...meta }`). Default `text`.
2560
+ * - SINK: always the console; PLUS an optional append-only file stream when
2561
+ * `file` is set (lazy-open; a write/open error is swallowed → the daemon never
2562
+ * crashes on a logging failure, it just falls back to the console).
2563
+ *
2564
+ * ZERO-REGRESSION DEFAULT: `new ConfigurableLogger()` (no config) = console +
2565
+ * all levels + text = behaviorally byte-identical to the legacy `ConsoleLogger`
2566
+ * (same `console` method per level, same `(message[, meta])` / error arg shape).
2567
+ *
2568
+ * CAUTION (per the #3 host:port-only-logging precedent): the JSON serializer
2569
+ * reduces an `Error` to `{ message, stack }` and spreads a plain `meta` object,
2570
+ * but it is NOT a secret redactor — call sites remain responsible for not passing
2571
+ * secret-bearing objects.
2572
+ *
2573
+ * @module @omnicross/daemon/ports/ConfigurableLogger
2574
+ */
2575
+
2576
+ declare class ConfigurableLogger implements Logger {
2577
+ private readonly threshold;
2578
+ private readonly format;
2579
+ private readonly filePath;
2580
+ private fileStream;
2581
+ private fileDisabled;
2582
+ constructor(cfg?: LoggingConfig);
2583
+ info(message: string, meta?: Record<string, unknown> | Error | object): void;
2584
+ warn(message: string, meta?: Record<string, unknown> | Error | object): void;
2585
+ error(message: string, error?: unknown, meta?: Record<string, unknown> | object): void;
2586
+ debug(message: string, meta?: Record<string, unknown> | Error | object): void;
2587
+ /**
2588
+ * Flush + close the file sink (tests / graceful shutdown). Resolves once the
2589
+ * append stream has finished flushing to disk. No-op when no file sink is open.
2590
+ */
2591
+ close(): Promise<void>;
2592
+ private emit;
2593
+ /**
2594
+ * Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
2595
+ * EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
2596
+ * byte drop-in; in `json` format it prints the structured line.
2597
+ */
2598
+ private writeConsole;
2599
+ /** Append one line to the file sink; a failure disables the sink (swallowed). */
2600
+ private writeFile;
2601
+ /** Lazily open the append-only file stream; disable the sink on any error. */
2602
+ private getFileStream;
2603
+ private consoleFn;
2604
+ /** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
2605
+ private jsonLine;
2606
+ /** Human-readable file line: `ISO [level] message {metaJson}`. */
2607
+ private textLine;
2608
+ }
2609
+
2610
+ /**
2611
+ * ConsoleLogger — the daemon's file-less default `Logger` port impl (design D5).
2612
+ *
2613
+ * A thin `console.*` wrapper. The serving core depends on the `Logger` port
2614
+ * (never a host class), so this trivial implementation is the only logger the
2615
+ * standalone daemon needs. `error` uses the WIDEST `(message, error?, meta?)`
2616
+ * signature so every core call site stays assignable.
2617
+ *
2618
+ * @module @omnicross/daemon/ports/ConsoleLogger
2619
+ */
2620
+
2621
+ declare class ConsoleLogger implements Logger {
2622
+ info(message: string, meta?: Record<string, unknown> | Error | object): void;
2623
+ warn(message: string, meta?: Record<string, unknown> | Error | object): void;
2624
+ error(message: string, error?: unknown, meta?: Record<string, unknown> | object): void;
2625
+ debug(message: string, meta?: Record<string, unknown> | Error | object): void;
2626
+ }
2627
+
1701
2628
  /**
1702
2629
  * ccr-import.ts — translate a `claude-code-router` (CCR) `config.json` into an
1703
2630
  * omnicross daemon config (design D9). Pure + testable: `parseCcrConfig(raw)` +
@@ -1761,4 +2688,4 @@ declare function mapCcrToOmnicross(ccr: CcrConfig): {
1761
2688
  notes: string[];
1762
2689
  };
1763
2690
 
1764
- export { type AdminApiDeps, AdminServer, type AdminServerDeps, type AdminServerStatus, type CcrConfig, type CcrProvider, type CcrRouter, ConfigFileProviderConfigSource, ConsoleLogger, DEFAULT_ADMIN_PORT, type Daemon, type DaemonAdminConfig, type DaemonApiFormat, type DaemonConfig, type DaemonPaths, type DaemonProviderConfig, JsonApiServerSettingsStore, JsonOutboundKeyDb, JsonSubscriptionCredentialStore, type ResolvedAdminConfig, buildDaemon, handleAdminApi, inferApiFormat, loadConfig, mapCcrToOmnicross, parseCcrConfig, resetDaemonSingletonsForTests, resolveAdminConfig, saveConfig, validateConfig };
2691
+ export { type AdminApiDeps, AdminServer, type AdminServerDeps, type AdminServerStatus, type CcrConfig, type CcrProvider, type CcrRouter, ConfigFileProviderConfigSource, ConfigurableLogger, ConsoleLogger, DEFAULT_ADMIN_PORT, type Daemon, type DaemonAdminConfig, type DaemonApiFormat, type DaemonConfig, type DaemonPaths, type DaemonProviderConfig, type HealthReportDeps, JsonApiServerSettingsStore, JsonOutboundKeyDb, JsonSubscriptionCredentialStore, type ResolvedAdminConfig, buildDaemon, buildHealthReport, handleAdminApi, inferApiFormat, loadConfig, mapCcrToOmnicross, parseCcrConfig, resetDaemonSingletonsForTests, resolveAdminConfig, saveConfig, validateConfig };