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.
- package/.env.example +106 -113
- package/dist/dashboard/assets/{BannedPage-1v5rzKMl.js → BannedPage-P76QLGJA.js} +1 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-chMg74ct.js → CliToolDetailPage-DdBggJS9.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-CKG-Vxrt.js → CliToolsPage-aN1iXhhP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-JYmVKzIx.js → CombosPage-DE-F1nxZ.js} +1 -1
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CPflWDry.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-B0EYUE47.js → CustomizationPage-3CUxMy2X.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-F1c4lkNG.js → HealthEventsModal-BkmJQKC0.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BYTpzcoU.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-CM4MNy45.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Bsoqg2HG.js → ProvidersPage-CEplAR-i.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-8G3doV7j.js → ProxyPage-CAN7hy0f.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-CVIQq6JG.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-BCx07-bj.js → SetupPage-_bCaTM0v.js} +1 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-MhkB6msu.js → SortableList-fI7JkdIB.js} +1 -1
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CTqK6Nsj.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-CBHzQ6Qh.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-Ck4uwuYb.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-BrrUDUPc.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/providers/operations/account-health-service.ts +52 -3
- 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/src/workers/scheduler.ts +73 -6
- package/dist/dashboard/assets/BannedPage-1v5rzKMl.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-chMg74ct.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-CKG-Vxrt.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-JYmVKzIx.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CPflWDry.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-B0EYUE47.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-F1c4lkNG.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BYTpzcoU.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CM4MNy45.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bsoqg2HG.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-8G3doV7j.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CVIQq6JG.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js +0 -1
- package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BCx07-bj.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-MhkB6msu.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CTqK6Nsj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Do65OWbr.js +0 -56
- package/dist/dashboard/assets/UsagePage-Do65OWbr.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-CBHzQ6Qh.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-Ck4uwuYb.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-BrrUDUPc.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
|
@@ -378,14 +378,57 @@ function accountDeadlineMs(
|
|
|
378
378
|
* Matched on the code *and* the account-restriction wording, so an unrelated
|
|
379
379
|
* upstream that happens to say "restricted" in prose cannot disable accounts.
|
|
380
380
|
*/
|
|
381
|
-
function isBuddyAccountBanned(
|
|
381
|
+
function isBuddyAccountBanned(
|
|
382
|
+
lower: string,
|
|
383
|
+
providerCode: string,
|
|
384
|
+
upstreamBody: unknown,
|
|
385
|
+
): boolean {
|
|
382
386
|
if (providerCode !== "11140") return false;
|
|
387
|
+
if (banWording(lower)) return true;
|
|
388
|
+
// The message alone is not enough, and this is the case that proved it: the
|
|
389
|
+
// provider's message for a ban is the same two words it uses for a refused
|
|
390
|
+
// request — "request illegal" — while the actual verdict ("Your account has
|
|
391
|
+
// been restricted due to unusual activity") lives in `displayMsg`, which the
|
|
392
|
+
// message extractor never reads. Matching only the message therefore read
|
|
393
|
+
// every ban as a mere request refusal: no category, no disable, and the
|
|
394
|
+
// account kept serving.
|
|
395
|
+
const body = isRecord(upstreamBody) ? upstreamBody : undefined;
|
|
396
|
+
if (body === undefined) return false;
|
|
397
|
+
for (const field of ["displayMsg", "displayTips", "msg", "message"]) {
|
|
398
|
+
const value = body[field];
|
|
399
|
+
if (typeof value === "string" && banWording(value.toLowerCase())) return true;
|
|
400
|
+
// `displayMsg` is a per-language map: `{ en: "…", zh: "…" }`. Any
|
|
401
|
+
// language states the same verdict, so all of them are read.
|
|
402
|
+
if (!isRecord(value)) continue;
|
|
403
|
+
for (const nested of Object.values(value)) {
|
|
404
|
+
if (typeof nested === "string" && banWording(nested.toLowerCase())) return true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// `actions` names the remedy: an appeal can only be offered for a verdict
|
|
408
|
+
// about the account, and "edit input" only ever describes a request.
|
|
409
|
+
const actions = body["actions"];
|
|
410
|
+
if (Array.isArray(actions)) {
|
|
411
|
+
for (const action of actions) {
|
|
412
|
+
if (typeof action !== "string") continue;
|
|
413
|
+
if (action.toLowerCase().includes("appeal") || action.toLowerCase().includes("feedback")) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The wording an upstream uses when it restricted the account, not the request. */
|
|
422
|
+
function banWording(lower: string): boolean {
|
|
383
423
|
return (
|
|
384
424
|
lower.includes("restricted") ||
|
|
385
425
|
lower.includes("has been banned") ||
|
|
386
426
|
lower.includes("unusual activity") ||
|
|
387
427
|
lower.includes("appeal this restriction") ||
|
|
388
|
-
lower.includes("submit feedback to appeal")
|
|
428
|
+
lower.includes("submit feedback to appeal") ||
|
|
429
|
+
lower.includes("account has been") ||
|
|
430
|
+
lower.includes("账号存在异常行为") ||
|
|
431
|
+
lower.includes("帳號存在異常行為")
|
|
389
432
|
);
|
|
390
433
|
}
|
|
391
434
|
|
|
@@ -619,6 +662,12 @@ function accountErrorContext(error: unknown, options?: AccountFailureEvidence):
|
|
|
619
662
|
const bodyCooldown =
|
|
620
663
|
parseUpstreamBodyReset(options?.upstreamBody) ??
|
|
621
664
|
(rawUpstreamResponse === undefined ? null : parseUpstreamBodyReset(tryParseUpstreamBody(rawUpstreamResponse)));
|
|
665
|
+
// The body the ban is named in. The caller's parsed body when there is one,
|
|
666
|
+
// and the raw response text otherwise — the dispatch path stores the
|
|
667
|
+
// upstream's JSON there, and that is where `displayMsg` lives.
|
|
668
|
+
const banBody =
|
|
669
|
+
options?.upstreamBody ??
|
|
670
|
+
(rawUpstreamResponse === undefined ? undefined : tryParseUpstreamBody(rawUpstreamResponse));
|
|
622
671
|
// Whether the provider itself named a window. Decided once, from the
|
|
623
672
|
// evidence, so every branch of this classification agrees about it.
|
|
624
673
|
const providerStated =
|
|
@@ -665,7 +714,7 @@ function accountErrorContext(error: unknown, options?: AccountFailureEvidence):
|
|
|
665
714
|
canMutate,
|
|
666
715
|
providerCode,
|
|
667
716
|
statedCooldownMs,
|
|
668
|
-
buddyBanned: isBuddyAccountBanned(lower, providerCode),
|
|
717
|
+
buddyBanned: isBuddyAccountBanned(lower, providerCode, banBody),
|
|
669
718
|
reason,
|
|
670
719
|
result,
|
|
671
720
|
};
|
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
|
+
}
|