@everystack/server 0.1.0 → 0.2.0

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.
Files changed (64) hide show
  1. package/LICENSE +681 -0
  2. package/package.json +29 -21
  3. package/src/cdn/compose.ts +3 -0
  4. package/src/cdn/features.ts +112 -68
  5. package/src/cdn/index.ts +11 -13
  6. package/src/cdn/transforms.ts +157 -0
  7. package/src/db.ts +1 -0
  8. package/src/image.ts +28 -1
  9. package/src/index.ts +2 -0
  10. package/src/kvs.ts +50 -0
  11. package/src/plugin.ts +517 -0
  12. package/src/ssr.ts +1 -1
  13. package/src/worker.ts +46 -2
  14. package/stubs/@tanstack/react-query/index.mjs +13 -0
  15. package/stubs/@tanstack/react-query/package.json +10 -0
  16. package/stubs/@tanstack/react-query/sst-env.d.ts +10 -0
  17. package/stubs/d3-array/index.mjs +15 -0
  18. package/stubs/d3-array/package.json +10 -0
  19. package/stubs/d3-array/sst-env.d.ts +10 -0
  20. package/stubs/d3-scale/index.mjs +11 -0
  21. package/stubs/d3-scale/package.json +10 -0
  22. package/stubs/d3-scale/sst-env.d.ts +10 -0
  23. package/stubs/d3-shape/index.mjs +16 -0
  24. package/stubs/d3-shape/package.json +10 -0
  25. package/stubs/d3-shape/sst-env.d.ts +10 -0
  26. package/stubs/d3-time/index.mjs +13 -0
  27. package/stubs/d3-time/package.json +10 -0
  28. package/stubs/d3-time/sst-env.d.ts +10 -0
  29. package/stubs/d3-time-format/index.mjs +11 -0
  30. package/stubs/d3-time-format/package.json +10 -0
  31. package/stubs/d3-time-format/sst-env.d.ts +10 -0
  32. package/stubs/expo/index.mjs +12 -0
  33. package/stubs/expo/package.json +10 -0
  34. package/stubs/expo/sst-env.d.ts +10 -0
  35. package/stubs/expo-router/index.mjs +18 -0
  36. package/stubs/expo-router/package.json +10 -0
  37. package/stubs/expo-router/sst-env.d.ts +10 -0
  38. package/stubs/expo-status-bar/index.mjs +8 -0
  39. package/stubs/expo-status-bar/package.json +10 -0
  40. package/stubs/expo-status-bar/sst-env.d.ts +10 -0
  41. package/stubs/react/index.mjs +34 -0
  42. package/stubs/react/jsx-dev-runtime/index.mjs +11 -0
  43. package/stubs/react/jsx-dev-runtime/package.json +6 -0
  44. package/stubs/react/jsx-dev-runtime/sst-env.d.ts +10 -0
  45. package/stubs/react/jsx-runtime/index.mjs +10 -0
  46. package/stubs/react/jsx-runtime/package.json +6 -0
  47. package/stubs/react/jsx-runtime/sst-env.d.ts +10 -0
  48. package/stubs/react/package.json +12 -0
  49. package/stubs/react/sst-env.d.ts +10 -0
  50. package/stubs/react-dom/index.mjs +11 -0
  51. package/stubs/react-dom/package.json +10 -0
  52. package/stubs/react-dom/sst-env.d.ts +10 -0
  53. package/stubs/react-native/index.mjs +78 -0
  54. package/stubs/react-native/package.json +11 -0
  55. package/stubs/react-native/sst-env.d.ts +10 -0
  56. package/stubs/react-native-safe-area-context/index.mjs +11 -0
  57. package/stubs/react-native-safe-area-context/package.json +10 -0
  58. package/stubs/react-native-safe-area-context/sst-env.d.ts +10 -0
  59. package/stubs/react-native-screens/index.mjs +13 -0
  60. package/stubs/react-native-screens/package.json +10 -0
  61. package/stubs/react-native-screens/sst-env.d.ts +10 -0
  62. package/stubs/react-native-web/index.mjs +7 -0
  63. package/stubs/react-native-web/package.json +10 -0
  64. package/stubs/react-native-web/sst-env.d.ts +10 -0
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
- "license": "MIT",
5
+ "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "files": [
10
10
  "src",
