diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 85cfcfb..c598e51 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/releases.service'; export * from './lib/changes.service'; export * from './lib/workflows.service'; export * from './lib/blockers.service'; +export * from './lib/proposal-quota.service'; export * from './lib/notifications.service'; export * from './lib/notifications'; export * from './lib/saved-filters.service'; diff --git a/libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts b/libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts new file mode 100644 index 0000000..7600cb9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/proposal-quota.service.spec.ts @@ -0,0 +1,22 @@ +import { ProposalQuota } from './proposal-quota.service'; +import { RateLimitStore } from './ports/rate-limit.store'; + +class MemoryStore extends RateLimitStore { + readonly counts = new Map(); + async hit(key: string): Promise { + const count = (this.counts.get(key) ?? 0) + 1; + this.counts.set(key, count); + return count; + } +} + +describe('ProposalQuota', () => { + it('lets five proposals through per address, then says no', async () => { + const quota = new ProposalQuota(new MemoryStore()); + const answers = []; + for (let i = 0; i < 6; i++) answers.push(await quota.take('1.2.3.4')); + + expect(answers).toEqual([true, true, true, true, true, false]); + expect(await quota.take('5.6.7.8')).toBe(true); + }); +}); diff --git a/libs/api/tracker/application/src/lib/proposal-quota.service.ts b/libs/api/tracker/application/src/lib/proposal-quota.service.ts new file mode 100644 index 0000000..fc2b7a9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/proposal-quota.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { RateLimitStore } from './ports/rate-limit.store'; + +/** Proposals from one address within the window. */ +const LIMIT = 5; +const WINDOW_MS = 15 * 60_000; + +/** + * Keeps one address from flooding a project with proposals. Counted in + * Redis, so the limit holds across every instance of the API. + */ +@Injectable() +export class ProposalQuota { + constructor(private readonly store: RateLimitStore) {} + + /** Counts one more proposal from `address`; false once it is over. */ + async take(address: string): Promise { + const count = await this.store.hit(`proposals:${address}`, WINDOW_MS); + return count <= LIMIT; + } +} diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 2df214e..364cf3d 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -18,6 +18,7 @@ import { ReleasesService } from './releases.service'; import { SAGAS } from './sagas/sagas'; import { WorkflowsService } from './workflows.service'; import { BlockersService } from './blockers.service'; +import { ProposalQuota } from './proposal-quota.service'; import { NotificationsService } from './notifications.service'; import { Notifier } from './notifications/notifier'; import { NotificationsEventHandler } from './notifications/notifications.event-handler'; @@ -69,6 +70,7 @@ const SERVICES = [ WebhooksService, TransferService, AutomationsService, + ProposalQuota, ]; /** diff --git a/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts b/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts index 300e16b..0c07c6e 100644 --- a/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts +++ b/libs/api/tracker/presenters/src/lib/http/proposal-limiter.ts @@ -1,21 +1,13 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { RateLimitStore } from '@task-stream/api/tracker/application'; +import { ProposalQuota } from '@task-stream/api/tracker/application'; -/** Proposals from one address within the window. */ -const LIMIT = 5; -const WINDOW_MS = 15 * 60_000; - -/** - * Keeps one address from flooding a project with proposals. Counted in - * Redis, so the limit holds across every instance of the API. - */ +/** The proposal quota, as the HTTP answer: 429 once it is used up. */ @Injectable() export class ProposalLimiter { - constructor(private readonly store: RateLimitStore) {} + constructor(private readonly quota: ProposalQuota) {} async take(address: string): Promise { - const count = await this.store.hit(`proposals:${address}`, WINDOW_MS); - if (count > LIMIT) + if (!(await this.quota.take(address))) throw new HttpException( 'Too many proposals from here; try again in a few minutes', HttpStatus.TOO_MANY_REQUESTS,