cartethyia 2.6.7 → 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 (187) hide show
  1. package/.env.example +106 -113
  2. package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-P76QLGJA.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-DdBggJS9.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-aN1iXhhP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BcS7kGG1.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-ChmiSLwO.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-3CUxMy2X.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-fKAWd-OQ.js → HealthEventsModal-BkmJQKC0.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-DoOUthbG.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-CFu1qGcT.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
  23. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-CnlJZbxF.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-B9mBTweh.js → ProxyPage-CAN7hy0f.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-BueSWX5D.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-DQ9oDRpi.js → SetupPage-_bCaTM0v.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-fI7JkdIB.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-qjuYtccc.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-DOddwoc0.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-BeLRhsmt.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-BfZ3SCTM.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/security/abuse.ts +17 -0
  123. package/src/transport/guards/auth-guard.ts +109 -127
  124. package/src/transport/guards/pipeline.ts +220 -226
  125. package/src/transport/guards/request-context.ts +238 -318
  126. package/src/transport/request/inflight.ts +21 -2
  127. package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
  128. package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
  129. package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
  130. package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
  131. package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
  132. package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
  133. package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
  134. package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
  135. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  136. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
  137. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
  138. package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
  139. package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
  140. package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
  141. package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
  142. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
  143. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
  144. package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
  145. package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
  146. package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
  147. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
  148. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
  149. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  150. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  151. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  152. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  153. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  154. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  155. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  156. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  157. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  158. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  159. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  160. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  161. package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
  162. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  163. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  164. package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
  165. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  166. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  167. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  168. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  169. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  170. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  171. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  172. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  173. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  174. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  175. package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
  176. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  177. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  178. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  179. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  180. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  181. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  182. package/src/console/admin/model-abuse/contracts.ts +0 -38
  183. package/src/console/admin/model-abuse/routes.ts +0 -97
  184. package/src/security/model-abuse.ts +0 -704
  185. package/src/transport/guards/concurrency-guard.ts +0 -125
  186. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
  187. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
@@ -1,4 +1,6 @@
1
1
  import { GatewayError } from "../protocol/gateway-error";
2
+ import { log } from "../observability/logger";
3
+ import { metrics } from "../observability/metrics";
2
4
  import { redisEvalTuple, type RedisClient } from "../persistence/redis";
3
5
 
4
6
  export interface ClientIdentity {
@@ -419,7 +421,21 @@ export class IpAbuseProtectionService {
419
421
  banDurationMs: this.banDurationMs,
420
422
  });
421
423
 
