@stacksjs/router 0.70.87 → 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.js +0 -0
- package/dist/api-shape.js +11 -0
- package/dist/encrypted-session-store.js +73 -0
- package/dist/error-handler.js +310 -0
- package/dist/index.js +37 -14
- package/dist/middleware.js +23 -0
- package/dist/path-sanitize.js +37 -0
- package/dist/rate-limit.js +73 -0
- package/dist/request-augmentation.js +0 -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.js +72 -0
- package/dist/route-types.js +0 -0
- package/dist/security-headers.js +46 -0
- package/dist/session-factory.js +26 -0
- package/dist/signed-url.js +76 -0
- package/dist/stacks-router.js +1425 -0
- package/package.json +11 -11
|
@@ -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
|
+
}
|
|
File without changes
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { createSessionStore } from "@stacksjs/bun-router";
|
|
3
|
+
import { EncryptedSessionStore } from "./encrypted-session-store";
|
|
4
|
+
export function createStacksSessionStore(config) {
|
|
5
|
+
const base = createSessionStore(config);
|
|
6
|
+
if (!resolveEncryptionMode(config.encrypt))
|
|
7
|
+
return base;
|
|
8
|
+
const appKey = config.appKey ?? process.env.APP_KEY;
|
|
9
|
+
if (!appKey || appKey.length < 16)
|
|
10
|
+
throw Error("[session] createStacksSessionStore: encryption requested but APP_KEY is missing or too short " + "(need \u226516 chars). Either set APP_KEY in env, pass `appKey` in config, or set `encrypt: false` to opt out.");
|
|
11
|
+
return new EncryptedSessionStore(base, { appKey });
|
|
12
|
+
}
|
|
13
|
+
function resolveEncryptionMode(mode) {
|
|
14
|
+
if (mode === !0)
|
|
15
|
+
return !0;
|
|
16
|
+
if (mode === !1)
|
|
17
|
+
return !1;
|
|
18
|
+
return (process.env.APP_ENV ?? process.env.NODE_ENV ?? "").toLowerCase() === "production";
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
DatabaseSessionStore,
|
|
22
|
+
FileSessionStore,
|
|
23
|
+
MemorySessionStore,
|
|
24
|
+
RedisSessionStore,
|
|
25
|
+
createSessionStore
|
|
26
|
+
} from "@stacksjs/bun-router";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { url as buildUrl } from "./stacks-router";
|
|
5
|
+
const SIGNATURE_PARAM = "signature", EXPIRES_PARAM = "expires";
|
|
6
|
+
function getSigningSecret() {
|
|
7
|
+
const secret = process.env.APP_KEY || process.env.STACKS_SIGNED_URL_SECRET;
|
|
8
|
+
if (!secret || secret.length < 16)
|
|
9
|
+
throw Error("[router] signed URLs require APP_KEY (\u2265 16 chars) or STACKS_SIGNED_URL_SECRET. " + "Run `./buddy key:generate` or set the env var.");
|
|
10
|
+
return secret;
|
|
11
|
+
}
|
|
12
|
+
function hmacHex(payload, secret) {
|
|
13
|
+
return new Bun.CryptoHasher("sha256", secret).update(payload).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
function safeEqualHex(a, b) {
|
|
16
|
+
const ba = Buffer.from(a.toLowerCase(), "hex"), bb = Buffer.from(b.toLowerCase(), "hex");
|
|
17
|
+
if (ba.length !== bb.length)
|
|
18
|
+
return !1;
|
|
19
|
+
return timingSafeEqual(ba, bb);
|
|
20
|
+
}
|
|
21
|
+
function stripSignatureParam(input) {
|
|
22
|
+
const u = new URL(input.toString());
|
|
23
|
+
u.searchParams.delete(SIGNATURE_PARAM);
|
|
24
|
+
return u;
|
|
25
|
+
}
|
|
26
|
+
function buildSignaturePayload(input) {
|
|
27
|
+
const stripped = stripSignatureParam(input);
|
|
28
|
+
stripped.searchParams.sort();
|
|
29
|
+
return stripped.toString();
|
|
30
|
+
}
|
|
31
|
+
export function signUrl(input, options = {}) {
|
|
32
|
+
const secret = getSigningSecret(), url = input.startsWith("http") ? new URL(input) : new URL(input.startsWith("/") ? input : `/${input}`, process.env.APP_URL || "https://localhost");
|
|
33
|
+
if (options.ttl !== void 0 && options.expiresAt !== void 0)
|
|
34
|
+
warnOnce("dual-expiry", "[router] signUrl: both `ttl` and `expiresAt` provided \u2014 using `ttl`.");
|
|
35
|
+
if (options.ttl !== void 0)
|
|
36
|
+
url.searchParams.set(EXPIRES_PARAM, String(Math.floor(Date.now() / 1000) + options.ttl));
|
|
37
|
+
else if (options.expiresAt !== void 0)
|
|
38
|
+
url.searchParams.set(EXPIRES_PARAM, String(Math.floor(options.expiresAt)));
|
|
39
|
+
const payload = buildSignaturePayload(url);
|
|
40
|
+
url.searchParams.set(SIGNATURE_PARAM, hmacHex(payload, secret));
|
|
41
|
+
return url.toString();
|
|
42
|
+
}
|
|
43
|
+
const _warnedKeys = new Set;
|
|
44
|
+
function warnOnce(key, message) {
|
|
45
|
+
if (_warnedKeys.has(key))
|
|
46
|
+
return;
|
|
47
|
+
_warnedKeys.add(key);
|
|
48
|
+
console.warn(message);
|
|
49
|
+
}
|
|
50
|
+
export function signedUrl(routeName, params = {}, options = {}) {
|
|
51
|
+
return signUrl(buildUrl(routeName, params), options);
|
|
52
|
+
}
|
|
53
|
+
export function verifySignedUrl(input) {
|
|
54
|
+
const url = typeof input === "string" ? new URL(input, process.env.APP_URL || "https://localhost") : input, presented = url.searchParams.get(SIGNATURE_PARAM);
|
|
55
|
+
if (!presented)
|
|
56
|
+
return { valid: !1, reason: "missing-signature" };
|
|
57
|
+
const expiresRaw = url.searchParams.get(EXPIRES_PARAM);
|
|
58
|
+
if (expiresRaw !== null) {
|
|
59
|
+
const expires = Number.parseInt(expiresRaw, 10);
|
|
60
|
+
if (!Number.isFinite(expires) || Date.now() / 1000 > expires)
|
|
61
|
+
return { valid: !1, reason: "expired" };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const expected = hmacHex(buildSignaturePayload(url), getSigningSecret());
|
|
65
|
+
return safeEqualHex(presented, expected) ? { valid: !0 } : { valid: !1, reason: "invalid-signature" };
|
|
66
|
+
} catch {
|
|
67
|
+
return { valid: !1, reason: "invalid-signature" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function verifySignedUrlMiddleware(req) {
|
|
71
|
+
const result = verifySignedUrl(req.url);
|
|
72
|
+
if (result.valid)
|
|
73
|
+
return;
|
|
74
|
+
const status = result.reason === "expired" ? 410 : 401;
|
|
75
|
+
throw Response.json({ error: result.reason }, { status });
|
|
76
|
+
}
|