@@ -42,6 +42,31 @@ export const LIMIT_MAX = 100
4242const tokenCache = new Map < string , { token : string ; expiresAt : number } > ( )
4343const TOKEN_CACHE_TTL_MS = 5 * 60 * 1000
4444
45+ /**
46+ * Ceiling on distinct credential pairs held at once.
47+ *
48+ * The cache is process-wide, so on a long-lived worker serving many CB Insights
49+ * accounts it would otherwise grow with the cumulative number of accounts seen.
50+ */
51+ const TOKEN_CACHE_MAX_ENTRIES = 128
52+
53+ /**
54+ * Drops expired entries, then the oldest surviving ones if still over the cap.
55+ *
56+ * Map iteration is insertion-ordered, so the first surviving key is the least
57+ * recently authorized — evicting it costs at most one extra token exchange.
58+ */
59+ function pruneTokenCache ( ) : void {
60+ const now = Date . now ( )
61+ for ( const [ key , entry ] of tokenCache ) {
62+ if ( entry . expiresAt <= now ) tokenCache . delete ( key )
63+ }
64+ for ( const key of tokenCache . keys ( ) ) {
65+ if ( tokenCache . size <= TOKEN_CACHE_MAX_ENTRIES ) break
66+ tokenCache . delete ( key )
67+ }
68+ }
69+
4570async function credentialDigest ( clientId : string , clientSecret : string ) : Promise < string > {
4671 const bytes = new TextEncoder ( ) . encode ( `${ clientId } :${ clientSecret } ` )
4772 const digest = await crypto . subtle . digest ( 'SHA-256' , bytes )
@@ -140,6 +165,8 @@ async function getToken(
140165
141166 const token = await authorize ( clientId , clientSecret , signal )
142167 tokenCache . set ( key , { token, expiresAt : Date . now ( ) + TOKEN_CACHE_TTL_MS } )
168+ /* Pruned after the insert so the cap bounds the cache including this entry. */
169+ pruneTokenCache ( )
143170 return token
144171}
145172
@@ -148,6 +175,11 @@ export function resetCbInsightsTokenCache(): void {
148175 tokenCache . clear ( )
149176}
150177
178+ /** Current number of cached tokens. Exported for tests. */
179+ export function cbInsightsTokenCacheSize ( ) : number {
180+ return tokenCache . size
181+ }
182+
151183interface CbInsightsRequestSpec {
152184 /** Path below the API origin, e.g. `/v2/firmographics`. */
153185 path : string
@@ -253,9 +285,7 @@ export function requireOrgIds(value: unknown): number[] {
253285 raw = [ value ]
254286 }
255287
256- const orgIds = raw
257- . map ( ( entry ) => ( typeof entry === 'number' ? entry : Number ( String ( entry ) . trim ( ) ) ) )
258- . filter ( ( entry ) => Number . isInteger ( entry ) && entry > 0 )
288+ const orgIds = toPositiveIntegers ( raw , 'orgIds' )
259289
260290 if ( orgIds . length === 0 ) {
261291 throw new Error ( 'CB Insights "orgIds" must contain at least one positive integer' )
@@ -266,6 +296,36 @@ export function requireOrgIds(value: unknown): number[] {
266296 return orgIds
267297}
268298
299+ /**
300+ * Converts every entry to a positive integer, rejecting the whole list if any
301+ * entry is not one.
302+ *
303+ * Dropping the bad entries instead would run the request against a silently
304+ * narrower set — a typo in an ID list would spend credits on the wrong
305+ * organizations, or quietly widen a filtered search, and still report success.
306+ */
307+ function toPositiveIntegers ( entries : readonly unknown [ ] , paramName : string ) : number [ ] {
308+ const invalid : string [ ] = [ ]
309+ const ids : number [ ] = [ ]
310+
311+ for ( const entry of entries ) {
312+ const label = typeof entry === 'number' ? String ( entry ) : String ( entry ) . trim ( )
313+ const parsed = typeof entry === 'number' ? entry : Number ( label )
314+ if ( ! Number . isInteger ( parsed ) || parsed <= 0 ) {
315+ invalid . push ( label )
316+ continue
317+ }
318+ ids . push ( parsed )
319+ }
320+
321+ if ( invalid . length > 0 ) {
322+ throw new Error (
323+ `CB Insights "${ paramName } " must contain only positive integers (invalid: ${ invalid . join ( ', ' ) } )`
324+ )
325+ }
326+ return ids
327+ }
328+
269329/** Coerces a numeric param and clamps it into the documented [1, 100] range. */
270330export function clampLimit ( value : unknown ) : number | undefined {
271331 if ( value === undefined || value === null || value === '' ) return undefined
@@ -307,14 +367,18 @@ export function parseListParam(value: unknown, paramName: string): unknown[] | u
307367 return [ value ]
308368}
309369
310- /** Parses a list of IDs, keeping only positive integers. */
370+ /**
371+ * Parses an optional list of IDs, rejecting any entry that is not a positive
372+ * integer.
373+ *
374+ * An unset filter is fine and returns undefined; a filter the caller *did* set
375+ * but mistyped is not, because dropping it would silently widen the search
376+ * rather than narrow it — and the wider search still spends credits.
377+ */
311378export function parseIdListParam ( value : unknown , paramName : string ) : number [ ] | undefined {
312379 const entries = parseListParam ( value , paramName )
313380 if ( ! entries ) return undefined
314- const ids = entries
315- . map ( ( entry ) => ( typeof entry === 'number' ? entry : Number ( String ( entry ) . trim ( ) ) ) )
316- . filter ( ( entry ) => Number . isInteger ( entry ) && entry > 0 )
317- return ids . length > 0 ? ids : undefined
381+ return toPositiveIntegers ( entries , paramName )
318382}
319383
320384/** Parses a list of free-text values. */
0 commit comments