424
+ // Logged once, at the transition. A ban is the one state here the
425
+ // operator cannot see from a metric and cannot wait out by guessing:
426
+ // every request afterwards answers 429 with the same message, whether
427
+ // the address is banned or merely over the window. Naming the address,
428
+ // the count that tripped it, and how long it lasts is what turns
429
+ // "429, sometimes" into something to act on.
430
+ if (outcome.bannedNow) {
431
+ log.warn(
432
+ `ip abuse: banned ${ip} for ${Math.round(this.banDurationMs / 60_000)}min` +
433
+ ` (route ${input.route}, ceiling ${this.banThreshold})`,
434
+ );
435
+ }
436
+
422
437
  if (outcome.banned) {
438
+ metrics.cartethyia_ip_abuse_refusals_total.inc(1, { reason: "ip-banned" });
423
439
  throw new GatewayError("quota_exceeded", 429, "This address is temporarily banned for too many failed requests. Wait for the ban to lift, then slow down.", {
424
440
  // Distinguishes this from the rate-limited 429 below and from an API
425
441
  // key's own budget. It used to be "admission-unavailable", which is
@@ -437,6 +453,7 @@ export class IpAbuseProtectionService {
437
453
  }
438
454
 
439
455
  if (outcome.count > this.maxRequestsPerWindow) {
456
+ metrics.cartethyia_ip_abuse_refusals_total.inc(1, { reason: "ip-rate-limited" });
440
457
  throw new GatewayError("quota_exceeded", 429, "This address is sending requests faster than the abuse limit allows. Back off and retry after the window rolls over.", {
441
458
  reason: "ip-rate-limited",
442
459
  ip,
@@ -1,127 +1,109 @@
1
- import { log } from "../../observability/logger";
2
- // API-key authentication guard for `/v1/*`: token resolution, scope check,
3
- // client-router denylist, and the model-abuse ban gate.
4
- import { Elysia } from "elysia";
5
- import { GatewayError } from "../../protocol/gateway-error";
6
- import type { ProxyRequestStateStore } from "../request/state";
7
- import { fastPathname } from "../pathname";
8
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
9
- import { requestToken, resolveApiKeyAuthorization, unknownApiKeyError } from "../../security/api-key-auth";
10
- import {
11
- deniedClientRouter,
12
- detectClientRouter,
13
- } from "../../security/client-router-fingerprint";
14
- import { createAccessDecision } from "../../security/access-control";
15
- import { modelAbuseBannedError, type ModelStrikeService } from "../../security/model-abuse";
16
-
17
- /**
18
- * Stateless Elysia `beforeHandle` gateway check: API-key authentication,
19
- * wired into the middleware chain in `app.ts`.
20
- */
21
- export function createApiKeyAuthenticationMiddleware(deps: {
22
- readonly db: CartethyiaDatabase;
23
- readonly stateStore: ProxyRequestStateStore;
24
- /**
25
- * Model-abuse ban gate. A banned IP or key is refused here, before the
26
- * canonical parse and route preparation, and before `state.authorization` is
27
- * assigned — so a banned caller's attempts produce no telemetry row and no
28
- * console error, which is exactly the noise this layer exists to stop.
29
- */
30
- readonly modelStrikes?: Pick<ModelStrikeService, "check">;
31
- /**
32
- * Tenant invoke kill switch. Checked after the key resolves, because the
33
- * tenant is a property of the key: the same gateway serves several tenants
34
- * and one switching itself off must not stop the others.
35
- */
36
- readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
37
- }): Elysia {
38
- // Single policy for every `/v1/*` gateway route: authenticated, scoped to
39
- // `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
40
- // tenant this key may address). The check is inline rather than routed
41
- // through a per-route policy table: Elysia's router already owns which
42
- // paths exist, so a per-route table only added a second place to keep in
43
- // sync.
44
- return new Elysia()
45
- .beforeHandle(async ({ request }) => {
46
- const pathname = fastPathname(request.url);
47
- if (!pathname.startsWith("/v1/")) return;
48
- const token = requestToken(request.headers);
49
- // A database failure here is not "the key is bad". Left to propagate it
50
- // surfaced as a 500 whose log line carried the whole statement and its
51
- // bound parameters — including the key hash — once per request. Under an
52
- // overloaded database that is a credential leak written at request rate.
53
- //
54
- // It is also the wrong answer: the caller cannot fix it by sending a
55
- // different key, so it is a capacity signal, not an auth one.
56
- const authorization = await resolveApiKeyAuthorization(deps.db, token).catch((error: unknown) => {
57
- // Only the error's identity is logged, never its message: a drizzle
58
- // query error carries the full statement and its bound parameters, and
59
- // on this path one of those parameters is the key hash.
60
- // A sanitized stand-in, not the original: a drizzle query error's
61
- // message is the statement plus its bound parameters, and one of those
62
- // parameters is the key hash.
63
- const safe = new Error("[redacted: credential-store query failed]");
64
- safe.name = error instanceof Error ? error.name : "Error";
65
- log.error("[auth] key lookup failed; treating as unavailable", safe);
66
- throw new GatewayError(
67
- "accounts_unavailable",
68
- 503,
69
- "The gateway could not verify this API key because its credential store is unavailable. Retry shortly.",
70
- {},
71
- "cartethyia",
72
- );
73
- });
74
- if (!authorization) throw unknownApiKeyError();
75
- const decision = createAccessDecision({
76
- id: authorization.id,
77
- tenantId: authorization.tenantId,
78
- scopes: authorization.scopes,
79
- });
80
- if (!decision.scopes.includes("routing:invoke"))
81
- throw new GatewayError("invalid_request", 403, "This API key is not allowed to invoke routing. Grant it the routing:invoke scope, or use a key that has it.");
82
- // Kill switch: refuse before any upstream is touched. Distinct from the
83
- // scope check above, which is a property of the key — this is the tenant
84
- // having stopped all traffic, and it applies to every key the tenant owns.
85
- // 503 rather than 403: nothing about the request is invalid, and the
86
- // caller should retry later instead of "fixing" a request that is fine.
87
- if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
88
- throw new GatewayError(
89
- "platform_unavailable",
90
- 503,
91
- "This gateway has invoke switched off. Nothing upstream is being dialed while the switch is off; turn it back on from the dashboard overview to resume routing.",
92
- );
93
- // A key may refuse a downstream router it has been resold to. This is a
94
- // policy check on the resolved key, so it belongs with the other
95
- // authorization decisions rather than in routing: the request is refused
96
- // before a route is planned or an upstream is touched.
97
- const denied = deniedClientRouter(
98
- authorization.snapshot.client_router_denylist,
99
- detectClientRouter({ headers: request.headers }),
100
- );
101
- if (denied !== null)
102
- throw new GatewayError(
103
- "client_router_denied",
104
- 403,
105
- "No API invocation access for this client.",
106
- { reason: "client_router_denied", clientRouter: denied },
107
- );
108
- // Abuse ban: refuse before parse/prepare and before authorization is
109
- // recorded, so a banned caller cannot keep producing failed rows. The ban
110
- // is keyed on the client address *and* the API key: one key can be shared
111
- // by every recipient of a share link, so refusing the address alone would
112
- // punish callers that did nothing, while refusing the key alone would let
113
- // the caller that probed mint a fresh one.
114
- if (deps.modelStrikes) {
115
- const state = deps.stateStore.get(request);
116
- const ip = state?.clientIdentity?.address;
117
- if (ip !== undefined) {
118
- const banned = await deps.modelStrikes
119
- .check({ ip, apiKeyId: authorization.id })
120
- .catch(() => false);
121
- if (banned) throw modelAbuseBannedError();
122
- }
123
- }
124
- deps.stateStore.require(request).authorization = authorization;
125
- })
126
- .as("plugin");
127
- }
1
+ import { log } from "../../observability/logger";
2
+ // API-key authentication guard for `/v1/*`: token resolution, scope check,
3
+ // client-router denylist, and the model-abuse ban gate.
4
+ import { Elysia } from "elysia";
5
+ import { GatewayError } from "../../protocol/gateway-error";
6
+ import type { ProxyRequestStateStore } from "../request/state";
7
+ import { fastPathname } from "../pathname";
8
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
9
+ import { requestToken, resolveApiKeyAuthorization, unknownApiKeyError } from "../../security/api-key-auth";
10
+ import {
11
+ deniedClientRouter,
12
+ detectClientRouter,
13
+ } from "../../security/client-router-fingerprint";
14
+ import { createAccessDecision } from "../../security/access-control";
15
+
16
+ /**
17
+ * Stateless Elysia `beforeHandle` gateway check: API-key authentication,
18
+ * wired into the middleware chain in `app.ts`.
19
+ */
20
+ export function createApiKeyAuthenticationMiddleware(deps: {
21
+ readonly db: CartethyiaDatabase;
22
+ readonly stateStore: ProxyRequestStateStore;
23
+ /**
24
+ * Model-abuse ban gate. A banned IP or key is refused here, before the
25
+ * canonical parse and route preparation, and before `state.authorization` is
26
+ * assigned — so a banned caller's attempts produce no telemetry row and no
27
+ * console error, which is exactly the noise this layer exists to stop.
28
+ */
29
+ /**
30
+ * Tenant invoke kill switch. Checked after the key resolves, because the
31
+ * tenant is a property of the key: the same gateway serves several tenants
32
+ * and one switching itself off must not stop the others.
33
+ */
34
+ readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
35
+ }): Elysia {
36
+ // Single policy for every `/v1/*` gateway route: authenticated, scoped to
37
+ // `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
38
+ // tenant this key may address). The check is inline rather than routed
39
+ // through a per-route policy table: Elysia's router already owns which
40
+ // paths exist, so a per-route table only added a second place to keep in
41
+ // sync.
42
+ return new Elysia()
43
+ .beforeHandle(async ({ request }) => {
44
+ const pathname = fastPathname(request.url);
45
+ if (!pathname.startsWith("/v1/")) return;
46
+ const token = requestToken(request.headers);
47
+ // A database failure here is not "the key is bad". Left to propagate it
48
+ // surfaced as a 500 whose log line carried the whole statement and its
49
+ // bound parameters — including the key hash — once per request. Under an
50
+ // overloaded database that is a credential leak written at request rate.
51
+ //
52
+ // It is also the wrong answer: the caller cannot fix it by sending a
53
+ // different key, so it is a capacity signal, not an auth one.
54
+ const authorization = await resolveApiKeyAuthorization(deps.db, token).catch((error: unknown) => {
55
+ // Only the error's identity is logged, never its message: a drizzle
56
+ // query error carries the full statement and its bound parameters, and
57
+ // on this path one of those parameters is the key hash.
58
+ // A sanitized stand-in, not the original: a drizzle query error's
59
+ // message is the statement plus its bound parameters, and one of those
60
+ // parameters is the key hash.
61
+ const safe = new Error("[redacted: credential-store query failed]");
62
+ safe.name = error instanceof Error ? error.name : "Error";
63
+ log.error("[auth] key lookup failed; treating as unavailable", safe);
64
+ throw new GatewayError(
65
+ "accounts_unavailable",
66
+ 503,
67
+ "The gateway could not verify this API key because its credential store is unavailable. Retry shortly.",
68
+ {},
69
+ "cartethyia",
70
+ );
71
+ });
72
+ if (!authorization) throw unknownApiKeyError();
73
+ const decision = createAccessDecision({
74
+ id: authorization.id,
75
+ tenantId: authorization.tenantId,
76
+ scopes: authorization.scopes,
77
+ });
78
+ if (!decision.scopes.includes("routing:invoke"))
79
+ throw new GatewayError("invalid_request", 403, "This API key is not allowed to invoke routing. Grant it the routing:invoke scope, or use a key that has it.");
80
+ // Kill switch: refuse before any upstream is touched. Distinct from the
81
+ // scope check above, which is a property of the key — this is the tenant
82
+ // having stopped all traffic, and it applies to every key the tenant owns.
83
+ // 503 rather than 403: nothing about the request is invalid, and the
84
+ // caller should retry later instead of "fixing" a request that is fine.
85
+ if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
86
+ throw new GatewayError(
87
+ "platform_unavailable",
88
+ 503,
89
+ "This gateway has invoke switched off. Nothing upstream is being dialed while the switch is off; turn it back on from the dashboard overview to resume routing.",
90
+ );
91
+ // A key may refuse a downstream router it has been resold to. This is a
92
+ // policy check on the resolved key, so it belongs with the other
93
+ // authorization decisions rather than in routing: the request is refused
94
+ // before a route is planned or an upstream is touched.
95
+ const denied = deniedClientRouter(
96
+ authorization.snapshot.client_router_denylist,
97
+ detectClientRouter({ headers: request.headers }),
98
+ );
99
+ if (denied !== null)
100
+ throw new GatewayError(
101
+ "client_router_denied",
102
+ 403,
103
+ "No API invocation access for this client.",
104
+ { reason: "client_router_denied", clientRouter: denied },
105
+ );
106
+ deps.stateStore.require(request).authorization = authorization;
107
+ })
108
+ .as("plugin");
109
+ }