shokupan 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -7,6 +7,8 @@ Shokupan is designed to make building APIs delightful again. With zero-config de
7
7
 
8
8
  ### Note: Shokupan is still in alpha and is not guaranteed to be stable. Please use with caution. We will be adding more features and APIs in the future. Please file an issue if you find any bugs or have suggestions for improvement.
9
9
 
10
+ 📚 **[Full documentation available at https://shokupan.dev](https://shokupan.dev)**
11
+
10
12
  ## ✨ Features
11
13
 
12
14
  - 🎯 **TypeScript First** - End-to-end type safety with decorators and generics. No manual types needed.
package/dist/context.d.ts CHANGED
@@ -18,12 +18,73 @@ export interface DebugCollector {
18
18
  setNode(id: string): void;
19
19
  getCurrentNode(): string | undefined;
20
20
  }
21
- export declare class ShokupanContext<State extends Record<string, any> = Record<string, any>> {
21
+ /**
22
+ * Shokupan Request Context
23
+ *
24
+ * The context object passed to all middleware and route handlers.
25
+ * Provides access to request data, response helpers, and typed state management.
26
+ *
27
+ * @template State - The shape of `ctx.state` for type-safe state access across middleware.
28
+ * @template Params - The shape of `ctx.params` based on the route path pattern.
29
+ *
30
+ * @example Basic Usage
31
+ * ```typescript
32
+ * app.get('/hello', (ctx) => {
33
+ * return ctx.json({ message: 'Hello' });
34
+ * });
35
+ * ```
36
+ *
37
+ * @example Typed State
38
+ * ```typescript
39
+ * interface AppState {
40
+ * userId: string;
41
+ * requestId: string;
42
+ * }
43
+ *
44
+ * const app = new Shokupan<AppState>();
45
+ *
46
+ * app.use((ctx, next) => {
47
+ * ctx.state.requestId = crypto.randomUUID(); // ✓ Type-safe
48
+ * return next();
49
+ * });
50
+ * ```
51
+ *
52
+ * @example Typed Path Parameters
53
+ * ```typescript
54
+ * app.get('/users/:userId/posts/:postId', (ctx) => {
55
+ * // ctx.params is automatically typed as { userId: string; postId: string }
56
+ * const { userId, postId } = ctx.params;
57
+ * return ctx.json({ userId, postId });
58
+ * });
59
+ * ```
60
+ *
61
+ * @example Full Type Safety (State + Params)
62
+ * ```typescript
63
+ * interface RequestState {
64
+ * userId: string;
65
+ * permissions: string[];
66
+ * }
67
+ *
68
+ * const app = new Shokupan<RequestState>();
69
+ *
70
+ * app.get('/admin/users/:userId', (ctx) => {
71
+ * // Both typed!
72
+ * const { userId } = ctx.params; // ✓ From path
73
+ * const { permissions } = ctx.state; // ✓ From state
74
+ *
75
+ * if (!permissions.includes('admin')) {
76
+ * return ctx.json({ error: 'Forbidden' }, 403);
77
+ * }
78
+ * return ctx.json({ userId });
79
+ * });
80
+ * ```
81
+ */
82
+ export declare class ShokupanContext<State extends Record<string, any> = Record<string, any>, Params extends Record<string, string> = Record<string, string>> {
22
83
  readonly request: ShokupanRequest<any>;
23
84
  readonly server?: Server;
24
85
  readonly app?: Shokupan;
25
86
  readonly signal?: AbortSignal;
26
- params: Record<string, string>;
87
+ params: Params;
27
88
  state: State;
28
89
  handlerStack: HandlerStackItem[];
29
90
  readonly response: ShokupanResponse;
package/dist/index.cjs CHANGED
@@ -119,6 +119,15 @@ class ShokupanResponse {
119
119
  return this._headers !== null;
120
120
  }
121
121
  }
122
+ function isValidCookieDomain(domain, currentHost) {
123
+ const hostWithoutPort = currentHost.split(":")[0];
124
+ if (domain === hostWithoutPort) return true;
125
+ if (domain.startsWith(".")) {
126
+ const domainWithoutDot = domain.slice(1);
127
+ return hostWithoutPort.endsWith(domainWithoutDot);
128
+ }
129
+ return false;
130
+ }
122
131
  const VALID_HTTP_STATUSES = /* @__PURE__ */ new Set([
123
132
  100,
124
133
  101,
@@ -278,16 +287,20 @@ class ShokupanContext {
278
287
  */
279
288
  get query() {
280
289
  if (this._cachedQuery) return this._cachedQuery;
281
- const q = {};
290
+ const q = /* @__PURE__ */ Object.create(null);
291
+ const blocklist = ["__proto__", "constructor", "prototype"];
282
292
  const entries = Object.entries(this.url.searchParams);
283
293
  for (let i = 0; i < entries.length; i++) {
284
294
  const [key, value] = entries[i];
285
- if (q[key] === void 0) {
286
- q[key] = value;
287
- } else if (Array.isArray(q[key])) {
288
- q[key].push(value);
295
+ if (blocklist.includes(key)) continue;
296
+ if (Object.prototype.hasOwnProperty.call(q, key)) {
297
+ if (Array.isArray(q[key])) {
298
+ q[key].push(value);
299
+ } else {
300
+ q[key] = [q[key], value];
301
+ }
289
302
  } else {
290
- q[key] = [q[key], value];
303
+ q[key] = value;
291
304
  }
292
305
  }
293
306
  this._cachedQuery = q;
@@ -364,6 +377,12 @@ class ShokupanContext {
364
377
  * @param options Cookie options
365
378
  */
366
379
  setCookie(name, value, options = {}) {
380
+ if (options.domain) {
381
+ const currentHost = this.hostname;
382
+ if (!isValidCookieDomain(options.domain, currentHost)) {
383
+ throw new Error(`Invalid cookie domain: ${options.domain} for host ${currentHost}`);
384
+ }
385
+ }
367
386
  let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
368
387
  if (options.maxAge) cookie += `; Max-Age=${Math.floor(options.maxAge)}`;
369
388
  if (options.domain) cookie += `; Domain=${options.domain}`;
@@ -657,11 +676,24 @@ function RateLimitMiddleware(options = {}) {
657
676
  const statusCode = options.statusCode || 429;
658
677
  const headers = options.headers !== false;
659
678
  const mode = options.mode || "user";
679
+ const trustedProxies = options.trustedProxies || [];
660
680
  const keyGenerator = options.keyGenerator || ((ctx) => {
661
681
  if (mode === "absolute") {
662
682
  return "global";
663
683
  }
664
- return ctx.headers.get("x-forwarded-for") || ctx.request.headers.get("x-forwarded-for") || ctx.server?.requestIP?.(ctx.request)?.address || "unknown";
684
+ const xForwardedFor = ctx.headers.get("x-forwarded-for");
685
+ if (xForwardedFor && trustedProxies.length > 0) {
686
+ const ips = xForwardedFor.split(",").map((ip) => ip.trim());
687
+ for (let i = ips.length - 1; i >= 0; i--) {
688
+ const ip = ips[i];
689
+ if (!trustedProxies.includes(ip)) {
690
+ if (/^[\d.:a-fA-F]+$/.test(ip)) {
691
+ return ip;
692
+ }
693
+ }
694
+ }
695
+ }
696
+ return ctx.server?.requestIP?.(ctx.request)?.address || "unknown";
665
697
  });
666
698
  const skip = options.skip || (() => false);
667
699
  const hits = /* @__PURE__ */ new Map();
@@ -1337,12 +1369,23 @@ function serveStatic(config, prefix) {
1337
1369
  let relative = ctx.path.slice(normalizedPrefix.length);
1338
1370
  if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
1339
1371
  if (relative.length === 0) relative = "/";
1340
- relative = decodeURIComponent(relative);
1341
- const requestPath = path.join(rootPath, relative);
1342
- if (!requestPath.startsWith(rootPath)) {
1372
+ if (relative.includes("\0")) {
1373
+ return ctx.json({ error: "Forbidden" }, 403);
1374
+ }
1375
+ try {
1376
+ relative = decodeURIComponent(relative);
1377
+ } catch (e) {
1378
+ return ctx.json({ error: "Bad Request" }, 400);
1379
+ }
1380
+ if (relative.includes("\0")) {
1381
+ return ctx.json({ error: "Forbidden" }, 403);
1382
+ }
1383
+ if (relative.includes("../") || relative.includes("..\\")) {
1343
1384
  return ctx.json({ error: "Forbidden" }, 403);
1344
1385
  }
1345
- if (requestPath.includes("\0")) {
1386
+ const requestPath = path.resolve(path.join(rootPath, relative));
1387
+ const normalizedRoot = path.resolve(rootPath);
1388
+ if (!requestPath.startsWith(normalizedRoot + path.sep) && requestPath !== normalizedRoot) {
1346
1389
  return ctx.json({ error: "Forbidden" }, 403);
1347
1390
  }
1348
1391
  if (config.hooks?.onRequest) {
@@ -2191,10 +2234,13 @@ class ShokupanRouter {
2191
2234
  }
2192
2235
  parsePath(path2) {
2193
2236
  const keys = [];
2237
+ if (path2.length > 2048) {
2238
+ throw new Error("Path too long");
2239
+ }
2194
2240
  const pattern = path2.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
2195
2241
  keys.push(key);
2196
- return "([^/]+)";
2197
- }).replace(/\*\*/g, ".*").replace(/\*/g, "[^/]+");
2242
+ return "([^/]{1,255})";
2243
+ }).replace(/\*\*/g, ".{0,1000}").replace(/\*/g, "[^/]{1,255}");
2198
2244
  return {
2199
2245
  regex: new RegExp(`^${pattern}$`),
2200
2246
  keys
@@ -2982,9 +3028,10 @@ class AuthPlugin extends ShokupanRouter {
2982
3028
  } else {
2983
3029
  return ctx.text("Provider config error", 500);
2984
3030
  }
2985
- ctx.res.headers.set("Set-Cookie", `oauth_state=${state}; Path=/; HttpOnly; Max-Age=600`);
3031
+ const isSecure = ctx.secure;
3032
+ ctx.res.headers.set("Set-Cookie", `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax${isSecure ? "; Secure" : ""}; Max-Age=600`);
2986
3033
  if (codeVerifier) {
2987
- ctx.res.headers.append("Set-Cookie", `oauth_verifier=${codeVerifier}; Path=/; HttpOnly; Max-Age=600`);
3034
+ ctx.res.headers.append("Set-Cookie", `oauth_verifier=${codeVerifier}; Path=/; HttpOnly; SameSite=Lax${isSecure ? "; Secure" : ""}; Max-Age=600`);
2988
3035
  }
2989
3036
  return ctx.redirect(url.toString());
2990
3037
  });
@@ -3025,7 +3072,7 @@ class AuthPlugin extends ShokupanRouter {
3025
3072
  return ctx.json({ token: jwt, user });
3026
3073
  } catch (e) {
3027
3074
  console.error("Auth Error", e);
3028
- return ctx.text("Authentication failed: " + e.message + "\n" + e.stack, 500);
3075
+ return ctx.text("Authentication failed. Please try again.", 500);
3029
3076
  }
3030
3077
  });
3031
3078
  }
@@ -3236,14 +3283,21 @@ function Cors(options = {}) {
3236
3283
  const origin = ctx.headers.get("origin");
3237
3284
  const set = (k, v) => headers.set(k, v);
3238
3285
  const append = (k, v) => headers.append(k, v);
3286
+ if (origin === "null" && opts.origin !== "null") {
3287
+ return next();
3288
+ }
3239
3289
  if (opts.origin === "*") {
3240
3290
  set("Access-Control-Allow-Origin", "*");
3241
3291
  } else if (typeof opts.origin === "string") {
3242
3292
  set("Access-Control-Allow-Origin", opts.origin);
3243
3293
  } else if (Array.isArray(opts.origin)) {
3244
- if (origin && opts.origin.includes(origin)) {
3245
- set("Access-Control-Allow-Origin", origin);
3246
- append("Vary", "Origin");
3294
+ if (origin) {
3295
+ const normalizedOrigin = origin.toLowerCase();
3296
+ const normalizedAllowed = opts.origin.map((o) => o.toLowerCase());
3297
+ if (normalizedAllowed.includes(normalizedOrigin)) {
3298
+ set("Access-Control-Allow-Origin", origin);
3299
+ append("Vary", "Origin");
3300
+ }
3247
3301
  }
3248
3302
  } else if (typeof opts.origin === "function") {
3249
3303
  const allowed = opts.origin(ctx);
@@ -3930,11 +3984,17 @@ function unsign(input, secret) {
3930
3984
  if (typeof secret !== "string") throw new TypeError("Secret string must be provided.");
3931
3985
  const tentValue = input.slice(0, input.lastIndexOf("."));
3932
3986
  const expectedInput = sign(tentValue, secret);
3933
- const expectedBuffer = Buffer.from(expectedInput);
3934
- const inputBuffer = Buffer.from(input);
3935
- if (expectedBuffer.length !== inputBuffer.length) return false;
3936
- const valid = require("crypto").timingSafeEqual(expectedBuffer, inputBuffer);
3937
- return valid ? tentValue : false;
3987
+ const maxLength = Math.max(expectedInput.length, input.length);
3988
+ const paddedExpected = Buffer.alloc(maxLength);
3989
+ const paddedInput = Buffer.alloc(maxLength);
3990
+ Buffer.from(expectedInput).copy(paddedExpected);
3991
+ Buffer.from(input).copy(paddedInput);
3992
+ try {
3993
+ const valid = require("crypto").timingSafeEqual(paddedExpected, paddedInput);
3994
+ return valid ? tentValue : false;
3995
+ } catch {
3996
+ return false;
3997
+ }
3938
3998
  }
3939
3999
  function Session(options) {
3940
4000
  const store = options.store || new MemoryStore();