11
+ "stubs",
11
12
  "README.md"
12
13
  ],
13
- "type": "module",
14
14
  "exports": {
15
15
  ".": {
16
16
  "types": "./src/index.ts",
@@ -40,20 +40,28 @@
40
40
  "types": "./src/stubs.ts",
41
41
  "default": "./src/stubs.ts"
42
42
  },
43
+ "./plugin": {
44
+ "types": "./src/plugin.ts",
45
+ "default": "./src/plugin.ts"
46
+ },
43
47
  "./cdn": {
44
48
  "types": "./src/cdn/index.ts",
45
49
  "default": "./src/cdn/index.ts"
50
+ },
51
+ "./kvs": {
52
+ "types": "./src/kvs.ts",
53
+ "default": "./src/kvs.ts"
46
54
  }
47
55
  },
48
56
  "peerDependencies": {
49
- "@aws-sdk/client-s3": ">=3.0.0",
50
- "@aws-sdk/s3-request-presigner": ">=3.0.0",
57
+ "@aws-sdk/client-s3": "3.1045.0",
58
+ "@aws-sdk/s3-request-presigner": "3.1045.0",
51
59
  "@everystack/auth": ">=0.1.0",
52
60
  "@everystack/cli": ">=0.1.0",
53
- "drizzle-orm": ">=0.30.0",
54
- "postgres": ">=3.0.0",
55
- "sharp": ">=0.33.0",
56
- "sst": ">=3.0.0"
61
+ "drizzle-orm": "0.41.0",
62
+ "postgres": "3.4.9",
63
+ "sharp": "0.34.5",
64
+ "sst": "4.13.1"
57
65
  },
58
66
  "peerDependenciesMeta": {
59
67
  "sst": {
@@ -76,20 +84,20 @@
76
84
  }
77
85
  },
78
86
  "devDependencies": {
79
- "@types/aws-lambda": "^8.10.145",
80
- "@types/jest": "^29.5.14",
81
- "@types/node": "^22.0.0",
82
- "drizzle-orm": "^0.41.0",
83
- "jest": "^29.7.0",
84
- "postgres": "^3.4.0",
85
- "sst": "^4.13.1",
86
- "ts-jest": "^29.3.0",
87
- "typescript": "^5.7.0",
88
- "@everystack/auth": "0.1.0",
89
- "@everystack/cli": "0.1.0"
87
+ "@types/aws-lambda": "8.10.161",
88
+ "@types/jest": "29.5.14",
89
+ "@types/node": "22.19.18",
90
+ "drizzle-orm": "0.41.0",
91
+ "jest": "29.7.0",
92
+ "postgres": "3.4.9",
93
+ "sst": "4.13.1",
94
+ "ts-jest": "29.4.9",
95
+ "typescript": "5.9.3",
96
+ "@everystack/cli": "0.2.0",
97
+ "@everystack/auth": "0.2.0"
90
98
  },
91
99
  "scripts": {
92
- "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
100
+ "test": "jest",
93
101
  "build": "tsc --build",
94
102
  "lint": "tsc --noEmit"
95
103
  }
@@ -72,6 +72,9 @@ export function composeViewerRequest(opts: ComposeOptions): ComposeResult {
72
72
  }
73
73
 
74
74
  // 2. Emit state as namespaced vars.
75
+ // SECURITY: State values originate from developer configuration (e.g., HandlerOptions,
76
+ // SST config), not from end-user input. JSON.stringify safely serializes these values.
77
+ // If state sources ever include user-controlled data, this interpolation must be revisited.
75
78
  const stateLines: string[] = [];
