@everystack/server 0.4.35 → 0.4.37

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `@everystack/server` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.37] - 2026-10-18
8
+
9
+ ### Fixed
10
+
11
+ - an action named __proto__ in a plugin or app action map is refused at init
12
+
13
+ ## [0.4.36] - 2026-10-18
14
+
15
+ ### Fixed
16
+
17
+ - the Lambda entry strips edge query params (_v by default) before routing
18
+
7
19
  ## [0.4.35] - 2026-10-14
8
20
 
9
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -193,7 +193,7 @@
193
193
  "ts-jest": "29.4.9",
194
194
  "typescript": "5.9.3",
195
195
  "@everystack/auth": "0.4.9",
196
- "@everystack/cli": "0.4.99"
196
+ "@everystack/cli": "0.4.100"
197
197
  },
198
198
  "scripts": {
199
199
  "test": "jest",
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
 
18
18
  import { log } from './log';
19
19
  import { buildTimingHeaders } from './timing';
20
+ import { DEFAULT_STRIP_QUERY_PARAMS, stripQueryParams } from './strip-query-params';
20
21
 
21
22
  export { buildTimingHeaders } from './timing';
22
23
  export type { TimingHeaderOptions } from './timing';
@@ -99,6 +100,13 @@ export interface LambdaHandlerOptions {
99
100
  logSink?: LogSink;
100
101
  /** Emit Server-Timing headers on responses. Default true. */
101
102
  observability?: boolean;
103
+ /**
104
+ * Query params removed from every HTTP request before routing, matched by decoded name whatever
105
+ * the value. The CDN edge appends `?_v=<epoch>` and CloudFront forwards the whole query string,
106
+ * so without this the REST handler would answer 400 to a param with no operator. List any params
107
+ * your own edge adds. Default `['_v']`; `[]` disables stripping.
108
+ */
109
+ stripQueryParams?: string[];
102
110
  }
103
111
 
104
112
  // --- Event Adapter ---
@@ -256,7 +264,10 @@ export function createLambdaHandler(
256
264
 
257
265
  try {
258
266
  const { router } = await ensureInitialized();
259
- const request = eventToRequest(httpEvent);
267
+ const request = stripQueryParams(
268
+ eventToRequest(httpEvent),
269
+ options.stripQueryParams ?? DEFAULT_STRIP_QUERY_PARAMS,
270
+ );
260
271
  const path = httpEvent.rawPath;
261
272
  const method = httpEvent.requestContext.http.method;
262
273
  const handlerFn = router(path, method);
package/src/plugin.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  log,
37
37
  } from './index';
38
38
  import { splitSqlStatements, stripSqlComments } from './sql-lex';
39
+ import { DEFAULT_STRIP_QUERY_PARAMS, stripQueryParams } from './strip-query-params';
39
40
 
40
41
  // --- Plugin Types ---
41
42
 
@@ -138,6 +139,13 @@ export interface PluginLambdaHandlerOptions {
138
139
  * here, not in the HTTP-facing API function.
139
140
  */
140
141
  http?: boolean;
142
+ /**
143
+ * Query params removed from every HTTP request before routing, matched by decoded name whatever
144
+ * the value. The CDN edge appends `?_v=<epoch>` and CloudFront forwards the whole query string,
145
+ * so without this the REST handler would answer 400 to a param with no operator. List any params
146
+ * your own edge adds. Default `['_v']`; `[]` disables stripping.
147
+ */
148
+ stripQueryParams?: string[];
141
149
  }
142
150
 
143
151
  // --- Plugin Lambda Handler Implementation ---
@@ -164,6 +172,17 @@ export function createPluginLambdaHandler(
164
172
  contributions.push(await plugin(ctx));
165
173
  }
166
174
 
175
+ // An OWN `__proto__` key (parsed JSON, a computed key) cannot be an action: Object.assign would
176
+ // run the `__proto__` setter on the map instead of adding it, and the spread below would define
177
+ // it as an own key. Refuse it at init rather than drop or dispatch it.
178
+ const refuseProtoKey = (source: string, actions: Record<string, ActionHandler> | undefined) => {
179
+ if (actions && Object.hasOwn(actions, '__proto__')) {
180
+ throw new Error(`[everystack/server] ${source} action name "__proto__" is not allowed`);
181
+ }
182
+ };
183
+ for (const contribution of contributions) refuseProtoKey('plugin', contribution.actions);
184
+ refuseProtoKey('app', options.actions);
185
+
167
186
  // Collect actions (app-level takes precedence on collision)
168
187
  const pluginActions: Record<string, ActionHandler> = {};
169
188
  for (const contribution of contributions) {
@@ -272,7 +291,10 @@ export function createPluginLambdaHandler(
272
291
 
273
292
  try {
274
293
  const { router, logSink } = await ensureInitialized();
275
- const request = eventToRequest(httpEvent);
294
+ const request = stripQueryParams(
295
+ eventToRequest(httpEvent),
296
+ options.stripQueryParams ?? DEFAULT_STRIP_QUERY_PARAMS,
297
+ );
276
298
  const path = httpEvent.rawPath;
277
299
  const method = httpEvent.requestContext.http.method;
278
300
  const handlerFn = router!(path, method);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Internal (not exported from the package): removes edge-added query params from a Request before
3
+ * routing. Both Lambda entry points (createLambdaHandler, createPluginLambdaHandler) call it right
4
+ * after eventToRequest.
5
+ */
6
+
7
+ /** The CDN edge's cache-version param (cacheVersionFeature, docs/caching.md). */
8
+ export const DEFAULT_STRIP_QUERY_PARAMS: readonly string[] = ['_v'];
9
+
10
+ /** Decodes a raw query-pair name the way a form-encoded parser does; null if it cannot decode. */
11
+ function decodeName(raw: string): string | null {
12
+ try {
13
+ return decodeURIComponent(raw.replace(/\+/g, ' '));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Returns `request` with every query pair whose decoded name is in `names` removed. The kept pairs
21
+ * keep their exact bytes and order (no URLSearchParams re-serialization). When nothing is removed
22
+ * the same Request object is returned; otherwise a new Request carries the original method,
23
+ * headers and body.
24
+ */
25
+ export function stripQueryParams(request: Request, names: readonly string[]): Request {
26
+ if (names.length === 0) return request;
27
+ const url = request.url;
28
+ const q = url.indexOf('?');
29
+ if (q === -1) return request;
30
+
31
+ const pairs = url.slice(q + 1).split('&');
32
+ const kept = pairs.filter((pair) => {
33
+ const eq = pair.indexOf('=');
34
+ const name = decodeName(eq === -1 ? pair : pair.slice(0, eq));
35
+ return name === null || !names.includes(name);
36
+ });
37
+ if (kept.length === pairs.length) return request;
38
+
39
+ const query = kept.join('&');
40
+ return new Request(url.slice(0, q) + (query ? '?' + query : ''), request);
41
+ }