Merge main checkpoint c01a388 into feat/erp-inventar-export

Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 22:49:53 +02:00
commit 53fab102f2
347 files changed
+26087 -2141

No files matched your search

@@ -19,6 +19,24 @@ export const API_KEY_SCOPES = [
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
/**
* Scopes that book (change data). Every other scope only reads. A new key
* never mixes both kinds: a leaked read key cannot book, a leaked booking
* key cannot read (ARCHITECTURE.md, Annahme 14).
*/
export const API_KEY_WRITE_SCOPES = [
'shop-sync:write',
] as const satisfies readonly ApiKeyScope[];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
function isWriteScope(scope: string): boolean {
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
}
/** True if the scopes hold at least one reading and one booking right. */
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
}
@@ -1,12 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
// ThrottlerStorageModule: the failure counters share the rate-limit table,
// so the limit holds across all api pods.
@Global()
@Module({
imports: [ThrottlerStorageModule],
controllers: [ApiKeysController],
providers: [ApiKeysService],
providers: [ApiKeysService, FailedAttemptLimiter],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -1,13 +1,19 @@
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
HttpException,
Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
import {
API_KEY_FAILURE_LIMIT,
ApiKeysService,
extractApiKeyToken,
} from './api-keys.service';
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
@@ -31,6 +37,38 @@ function storedKey(overrides: Record<string, unknown> = {}) {
};
}
/**
* Stand-in for the shared counter table with the same counting rule as
* PrismaThrottlerStorage within one window: count up to the limit, the hit
* after it blocks, a blocked source keeps its count. Time and the database
* itself are covered by the e2e tests.
*/
function inMemoryStorage(): PrismaThrottlerStorage {
const rows = new Map<string, { hits: number; blocked: boolean }>();
return {
increment: (
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
) => {
const id = `${throttlerName}|${key}`;
const row = rows.get(id) ?? { hits: 0, blocked: false };
const next = row.blocked
? row
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
rows.set(id, next);
return Promise.resolve({
totalHits: next.hits,
timeToExpire: ttl / 1000,
isBlocked: next.blocked,
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
});
},
} as unknown as PrismaThrottlerStorage;
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
@@ -47,6 +85,7 @@ describe('ApiKeysService.authenticate', () => {
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
@@ -166,6 +205,18 @@ describe('ApiKeysService.authenticate', () => {
).resolves.toBe(null);
});
it('meldet das Erreichen der Grenze einmal im Log', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
const limitMessages = warn.mock.calls.filter(([message]) =>
String(message).includes('get 429'),
);
expect(limitMessages).toHaveLength(1);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
@@ -210,6 +261,79 @@ describe('ApiKeysService.authenticate', () => {
});
});
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
let prisma: { apiKey: { create: jest.Mock } };
beforeEach(() => {
prisma = {
apiKey: {
create: jest.fn((args: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'key-1', keyHash: 'h', ...args.data }),
),
},
};
const permissionResolver = {
getPermissions: jest
.fn()
.mockResolvedValue(
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
),
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
});
it('refuses a new key with both shop scopes', async () => {
await expect(
service.create(
{
name: 'Shop',
scopes: ['shop-sync:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses the read-only ERP scope together with booking', async () => {
await expect(
service.create(
{
name: 'ERPNext',
scopes: ['erp-inventory:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s',
async (scope) => {
const created = await service.create(
{ name: 'Shop', scopes: [scope] },
'user-1',
);
expect(created.apiKey.scopes).toEqual([scope]);
},
);
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
@@ -11,11 +12,18 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import {
ApiKeyScope,
combinesReadAndWrite,
isApiKeyScope,
} from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
@@ -38,21 +46,6 @@ const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys for the rest of the window - the same
* budget as the shop's ERP export. It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
maxTrackedSources: 10_000,
} as const;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
@@ -91,12 +84,13 @@ function toSummary(apiKey: ApiKey): ApiKeySummary {
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
// Counters shared by all api pods (ThrottlerHit), not process memory.
private readonly failures: FailedAttemptLimiter,
) {}
async findAll(): Promise<ApiKeySummary[]> {
@@ -110,6 +104,14 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book,
// a leaked booking key cannot read. Existing combined keys stay valid.
// erp-inventory:read is a read scope too.
if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
@@ -224,20 +226,21 @@ export class ApiKeysService {
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
this.recordRejection(token, reason ?? 'unknown', clientIp);
await this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private recordRejection(
private async recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): void {
): Promise<void> {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
if (this.failures.isBlocked(subject)) {
const verdict = await this.failures.recordFailure(subject);
if (verdict.blocked) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
@@ -254,9 +257,9 @@ export class ApiKeysService {
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (this.failures.recordFailure(subject)) {
if (verdict.limitReached) {
this.logger.warn(
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
);
}
}
@@ -1,112 +1,91 @@
import { FailedAttemptLimiter } from './failed-attempt-limiter';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import {
API_KEY_FAILURE_LIMIT,
API_KEY_FAILURE_THROTTLER,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
let now: number;
let limiter: FailedAttemptLimiter;
const increment = jest.fn();
const limiter = new FailedAttemptLimiter({
increment,
} as unknown as PrismaThrottlerStorage);
function record(totalHits: number, isBlocked: boolean) {
return {
totalHits,
timeToExpire: 600,
isBlocked,
timeToBlockExpire: isBlocked ? 900 : 0,
};
}
beforeEach(() => {
now = 1_000_000;
limiter = new FailedAttemptLimiter({
maxFailures: 3,
windowMs: 60_000,
maxTrackedSources: 100,
clock: () => now,
increment.mockReset();
increment.mockResolvedValue(record(1, false));
});
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
await limiter.recordFailure('203.0.113.7');
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith(
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_LIMIT.maxFailures,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_THROTTLER,
);
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
});
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
expect(API_KEY_FAILURE_LIMIT).toEqual({
maxFailures: 20,
windowMs: 15 * 60 * 1000,
});
});
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
it('sperrt nicht unterhalb der Grenze', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
);
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
});
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
limiter.recordFailure('203.0.113.7');
limiter.recordFailure('203.0.113.7');
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
});
it('trennt Quellen voneinander', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
});
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
now += 60_001;
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
});
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: false,
});
small.recordFailure('a');
small.recordFailure('b');
now += 60_001;
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
small.recordFailure('c');
expect(small.trackedSources()).toBe(1);
expect(small.isBlocked('c')).toBe(true);
});
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
const small = new FailedAttemptLimiter({
maxFailures: 5,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
expect(small.trackedSources()).toBe(2);
});
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
const small = new FailedAttemptLimiter({
maxFailures: 2,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('angreifer');
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
small.recordFailure('neu');
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
);
expect(small.trackedSources()).toBe(2);
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
expect(small.isBlocked('angreifer')).toBe(true);
// Verdrängt wurde der nicht gesperrte Eintrag.
expect(small.recordFailure('harmlos')).toBe(false);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: true,
});
});
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
const small = new FailedAttemptLimiter({
maxFailures: 1,
windowMs: 60_000,
maxTrackedSources: 2,
clock: () => now,
});
small.recordFailure('a');
small.recordFailure('b');
small.recordFailure('c');
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
);
expect(small.trackedSources()).toBe(2);
expect(small.isBlocked('a')).toBe(false);
expect(small.isBlocked('b')).toBe(true);
expect(small.isBlocked('c')).toBe(true);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: true,
limitReached: false,
});
});
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
increment.mockRejectedValueOnce(new Error('db down'));
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
'db down',
);
});
});
@@ -1,94 +1,57 @@
export interface FailedAttemptLimiterOptions {
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
maxFailures: number;
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
windowMs: number;
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
maxTrackedSources: number;
clock?: () => number;
}
import { Injectable } from '@nestjs/common';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
interface Window {
failures: number;
startedAt: number;
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys - the same budget the ERP export
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
} as const;
/**
* Throttler name and key prefix of these counters in "ThrottlerHit", so
* they never mix with the request rate limits stored in the same table.
*/
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
export interface FailedAttemptVerdict {
/** The source is over the limit: refuse the request with 429. */
blocked: boolean;
/** This failure is the one that reached the limit (once per window). */
limitReached: boolean;
}
/**
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
*
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
* das ist für eine Bremse gegen Durchprobieren ausreichend.
* Counts invalid API keys per source (client network) in the shared,
* database-backed rate-limit storage, so the limit holds across all api
* pods. One upsert per failure, every time comparison on the database
* clock (PrismaThrottlerStorage). The window starts with the first failure;
* the failure after the limit blocks the source for one window length.
* A database error propagates: the request fails instead of passing.
*/
@Injectable()
export class FailedAttemptLimiter {
private readonly windows = new Map<string, Window>();
private readonly clock: () => number;
constructor(private readonly storage: PrismaThrottlerStorage) {}
constructor(private readonly options: FailedAttemptLimiterOptions) {
this.clock = options.clock ?? Date.now;
}
isBlocked(source: string): boolean {
const window = this.activeWindow(source);
return window !== undefined && window.failures >= this.options.maxFailures;
}
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
recordFailure(source: string): boolean {
const now = this.clock();
const current = this.activeWindow(source);
if (!current) {
this.makeRoom(now);
}
const failures = (current?.failures ?? 0) + 1;
this.windows.set(source, {
failures,
startedAt: current?.startedAt ?? now,
});
return failures === this.options.maxFailures;
}
trackedSources(): number {
return this.windows.size;
}
private activeWindow(source: string): Window | undefined {
const window = this.windows.get(source);
if (window && this.clock() - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
return undefined;
}
return window;
}
private makeRoom(now: number): void {
if (this.windows.size < this.options.maxTrackedSources) return;
for (const [source, window] of this.windows) {
if (now - window.startedAt > this.options.windowMs) {
this.windows.delete(source);
}
}
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
// die Einfügereihenfolge).
if (this.windows.size >= this.options.maxTrackedSources) {
const victim =
this.oldestWhere(
(window) => window.failures < this.options.maxFailures,
) ?? this.oldestWhere(() => true);
if (victim !== undefined) this.windows.delete(victim);
}
}
private oldestWhere(
matches: (window: Window) => boolean,
): string | undefined {
for (const [source, window] of this.windows) {
if (matches(window)) return source;
}
return undefined;
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
const record = await this.storage.increment(
`${API_KEY_FAILURE_THROTTLER}:${source}`,
windowMs,
maxFailures,
windowMs,
API_KEY_FAILURE_THROTTLER,
);
return {
blocked: record.isBlocked,
limitReached: !record.isBlocked && record.totalHits === maxFailures,
};
}
}