76
79
  for (const f of features) {
77
80
  if (!f.state) continue;
@@ -13,34 +13,34 @@ import type { Feature } from './compose';
13
13
 
14
14
  // ---------- auth ----------
15
15
 
16
- export type Role = string;
17
-
18
- export interface AuthRoleEntry {
19
- /** Read role required (GET/HEAD). */
20
- r: Role;
21
- /** Write role required (POST/PUT/PATCH/DELETE). */
22
- w: Role;
23
- }
24
-
25
16
  export interface AuthFeatureOptions {
26
17
  /** HS256 secret — same secret your Lambda mints with. */
27
18
  secret: string;
28
19
  /**
29
- * Path prefixes that bypass auth entirely. Default:
30
- * ['/api/auth/', '/api/health', '/api/updates', '/api/storage/public'].
31
- */
32
- publicPaths?: string[];
33
- /**
34
- * Optional role table. Keyed by table name extracted from /api/<table>/...
35
- * Use '*' for default. Requires `tableFromPath: true`.
20
+ * Paths that require authentication. Prefix with `!` to negate (exempt).
21
+ * Longest matching prefix wins. Paths with no match are public.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * requireAuth: ['/api/', '!/api/auth/', '!/api/health']
26
+ * ```
27
+ * - `/api/*` — requires JWT
28
+ * - `/api/auth/*` — public (exempted, longer match wins)
29
+ * - `/api/health` — public (exempted, longer match wins)
30
+ * - `/`, `/about` — public (no matching rule)
36
31
  */
37
- roles?: Record<string, AuthRoleEntry>;
38
- /** Role hierarchy, weakest first. Required when `roles` is set. */
39
- roleHierarchy?: Role[];
32
+ requireAuth: string[];
40
33
  /**
41
- * Extract table name from `/api/<table>/...` for role lookup. Default false.
34
+ * HTTP methods that bypass auth on protected paths.
35
+ * Aligns with the handler's `publicRoutes` option so the edge and Lambda
36
+ * share the same "reads are public" semantics.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * publicMethods: ['GET', 'HEAD']
41
+ * ```
42
42
  */
43
- tableFromPath?: boolean;
43
+ publicMethods?: string[];
44
44
  /** Allowed clock skew (seconds). Default 30. */
45
45
  clockSkewSec?: number;
46
46
  /**
@@ -58,28 +58,22 @@ export interface AuthFeatureOptions {
58
58
  order?: number;
59
59
  }
60
60
 
61
- const DEFAULT_AUTH_PUBLIC = [
62
- '/api/auth/',
63
- '/api/health',
64
- '/api/updates',
65
- '/api/storage/public',
66
- ];
67
-
68
61
  export function authFeature(opts: AuthFeatureOptions): Feature {
69
- const publicPaths = opts.publicPaths ?? DEFAULT_AUTH_PUBLIC;
70
- const roles = opts.roles ?? null;
71
- const hierarchy = opts.roleHierarchy ?? null;
72
- const tableFromPath = opts.tableFromPath === true;
73
62
  const cookieName = opts.cookieName !== false ? (opts.cookieName ?? '__es_token') : '';
74
63
 
75
- if (roles && !hierarchy) {
76
- throw new Error('authFeature: roleHierarchy is required when roles is set');
77
- }
78
-
79
64
  const verifierSrc = opts.compact
80
65
  ? generateCompactCffVerifier({ clockSkewSec: opts.clockSkewSec })
81
66
  : generateCffVerifier({ clockSkewSec: opts.clockSkewSec });
82
67
 
68
+ // Parse requireAuth entries: '!' prefix = negated (exempt from auth).
69
+ // Sort by path length descending so longest (most specific) prefix wins.
70
+ const rules = opts.requireAuth.map(entry => {
71
+ const negated = entry.startsWith('!');
72
+ const path = negated ? entry.slice(1) : entry;
73
+ return { path, auth: !negated };
74
+ });
75
+ rules.sort((a, b) => b.path.length - a.path.length);
76
+
83
77
  // Cookie fallback block — only emitted when cookieName is set.
84
78
  const cookieBlock = cookieName
85
79
  ? `
@@ -99,49 +93,35 @@ export function authFeature(opts: AuthFeatureOptions): Feature {
99
93
  },
100
94
  state: {
101
95
  secret: opts.secret,
102
- publicPaths,
103
- roles: roles ?? {},
104
- hierarchy: hierarchy ?? [],
105
- tableFromPath,
96
+ rulePaths: rules.map(r => r.path),
97
+ ruleAuth: rules.map(r => r.auth),
98
+ publicMethods: opts.publicMethods ?? [],
106
99
  cookieName,
107
100
  },
108
101
  body: `
109
- function __auth_isPublic(uri) {
110
- for (var i = 0; i < __auth_publicPaths.length; i++) {
111
- var p = __auth_publicPaths[i];
112
- if (uri === p || uri.indexOf(p) === 0) return true;
102
+ function __auth_needsAuth(uri) {
103
+ for (var i = 0; i < __auth_rulePaths.length; i++) {
104
+ var p = __auth_rulePaths[i];
105
+ if (uri === p || uri.indexOf(p) === 0) return __auth_ruleAuth[i];
113
106
  }
114
107
  return false;
115
108
  }
116
- function __auth_roleLevel(role) {
117
- for (var i = 0; i < __auth_hierarchy.length; i++) {
118
- if (__auth_hierarchy[i] === role) return i;
119
- }
120
- return -1;
121
- }
122
- function __auth_required(uri, method) {
123
- if (!__auth_tableFromPath) return null;
124
- var m = uri.match(/^\\/api\\/([^\\/?]+)/);
125
- if (!m) return 'public';
126
- var entry = __auth_roles[m[1]] || __auth_roles['*'];
127
- if (!entry) return null;
128
- return (method === 'GET' || method === 'HEAD') ? entry.r : entry.w;
129
- }
130
109
  function __auth_reject(status, message) {
131
110
  return { statusCode: status, statusDescription: status === 401 ? 'Unauthorized' : 'Forbidden', headers: { 'content-type': { value: 'application/json' }, 'cache-control': { value: 'no-store' } }, body: { encoding: 'text', data: JSON.stringify({ message: message }) } };
132
111
  }
133
112
  var __auth_req = event.request;
134
113
  var __auth_uri = __auth_req.uri;
135
- var __auth_method = __auth_req.method;
136
- var __auth_reqRole = __auth_required(__auth_uri, __auth_method);
137
- if (!__auth_isPublic(__auth_uri) && __auth_reqRole !== 'public') {
138
- var __auth_h = __auth_req.headers.authorization && __auth_req.headers.authorization.value;${cookieBlock}
139
- if (!__auth_h || __auth_h.indexOf('Bearer ') !== 0) return __auth_reject(401, 'Authentication required');
140
- var __auth_claims = verifyJwt(__auth_h.slice(7), __auth_secret);
141
- if (!__auth_claims) return __auth_reject(401, 'Invalid or expired token');
142
- if (__auth_reqRole && __auth_reqRole !== 'authenticated') {
143
- var __auth_userRole = __auth_claims.role || 'authenticated';
144
- if (__auth_roleLevel(__auth_userRole) < __auth_roleLevel(__auth_reqRole)) return __auth_reject(403, 'Insufficient role');
114
+ if (__auth_needsAuth(__auth_uri)) {
115
+ var __auth_m = __auth_req.method;
116
+ var __auth_pub = false;
117
+ for (var __auth_mi = 0; __auth_mi < __auth_publicMethods.length; __auth_mi++) {
118
+ if (__auth_m === __auth_publicMethods[__auth_mi]) { __auth_pub = true; break; }
119
+ }
120
+ if (!__auth_pub) {
121
+ var __auth_h = __auth_req.headers.authorization && __auth_req.headers.authorization.value;${cookieBlock}
122
+ if (!__auth_h || __auth_h.indexOf('Bearer ') !== 0) return __auth_reject(401, 'Authentication required');
123
+ var __auth_claims = verifyJwt(__auth_h.slice(7), __auth_secret);
124
+ if (!__auth_claims) return __auth_reject(401, 'Invalid or expired token');
145
125
  }
146
126
  }
147
127
  `,
@@ -182,6 +162,70 @@ if (__geo_country && __geo_blocked.length) {
182
162
  };
183
163
  }
184
164
 
165
+ // ---------- cache-version ----------
166
+
167
+ export interface CacheVersionFeatureOptions {
168
+ /**
169
+ * URI prefix → scope name mapping. When a request URI matches a prefix,
170
+ * the corresponding scope epoch (`__epoch__:<scope>`) is checked in KVS.
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * scopes: { '/api/': 'api', '/media/': 'media' }
175
+ * ```
176
+ * Requests to `/api/posts` check `__epoch__:api`, `/media/img.jpg` checks
177
+ * `__epoch__:media`, all others check `__epoch__:<defaultScope>`.
178
+ */
179
+ scopes?: Record<string, string>;
180
+ /** Scope for URIs that don't match any prefix. Default `'web'`. */
181
+ defaultScope?: string;
182
+ /** Override `order`. Default 10 (early — set cache key before auth). */
183
+ order?: number;
184
+ }
185
+
186
+ /**
187
+ * KVS-based cache versioning at the CloudFront edge.
188
+ *
189
+ * Reads three version layers from KVS and picks the highest:
190
+ * 1. Global epoch (`__epoch__`)
191
+ * 2. Scope epoch (`__epoch__:<scope>`) based on URI prefix
192
+ * 3. Per-path epoch (the URI itself)
193
+ *
194
+ * Sets `?_v=<version>` on the request querystring so CloudFront treats
195
+ * different versions as distinct cache keys.
196
+ *
197
+ * Requires `kvStore` to be set on the Router's `edge.viewerRequest`.
198
+ */
199
+ export function cacheVersionFeature(opts: CacheVersionFeatureOptions = {}): Feature {
200
+ const scopes = opts.scopes ?? {};
201
+ const defaultScope = opts.defaultScope ?? 'web';
202
+ const prefixes = Object.keys(scopes);
203
+ const scopeNames = Object.values(scopes);
204
+
205
+ return {
206
+ name: 'cacheVersion',
207
+ order: opts.order ?? 10,
208
+ state: {
209
+ prefixes,
210
+ scopeNames,
211
+ defaultScope,
212
+ },
213
+ body: `
214
+ var __cv_u = event.request.uri, __cv_q = event.request.querystring;
215
+ var __cv_gv = "", __cv_nv = "", __cv_pv = "";
216
+ try { __cv_gv = await cf.kvs().get("__epoch__") || ""; } catch(__cv_e) {}
217
+ var __cv_scope = __cacheVersion_defaultScope;
218
+ for (var __cv_i = 0; __cv_i < __cacheVersion_prefixes.length; __cv_i++) {
219
+ if (__cv_u.indexOf(__cacheVersion_prefixes[__cv_i]) === 0) { __cv_scope = __cacheVersion_scopeNames[__cv_i]; break; }
220
+ }
221
+ try { __cv_nv = await cf.kvs().get("__epoch__:" + __cv_scope) || ""; } catch(__cv_e) {}
222
+ try { __cv_pv = await cf.kvs().get(__cv_u) || ""; } catch(__cv_e) {}
223
+ var __cv_v = [__cv_gv, __cv_nv, __cv_pv].filter(function(x){return x;}).sort().pop() || "";
224
+ if (__cv_v) __cv_q._v = { value: __cv_v };
225
+ `,
226
+ };
227
+ }
228
+
185
229
  // ---------- cache-key ----------
186
230
 
187
231
  export interface CacheKeyFeatureOptions {
package/src/cdn/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { composeViewerRequest } from './compose';
19
- import { authFeature, type AuthFeatureOptions, type AuthRoleEntry, type Role } from './features';
19
+ import { authFeature, type AuthFeatureOptions } from './features';
20
20
 
21
21
  export {
22
22
  composeViewerRequest,
@@ -29,24 +29,25 @@ export {
29
29
  authFeature,
30
30
  geoFeature,
31
31
  cacheKeyFeature,
32
+ cacheVersionFeature,
32
33
  type AuthFeatureOptions,
33
- type AuthRoleEntry,
34
34
  type GeoFeatureOptions,
35
35
  type CacheKeyFeatureOptions,
36
- type Role,
36
+ type CacheVersionFeatureOptions,
37
37
  } from './features';
38
+ export {
39
+ stripSstDeadCode,
40
+ applyWafExclusions,
41
+ WAF_JSON_API_OVERRIDES,
42
+ } from './transforms';
38
43
 
39
44
  // ---------- Back-compat: createAuthFunction ----------
40
45
 
41
- export interface RoleEntry extends AuthRoleEntry {}
42
-
43
46
  export interface AuthFunctionConfig {
44
47
  /** HS256 secret — same secret your Lambda uses to mint tokens. */
45
48
  secret: string;
46
- publicPaths?: string[];
47
- roles?: Record<string, RoleEntry>;
48
- roleHierarchy?: Role[];
49
- tableFromPath?: boolean;
49
+ /** Auth rules. Default: `['/']` (protect everything). See authFeature. */
50
+ requireAuth?: string[];
50
51
  clockSkewSec?: number;
51
52
  /** Cookie name for SSR auth fallback. Default: '__es_token'. Set false to disable. */
52
53
  cookieName?: string | false;
@@ -65,10 +66,7 @@ export interface AuthFunctionResult {
65
66
  export function createAuthFunction(config: AuthFunctionConfig): AuthFunctionResult {
66
67
  const opts: AuthFeatureOptions = {
67
68
  secret: config.secret,
68
- publicPaths: config.publicPaths,
69
- roles: config.roles,
70
- roleHierarchy: config.roleHierarchy,
71
- tableFromPath: config.tableFromPath,
69
+ requireAuth: config.requireAuth ?? ['/'],
72
70
  clockSkewSec: config.clockSkewSec,
73
71
  cookieName: config.cookieName,
74
72
  };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * SST infrastructure transforms for the CDN layer.
3
+ *
4
+ * Utilities for $transform() callbacks in sst.config.ts. The $transform()
5
+ * call itself must stay in the app (it's an SST global), but the
6
+ * transformation logic lives here so every everystack app doesn't
7
+ * duplicate it.
8
+ */
9
+
10
+ // ---------- Dead code stripping for CloudFront Functions ----------
11
+
12
+ /**
13
+ * Strip a named function declaration from source code. Handles both
14
+ * `function name(` and `async function name(`. Tracks brace depth and
15
+ * skips string literals to find the matching closing brace.
16
+ *
17
+ * Returns code unchanged if the function isn't found (fail-safe).
18
+ */
19
+ function stripFunction(code: string, name: string): string {
20
+ let start = code.indexOf('function ' + name + '(');
21
+ if (start === -1) return code;
22
+ // Include "async " prefix if present
23
+ if (start >= 6 && code.slice(start - 6, start) === 'async ') {
24
+ start -= 6;
25
+ }
26
+ let i = code.indexOf('{', start);
27
+ if (i === -1) return code;
28
+ let depth = 1;
29
+ i++;
30
+ while (i < code.length && depth > 0) {
31
+ const c = code[i];
32
+ if (c === '"' || c === "'" || c === '`') {
33
+ i++;
34
+ while (i < code.length && code[i] !== c) {
35
+ if (code[i] === '\\') i++;
36
+ i++;
37
+ }
38
+ } else if (c === '{') {
39
+ depth++;
40
+ } else if (c === '}') {
41
+ depth--;
42
+ }
43
+ i++;
44
+ }
45
+ return code.slice(0, start) + code.slice(i);
46
+ }
47
+
48
+ /**
49
+ * Strip dead SST-generated functions from CloudFront Function wrapper code.
50
+ *
51
+ * SST's Router includes `routeSite()` (~3KB) for Next.js/static-site routing
52
+ * with nested helpers (setNextjsGeoHeaders, setNextjsCacheKey, getHeader,
53
+ * findNearestServer, haversineDistance). Expo apps only use "url" and "bucket"
54
+ * route types, so routeSite is dead code. Stripping it reclaims ~3000 bytes
55
+ * toward the 10,240-byte CFF limit.
56
+ *
57
+ * Also removes the `if (route.type === "site")` call site that invokes
58
+ * the stripped function.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * // In sst.config.ts:
63
+ * import { stripSstDeadCode } from '@everystack/server/cdn';
64
+ *
65
+ * $transform(aws.cloudfront.Function, (args: any) => {
66
+ * if (typeof args.code === 'string') {
67
+ * args.code = stripSstDeadCode(args.code);
68
+ * } else if (args.code?.apply) {
69
+ * args.code = args.code.apply(stripSstDeadCode);
70
+ * }
71
+ * });
72
+ * ```
73
+ */
74
+ export function stripSstDeadCode(code: string): string {
75
+ const deadFunctions = ['routeSite'];
76
+ for (const name of deadFunctions) {
77
+ code = stripFunction(code, name);
78
+ }
79
+ // Remove the call site: if (route.type === "site") { ... await routeSite(...); ... }
80
+ code = code.replace(
81
+ /if \(route\.type === "site"\) \{[^}]*await routeSite\([^)]*\);[^}]*\}/g,
82
+ '',
83
+ );
84
+ return code;
85
+ }
86
+
87
+ // ---------- WAF rule exclusions ----------
88
+
89
+ /**
90
+ * Default WAF rule exclusions for JSON APIs behind AWS Managed Rules.
91
+ *
92
+ * These rules commonly cause false positives on JSON request bodies:
93
+ * - SizeRestrictions_BODY: large JSON payloads (admin bulk operations)
94
+ * - GenericRFI_BODY: URLs in JSON values (avatarUrl, cdnUrl)
95
+ * - SQLi_BODY: JSON keys resembling SQL keywords (limit, select, order)
96
+ * - Log4JRCE_HEADER: JWT Authorization headers with encoded payloads
97
+ *
98
+ * Keys are AWS managed rule group names. Values are rule names to set to
99
+ * COUNT mode (log but don't block).
100
+ */
101
+ export const WAF_JSON_API_OVERRIDES: Record<string, string[]> = {
102
+ AWSManagedRulesCommonRuleSet: ['SizeRestrictions_BODY', 'GenericRFI_BODY'],
103
+ AWSManagedRulesSQLiRuleSet: ['SQLi_BODY'],
104
+ AWSManagedRulesKnownBadInputsRuleSet: ['Log4JRCE_HEADER'],
105
+ };
106
+
107
+ /**
108
+ * Apply WAF rule exclusions by setting specific rules to COUNT mode.
109
+ *
110
+ * Takes a resolved rules array (from `$resolve(args.rules).apply(...)`)
111
+ * and returns the transformed array with the specified rule overrides.
112
+ *
113
+ * @param rules Resolved WAF rules array from Pulumi.
114
+ * @param overrides Rule group → rule names to set to COUNT. Defaults to
115
+ * {@link WAF_JSON_API_OVERRIDES}.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * // In sst.config.ts:
120
+ * import { applyWafExclusions } from '@everystack/server/cdn';
121
+ *
122
+ * $transform(aws.wafv2.WebAcl, (args: any) => {
123
+ * if (!args.rules) return;
124
+ * args.rules = $resolve(args.rules).apply(applyWafExclusions);
125
+ * });
126
+ * ```
127
+ */
128
+ export function applyWafExclusions(
129
+ rules: any[],
130
+ overrides: Record<string, string[]> = WAF_JSON_API_OVERRIDES,
131
+ ): any[] {
132
+ // Build the override map in the shape WAF expects.
133
+ const ruleOverrides: Record<string, Array<{ name: string; actionToUse: { count: Record<string, never> } }>> = {};
134
+ for (const [group, ruleNames] of Object.entries(overrides)) {
135
+ ruleOverrides[group] = ruleNames.map((name) => ({
136
+ name,
137
+ actionToUse: { count: {} },
138
+ }));
139
+ }
140
+
141
+ return rules.map((rule: any) => {
142
+ const groupName = rule.statement?.managedRuleGroupStatement?.name;
143
+ if (groupName && ruleOverrides[groupName]) {
144
+ return {
145
+ ...rule,
146
+ statement: {
147
+ ...rule.statement,
148
+ managedRuleGroupStatement: {
149
+ ...rule.statement.managedRuleGroupStatement,
150
+ ruleActionOverrides: ruleOverrides[groupName],
151
+ },
152
+ },
153
+ };
154
+ }
155
+ return rule;
156
+ });
157
+ }
package/src/db.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./sst-env.d.ts" />
1
2
  /**
2
3
  * @everystack/server/db — SST Resource-linked database helpers.
3
4
  *
package/src/image.ts CHANGED
@@ -26,6 +26,12 @@ export interface ImageHandlerConfig {
26
26
  /** Cache-Control for image responses.
27
27
  * Default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5' */
28
28
  cacheControl?: string;
29
+ /**
30
+ * Optional key validator. Called before fetching from S3.
31
+ * Return true to allow the request, false to reject with 403.
32
+ * Use this to verify the key exists in your database or matches an allowed prefix.
33
+ */
34
+ validateKey?: (key: string) => boolean | Promise<boolean>;
29
35
  }
30
36
 
31
37
  export interface TransformParams {
@@ -142,7 +148,7 @@ export async function processImage(
142
148
  export function createImageHandler(
143
149
  config: ImageHandlerConfig
144
150
  ): (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyStructuredResultV2> {
145
- const { bucket, pathPrefix = '/media/' } = config;
151
+ const { bucket, pathPrefix = '/media/', validateKey } = config;
146
152
  const region = config.region || process.env.AWS_REGION;
147
153
 
148
154
  return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
@@ -159,6 +165,27 @@ export function createImageHandler(
159
165
  };
160
166
  }
161
167
 
168
+ // Block path traversal attempts
169
+ if (key.includes('..') || key.startsWith('/')) {
170
+ return {
171
+ statusCode: 403,
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({ error: 'Forbidden' }),
174
+ };
175
+ }
176
+
177
+ // Optional key validation (e.g. verify key exists in database)
178
+ if (validateKey) {
179
+ const allowed = await validateKey(key);
180
+ if (!allowed) {
181
+ return {
182
+ statusCode: 403,
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({ error: 'Forbidden' }),
185
+ };
186
+ }
187
+ }
188
+
162
189
  const params = parseParams(event.queryStringParameters || {});
163
190
 
164
191
  const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * - createRouter — path-based request dispatch
7
7
  * - createLambdaHandler — full handler with lazy init, routing, error handling
8
8
  * - log — structured JSON logging
9
+ *
10
+ * For the plugin-based composition API, import from '@everystack/server/plugin'.
9
11
  */
10
12
 
11
13
  import type {
package/src/kvs.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * CloudFront KeyValueStore cache invalidation utility.
3
+ *
4
+ * Writes a timestamp to KVS keys, signaling CloudFront Functions
5
+ * to treat cached responses as stale. Used by both the events
6
+ * listener (wildcard callback) and worker (RPC dispatch).
7
+ *
8
+ * Usage:
9
+ * import { invalidateKvsPaths } from '@everystack/server/kvs';
10
+ * await invalidateKvsPaths(kvsArn, ['/api/posts', '/api/profiles']);
11
+ */
12
+
13
+ /**
14
+ * Write a fresh timestamp to each KVS path, invalidating CloudFront caches.
15
+ *
16
+ * Each put changes the ETag, so we re-describe after each write.
17
+ * Dynamically imports AWS SDK to avoid bundling when unused.
18
+ */
19
+ export async function invalidateKvsPaths(kvsArn: string, paths: string[]): Promise<void> {
20
+ if (!kvsArn || paths.length === 0) return;
21
+
22
+ await import('@aws-sdk/signature-v4a');
23
+ const {
24
+ CloudFrontKeyValueStoreClient,
25
+ DescribeKeyValueStoreCommand,
26
+ PutKeyCommand,
27
+ } = await import('@aws-sdk/client-cloudfront-keyvaluestore');
28
+
29
+ const client = new CloudFrontKeyValueStoreClient({});
30
+ const desc = await client.send(
31
+ new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
32
+ );
33
+ let etag = desc.ETag!;
34
+
35
+ for (const path of paths) {
36
+ await client.send(
37
+ new PutKeyCommand({
38
+ KvsARN: kvsArn,
39
+ Key: path,
40
+ Value: String(Math.floor(Date.now() / 1000)),
41
+ IfMatch: etag,
42
+ })
43
+ );
44
+ // KVS etag changes after each put — re-describe for next iteration
45
+ const newDesc = await client.send(
46
+ new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
47
+ );
48
+ etag = newDesc.ETag!;
49
+ }
50
+ }