@stacksjs/router 0.75.226 → 0.75.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -84,7 +84,10 @@ export {
84
84
  // the same decision the framework makes in formatResult / error-handler.
85
85
  export { isApiRequest, JSON_CONTENT_TYPE } from './api-shape';
86
86
  // Export action-level rate limiting helpers
87
- export { rateLimit, rateLimitStatus, clearRateLimit } from './rate-limit';
87
+ export { rateLimit, rateLimitStatus, clearRateLimit, resetRateLimiters } from './rate-limit';
88
+ // `clientAddress` here shadows bun-router's (re-exported above) with one that
89
+ // reads `config.security.trustedProxies`.
90
+ export { cacheRedisConnection, clientAddress, fallbackStorage, rateLimitScale, resetSharedRateLimitStorage, scaledLimit, sharedRateLimitStorage, trustedProxies, useRateLimitStorage } from './security-settings';
88
91
  // Export path-param sanitization helper (stacksjs/stacks#1870 R-12).
89
92
  // Defense-in-depth for actions that interpolate route params into
90
93
  // filesystem paths; the helper enforces no-traversal / no-absolute /
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import"./request-augmentation";export{readJsonBody,readTextBody,RequestBodyError}from"./body";export*from"@stacksjs/bun-router";export{assertRouteMiddlewareResolvable,configureViewDirectories,clearCsrfModuleCache,clearMiddlewareCache,createStacksRouter,disableViewRouting,enhanceRequest,findUnresolvableRouteMiddleware,installMiddlewareHotReload,loadMiddlewareHandlers,resetBootHooks,route,runBootHooks,serve,serverResponse,url,warnOnMultipleRouterInstances}from"./stacks-router";export{cacheRequestQuery,clearCurrentRequest,getCurrentRequest,getTraceId,request,runWithRequest,setCurrentRequest,withTraceId}from"./request-context";export{defineMiddleware,Middleware}from"./middleware";export{appRouteRegistry,bundleMounts,DEFAULT_ROUTE_BUNDLE_FEATURES,DEFAULT_ROUTE_REGISTRY,listRootMountedAppRoutes,loadRoutes,mountedDefaultRouteBundles}from"./route-loader";export{clearTrackedQueries,createErrorResponse,createMiddlewareErrorResponse,createNotFoundResponse,createValidationErrorResponse,getQueryShapeCounts,trackQuery}from"./error-handler";export{listNamedRoutes,listRegisteredRoutes,routeParams}from"./stacks-router";export{DASHBOARD_ROUTE_GROUPS,dashboardGroupMiddleware,describeUnknownDashboardGroups,resolveDashboardAccess}from"./dashboard-access";export{isRouterAction,wrapAction}from"./stacks-router";export{createTypedRouter}from"./typed-router";export{clearRouteModelBindings,defineRouteModelBinding,resolveRouteModel,routeModelBindings,setRouteModelFallback}from"./route-model-binding";export{isApiRequest,JSON_CONTENT_TYPE}from"./api-shape";export{rateLimit,rateLimitStatus,clearRateLimit}from"./rate-limit";export{PathParamError,safePathParam,sanitizePathParam}from"./path-sanitize";export{checkApplicationHealth,runHealthProbes}from"./health";export{stream}from"./stacks-router";export{signedUrl,signUrl,verifySignedUrl,verifySignedUrlMiddleware}from"./signed-url";export{EncryptedSessionStore}from"./encrypted-session-store";export{createSessionStore,createStacksSessionStore,DatabaseSessionStore,FileSessionStore,MemorySessionStore,RedisSessionStore}from"./session-factory";
1
+ import"./request-augmentation";export{readJsonBody,readTextBody,RequestBodyError}from"./body";export*from"@stacksjs/bun-router";export{assertRouteMiddlewareResolvable,configureViewDirectories,clearCsrfModuleCache,clearMiddlewareCache,createStacksRouter,disableViewRouting,enhanceRequest,findUnresolvableRouteMiddleware,installMiddlewareHotReload,loadMiddlewareHandlers,resetBootHooks,route,runBootHooks,serve,serverResponse,url,warnOnMultipleRouterInstances}from"./stacks-router";export{cacheRequestQuery,clearCurrentRequest,getCurrentRequest,getTraceId,request,runWithRequest,setCurrentRequest,withTraceId}from"./request-context";export{defineMiddleware,Middleware}from"./middleware";export{appRouteRegistry,bundleMounts,DEFAULT_ROUTE_BUNDLE_FEATURES,DEFAULT_ROUTE_REGISTRY,listRootMountedAppRoutes,loadRoutes,mountedDefaultRouteBundles}from"./route-loader";export{clearTrackedQueries,createErrorResponse,createMiddlewareErrorResponse,createNotFoundResponse,createValidationErrorResponse,getQueryShapeCounts,trackQuery}from"./error-handler";export{listNamedRoutes,listRegisteredRoutes,routeParams}from"./stacks-router";export{DASHBOARD_ROUTE_GROUPS,dashboardGroupMiddleware,describeUnknownDashboardGroups,resolveDashboardAccess}from"./dashboard-access";export{isRouterAction,wrapAction}from"./stacks-router";export{createTypedRouter}from"./typed-router";export{clearRouteModelBindings,defineRouteModelBinding,resolveRouteModel,routeModelBindings,setRouteModelFallback}from"./route-model-binding";export{isApiRequest,JSON_CONTENT_TYPE}from"./api-shape";export{rateLimit,rateLimitStatus,clearRateLimit,resetRateLimiters}from"./rate-limit";export{cacheRedisConnection,clientAddress,fallbackStorage,rateLimitScale,resetSharedRateLimitStorage,scaledLimit,sharedRateLimitStorage,trustedProxies,useRateLimitStorage}from"./security-settings";export{PathParamError,safePathParam,sanitizePathParam}from"./path-sanitize";export{checkApplicationHealth,runHealthProbes}from"./health";export{stream}from"./stacks-router";export{signedUrl,signUrl,verifySignedUrl,verifySignedUrlMiddleware}from"./signed-url";export{EncryptedSessionStore}from"./encrypted-session-store";export{createSessionStore,createStacksSessionStore,DatabaseSessionStore,FileSessionStore,MemorySessionStore,RedisSessionStore}from"./session-factory";
@@ -1,3 +1,5 @@
1
+ /** Drop cached limiters and the resolved store (tests, or after a config change). */
2
+ export declare function resetRateLimiters(): void;
1
3
  /**
2
4
  * Check + consume a rate-limit slot for the current scope.
3
5
  *
@@ -1 +1 @@
1
- import{clientAddress}from"@stacksjs/bun-router";import{getCurrentRequest}from"./request-context";let limiterCache,limiterModule,httpErrorModule,limiterModulePromise;function loadLimiterModule(){return limiterModulePromise??=import("ts-rate-limiter").then((module)=>limiterModule=module)}function getLimiter(max,windowMs){const cacheKey=`${windowMs}:${max}`;let limiter=limiterCache?.get(cacheKey);if(!limiter){if(!limiterModule)return loadLimiterModule().then(()=>getLimiter(max,windowMs));const{RateLimiter}=limiterModule;limiter=new RateLimiter({windowMs,maxRequests:max,algorithm:"fixed-window",standardHeaders:!1,legacyHeaders:!1});(limiterCache??=new Map).set(cacheKey,limiter)}return limiter}function resolveIdentity(explicit){if(explicit!==void 0)return explicit;const req=getCurrentRequest();if(!req)return"anon";const id=req._authenticatedUser?.id;if(typeof id==="number"&&Number.isFinite(id)||typeof id==="string"&&id!=="")return`user:${id}`;return`ip:${clientAddress(req)??"unknown"}`}export function rateLimit(key,max,options={}){const run=async(windowMs)=>{const id=resolveIdentity(options.identity),pendingLimiter=getLimiter(max,windowMs),limiter=pendingLimiter instanceof Promise?await pendingLimiter:pendingLimiter,bucketKey=`${key}:${id}`;try{await limiter.enforce(bucketKey)}catch(err){const{RateLimitError}=await loadLimiterModule();if(err instanceof RateLimitError){const{HttpError}=httpErrorModule??=await import("@stacksjs/error-handling/http-error");throw Object.assign(new HttpError(429,"Too many requests",{key,max,retryAfter:err.retryAfter}),{headers:err.toHeaders()})}throw err}};return{async per(period){const seconds=period==="second"?1:period==="minute"?60:period==="hour"?3600:period==="day"?86400:void 0;if(!seconds)throw Error(`rateLimit().per: unknown period '${period}'`);await run(seconds*1000)},async over(ttlSeconds){if(!Number.isFinite(ttlSeconds)||ttlSeconds<=0)throw Error(`rateLimit().over: ttl must be a positive number, got ${ttlSeconds}`);await run(ttlSeconds*1000)}}}export async function rateLimitStatus(key,max,windowSeconds,options={}){const identity=resolveIdentity(options.identity),pendingLimiter=getLimiter(max,windowSeconds*1000),[id,limiter]=typeof identity==="string"&&!(pendingLimiter instanceof Promise)?[identity,pendingLimiter]:await Promise.all([identity,pendingLimiter]),bucketKey=`${key}:${id}`,result=await limiter.peek(bucketKey);if(!result)return null;return{count:result.current,limit:result.limit,remaining:Math.max(0,result.limit-result.current)}}export async function clearRateLimit(key,max,windowSeconds,options={}){const identity=resolveIdentity(options.identity),pendingLimiter=getLimiter(max,windowSeconds*1000),[id,limiter]=typeof identity==="string"&&!(pendingLimiter instanceof Promise)?[identity,pendingLimiter]:await Promise.all([identity,pendingLimiter]),bucketKey=`${key}:${id}`;await limiter.reset(bucketKey)}
1
+ import{getCurrentRequest}from"./request-context";import{clientAddress,scaledLimit,sharedRateLimitStorage}from"./security-settings";let limiterCache,limiterModule,httpErrorModule,limiterModulePromise,limiterStorage,limiterStoragePromise;function loadLimiterModule(){return limiterModulePromise??=import("ts-rate-limiter").then((module)=>limiterModule=module)}function loadLimiterStorage(){return limiterStoragePromise??=sharedRateLimitStorage().then((storage)=>limiterStorage=storage)}export function resetRateLimiters(){for(const limiter of limiterCache?.values()??[])limiter.dispose?.();limiterCache=void 0;limiterStorage=void 0;limiterStoragePromise=void 0}function getLimiter(requestedMax,windowMs){const max=scaledLimit(requestedMax),cacheKey=`${windowMs}:${max}`;let limiter=limiterCache?.get(cacheKey);if(!limiter){if(!limiterModule||limiterStorage===void 0)return Promise.all([loadLimiterModule(),loadLimiterStorage()]).then(()=>getLimiter(requestedMax,windowMs));const{RateLimiter}=limiterModule;limiter=new RateLimiter({windowMs,maxRequests:max,algorithm:"fixed-window",...limiterStorage&&{storage:limiterStorage},standardHeaders:!1,legacyHeaders:!1});(limiterCache??=new Map).set(cacheKey,limiter)}return limiter}function resolveIdentity(explicit){if(explicit!==void 0)return explicit;const req=getCurrentRequest();if(!req)return"anon";const id=req._authenticatedUser?.id;if(typeof id==="number"&&Number.isFinite(id)||typeof id==="string"&&id!=="")return`user:${id}`;return`ip:${clientAddress(req)??"unknown"}`}export function rateLimit(key,max,options={}){const run=async(windowMs)=>{const id=resolveIdentity(options.identity),pendingLimiter=getLimiter(max,windowMs),limiter=pendingLimiter instanceof Promise?await pendingLimiter:pendingLimiter,bucketKey=`${key}:${id}`;try{await limiter.enforce(bucketKey)}catch(err){const{RateLimitError}=await loadLimiterModule();if(err instanceof RateLimitError){const{HttpError}=httpErrorModule??=await import("@stacksjs/error-handling/http-error");throw Object.assign(new HttpError(429,"Too many requests",{key,max,retryAfter:err.retryAfter}),{headers:err.toHeaders()})}throw err}};return{async per(period){const seconds=period==="second"?1:period==="minute"?60:period==="hour"?3600:period==="day"?86400:void 0;if(!seconds)throw Error(`rateLimit().per: unknown period '${period}'`);await run(seconds*1000)},async over(ttlSeconds){if(!Number.isFinite(ttlSeconds)||ttlSeconds<=0)throw Error(`rateLimit().over: ttl must be a positive number, got ${ttlSeconds}`);await run(ttlSeconds*1000)}}}export async function rateLimitStatus(key,max,windowSeconds,options={}){const identity=resolveIdentity(options.identity),pendingLimiter=getLimiter(max,windowSeconds*1000),[id,limiter]=typeof identity==="string"&&!(pendingLimiter instanceof Promise)?[identity,pendingLimiter]:await Promise.all([identity,pendingLimiter]),bucketKey=`${key}:${id}`,result=await limiter.peek(bucketKey);if(!result)return null;return{count:result.current,limit:result.limit,remaining:Math.max(0,result.limit-result.current)}}export async function clearRateLimit(key,max,windowSeconds,options={}){const identity=resolveIdentity(options.identity),pendingLimiter=getLimiter(max,windowSeconds*1000),[id,limiter]=typeof identity==="string"&&!(pendingLimiter instanceof Promise)?[identity,pendingLimiter]:await Promise.all([identity,pendingLimiter]),bucketKey=`${key}:${id}`;await limiter.reset(bucketKey)}
@@ -0,0 +1,60 @@
1
+ import type { ClientAddressOptions } from '@stacksjs/bun-router';
2
+ import type { StorageProvider } from 'ts-rate-limiter';
3
+ /**
4
+ * The `config/security.ts` settings the request layer reads on every request:
5
+ * the rate-limit scale, the trusted proxies behind `clientAddress()`, and the
6
+ * shared store rate limits count in.
7
+ */
8
+ /**
9
+ * The multiplier applied to every application rate limit and the login
10
+ * lockout. `STACKS_RATE_LIMIT_SCALE` wins over `config.security.rateLimit.scale`;
11
+ * anything that is not a positive finite number reads as 1.
12
+ */
13
+ export declare function rateLimitScale(): number;
14
+ /** `max` under the configured scale, never below 1. */
15
+ export declare function scaledLimit(max: number): number;
16
+ /**
17
+ * `config.security.trustedProxies`, or undefined for bun-router's default
18
+ * (loopback, private and link-local networks). A non-array is ignored with
19
+ * the default kept, rather than trusting everything or nothing by accident.
20
+ */
21
+ export declare function trustedProxies(): readonly string[] | undefined;
22
+ /**
23
+ * The client's address, believing forwarding headers only from the proxies
24
+ * `config.security.trustedProxies` names (default: loopback and private
25
+ * networks). Explicit `options` win. This is what `@stacksjs/router` exports
26
+ * as `clientAddress`, shadowing bun-router's config-unaware export.
27
+ */
28
+ export declare function clientAddress(req: Request, options?: ClientAddressOptions): string | null;
29
+ /**
30
+ * The Redis URL and key prefix of the configured cache, or null when the
31
+ * cache is not Redis. Shared by every limiter that must count across
32
+ * processes (this package's and `@stacksjs/auth`'s lockout).
33
+ */
34
+ export declare function cacheRedisConnection(cache?: CacheSettings | undefined): { url: string, prefix: string } | null;
35
+ /**
36
+ * A ts-rate-limiter storage that counts in the shared store and falls back
37
+ * to process memory when the shared store errors, so a Redis outage weakens
38
+ * limits to per-process instead of failing every rate-limited request.
39
+ */
40
+ export declare function fallbackStorage(shared: StorageProvider, local: StorageProvider): StorageProvider;
41
+ /**
42
+ * The storage route and action rate limits count in: the configured Redis
43
+ * cache when there is one (so a limit holds across every process and
44
+ * instance), otherwise null for ts-rate-limiter's in-memory default.
45
+ */
46
+ export declare function sharedRateLimitStorage(): Promise<StorageProvider | null>;
47
+ /**
48
+ * Count route and action rate limits in `storage` (any ts-rate-limiter
49
+ * `StorageProvider`: a database-backed one, a test double), or `null` for
50
+ * process memory. Call `resetRateLimiters()` afterwards so limiters already
51
+ * built pick it up.
52
+ */
53
+ export declare function useRateLimitStorage(storage: StorageProvider | null): void;
54
+ /** Forget the resolved store (tests, or after changing the cache config). */
55
+ export declare function resetSharedRateLimitStorage(): void;
56
+ declare interface CacheSettings {
57
+ driver?: string
58
+ prefix?: string
59
+ drivers?: { redis?: { url?: string, host?: string, port?: number, username?: string, password?: string, database?: number, tls?: boolean } }
60
+ }
@@ -0,0 +1 @@
1
+ import process from"node:process";import{clientAddress as resolveClientAddress}from"@stacksjs/bun-router";import{config}from"@stacksjs/config";export function rateLimitScale(){const fromEnv=process.env.STACKS_RATE_LIMIT_SCALE,raw=fromEnv!==void 0&&fromEnv!==""?Number(fromEnv):config.security?.rateLimit?.scale,scale=typeof raw==="number"?raw:Number(raw);return Number.isFinite(scale)&&scale>0?scale:1}export function scaledLimit(max){return Math.max(1,Math.floor(max*rateLimitScale()))}export function trustedProxies(){const configured=config.security?.trustedProxies;if(!Array.isArray(configured))return;return configured.filter((entry)=>typeof entry==="string"&&entry.length>0)}export function clientAddress(req,options){if(options)return resolveClientAddress(req,options);const proxies=trustedProxies();return resolveClientAddress(req,proxies?{trustedProxies:proxies}:void 0)}export function cacheRedisConnection(cache=config.cache){const redis=cache?.drivers?.redis;if(cache?.driver!=="redis"||!redis)return null;const prefix=cache.prefix??"stacks";if(redis.url)return{url:redis.url,prefix};const protocol=redis.tls?"rediss":"redis",credentials=redis.username?`${encodeURIComponent(redis.username)}:${encodeURIComponent(redis.password??"")}@`:redis.password?`:${encodeURIComponent(redis.password)}@`:"",database=redis.database==null?"":`/${redis.database}`;return{url:`${protocol}://${credentials}${redis.host??"127.0.0.1"}:${redis.port??6379}${database}`,prefix}}let warnedSharedStore=!1;function warnSharedStoreOnce(error){if(warnedSharedStore)return;warnedSharedStore=!0;console.warn(`[router] rate limits fell back to this process's memory: the configured Redis cache is unreachable (${error instanceof Error?error.message:String(error)}). Limits are per process until it recovers.`)}export function fallbackStorage(shared,local){async function attempt(primary,secondary){try{return await primary()}catch(error){warnSharedStoreOnce(error);return secondary()}}return{increment:(key,windowMs)=>attempt(()=>shared.increment(key,windowMs),()=>local.increment(key,windowMs)),reset:(key)=>attempt(()=>shared.reset(key),()=>local.reset(key)),getCount:(key)=>attempt(()=>shared.getCount?.(key)??Promise.resolve(0),()=>local.getCount?.(key)??Promise.resolve(0)),dispose:()=>{shared.dispose?.();local.dispose?.()}}}let sharedStorage;export function sharedRateLimitStorage(){return sharedStorage??=(async()=>{const connection=cacheRedisConnection();if(!connection)return null;const[{RedisClient},{MemoryStorage,RedisStorage}]=await Promise.all([import("bun"),import("ts-rate-limiter")]),client=new RedisClient(connection.url),shared=new RedisStorage({client,keyPrefix:`${connection.prefix}:ratelimit:`});return fallbackStorage(shared,new MemoryStorage)})()}export function useRateLimitStorage(storage){sharedStorage=Promise.resolve(storage)}export function resetSharedRateLimitStorage(){sharedStorage=void 0;warnedSharedStore=!1}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/router",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.226",
5
+ "version": "0.75.230",
6
6
  "description": "The Stacks framework router.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -67,27 +67,27 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@stacksjs/bun-router": "^0.1.24",
70
- "@stacksjs/cache": "0.75.226",
71
- "@stacksjs/collections": "0.75.226",
72
- "@stacksjs/config": "0.75.226",
73
- "@stacksjs/error-handling": "0.75.226",
74
- "@stacksjs/logging": "0.75.226",
75
- "@stacksjs/pagination": "0.75.226",
76
- "@stacksjs/path": "0.75.226",
77
- "@stacksjs/security": "0.75.226",
78
- "@stacksjs/storage": "0.75.226",
70
+ "@stacksjs/cache": "0.75.230",
71
+ "@stacksjs/collections": "0.75.230",
72
+ "@stacksjs/config": "0.75.230",
73
+ "@stacksjs/error-handling": "0.75.230",
74
+ "@stacksjs/logging": "0.75.230",
75
+ "@stacksjs/pagination": "0.75.230",
76
+ "@stacksjs/path": "0.75.230",
77
+ "@stacksjs/security": "0.75.230",
78
+ "@stacksjs/storage": "0.75.230",
79
79
  "ts-rate-limiter": "^0.4.10"
80
80
  },
81
81
  "devDependencies": {
82
- "@stacksjs/actions": "0.75.226",
83
- "@stacksjs/types": "0.75.226",
82
+ "@stacksjs/actions": "0.75.230",
83
+ "@stacksjs/types": "0.75.230",
84
84
  "better-dx": "^0.2.24"
85
85
  },
86
86
  "peerDependencies": {
87
- "@stacksjs/api": "0.75.226",
88
- "@stacksjs/auth": "0.75.226",
89
- "@stacksjs/database": "0.75.226",
90
- "@stacksjs/validation": "0.75.226"
87
+ "@stacksjs/api": "0.75.230",
88
+ "@stacksjs/auth": "0.75.230",
89
+ "@stacksjs/database": "0.75.230",
90
+ "@stacksjs/validation": "0.75.230"
91
91
  },
92
92
  "peerDependenciesMeta": {
93
93
  "@stacksjs/api": {