cartethyia 2.6.6 → 2.7.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.
Files changed (189) hide show
  1. package/.env.example +106 -113
  2. package/dist/dashboard/assets/{BannedPage-1v5rzKMl.js → BannedPage-P76QLGJA.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-chMg74ct.js → CliToolDetailPage-DdBggJS9.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-CKG-Vxrt.js → CliToolsPage-aN1iXhhP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-JYmVKzIx.js → CombosPage-DE-F1nxZ.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CPflWDry.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-B0EYUE47.js → CustomizationPage-3CUxMy2X.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-F1c4lkNG.js → HealthEventsModal-BkmJQKC0.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BYTpzcoU.js → LoginPage-B_0FUKWc.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  22. package/dist/dashboard/assets/{ProviderDetailPage-CM4MNy45.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
  23. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-Bsoqg2HG.js → ProvidersPage-CEplAR-i.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-8G3doV7j.js → ProxyPage-CAN7hy0f.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-CVIQq6JG.js → QuotaPage-BjldQwIU.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-BCx07-bj.js → SetupPage-_bCaTM0v.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-MhkB6msu.js → SortableList-fI7JkdIB.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CTqK6Nsj.js → StudioPage-CHVZqskZ.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
  43. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  44. package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
  45. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  46. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
  47. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  48. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
  49. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  50. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
  51. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  52. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
  53. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  54. package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
  55. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  56. package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
  57. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  58. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
  59. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  60. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
  61. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CBHzQ6Qh.js → entry-CWBY5A0f.js} +3 -3
  63. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
  65. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  66. package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
  67. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  68. package/dist/dashboard/assets/{index-Ck4uwuYb.js → index-CDzP3ovJ.js} +2 -2
  69. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  70. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
  71. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  72. package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
  73. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  74. package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
  75. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  76. package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
  77. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  78. package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
  79. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  80. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  81. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  82. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
  83. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
  85. package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
  86. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
  87. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  88. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
  89. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  90. package/dist/dashboard/assets/{select-BrrUDUPc.js → select-KIT0rC6G.js} +1 -1
  91. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  92. package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
  93. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  94. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
  95. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  96. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
  97. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  98. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
  99. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -51
  105. package/package.json +96 -96
  106. package/src/boot/app.ts +714 -722
  107. package/src/boot/dependencies.ts +729 -750
  108. package/src/config/resources.ts +70 -137
  109. package/src/config/security.ts +37 -73
  110. package/src/config/spec.ts +358 -378
  111. package/src/console/console-router.ts +225 -233
  112. package/src/console/domain-registration.ts +381 -391
  113. package/src/console/remote-support/contracts.ts +3 -1
  114. package/src/console/remote-support/service.ts +115 -6
  115. package/src/main.ts +167 -169
  116. package/src/observability/metrics.ts +467 -457
  117. package/src/observability/runtime-metrics.ts +269 -254
  118. package/src/protocol/gateway-error.ts +232 -233
  119. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  120. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  121. package/src/providers/integrations/qoder/qoder.ts +597 -23
  122. package/src/providers/operations/account-health-service.ts +52 -3
  123. package/src/security/abuse.ts +17 -0
  124. package/src/transport/guards/auth-guard.ts +109 -127
  125. package/src/transport/guards/pipeline.ts +220 -226
  126. package/src/transport/guards/request-context.ts +238 -318
  127. package/src/transport/request/inflight.ts +21 -2
  128. package/src/workers/scheduler.ts +73 -6
  129. package/dist/dashboard/assets/BannedPage-1v5rzKMl.js.gz +0 -0
  130. package/dist/dashboard/assets/CliToolDetailPage-chMg74ct.js.gz +0 -0
  131. package/dist/dashboard/assets/CliToolsPage-CKG-Vxrt.js.gz +0 -0
  132. package/dist/dashboard/assets/CombosPage-JYmVKzIx.js.gz +0 -0
  133. package/dist/dashboard/assets/ConsoleLogPage-CPflWDry.js.gz +0 -0
  134. package/dist/dashboard/assets/CustomizationPage-B0EYUE47.js.gz +0 -0
  135. package/dist/dashboard/assets/HealthEventsModal-F1c4lkNG.js.gz +0 -0
  136. package/dist/dashboard/assets/LoginPage-BYTpzcoU.js.gz +0 -0
  137. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  138. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js +0 -1
  139. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js.gz +0 -0
  140. package/dist/dashboard/assets/ProviderDetailPage-CM4MNy45.js.gz +0 -0
  141. package/dist/dashboard/assets/ProvidersPage-Bsoqg2HG.js.gz +0 -0
  142. package/dist/dashboard/assets/ProxyPage-8G3doV7j.js.gz +0 -0
  143. package/dist/dashboard/assets/QuotaPage-CVIQq6JG.js.gz +0 -0
  144. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js +0 -1
  145. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js.gz +0 -0
  146. package/dist/dashboard/assets/SetupPage-BCx07-bj.js.gz +0 -0
  147. package/dist/dashboard/assets/SortableList-MhkB6msu.js.gz +0 -0
  148. package/dist/dashboard/assets/StudioPage-CTqK6Nsj.js.gz +0 -0
  149. package/dist/dashboard/assets/UsagePage-Do65OWbr.js +0 -56
  150. package/dist/dashboard/assets/UsagePage-Do65OWbr.js.gz +0 -0
  151. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  152. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  153. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  154. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  155. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  156. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  157. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  158. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  159. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  160. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  161. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  162. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  163. package/dist/dashboard/assets/entry-CBHzQ6Qh.js.gz +0 -0
  164. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  165. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  166. package/dist/dashboard/assets/index-Ck4uwuYb.js.gz +0 -0
  167. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  168. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  169. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  170. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  171. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  172. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  173. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  174. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  175. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  176. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  177. package/dist/dashboard/assets/select-BrrUDUPc.js.gz +0 -0
  178. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  179. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  180. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  181. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  182. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  183. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  184. package/src/console/admin/model-abuse/contracts.ts +0 -38
  185. package/src/console/admin/model-abuse/routes.ts +0 -97
  186. package/src/security/model-abuse.ts +0 -704
  187. package/src/transport/guards/concurrency-guard.ts +0 -125
  188. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
  189. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
@@ -1,233 +1,225 @@
1
- /**
2
- * Console API composition root: assembles every console route group with
3
- * explicit persistence boundaries. All dependencies are injected at
4
- * composition time; concrete Drizzle stores are the defaults, so this file
5
- * is the single place that knows them.
6
- */
7
- import { Elysia } from "elysia";
8
- import { log } from "../observability/logger";
9
- import { resolveConsoleAccess, resolveConsoleUser } from "./auth/session-resolver";
10
- import { ConsoleCredentialService } from "./auth/credentials";
11
- import { resolveDefaultGatewayApiKey } from "../config/runtime";
12
- import { FirstBootSetupService } from "./auth/setup-service";
13
- import { ConsoleSessionService, defaultSessionCookiePolicy } from "./auth/session-service";
14
- import { ConsoleLockoutService } from "./auth/lockout";
15
- import { AuditRecorder } from "./auth/audit-recorder";
16
- import { createConsoleAuthRoutes } from "./auth/session";
17
- import { createConsoleCsrfMiddleware } from "./console-router-guards";
18
- import { createConsoleMutationLimiterMiddleware } from "./console-router-guards";
19
- import { registerConsoleDomains, type ConsoleDomainContext } from "./domain-registration";
20
- import type { ConsoleAccessResolver } from "./auth/access";
21
- import type { CartethyiaDatabase } from "../persistence/db-handle";
22
- import { createAccessDecision, type AccessDecision } from "../security/access-control";
23
- import { resolveApiKeyAuthorization } from "../security/api-key-auth";
24
- import type { LiveInFlightSource } from "./observability/live";
25
- import type { NetworkPoolSelector } from "../network/pool/selector";
26
- import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
27
- import type { OAuthRefreshService } from "../providers/authentication/oauth-refresh-service";
28
- import type { ApiKeyAdmissionService } from "../security/admission/service";
29
- import type { ModelStrikeService } from "../security/model-abuse";
30
- import type { RedisClient } from "../persistence/redis";
31
- import { CliToolMappingStore } from "./cli-tools/store";
32
- import { CliToolService } from "./cli-tools/service";
33
- import { DrizzleCliToolSecretSource } from "./cli-tools/secret-source";
34
- import type { ProviderRegistry } from "../providers/provider-registry";
35
- import type { BundledProviderCatalog } from "../providers/operations/provider-catalog-service";
36
- import type { ValidatedNetworkBindingFactory } from "../network/pool/resolver";
37
- import type { TrustedProxyBoundary } from "../config/network";
38
- import { gzipResponse } from "./response-compression";
39
-
40
- export interface ConsoleApiCompositionDeps {
41
- readonly db: CartethyiaDatabase;
42
- readonly accessResolver: ConsoleAccessResolver;
43
- /**
44
- * Drops the cached route snapshot after a console write changes the catalog.
45
- * Structurally satisfied by `RouteSnapshotService`; declared here so the
46
- * console composition root does not import the transport layer.
47
- */
48
- readonly routeSnapshotService: { invalidate(): Promise<number> };
49
- readonly poolSelector: NetworkPoolSelector;
50
- readonly telemetryBuffer: TelemetryBatchBuffer;
51
- /**
52
- * Queued writers the console can drain on demand (telemetry, usage, health
53
- * events). Optional for the same reason the other reduced composition
54
- * surface is: absent means the flush control is not offered rather than the
55
- * console failing to build.
56
- */
57
- readonly flushBuffers?: { flush(): Promise<void> };
58
- /**
59
- * The request state store owns the live in-flight gauge, so the console reads
60
- * its snapshot/subscription from here rather than a free-standing module.
61
- */
62
- readonly stateStore?: LiveInFlightSource;
63
- readonly providerRegistry: ProviderRegistry;
64
- readonly bundledModelCatalog: BundledProviderCatalog;
65
- readonly networkBindingFactory: ValidatedNetworkBindingFactory;
66
- readonly redis: RedisClient | undefined;
67
- readonly oauthRefreshService: OAuthRefreshService;
68
- readonly admissionService: Pick<ApiKeyAdmissionService, "purgeKey">;
69
- /**
70
- * Graduated model-abuse strikes: list and lift bans from the console.
71
- * Optional so reduced compositions (route-only shell, console stubs) stay
72
- * valid; absent means the `/model-bans` routes are not mounted.
73
- */
74
- readonly modelStrikes?: Pick<ModelStrikeService, "listBans" | "unban" | "ban">;
75
- readonly readRoutingAccountInflight?:
76
- | ((
77
- providerId: string,
78
- tenantId: string | null,
79
- ) => Promise<readonly { accountId: string; inflight: number }[]>)
80
- | undefined;
81
- readonly resolvePeerAddress?: (request: Request) => string | null;
82
- readonly trustedProxyBoundary?: TrustedProxyBoundary;
83
- }
84
- /**
85
- * Extracts a bearer API key from a console request, or `undefined`.
86
- *
87
- * Deliberately narrower than the proxy surface's `requestToken`: that one also
88
- * accepts `x-api-key` and throws on a malformed header, because a proxy caller
89
- * must be told its credential is wrong. Here the header is only an *alternative*
90
- * to a session cookie, so an absent or unparsable one means "not a key" and the
91
- * request proceeds as an unauthenticated session request — which the routes
92
- * then reject with their own 401. Returning `undefined` rather than throwing
93
- * keeps a browser that sends a stray Authorization header from being told its
94
- * session is invalid.
95
- */
96
- function readApiKeyBearer(request: Request): string | undefined {
97
- const authorization = request.headers.get("authorization");
98
- if (authorization === null) return undefined;
99
- const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
100
- return match?.[1];
101
- }
102
-
103
- export function createConsoleRouter(deps: ConsoleApiCompositionDeps): Elysia {
104
- const { db, routeSnapshotService, poolSelector, telemetryBuffer, redis } = deps;
105
- /**
106
- * Per-request cache: `beforeHandle` resolves the session asynchronously, and
107
- * the sync `ConsoleAccessResolver` contract reads the decision back out of
108
- * it. Scoped to this composition rather than the module so two routers in one
109
- * process (tests, or a second app instance) never share decisions.
110
- */
111
- const sessionAccessCache = new WeakMap<Request, AccessDecision | null>();
112
- const cookiePolicy = defaultSessionCookiePolicy;
113
- const credentialService = new ConsoleCredentialService();
114
- const sessionService = new ConsoleSessionService(db, cookiePolicy);
115
- const lockoutService = new ConsoleLockoutService(db);
116
- const setupService = new FirstBootSetupService(db, credentialService);
117
- // The default gateway key is seeded only when the first-boot wizard runs, so a
118
- // key set in the environment does nothing until an operator completes setup.
119
- // Fire and forget: a failed check must not stop the console from mounting.
120
- void setupService.requiresSetup().then((needsSetup) => {
121
- if (needsSetup && resolveDefaultGatewayApiKey() !== undefined) {
122
- log.warn(
123
- "[setup] CARTETHYIA_API_KEY is set but no console user exists yet; complete the first-boot wizard at /console so the key is seeded",
124
- );
125
- }
126
- }).catch((error: unknown) => {
127
- log.warn("[setup] could not check first-boot state", error);
128
- });
129
- const auditRecorder = new AuditRecorder(db);
130
- const cliToolService = new CliToolService(
131
- new CliToolMappingStore(db),
132
- new DrizzleCliToolSecretSource(db),
133
- );
134
- const accessResolver: ConsoleAccessResolver = (request) =>
135
- deps.accessResolver(request) ?? sessionAccessCache.get(request) ?? undefined;
136
- const console = new Elysia({ prefix: "/console/api" })
137
- .use(createConsoleCsrfMiddleware())
138
- .use(createConsoleMutationLimiterMiddleware())
139
- .beforeHandle(async ({ request }: { request: Request }) => {
140
- // The auth routes (`/console/api/auth/*`) resolve the session themselves
141
- // and never read `accessResolver`, so resolving here too would repeat the
142
- // session lookup plus its user query on every login/session/logout call
143
- // for a decision nothing consumes. Skip them.
144
- if (new URL(request.url).pathname.startsWith("/console/api/auth/")) return;
145
- const access = await resolveConsoleAccess(db, sessionService, request);
146
- if (access) {
147
- sessionAccessCache.set(request, access);
148
- return;
149
- }
150
- // No session cookie. A tenant API key may still reach the catalog routes
151
- // it is scoped for — that is what lets a key register a BYOK provider or
152
- // add a model without a browser session. The key is resolved through the
153
- // same path `/v1` uses, so scopes and revocation behave identically, and
154
- // a request carrying no credential at all stays unauthenticated (the
155
- // routes reject it) rather than falling back to an implicit identity.
156
- const bearer = readApiKeyBearer(request);
157
- if (bearer === undefined) return;
158
- const resolved = await resolveApiKeyAuthorization(db, bearer);
159
- if (resolved === undefined) return;
160
- sessionAccessCache.set(
161
- request,
162
- createAccessDecision({
163
- id: resolved.id,
164
- tenantId: resolved.tenantId,
165
- scopes: resolved.scopes,
166
- admissionIdentity: resolved.id,
167
- }),
168
- );
169
- })
170
- .use(
171
- createConsoleAuthRoutes(
172
- db,
173
- credentialService,
174
- sessionService,
175
- lockoutService,
176
- setupService,
177
- cookiePolicy,
178
- deps.trustedProxyBoundary ?? { mode: "disabled" },
179
- (request) => deps.resolvePeerAddress?.(request) ?? undefined,
180
- ),
181
- );
182
- const ctx: ConsoleDomainContext = {
183
- db,
184
- accessResolver,
185
- auditRecorder,
186
- oauthRefreshService: deps.oauthRefreshService,
187
- cliToolService,
188
- redis,
189
- routeSnapshotService,
190
- poolSelector,
191
- telemetryBuffer,
192
- ...(deps.stateStore ? { stateStore: deps.stateStore } : {}),
193
- providerRegistry: deps.providerRegistry,
194
- bundledModelCatalog: deps.bundledModelCatalog,
195
- networkBindingFactory: deps.networkBindingFactory,
196
- admissionService: deps.admissionService,
197
- ...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
198
- readRoutingAccountInflight: deps.readRoutingAccountInflight,
199
- ...(deps.flushBuffers ? { flushBuffers: deps.flushBuffers } : {}),
200
- credentialService,
201
- // The backup surface re-authenticates the operator, so it needs the current
202
- // user's hash. Read from the session on the request that asks for it, never
203
- // cached in a field, so a password change or a deactivated user takes
204
- // effect on the very next attempt.
205
- loadConsoleUser: async (request: Request) => {
206
- const user = await resolveConsoleUser(db, sessionService, request);
207
- return user === null ? null : { passwordHash: user.passwordHash };
208
- },
209
- };
210
- registerConsoleDomains(console as unknown as Elysia, ctx);
211
- // Compress the JSON control plane on the way out.
212
- //
213
- // `wrap` is used rather than a lifecycle hook because this Elysia beta does
214
- // not deliver the response body to them: measured, both `mapResponse` and
215
- // `afterHandle` receive `responseValue`/`response` as `undefined` for a
216
- // plain-object route, and Elysia sets no `content-length`, so a hook has
217
- // nothing to compress and no size to judge. `wrap` sits outside the router
218
- // and sees the finished `Response`, which is what this needs.
219
- //
220
- // Scoped by path *inside* `gzipResponse`, not by this instance: Elysia's
221
- // `use()` merges a child's `hoc` into the root app's ext, so this wrap is
222
- // reached by `/v1/*` as well. The allowlist is `/console/api/` + `/metrics`.
223
- // `/v1/*` is deliberately excluded, because a streaming completion must not
224
- // be buffered and proxied bodies are the provider's own bytes. See
225
- // `response-compression.ts` for the full reasoning and the measured win.
226
- console.wrap(
227
- (fetch) => (request: Request, ...rest: unknown[]) =>
228
- Promise.resolve(fetch(request, ...rest)).then((response) =>
229
- gzipResponse(request, response),
230
- ),
231
- );
232
- return console as unknown as Elysia;
233
- }
1
+ /**
2
+ * Console API composition root: assembles every console route group with
3
+ * explicit persistence boundaries. All dependencies are injected at
4
+ * composition time; concrete Drizzle stores are the defaults, so this file
5
+ * is the single place that knows them.
6
+ */
7
+ import { Elysia } from "elysia";
8
+ import { log } from "../observability/logger";
9
+ import { resolveConsoleAccess, resolveConsoleUser } from "./auth/session-resolver";
10
+ import { ConsoleCredentialService } from "./auth/credentials";
11
+ import { resolveDefaultGatewayApiKey } from "../config/runtime";
12
+ import { FirstBootSetupService } from "./auth/setup-service";
13
+ import { ConsoleSessionService, defaultSessionCookiePolicy } from "./auth/session-service";
14
+ import { ConsoleLockoutService } from "./auth/lockout";
15
+ import { AuditRecorder } from "./auth/audit-recorder";
16
+ import { createConsoleAuthRoutes } from "./auth/session";
17
+ import { createConsoleCsrfMiddleware } from "./console-router-guards";
18
+ import { createConsoleMutationLimiterMiddleware } from "./console-router-guards";
19
+ import { registerConsoleDomains, type ConsoleDomainContext } from "./domain-registration";
20
+ import type { ConsoleAccessResolver } from "./auth/access";
21
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
22
+ import { createAccessDecision, type AccessDecision } from "../security/access-control";
23
+ import { resolveApiKeyAuthorization } from "../security/api-key-auth";
24
+ import type { LiveInFlightSource } from "./observability/live";
25
+ import type { NetworkPoolSelector } from "../network/pool/selector";
26
+ import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
27
+ import type { OAuthRefreshService } from "../providers/authentication/oauth-refresh-service";
28
+ import type { ApiKeyAdmissionService } from "../security/admission/service";
29
+ import type { RedisClient } from "../persistence/redis";
30
+ import { CliToolMappingStore } from "./cli-tools/store";
31
+ import { CliToolService } from "./cli-tools/service";
32
+ import { DrizzleCliToolSecretSource } from "./cli-tools/secret-source";
33
+ import type { ProviderRegistry } from "../providers/provider-registry";
34
+ import type { BundledProviderCatalog } from "../providers/operations/provider-catalog-service";
35
+ import type { ValidatedNetworkBindingFactory } from "../network/pool/resolver";
36
+ import type { TrustedProxyBoundary } from "../config/network";
37
+ import { gzipResponse } from "./response-compression";
38
+
39
+ export interface ConsoleApiCompositionDeps {
40
+ readonly db: CartethyiaDatabase;
41
+ readonly accessResolver: ConsoleAccessResolver;
42
+ /**
43
+ * Drops the cached route snapshot after a console write changes the catalog.
44
+ * Structurally satisfied by `RouteSnapshotService`; declared here so the
45
+ * console composition root does not import the transport layer.
46
+ */
47
+ readonly routeSnapshotService: { invalidate(): Promise<number> };
48
+ readonly poolSelector: NetworkPoolSelector;
49
+ readonly telemetryBuffer: TelemetryBatchBuffer;
50
+ /**
51
+ * Queued writers the console can drain on demand (telemetry, usage, health
52
+ * events). Optional for the same reason the other reduced composition
53
+ * surface is: absent means the flush control is not offered rather than the
54
+ * console failing to build.
55
+ */
56
+ readonly flushBuffers?: { flush(): Promise<void> };
57
+ /**
58
+ * The request state store owns the live in-flight gauge, so the console reads
59
+ * its snapshot/subscription from here rather than a free-standing module.
60
+ */
61
+ readonly stateStore?: LiveInFlightSource;
62
+ readonly providerRegistry: ProviderRegistry;
63
+ readonly bundledModelCatalog: BundledProviderCatalog;
64
+ readonly networkBindingFactory: ValidatedNetworkBindingFactory;
65
+ readonly redis: RedisClient | undefined;
66
+ readonly oauthRefreshService: OAuthRefreshService;
67
+ readonly admissionService: Pick<ApiKeyAdmissionService, "purgeKey">;
68
+ readonly readRoutingAccountInflight?:
69
+ | ((
70
+ providerId: string,
71
+ tenantId: string | null,
72
+ ) => Promise<readonly { accountId: string; inflight: number }[]>)
73
+ | undefined;
74
+ readonly resolvePeerAddress?: (request: Request) => string | null;
75
+ readonly trustedProxyBoundary?: TrustedProxyBoundary;
76
+ }
77
+ /**
78
+ * Extracts a bearer API key from a console request, or `undefined`.
79
+ *
80
+ * Deliberately narrower than the proxy surface's `requestToken`: that one also
81
+ * accepts `x-api-key` and throws on a malformed header, because a proxy caller
82
+ * must be told its credential is wrong. Here the header is only an *alternative*
83
+ * to a session cookie, so an absent or unparsable one means "not a key" and the
84
+ * request proceeds as an unauthenticated session request — which the routes
85
+ * then reject with their own 401. Returning `undefined` rather than throwing
86
+ * keeps a browser that sends a stray Authorization header from being told its
87
+ * session is invalid.
88
+ */
89
+ function readApiKeyBearer(request: Request): string | undefined {
90
+ const authorization = request.headers.get("authorization");
91
+ if (authorization === null) return undefined;
92
+ const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim());
93
+ return match?.[1];
94
+ }
95
+
96
+ export function createConsoleRouter(deps: ConsoleApiCompositionDeps): Elysia {
97
+ const { db, routeSnapshotService, poolSelector, telemetryBuffer, redis } = deps;
98
+ /**
99
+ * Per-request cache: `beforeHandle` resolves the session asynchronously, and
100
+ * the sync `ConsoleAccessResolver` contract reads the decision back out of
101
+ * it. Scoped to this composition rather than the module so two routers in one
102
+ * process (tests, or a second app instance) never share decisions.
103
+ */
104
+ const sessionAccessCache = new WeakMap<Request, AccessDecision | null>();
105
+ const cookiePolicy = defaultSessionCookiePolicy;
106
+ const credentialService = new ConsoleCredentialService();
107
+ const sessionService = new ConsoleSessionService(db, cookiePolicy);
108
+ const lockoutService = new ConsoleLockoutService(db);
109
+ const setupService = new FirstBootSetupService(db, credentialService);
110
+ // The default gateway key is seeded only when the first-boot wizard runs, so a
111
+ // key set in the environment does nothing until an operator completes setup.
112
+ // Fire and forget: a failed check must not stop the console from mounting.
113
+ void setupService.requiresSetup().then((needsSetup) => {
114
+ if (needsSetup && resolveDefaultGatewayApiKey() !== undefined) {
115
+ log.warn(
116
+ "[setup] CARTETHYIA_API_KEY is set but no console user exists yet; complete the first-boot wizard at /console so the key is seeded",
117
+ );
118
+ }
119
+ }).catch((error: unknown) => {
120
+ log.warn("[setup] could not check first-boot state", error);
121
+ });
122
+ const auditRecorder = new AuditRecorder(db);
123
+ const cliToolService = new CliToolService(
124
+ new CliToolMappingStore(db),
125
+ new DrizzleCliToolSecretSource(db),
126
+ );
127
+ const accessResolver: ConsoleAccessResolver = (request) =>
128
+ deps.accessResolver(request) ?? sessionAccessCache.get(request) ?? undefined;
129
+ const console = new Elysia({ prefix: "/console/api" })
130
+ .use(createConsoleCsrfMiddleware())
131
+ .use(createConsoleMutationLimiterMiddleware())
132
+ .beforeHandle(async ({ request }: { request: Request }) => {
133
+ // The auth routes (`/console/api/auth/*`) resolve the session themselves
134
+ // and never read `accessResolver`, so resolving here too would repeat the
135
+ // session lookup plus its user query on every login/session/logout call
136
+ // for a decision nothing consumes. Skip them.
137
+ if (new URL(request.url).pathname.startsWith("/console/api/auth/")) return;
138
+ const access = await resolveConsoleAccess(db, sessionService, request);
139
+ if (access) {
140
+ sessionAccessCache.set(request, access);
141
+ return;
142
+ }
143
+ // No session cookie. A tenant API key may still reach the catalog routes
144
+ // it is scoped for — that is what lets a key register a BYOK provider or
145
+ // add a model without a browser session. The key is resolved through the
146
+ // same path `/v1` uses, so scopes and revocation behave identically, and
147
+ // a request carrying no credential at all stays unauthenticated (the
148
+ // routes reject it) rather than falling back to an implicit identity.
149
+ const bearer = readApiKeyBearer(request);
150
+ if (bearer === undefined) return;
151
+ const resolved = await resolveApiKeyAuthorization(db, bearer);
152
+ if (resolved === undefined) return;
153
+ sessionAccessCache.set(
154
+ request,
155
+ createAccessDecision({
156
+ id: resolved.id,
157
+ tenantId: resolved.tenantId,
158
+ scopes: resolved.scopes,
159
+ admissionIdentity: resolved.id,
160
+ }),
161
+ );
162
+ })
163
+ .use(
164
+ createConsoleAuthRoutes(
165
+ db,
166
+ credentialService,
167
+ sessionService,
168
+ lockoutService,
169
+ setupService,
170
+ cookiePolicy,
171
+ deps.trustedProxyBoundary ?? { mode: "disabled" },
172
+ (request) => deps.resolvePeerAddress?.(request) ?? undefined,
173
+ ),
174
+ );
175
+ const ctx: ConsoleDomainContext = {
176
+ db,
177
+ accessResolver,
178
+ auditRecorder,
179
+ oauthRefreshService: deps.oauthRefreshService,
180
+ cliToolService,
181
+ redis,
182
+ routeSnapshotService,
183
+ poolSelector,
184
+ telemetryBuffer,
185
+ ...(deps.stateStore ? { stateStore: deps.stateStore } : {}),
186
+ providerRegistry: deps.providerRegistry,
187
+ bundledModelCatalog: deps.bundledModelCatalog,
188
+ networkBindingFactory: deps.networkBindingFactory,
189
+ admissionService: deps.admissionService,
190
+ readRoutingAccountInflight: deps.readRoutingAccountInflight,
191
+ ...(deps.flushBuffers ? { flushBuffers: deps.flushBuffers } : {}),
192
+ credentialService,
193
+ // The backup surface re-authenticates the operator, so it needs the current
194
+ // user's hash. Read from the session on the request that asks for it, never
195
+ // cached in a field, so a password change or a deactivated user takes
196
+ // effect on the very next attempt.
197
+ loadConsoleUser: async (request: Request) => {
198
+ const user = await resolveConsoleUser(db, sessionService, request);
199
+ return user === null ? null : { passwordHash: user.passwordHash };
200
+ },
201
+ };
202
+ registerConsoleDomains(console as unknown as Elysia, ctx);
203
+ // Compress the JSON control plane on the way out.
204
+ //
205
+ // `wrap` is used rather than a lifecycle hook because this Elysia beta does
206
+ // not deliver the response body to them: measured, both `mapResponse` and
207
+ // `afterHandle` receive `responseValue`/`response` as `undefined` for a
208
+ // plain-object route, and Elysia sets no `content-length`, so a hook has
209
+ // nothing to compress and no size to judge. `wrap` sits outside the router
210
+ // and sees the finished `Response`, which is what this needs.
211
+ //
212
+ // Scoped by path *inside* `gzipResponse`, not by this instance: Elysia's
213
+ // `use()` merges a child's `hoc` into the root app's ext, so this wrap is
214
+ // reached by `/v1/*` as well. The allowlist is `/console/api/` + `/metrics`.
215
+ // `/v1/*` is deliberately excluded, because a streaming completion must not
216
+ // be buffered and proxied bodies are the provider's own bytes. See
217
+ // `response-compression.ts` for the full reasoning and the measured win.
218
+ console.wrap(
219
+ (fetch) => (request: Request, ...rest: unknown[]) =>
220
+ Promise.resolve(fetch(request, ...rest)).then((response) =>
221
+ gzipResponse(request, response),
222
+ ),
223
+ );
224
+ return console as unknown as Elysia;
225
+ }