@@ -11,26 +11,40 @@ import { getAkritesExternalContactDetail } from '../packages/getAkritesExternalC
1111import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExternalContactDetailBatch'
1212import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail'
1313import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch'
14+ import { getBlastRadiusJob } from '../packages/getBlastRadiusJob'
15+ import { ingestAkritesExternalContactDetail } from '../packages/ingestAkritesExternalContactDetail'
1416import { submitBlastRadiusJob } from '../packages/submitBlastRadiusJob'
1517
1618const rateLimiter = createRateLimiter ( { max : 60 , windowMs : 60 * 1000 } )
1719
18- // Blast-radius jobs kick off a Temporal workflow per request, so they get their own,
19- // much stricter limiter — configurable via env so it can be tuned without a redeploy
20- // while the pipeline is still a no-op stub. Defaults to 5 requests/hour.
21- const blastRadiusRateLimitMax = Number ( process . env . AKRITES_BLAST_RADIUS_RATE_LIMIT_MAX )
22- 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+ )
2338
24- const blastRadiusRateLimiter = createRateLimiter ( {
25- max :
26- Number . isSafeInteger ( blastRadiusRateLimitMax ) && blastRadiusRateLimitMax > 0
27- ? blastRadiusRateLimitMax
28- : 5 ,
29- windowMs :
30- Number . isSafeInteger ( blastRadiusRateLimitWindowMs ) && blastRadiusRateLimitWindowMs > 0
31- ? blastRadiusRateLimitWindowMs
32- : 60 * 60 * 1000 ,
33- } )
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+ )
3448
3549export function akritesExternalRouter ( ) : Router {
3650 const router = Router ( )
@@ -61,20 +75,42 @@ export function akritesExternalRouter(): Router {
6175 // them via the packages scope. That scope isn't issued by Auth0 yet, so reuse the
6276 // closest issued one — READ_MAINTAINER_ROLES (maintainer data) — NOT READ_PACKAGES.
6377 // 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 ]
6484 const contactsSubRouter = Router ( )
65- contactsSubRouter . use ( rateLimiter )
66- contactsSubRouter . use ( requireScopes ( [ SCOPES . READ_MAINTAINER_ROLES ] ) )
67- contactsSubRouter . get ( '/detail' , safeWrap ( getAkritesExternalContactDetail ) )
68- 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+ )
69105 router . use ( '/contacts' , contactsSubRouter )
70106
71107 // TODO: the contract gates blast-radius behind a dedicated read:advisories scope
72108 // (same as advisories above — see the scope-naming note in the akrites-external
73109 // OpenAPI). Not issued by Auth0 yet, so reuse READ_PACKAGES for now.
74110 const blastRadiusSubRouter = Router ( )
75- blastRadiusSubRouter . use ( blastRadiusRateLimiter )
76111 blastRadiusSubRouter . use ( requireScopes ( [ SCOPES . READ_PACKAGES ] ) )
77- blastRadiusSubRouter . post ( '/jobs' , safeWrap ( submitBlastRadiusJob ) )
112+ blastRadiusSubRouter . post ( '/jobs' , blastRadiusRateLimiter , safeWrap ( submitBlastRadiusJob ) )
113+ blastRadiusSubRouter . get ( '/jobs/:analysisId' , rateLimiter , safeWrap ( getBlastRadiusJob ) )
78114 router . use ( '/blast-radius' , blastRadiusSubRouter )
79115
80116 return router
0 commit comments