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]>
58 lines
2.2 KiB
TypeScript
58 lines
2.2 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|