@@ -12,26 +12,39 @@ import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExte
1212import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
1313import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
1414import { getBlastRadiusJob } from '../packages/getBlastRadiusJob'
15+ import { ingestAkritesExternalContactDetail } from '../packages/ingestAkritesExternalContactDetail'
1516import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
1617
1718const rateLimiter = createRateLimiter ( { max : 60 , windowMs : 60 * 1000 } )
1819
19- // Blast-radius jobs kick off a Temporal workflow per request, so they get their own,
20- // much stricter limiter — configurable via env so it can be tuned without a redeploy.
21- // Defaults to 5 requests/hour.
22- const blastRadiusRateLimitMax = Number ( process . env . AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX )
23- const blastRadiusRateLimitWindowMs = Number ( process . env . AKRITES_BLAST_RADIUS_RATE_LIMIT_WINDOW_MS )
20+ // Shared by every endpoint below that kicks off a Temporal workflow per request — those
21+ // get their own, much stricter limiter than plain reads, configurable via env so it can
22+ // be tuned without a redeploy.
23+ function envTunableRateLimiter ( envPrefix : string , defaultMax : number , defaultWindowMs : number ) {
24+ const max = Number ( process . env [ `${ envPrefix } _MAX` ] )
25+ const windowMs = Number ( process . env [ `${ envPrefix } _WINDOW_MS` ] )
26+ return createRateLimiter ( {
27+ max : Number . isSafeInteger ( max ) && max > 0 ? max : defaultMax ,
28+ windowMs : Number . isSafeInteger ( windowMs ) && windowMs > 0 ? windowMs : defaultWindowMs ,
29+ } )
30+ }
31+
32+ // Blast-radius jobs default to 5 requests/hour.
33+ const blastRadiusRateLimiter = envTunableRateLimiter (
34+ 'AKRITES_BLAST_RADIUS_RATE_LIMIT' ,
35+ 5 ,
36+ 60 * 60 * 1000 ,
37+ )
2438
25- const blastRadiusRateLimiter = createRateLimiter ( {
26- max :
27- Number . isSafeInteger ( blastRadiusRateLimitMax ) && blastRadiusRateLimitMax > 0
28- ? blastRadiusRateLimitMax
29- : 5 ,
30- windowMs :
31- Number . isSafeInteger ( blastRadiusRateLimitWindowMs ) && blastRadiusRateLimitWindowMs > 0
32- ? blastRadiusRateLimitWindowMs
33- : 60 * 60 * 1000 ,
34- } )
39+ // /contacts/ingest starts a Temporal workflow and blocks for it (worst case ~95s per
40+ // attempt cycle, plus unbounded time waiting for a free worker slot — see
41+ // security-contacts/workflows.ts's singleActs config), vs. the read-only /contacts/detail
42+ // endpoints, so it gets its own limiter. Defaults to 20 requests/hour.
43+ const contactIngestRateLimiter = envTunableRateLimiter (
44+ 'AKRITES_CONTACT_INGEST_RATE_LIMIT' ,
45+ 20 ,
46+ 60 * 60 * 1000 ,
47+ )
3548
3649export function akritesExternalRouter ( ) : Router {
3750 const router = Router ( )
@@ -62,11 +75,33 @@ export function akritesExternalRouter(): Router {
6275 // them via the packages scope. That scope isn't issued by Auth0 yet, so reuse the
6376 // closest issued one — READ_MAINTAINER_ROLES (maintainer data) — NOT READ_PACKAGES.
6477 // TODO: swap for cdp:maintainers:read once issued.
78+ // requireScopes is applied per-route (not router-level) so each route can put its own
79+ // rate limiter *before* the scope check — failed-auth requests still count against that
80+ // route's quota — without forcing every route in this subrouter onto the same limiter
81+ // instance. /ingest gets its own dedicated contactIngestRateLimiter instead of sharing
82+ // the read endpoints' quota, matching the blast-radius jobs endpoint below.
83+ const contactsScopes = [ SCOPES . READ_MAINTAINER_ROLES ]
6584 const contactsSubRouter = Router ( )
66- contactsSubRouter . use ( rateLimiter )
67- contactsSubRouter . use ( requireScopes ( [ SCOPES . READ_MAINTAINER_ROLES ] ) )
68- contactsSubRouter . get ( '/detail' , safeWrap ( getAkritesExternalContactDetail ) )
69- contactsSubRouter . post ( / ^ \/ d e t a i l : b a t c h \/ ? $ / , safeWrap ( getAkritesExternalContactDetailBatch ) )
85+ contactsSubRouter . get (
86+ '/detail' ,
87+ rateLimiter ,
88+ requireScopes ( contactsScopes ) ,
89+ safeWrap ( getAkritesExternalContactDetail ) ,
90+ )
91+ contactsSubRouter . post (
92+ / ^ \/ d e t a i l : b a t c h \/ ? $ / ,
93+ rateLimiter ,
94+ requireScopes ( contactsScopes ) ,
95+ safeWrap ( getAkritesExternalContactDetailBatch ) ,
96+ )
97+ // Sync, single-purl on-demand ingest — starts a Temporal workflow and blocks a while,
98+ // so it gets the dedicated contactIngestRateLimiter, not the shared rateLimiter above.
99+ contactsSubRouter . post (
100+ '/ingest' ,
101+ contactIngestRateLimiter ,
102+ requireScopes ( contactsScopes ) ,
103+ safeWrap ( ingestAkritesExternalContactDetail ) ,
104+ )
70105 router . use ( '/contacts' , contactsSubRouter )
71106
72107 // TODO: the contract gates blast-radius behind a dedicated read:advisories scope
0 commit comments