@stacksjs/router 0.70.88 → 0.70.90
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/action-paths.d.ts +7 -0
- package/dist/action-paths.js +0 -0
- package/dist/api-shape.d.ts +24 -0
- package/dist/api-shape.js +11 -0
- package/dist/encrypted-session-store.d.ts +19 -0
- package/dist/encrypted-session-store.js +73 -0
- package/dist/error-handler.d.ts +68 -0
- package/dist/error-handler.js +310 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +37 -0
- package/dist/middleware.d.ts +37 -0
- package/dist/middleware.js +23 -0
- package/dist/path-sanitize.d.ts +64 -0
- package/dist/path-sanitize.js +37 -0
- package/dist/rate-limit.d.ts +35 -0
- package/dist/rate-limit.js +73 -0
- package/dist/request-augmentation.d.ts +71 -0
- package/dist/request-augmentation.js +0 -0
- package/dist/request-context.d.ts +82 -0
- package/dist/request-context.js +78 -0
- package/dist/response.d.ts +31 -0
- package/dist/response.js +1 -0
- package/dist/route-loader.d.ts +5 -0
- package/dist/route-loader.js +72 -0
- package/dist/route-types.d.ts +12 -0
- package/dist/route-types.js +0 -0
- package/dist/security-headers.d.ts +20 -0
- package/dist/security-headers.js +46 -0
- package/dist/session-factory.d.ts +57 -0
- package/dist/session-factory.js +26 -0
- package/dist/signed-url.d.ts +50 -0
- package/dist/signed-url.js +76 -0
- package/dist/stacks-router.d.ts +213 -0
- package/dist/stacks-router.js +1425 -0
- package/package.json +11 -11
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class Middleware {
|
|
2
|
+
name;
|
|
3
|
+
priority;
|
|
4
|
+
handle;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.name = config.name;
|
|
7
|
+
this.priority = config.priority ?? 10;
|
|
8
|
+
this.handle = config.handle;
|
|
9
|
+
}
|
|
10
|
+
toRouterHandler() {
|
|
11
|
+
const handle = this.handle.bind(this);
|
|
12
|
+
return async (req, next) => {
|
|
13
|
+
try {
|
|
14
|
+
await handle(req);
|
|
15
|
+
} catch (thrown) {
|
|
16
|
+
if (thrown instanceof Response)
|
|
17
|
+
return thrown;
|
|
18
|
+
throw thrown;
|
|
19
|
+
}
|
|
20
|
+
return next();
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate and return a path parameter, throwing if it's unsafe to use
|
|
3
|
+
* in filesystem interpolation.
|
|
4
|
+
*
|
|
5
|
+
* The default contract is single-segment: no `/`, no `\`, no `..`, no
|
|
6
|
+
* absolute path, no null bytes, no control characters, length ≤ 255.
|
|
7
|
+
* Pass `allowSlashes: true` for a multi-segment path (still rejects
|
|
8
|
+
* the rest).
|
|
9
|
+
*
|
|
10
|
+
* @throws {PathParamError} when the value fails any check.
|
|
11
|
+
*/
|
|
12
|
+
export declare function sanitizePathParam(value: unknown, options?: SanitizePathParamOptions): string;
|
|
13
|
+
/**
|
|
14
|
+
* Non-throwing variant. Returns the sanitized value or `null` if any
|
|
15
|
+
* check failed. Use when you want a fast yes/no in a conditional
|
|
16
|
+
* without a try/catch around the throw site.
|
|
17
|
+
*/
|
|
18
|
+
export declare function safePathParam(value: unknown, options?: SanitizePathParamOptions): string | null;
|
|
19
|
+
export declare interface SanitizePathParamOptions {
|
|
20
|
+
context?: string
|
|
21
|
+
maxLength?: number
|
|
22
|
+
allowSlashes?: boolean
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Path-parameter sanitization helpers.
|
|
26
|
+
*
|
|
27
|
+
* Route params arrive from the URL as untyped strings and are merged
|
|
28
|
+
* directly into `req.params`. Actions that interpolate those values
|
|
29
|
+
* into filesystem paths or shell commands without first scrubbing
|
|
30
|
+
* them are vulnerable to `..`-traversal, absolute-path takeovers, and
|
|
31
|
+
* null-byte truncation attacks.
|
|
32
|
+
*
|
|
33
|
+
* The router itself can't auto-sanitize every param (some are
|
|
34
|
+
* deliberately path-shaped — file servers, asset proxies, etc.). What
|
|
35
|
+
* we ship instead is a single canonical helper that callers reach for
|
|
36
|
+
* at the boundary where the param meets the filesystem.
|
|
37
|
+
*
|
|
38
|
+
* See stacksjs/stacks#1870 R-12.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { sanitizePathParam } from '@stacksjs/router'
|
|
43
|
+
*
|
|
44
|
+
* const filename = sanitizePathParam(req.params.filename, {
|
|
45
|
+
* context: 'avatar download',
|
|
46
|
+
* })
|
|
47
|
+
* return new Response(Bun.file(path.appPath(`avatars/${filename}`)))
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Reasons {@link sanitizePathParam} rejects a value. Surfaced via the
|
|
52
|
+
* thrown error so callers can log or branch.
|
|
53
|
+
*/
|
|
54
|
+
export type PathParamRejection = | 'empty'
|
|
55
|
+
| 'not-string'
|
|
56
|
+
| 'absolute-path'
|
|
57
|
+
| 'traversal'
|
|
58
|
+
| 'null-byte'
|
|
59
|
+
| 'control-char'
|
|
60
|
+
| 'too-long';
|
|
61
|
+
export declare class PathParamError extends Error {
|
|
62
|
+
readonly reason: PathParamRejection;
|
|
63
|
+
constructor(reason: PathParamRejection, value: unknown, context?: string);
|
|
64
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class PathParamError extends Error {
|
|
2
|
+
reason;
|
|
3
|
+
constructor(reason, value, context) {
|
|
4
|
+
const ctx = context ? ` in ${context}` : "";
|
|
5
|
+
super(`[router] Refusing to use ${JSON.stringify(value)} as a path parameter${ctx} \u2014 ${reason}`);
|
|
6
|
+
this.name = "PathParamError";
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
|
|
11
|
+
export function sanitizePathParam(value, options = {}) {
|
|
12
|
+
if (typeof value !== "string")
|
|
13
|
+
throw new PathParamError("not-string", value, options.context);
|
|
14
|
+
if (value.length === 0)
|
|
15
|
+
throw new PathParamError("empty", value, options.context);
|
|
16
|
+
const maxLength = options.maxLength ?? 255;
|
|
17
|
+
if (value.length > maxLength)
|
|
18
|
+
throw new PathParamError("too-long", value, options.context);
|
|
19
|
+
if (value.includes("\x00"))
|
|
20
|
+
throw new PathParamError("null-byte", value, options.context);
|
|
21
|
+
if (CONTROL_CHARS.test(value))
|
|
22
|
+
throw new PathParamError("control-char", value, options.context);
|
|
23
|
+
if (value.startsWith("/") || /^[A-Z]:[\\/]/i.test(value))
|
|
24
|
+
throw new PathParamError("absolute-path", value, options.context);
|
|
25
|
+
if (/(^|[\\/])\.\.([\\/]|$)/.test(value))
|
|
26
|
+
throw new PathParamError("traversal", value, options.context);
|
|
27
|
+
if (!options.allowSlashes && /[\\/]/.test(value))
|
|
28
|
+
throw new PathParamError("traversal", value, options.context);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function safePathParam(value, options = {}) {
|
|
32
|
+
try {
|
|
33
|
+
return sanitizePathParam(value, options);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check + consume a rate-limit slot for the current scope.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* await rateLimit('create-post', 10).per('hour')
|
|
7
|
+
* await rateLimit('login-attempts', 5, { identity: email }).per('minute')
|
|
8
|
+
* await rateLimit('expensive-job', 3).over(900) // custom 15-minute ttl
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare function rateLimit(key: string, max: number, options?: { identity?: string }): {
|
|
12
|
+
/** Run with a string period name (`'minute'`, `'hour'`, …). */
|
|
13
|
+
per: (period: Period) => Promise<void>
|
|
14
|
+
/** Run with a numeric ttl in seconds. */
|
|
15
|
+
over: (ttlSeconds: number) => Promise<void>
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Read the current bucket state without consuming a slot. Useful for
|
|
19
|
+
* "you have N attempts remaining" hints in dashboards and pre-flight
|
|
20
|
+
* checks. Returns `null` if the limiter's storage doesn't expose
|
|
21
|
+
* `getCount` (the default memory storage does; redis storage may not).
|
|
22
|
+
*/
|
|
23
|
+
export declare function rateLimitStatus(key: string, max: number, windowSeconds: number, options?: { identity?: string }): Promise<{ count: number, limit: number, remaining: number } | null>;
|
|
24
|
+
/**
|
|
25
|
+
* Drop the bucket for the given key (e.g. after a successful login,
|
|
26
|
+
* the failed-attempt counter should reset).
|
|
27
|
+
*/
|
|
28
|
+
export declare function clearRateLimit(key: string, max: number, windowSeconds: number, options?: { identity?: string }): Promise<void>;
|
|
29
|
+
declare const PERIOD_SECONDS: {
|
|
30
|
+
second: 1;
|
|
31
|
+
minute: 60;
|
|
32
|
+
hour: 3600;
|
|
33
|
+
day: unknown
|
|
34
|
+
};
|
|
35
|
+
declare type Period = keyof typeof PERIOD_SECONDS;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { HttpError } from "@stacksjs/error-handling";
|
|
2
|
+
import { RateLimitError, RateLimiter, defaultIdentity } from "ts-rate-limiter";
|
|
3
|
+
import { getCurrentRequest } from "./request-context";
|
|
4
|
+
const PERIOD_SECONDS = {
|
|
5
|
+
second: 1,
|
|
6
|
+
minute: 60,
|
|
7
|
+
hour: 3600,
|
|
8
|
+
day: 86400
|
|
9
|
+
}, limiterCache = new Map;
|
|
10
|
+
function getLimiter(max, windowMs) {
|
|
11
|
+
const cacheKey = `${windowMs}:${max}`;
|
|
12
|
+
let limiter = limiterCache.get(cacheKey);
|
|
13
|
+
if (!limiter) {
|
|
14
|
+
limiter = new RateLimiter({
|
|
15
|
+
windowMs,
|
|
16
|
+
maxRequests: max,
|
|
17
|
+
algorithm: "fixed-window",
|
|
18
|
+
standardHeaders: !1,
|
|
19
|
+
legacyHeaders: !1
|
|
20
|
+
});
|
|
21
|
+
limiterCache.set(cacheKey, limiter);
|
|
22
|
+
}
|
|
23
|
+
return limiter;
|
|
24
|
+
}
|
|
25
|
+
function resolveIdentity(explicit) {
|
|
26
|
+
if (explicit !== void 0)
|
|
27
|
+
return explicit;
|
|
28
|
+
const req = getCurrentRequest();
|
|
29
|
+
return req ? defaultIdentity(req) : "anon";
|
|
30
|
+
}
|
|
31
|
+
export function rateLimit(key, max, options = {}) {
|
|
32
|
+
const id = resolveIdentity(options.identity), bucketKey = `${key}:${id}`, run = async (windowMs) => {
|
|
33
|
+
const limiter = getLimiter(max, windowMs);
|
|
34
|
+
try {
|
|
35
|
+
await limiter.enforce(bucketKey);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err instanceof RateLimitError)
|
|
38
|
+
throw Object.assign(new HttpError(429, "Too many requests", {
|
|
39
|
+
key,
|
|
40
|
+
max,
|
|
41
|
+
retryAfter: err.retryAfter
|
|
42
|
+
}), { headers: err.toHeaders() });
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
async per(period) {
|
|
48
|
+
const seconds = PERIOD_SECONDS[period];
|
|
49
|
+
if (!seconds)
|
|
50
|
+
throw Error(`rateLimit().per: unknown period '${period}'`);
|
|
51
|
+
await run(seconds * 1000);
|
|
52
|
+
},
|
|
53
|
+
async over(ttlSeconds) {
|
|
54
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
|
|
55
|
+
throw Error(`rateLimit().over: ttl must be a positive number, got ${ttlSeconds}`);
|
|
56
|
+
await run(ttlSeconds * 1000);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function rateLimitStatus(key, max, windowSeconds, options = {}) {
|
|
61
|
+
const id = resolveIdentity(options.identity), bucketKey = `${key}:${id}`, result = await getLimiter(max, windowSeconds * 1000).peek(bucketKey);
|
|
62
|
+
if (!result)
|
|
63
|
+
return null;
|
|
64
|
+
return {
|
|
65
|
+
count: result.current,
|
|
66
|
+
limit: result.limit,
|
|
67
|
+
remaining: Math.max(0, result.limit - result.current)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function clearRateLimit(key, max, windowSeconds, options = {}) {
|
|
71
|
+
const id = resolveIdentity(options.identity), bucketKey = `${key}:${id}`;
|
|
72
|
+
await getLimiter(max, windowSeconds * 1000).reset(bucketKey);
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FileInfo } from '@stacksjs/bun-router';
|
|
2
|
+
/**
|
|
3
|
+
* Stacks-specific marker fields attached to the request by the
|
|
4
|
+
* router itself and the framework's default middleware.
|
|
5
|
+
*
|
|
6
|
+
* Markers are deliberately prefixed with `_` so they can't collide
|
|
7
|
+
* with userland keys on the request, and their lifetimes are bounded
|
|
8
|
+
* by the request's lifetime — they're never persisted.
|
|
9
|
+
*/
|
|
10
|
+
export declare interface StacksRequestMarkers {
|
|
11
|
+
_corsConfig?: unknown
|
|
12
|
+
_forceJson?: boolean
|
|
13
|
+
_skipCsrf?: boolean
|
|
14
|
+
_compress?: boolean
|
|
15
|
+
_middlewareParams?: Record<string, string>
|
|
16
|
+
_requestId?: string
|
|
17
|
+
_startNs?: bigint
|
|
18
|
+
_authenticatedUser?: unknown
|
|
19
|
+
_currentAccessToken?: unknown
|
|
20
|
+
_bodyParsed?: boolean
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Laravel-style request-input macros that Stacks attaches in
|
|
24
|
+
* `enhanceRequest` (router/src/stacks-router.ts). These shadow some
|
|
25
|
+
* of bun-router's `RequestMacroMethods` with Stacks-specific
|
|
26
|
+
* implementations (more permissive `T = any` generics so action
|
|
27
|
+
* callers don't have to specify the return type for every read).
|
|
28
|
+
*
|
|
29
|
+
* Listed here as part of the augmentation so call sites like
|
|
30
|
+
* `request.input(key)` type-check without `as any`.
|
|
31
|
+
*/
|
|
32
|
+
export declare interface StacksRequestMacros {
|
|
33
|
+
input?: <T = unknown>(key: string, defaultValue?: T) => T
|
|
34
|
+
get?: <T = unknown>(key: string, defaultValue?: T) => T
|
|
35
|
+
all?: () => Record<string, unknown>
|
|
36
|
+
only?: <T extends Record<string, unknown>>(keys: string[]) => T
|
|
37
|
+
except?: <T extends Record<string, unknown>>(keys: string[]) => T
|
|
38
|
+
has?: (key: string | string[]) => boolean
|
|
39
|
+
hasAny?: (keys: string[]) => boolean
|
|
40
|
+
missing?: (key: string) => boolean
|
|
41
|
+
filled?: (key: string) => boolean
|
|
42
|
+
integer?: (key: string, defaultValue?: number) => number
|
|
43
|
+
float?: (key: string, defaultValue?: number) => number
|
|
44
|
+
boolean?: (key: string, defaultValue?: boolean) => boolean
|
|
45
|
+
string?: (key: string, defaultValue?: string) => string
|
|
46
|
+
array?: <T = unknown>(key: string, defaultValue?: T[]) => T[]
|
|
47
|
+
file?: (key: string) => FileInfo | null
|
|
48
|
+
files?: (key: string) => FileInfo[]
|
|
49
|
+
hasFile?: (key: string) => boolean
|
|
50
|
+
allFiles?: () => Record<string, FileInfo | FileInfo[]>
|
|
51
|
+
getFiles?: () => Record<string, FileInfo | FileInfo[]>
|
|
52
|
+
user?: () => Promise<unknown>
|
|
53
|
+
userToken?: () => Promise<unknown>
|
|
54
|
+
tokenCan?: (ability: string) => Promise<boolean>
|
|
55
|
+
tokenCant?: (ability: string) => Promise<boolean>
|
|
56
|
+
can?: (ability: string, ...args: unknown[]) => Promise<boolean>
|
|
57
|
+
cannot?: (ability: string, ...args: unknown[]) => Promise<boolean>
|
|
58
|
+
authorize?: (ability: string, ...args: unknown[]) => Promise<void>
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Union of Stacks markers + macros — useful as a single type alias for
|
|
62
|
+
* places that previously cast to `any`.
|
|
63
|
+
*/
|
|
64
|
+
export type StacksRequestExtensions = StacksRequestMarkers & StacksRequestMacros;
|
|
65
|
+
declare module '@stacksjs/bun-router' {
|
|
66
|
+
interface EnhancedRequestextends StacksRequestMarkers {
|
|
67
|
+
allFiles?: StacksRequestMacros['allFiles']
|
|
68
|
+
tokenCan?: StacksRequestMacros['tokenCan']
|
|
69
|
+
can?: StacksRequestMacros['can']
|
|
70
|
+
}
|
|
71
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { EnhancedRequest } from '@stacksjs/bun-router';
|
|
2
|
+
import type { RequestInstance } from '@stacksjs/types';
|
|
3
|
+
/**
|
|
4
|
+
* Read the active trace id, or `undefined` outside any traced scope.
|
|
5
|
+
*
|
|
6
|
+
* Falls back to the request's `_requestId` if no explicit trace was
|
|
7
|
+
* set so the helper is always useful from an HTTP handler — the router
|
|
8
|
+
* sets `_requestId` per request, and that value is the implicit trace
|
|
9
|
+
* for downstream calls until something more specific is configured.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getTraceId(): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Run `fn` under a fresh trace scope. Used by queue workers and cron
|
|
14
|
+
* triggers to associate background work with the originating request
|
|
15
|
+
* (or a synthetic id when there's no parent).
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* await withTraceId(genId(), async () => {
|
|
20
|
+
* await job.handle()
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function withTraceId<T>(id: string, fn: () => T): T;
|
|
25
|
+
/**
|
|
26
|
+
* Run `fetcher()` once per `key` per request. Subsequent callers within
|
|
27
|
+
* the same request lifecycle await the cached Promise.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const user = await cacheRequestQuery(`User.find:${id}`, () => User.find(id))
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function cacheRequestQuery<T>(key: string, fetcher: () => T | Promise<T>): Promise<T>;
|
|
35
|
+
/**
|
|
36
|
+
* Set the current request context
|
|
37
|
+
* Called by middleware/router when handling a request
|
|
38
|
+
*/
|
|
39
|
+
export declare function setCurrentRequest(req: EnhancedRequest): void;
|
|
40
|
+
/**
|
|
41
|
+
* Clear the current request context.
|
|
42
|
+
*
|
|
43
|
+
* `setCurrentRequest` uses `AsyncLocalStorage.enterWith`, which mutates the
|
|
44
|
+
* caller's async scope and never restores it. Call this in test teardown
|
|
45
|
+
* (`afterEach`) whenever a test body calls `setCurrentRequest`, so the leaked
|
|
46
|
+
* frame doesn't poison subsequently-collected test files (bun's runner
|
|
47
|
+
* mis-registers tests when collected on a foreign async frame).
|
|
48
|
+
*/
|
|
49
|
+
export declare function clearCurrentRequest(): void;
|
|
50
|
+
/**
|
|
51
|
+
* Run a function with a request context
|
|
52
|
+
* All code executed within the callback will have access to the request
|
|
53
|
+
*/
|
|
54
|
+
export declare function runWithRequest<T>(req: EnhancedRequest, fn: () => T): T;
|
|
55
|
+
/**
|
|
56
|
+
* Get the current request from context
|
|
57
|
+
*/
|
|
58
|
+
export declare function getCurrentRequest(): EnhancedRequest | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Request proxy that provides access to the current request
|
|
61
|
+
* (Laravel's `request()` helper, but typed).
|
|
62
|
+
*
|
|
63
|
+
* The proxy is statically typed as {@link RequestInstance} —
|
|
64
|
+
* the canonical Stacks-side action-request surface
|
|
65
|
+
* (stacksjs/stacks#1851 Phase 1). All the macros action handlers
|
|
66
|
+
* reach for (`all`, `get`, `input`, `cookies`, `param`, `validate`,
|
|
67
|
+
* `user`, `bearerToken`, …) resolve to their declared types instead
|
|
68
|
+
* of `any`, eliminating most `(request as any)` casts in action code.
|
|
69
|
+
*
|
|
70
|
+
* Runtime is unchanged — the proxy still delegates to whichever
|
|
71
|
+
* `EnhancedRequest` is in the AsyncLocalStorage slot. The type swap
|
|
72
|
+
* is API-compatible: every method action code uses on `request`
|
|
73
|
+
* existed on either type already, but only `RequestInstance` carries
|
|
74
|
+
* the model-aware / path-aware narrowing.
|
|
75
|
+
*
|
|
76
|
+
* Methods worth knowing about:
|
|
77
|
+
* - `bearerToken()` — Authorization header
|
|
78
|
+
* - `user()` — authenticated user (async)
|
|
79
|
+
* - `userToken()` — current access token (async)
|
|
80
|
+
* - `tokenCan(ability)` / `tokenCant(ability)` — async ability checks
|
|
81
|
+
*/
|
|
82
|
+
export declare const request: RequestInstance;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { log } from "@stacksjs/logging";
|
|
4
|
+
const REQUEST_STORAGE_KEY = Symbol.for("stacks.router.requestStorage"), requestStorage = globalThis[REQUEST_STORAGE_KEY] ??= new AsyncLocalStorage, TRACE_STORAGE_KEY = Symbol.for("stacks.router.traceStorage"), traceStorage = globalThis[TRACE_STORAGE_KEY] ??= new AsyncLocalStorage;
|
|
5
|
+
export function getTraceId() {
|
|
6
|
+
const explicit = traceStorage.getStore();
|
|
7
|
+
if (explicit)
|
|
8
|
+
return explicit;
|
|
9
|
+
return requestStorage.getStore()?._requestId;
|
|
10
|
+
}
|
|
11
|
+
export function withTraceId(id, fn) {
|
|
12
|
+
return traceStorage.run(id, fn);
|
|
13
|
+
}
|
|
14
|
+
const REQUEST_QUERY_CACHE_KEY = Symbol.for("stacks.requestQueryCache");
|
|
15
|
+
function getRequestCache() {
|
|
16
|
+
const req = requestStorage.getStore();
|
|
17
|
+
if (!req)
|
|
18
|
+
return;
|
|
19
|
+
let cache = req[REQUEST_QUERY_CACHE_KEY];
|
|
20
|
+
if (!cache) {
|
|
21
|
+
cache = { map: new Map };
|
|
22
|
+
req[REQUEST_QUERY_CACHE_KEY] = cache;
|
|
23
|
+
}
|
|
24
|
+
return cache;
|
|
25
|
+
}
|
|
26
|
+
export async function cacheRequestQuery(key, fetcher) {
|
|
27
|
+
const cache = getRequestCache();
|
|
28
|
+
if (!cache)
|
|
29
|
+
return fetcher();
|
|
30
|
+
const existing = cache.map.get(key);
|
|
31
|
+
if (existing)
|
|
32
|
+
return existing;
|
|
33
|
+
const promise = Promise.resolve().then(() => fetcher());
|
|
34
|
+
cache.map.set(key, promise);
|
|
35
|
+
promise.catch(() => cache.map.delete(key));
|
|
36
|
+
return promise;
|
|
37
|
+
}
|
|
38
|
+
export function setCurrentRequest(req) {
|
|
39
|
+
log.debug(`[request] ${req.method} ${new URL(req.url).pathname}`);
|
|
40
|
+
requestStorage.enterWith(req);
|
|
41
|
+
}
|
|
42
|
+
export function clearCurrentRequest() {
|
|
43
|
+
requestStorage.disable();
|
|
44
|
+
}
|
|
45
|
+
export function runWithRequest(req, fn) {
|
|
46
|
+
return requestStorage.run(req, fn);
|
|
47
|
+
}
|
|
48
|
+
export function getCurrentRequest() {
|
|
49
|
+
return requestStorage.getStore();
|
|
50
|
+
}
|
|
51
|
+
export const request = new Proxy({}, {
|
|
52
|
+
get(_target, prop) {
|
|
53
|
+
const currentRequest = getCurrentRequest();
|
|
54
|
+
if (!currentRequest) {
|
|
55
|
+
if (process.env.NODE_ENV !== "production")
|
|
56
|
+
console.warn(`[RequestContext] Accessing request.${String(prop)} outside of request context`);
|
|
57
|
+
if (prop === "bearerToken")
|
|
58
|
+
return () => null;
|
|
59
|
+
if (prop === "user" || prop === "userToken")
|
|
60
|
+
return async () => {
|
|
61
|
+
return;
|
|
62
|
+
};
|
|
63
|
+
if (prop === "tokenCan" || prop === "tokenCant")
|
|
64
|
+
return async () => !1;
|
|
65
|
+
if (prop === "headers")
|
|
66
|
+
return new Headers;
|
|
67
|
+
if (prop === "url")
|
|
68
|
+
return "";
|
|
69
|
+
if (prop === "method")
|
|
70
|
+
return "GET";
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const value = currentRequest[prop];
|
|
74
|
+
if (typeof value === "function")
|
|
75
|
+
return value.bind(currentRequest);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `response` factory is re-exported from `@stacksjs/bun-router` —
|
|
3
|
+
* see `./index.ts`'s `export * from '@stacksjs/bun-router'` line. Look
|
|
4
|
+
* there (or at the bun-router source) for the canonical shape:
|
|
5
|
+
*
|
|
6
|
+
* response.json(data, options?)
|
|
7
|
+
* response.text(content, status?, headers?)
|
|
8
|
+
* response.xml(content, status?, headers?)
|
|
9
|
+
* response.html(content, status?, headers?)
|
|
10
|
+
* response.redirect(url, status?)
|
|
11
|
+
* response.notFound(message?)
|
|
12
|
+
* response.unauthorized(message?)
|
|
13
|
+
* response.forbidden(message?)
|
|
14
|
+
* response.tooManyRequests(message?, retryAfter?)
|
|
15
|
+
* response.success(data?, message?, status?)
|
|
16
|
+
* response.error(message, status?, errors?)
|
|
17
|
+
* response.paginate(data, { page, perPage, total, path })
|
|
18
|
+
* response.download(filePath, filename?, headers?)
|
|
19
|
+
* response.streamDownload(generator, filename, options?)
|
|
20
|
+
* ...
|
|
21
|
+
*
|
|
22
|
+
* Note that bun-router uses *positional* args for status/headers on
|
|
23
|
+
* `text`/`xml`/`html`/`view` (`text(content, status, headers)`) — NOT
|
|
24
|
+
* options-object. There used to be a competing `response` defined here
|
|
25
|
+
* in stacks/router with an options-object shape (`text(content, { status, headers })`).
|
|
26
|
+
* It was dead code (nothing imported it) but its existence misled
|
|
27
|
+
* readers and produced runtime crashes when a caller copy-pasted the
|
|
28
|
+
* wrong shape (e.g. the `response.send()` ghost method). Removed —
|
|
29
|
+
* use the bun-router factory directly.
|
|
30
|
+
*/
|
|
31
|
+
export {};
|
package/dist/response.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { log } from "@stacksjs/logging";
|
|
2
|
+
import { route } from "./stacks-router";
|
|
3
|
+
const NO_PREFIX_KEYS = ["web"];
|
|
4
|
+
export async function loadRoutes(registry) {
|
|
5
|
+
for (const [key, definition] of Object.entries(registry)) {
|
|
6
|
+
const config = normalizeDefinition(definition), prefix = config.prefix !== void 0 ? config.prefix ? config.prefix.startsWith("/") ? config.prefix : `/${config.prefix}` : void 0 : NO_PREFIX_KEYS.includes(key) ? void 0 : `/${key}`, middleware = normalizeMiddleware(config.middleware);
|
|
7
|
+
log.debug(`[route-loader] Loading: ${config.path} prefix=${prefix || "/"} middleware=[${middleware.join(", ")}]`);
|
|
8
|
+
try {
|
|
9
|
+
if (prefix || middleware.length > 0)
|
|
10
|
+
await route.group({
|
|
11
|
+
prefix,
|
|
12
|
+
middleware: middleware.length > 0 ? middleware : void 0
|
|
13
|
+
}, async () => {
|
|
14
|
+
await importRouteFile(config.path);
|
|
15
|
+
});
|
|
16
|
+
else
|
|
17
|
+
await importRouteFile(config.path);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
console.error(`[Routes] Failed to load route file '${config.path}': ${message}`);
|
|
21
|
+
throw Error(`Route loading failed for '${config.path}': ${message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await loadFrameworkRoutes();
|
|
25
|
+
}
|
|
26
|
+
async function loadFrameworkRoutes() {
|
|
27
|
+
if (process.env.STACKS_SKIP_DEFAULT_ROUTES === "1")
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const { frameworkPath } = await import("@stacksjs/path"), bootstrapPath = frameworkPath("defaults/bootstrap.ts");
|
|
31
|
+
if (await Bun.file(bootstrapPath).exists())
|
|
32
|
+
await import(bootstrapPath);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
if (!message.includes("Cannot find module") && !message.includes("MODULE_NOT_FOUND"))
|
|
36
|
+
console.error(`[Routes] Failed to load framework bootstrap: ${message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function assertSafeRouteName(routeName) {
|
|
40
|
+
if (typeof routeName !== "string" || routeName.length === 0)
|
|
41
|
+
throw Error("[route-loader] Invalid route path: empty or non-string");
|
|
42
|
+
if (routeName.includes("\x00"))
|
|
43
|
+
throw Error("[route-loader] Invalid route path: null byte");
|
|
44
|
+
let decoded;
|
|
45
|
+
try {
|
|
46
|
+
decoded = decodeURIComponent(routeName);
|
|
47
|
+
} catch {
|
|
48
|
+
throw Error("[route-loader] Invalid route path: malformed URL encoding");
|
|
49
|
+
}
|
|
50
|
+
const cleanPath = decoded.replace(/\.ts$/, "");
|
|
51
|
+
if (cleanPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(cleanPath))
|
|
52
|
+
throw Error(`[route-loader] Invalid route path: absolute paths not allowed (${cleanPath})`);
|
|
53
|
+
if (cleanPath.split(/[/\\]/).some((s) => s === ".."))
|
|
54
|
+
throw Error(`[route-loader] Invalid route path: '..' segment not allowed (${cleanPath})`);
|
|
55
|
+
return cleanPath;
|
|
56
|
+
}
|
|
57
|
+
async function importRouteFile(routeName) {
|
|
58
|
+
const cleanPath = assertSafeRouteName(routeName), { projectPath } = await import("@stacksjs/path");
|
|
59
|
+
await import(projectPath(`routes/${cleanPath}`));
|
|
60
|
+
}
|
|
61
|
+
function normalizeDefinition(def) {
|
|
62
|
+
if (typeof def === "string")
|
|
63
|
+
return { path: def };
|
|
64
|
+
return def;
|
|
65
|
+
}
|
|
66
|
+
function normalizeMiddleware(middleware) {
|
|
67
|
+
if (!middleware)
|
|
68
|
+
return [];
|
|
69
|
+
if (typeof middleware === "string")
|
|
70
|
+
return [middleware];
|
|
71
|
+
return middleware;
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route registry types — owned by `@stacksjs/router` because the router
|
|
3
|
+
* consumes them. `app/Routes.ts` (the project-level route map) imports
|
|
4
|
+
* these via the public package name rather than a relative reach into
|
|
5
|
+
* the framework defaults tree (stacksjs/stacks#1863, T-10).
|
|
6
|
+
*/
|
|
7
|
+
export declare interface RouteDefinition {
|
|
8
|
+
path: string
|
|
9
|
+
prefix?: string
|
|
10
|
+
middleware?: string | string[]
|
|
11
|
+
}
|
|
12
|
+
export type RouteRegistry = Record<string, string | RouteDefinition>;
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply the default security headers to a Headers instance in-place.
|
|
3
|
+
*
|
|
4
|
+
* Headers applied unconditionally (cheap, no compat risk):
|
|
5
|
+
* - `X-Content-Type-Options: nosniff` — blocks MIME-sniff XSS
|
|
6
|
+
* - `X-Frame-Options: SAMEORIGIN` — clickjacking protection (CSP
|
|
7
|
+
* `frame-ancestors` is the modern equivalent but XFO still ships)
|
|
8
|
+
* - `Referrer-Policy: strict-origin-when-cross-origin` — modern default
|
|
9
|
+
*
|
|
10
|
+
* Production-only:
|
|
11
|
+
* - `Strict-Transport-Security: max-age=31536000; includeSubDomains` —
|
|
12
|
+
* tells browsers to commit to HTTPS for a year. Omits `preload` since
|
|
13
|
+
* that's an irreversible commitment to the browser preload list.
|
|
14
|
+
*
|
|
15
|
+
* Skips overwriting any header that's already set — explicit userland
|
|
16
|
+
* config wins. Skips entirely when `STACKS_SECURITY_HEADERS_DISABLE=true`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function applySecurityHeaders(headers: Headers): void;
|
|
19
|
+
/** Test helper — reset the cached env-derived flags. */
|
|
20
|
+
export declare function __resetSecurityHeadersCache(): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
let _isProductionCache, _isDisabledCache, _cspCache;
|
|
3
|
+
function isProduction() {
|
|
4
|
+
if (_isProductionCache !== void 0)
|
|
5
|
+
return _isProductionCache;
|
|
6
|
+
_isProductionCache = (process.env.APP_ENV ?? process.env.NODE_ENV ?? "").toLowerCase() === "production";
|
|
7
|
+
return _isProductionCache;
|
|
8
|
+
}
|
|
9
|
+
function isDisabled() {
|
|
10
|
+
if (_isDisabledCache !== void 0)
|
|
11
|
+
return _isDisabledCache;
|
|
12
|
+
_isDisabledCache = process.env.STACKS_SECURITY_HEADERS_DISABLE === "true";
|
|
13
|
+
return _isDisabledCache;
|
|
14
|
+
}
|
|
15
|
+
function resolveCsp() {
|
|
16
|
+
if (_cspCache !== void 0)
|
|
17
|
+
return _cspCache;
|
|
18
|
+
const enforce = process.env.STACKS_CSP, report = process.env.STACKS_CSP_REPORT_ONLY;
|
|
19
|
+
if (enforce)
|
|
20
|
+
_cspCache = { header: "Content-Security-Policy", value: enforce };
|
|
21
|
+
else if (report)
|
|
22
|
+
_cspCache = { header: "Content-Security-Policy-Report-Only", value: report };
|
|
23
|
+
else
|
|
24
|
+
_cspCache = null;
|
|
25
|
+
return _cspCache;
|
|
26
|
+
}
|
|
27
|
+
export function applySecurityHeaders(headers) {
|
|
28
|
+
if (isDisabled())
|
|
29
|
+
return;
|
|
30
|
+
if (!headers.has("X-Content-Type-Options"))
|
|
31
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
32
|
+
if (!headers.has("X-Frame-Options"))
|
|
33
|
+
headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
34
|
+
if (!headers.has("Referrer-Policy"))
|
|
35
|
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
36
|
+
if (isProduction() && !headers.has("Strict-Transport-Security"))
|
|
37
|
+
headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
38
|
+
const csp = resolveCsp();
|
|
39
|
+
if (csp && !headers.has(csp.header))
|
|
40
|
+
headers.set(csp.header, csp.value);
|
|
41
|
+
}
|
|
42
|
+
export function __resetSecurityHeadersCache() {
|
|
43
|
+
_isProductionCache = void 0;
|
|
44
|
+
_isDisabledCache = void 0;
|
|
45
|
+
_cspCache = void 0;
|
|
46
|
+
}
|