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.
- package/.env.example +106 -113
- package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-P76QLGJA.js} +1 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-DdBggJS9.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-aN1iXhhP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-DE-F1nxZ.js} +1 -1
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-ChmiSLwO.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-3CUxMy2X.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-fKAWd-OQ.js → HealthEventsModal-BkmJQKC0.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DoOUthbG.js → LoginPage-B_0FUKWc.js} +1 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderDetailPage-CFu1qGcT.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-CnlJZbxF.js → ProvidersPage-CEplAR-i.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-B9mBTweh.js → ProxyPage-CAN7hy0f.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-BjldQwIU.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DQ9oDRpi.js → SetupPage-_bCaTM0v.js} +1 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-fI7JkdIB.js} +1 -1
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-CHVZqskZ.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-CWBY5A0f.js} +3 -3
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/{index-BeLRhsmt.js → index-CDzP3ovJ.js} +2 -2
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
- package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
- package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-KIT0rC6G.js} +1 -1
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/package.json +96 -96
- package/src/boot/app.ts +714 -722
- package/src/boot/dependencies.ts +729 -750
- package/src/config/resources.ts +70 -137
- package/src/config/security.ts +37 -73
- package/src/config/spec.ts +358 -378
- package/src/console/console-router.ts +225 -233
- package/src/console/domain-registration.ts +381 -391
- package/src/console/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- package/src/main.ts +167 -169
- package/src/observability/metrics.ts +467 -457
- package/src/observability/runtime-metrics.ts +269 -254
- package/src/protocol/gateway-error.ts +232 -233
- package/src/providers/integrations/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- package/src/providers/integrations/qoder/qoder.ts +597 -23
- package/src/security/abuse.ts +17 -0
- package/src/transport/guards/auth-guard.ts +109 -127
- package/src/transport/guards/pipeline.ts +220 -226
- package/src/transport/guards/request-context.ts +238 -318
- package/src/transport/request/inflight.ts +21 -2
- package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
- package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
- /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
package/src/security/abuse.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
readonly
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|