@everystack/mcp 0.4.0 → 0.4.2

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.
@@ -0,0 +1,78 @@
1
+ # Caching & WAF (CloudFront Edge)
2
+
3
+ > The edge pipeline: WAF filters malicious traffic, edge auth verifies JWTs, CloudFront caches public GETs and SSR HTML, the handler sets `Cache-Control` by auth state, and mutations bump epoch keys that invalidate stale entries. Read the WAF section BEFORE enabling managed rules — the default managed rules 403 every PostgREST/JSON API request without the shipped exclusions.
4
+
5
+ ## THE TRAP — AWS managed rules 403 your JSON API
6
+
7
+ AWS WAF managed rules inspect the **request body and headers**, not just the URL. The moment you enable them on a PostgREST/JSON API, several rules fire on legitimate traffic and return **403 before the request ever reaches Lambda**:
8
+
9
+ - `AWSManagedRulesSQLiRuleSet` → **`SQLi_BODY`**: PostgREST JSON keys (`limit`, `select`, `order`, `sort`) read as SQL keywords. Every `POST`/RPC and every `select()` with those keys 403s.
10
+ - `AWSManagedRulesCommonRuleSet` → **`SizeRestrictions_BODY`** (large JSON payloads) and **`GenericRFI_BODY`** (URLs inside JSON values look like remote-file-inclusion).
11
+ - `AWSManagedRulesKnownBadInputsRuleSet` → **`Log4JRCE_HEADER`**: the JWT in the `Authorization` header trips the Log4Shell signature.
12
+
13
+ These are false positives — Drizzle parameterizes every query server-side, so none of it is real injection. But WAF has no application context and blocks anyway.
14
+
15
+ > Do NOT "exclude `SQLi_QUERYARGUMENTS`." That rule lives in `AWSManagedRulesSQLiRuleSet`, and a `ruleActionOverride` naming a rule that isn't in the group you attach it to **silently no-ops** — the exclusion looks configured while the BODY rules keep 403-ing. Exclude the BODY/HEADER rules named above.
16
+
17
+ ### The fix — two composed transforms
18
+
19
+ everystack ships the exclusion set and a rate-limit-429 transform in `@everystack/server/cdn`. Set the false-positive rules to **COUNT** mode (they still log to CloudWatch `AWS/WAFV2`, they just stop blocking) and answer rate limits with **429** instead of 403:
20
+
21
+ ```ts
22
+ import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
23
+
24
+ // MUST be registered BEFORE `new sst.aws.Router(...)`. A $transform only applies to
25
+ // resources created AFTER it, and the Router is what creates the WebAcl — register it
26
+ // after the Router and it silently no-ops.
27
+ $transform(aws.wafv2.WebAcl, (args: any) => {
28
+ if (!args.rules) return;
29
+ args.rules = $resolve(args.rules).apply(
30
+ (rules: any[]) => applyWafRateLimit429(applyWafExclusions(rules)),
31
+ );
32
+ });
33
+
34
+ const router = new sst.aws.Router('Router', {
35
+ // ...
36
+ waf: {
37
+ rateLimitPerIp: 2000,
38
+ managedRules: { common: true, sqli: true, knownBadInputs: true },
39
+ },
40
+ });
41
+ ```
42
+
43
+ - **COUNT ≠ disabled.** The rule still evaluates and records to CloudWatch — you keep visibility, you just stop the false-positive block. Nothing is turned off.
44
+ - **`applyWafExclusions(rules, overrides?)`** defaults to the shipped `WAF_JSON_API_OVERRIDES` map. Pass a second argument (rule group → rule names) to extend it if you hit a new false positive.
45
+ - **`applyWafRateLimit429`** rewrites rate-based block actions to `429 + Retry-After`. This is SEO-load-bearing, not cosmetic: Google reads any 4xx except 429 as "gone" and deindexes the URL over days; 429 means "back off and retry." A rate-limited public SSR site returning 403 becomes a deindexing engine.
46
+
47
+ ### GET-filter false positives (diagnosing new ones)
48
+
49
+ PostgREST GET syntax (`?title=eq.hello`, `?or=(status.eq.draft,status.eq.review)`) can trip query-argument SQLi signatures that are NOT in the default map. Don't guess — turn on `waf.logging`, read the WAF **sampled requests** in the console to find the exact rule that matched, then add it via the second argument to `applyWafExclusions`. Set the offending rule to COUNT, confirm the request passes, and keep watching the count metric.
50
+
51
+ ## WAF configuration
52
+
53
+ `sst.aws.Router`'s `waf` block configures rate limiting and managed rule sets:
54
+
55
+ | Field | Purpose |
56
+ | --- | --- |
57
+ | `rateLimitPerIp` | Per-IP request cap over a 5-minute sliding window. On exceed, WAF 403s (429 with the transform above) until the window resets. `2000` (~6.7 req/s sustained) suits most APIs. |
58
+ | `managedRules.common` | `AWSManagedRulesCommonRuleSet` — broad OWASP-style protections. |
59
+ | `managedRules.sqli` | `AWSManagedRulesSQLiRuleSet` — SQL-injection signatures. |
60
+ | `managedRules.knownBadInputs` | `AWSManagedRulesKnownBadInputsRuleSet` — known exploit payloads (Log4Shell, etc.). |
61
+
62
+ All three managed sets are AWS Free-Tier rules (~$1/rule-group/month + per-request); no per-request cost beyond base WAF pricing.
63
+
64
+ **What WAF does NOT cover** (it sees IPs and HTTP, not application state):
65
+ - Per-account auth brute force — that belongs in `@everystack/auth` (account lockout, progressive delays). WAF rate limiting is per-IP.
66
+ - Bot quality — AWS Bot Control (Targeted) can fingerprint sophisticated bots but costs $10/M requests; managed rules + rate limiting suffice for most apps.
67
+
68
+ ## The rest of the edge pipeline
69
+
70
+ **Edge auth.** For latency-sensitive authenticated paths, a CloudFront function verifies the JWT signature at the edge before the request reaches Lambda. See `everystack://auth` for `authFeature` / `composeViewerRequest` and edge token lifetimes (keep edge access tokens short, ~15 min).
71
+
72
+ **Response caching (Cache-Control by auth state).** The handler sets headers based on authentication:
73
+ - Public GETs and SSR HTML → cacheable at the edge (`public, max-age=...`). SSR HTML is the same for every visitor (good for SEO); user-specific content loads on the client during rehydration.
74
+ - Authenticated API responses → `private, no-store`, bypassing the CDN cache entirely.
75
+
76
+ **Epoch invalidation.** Mutations bump an epoch-based version key (in the CloudFront KVS), which naturally invalidates stale public cache entries — no manual purging. Reads carry the current epoch; a write advances it so subsequent reads miss the old cached copy.
77
+
78
+ **Debugging an edge 403.** A 403 with **no corresponding Lambda log entry** was blocked at the edge (WAF or edge auth), not by your handler. Check the WAF sampled requests for the matched rule, confirm with `curl -sI` against the API path, and reach for the exclusions transform above. A 403 that DOES reach Lambda is an application authz decision, not WAF.
@@ -5,6 +5,8 @@
5
5
  ## When to Use
6
6
  Read this when deploying an everystack app to AWS for the first time or adding a new stage.
7
7
 
8
+ If your app.json has `web.output: "server"` (SSR pages or `+api.ts` routes — most Expo Router apps), read everystack://expo-server-deploy next: it covers what deploys where for that shape, including where your `+api.ts` routes run.
9
+
8
10
  ## Prerequisites
9
11
 
10
12
  - Node.js 20+, pnpm
@@ -57,6 +59,9 @@ export default $config({
57
59
  const router = new sst.aws.Router('Router', {
58
60
  routes: { '/*': api.url },
59
61
  });
62
+ // Adding WAF (`waf: { managedRules, rateLimitPerIp }`)? Read everystack://caching
63
+ // FIRST — AWS managed rules 403 every PostgREST request without the shipped
64
+ // exclusions transform, which must register BEFORE this Router.
60
65
 
61
66
  // CLI needs these outputs
62
67
  return {
@@ -0,0 +1,217 @@
1
+ # Deploying an Expo Router Server App
2
+
3
+ > How `web.output: "server"` maps to AWS: what deploys where, where your `+api.ts` routes run, and where the everystack handler sits among them.
4
+
5
+ ## When to Use
6
+
7
+ Read this when your app.json has `web.output: "server"` (SSR pages, `+api.ts` API routes) and you are deploying to AWS. This is the deploy chapter for the most common Expo Router app shape — if your app is fully static (`web.output: "static"`), everystack://deployment alone is enough.
8
+
9
+ ## The Two-Deployable Model
10
+
11
+ The single most important fact: **the Expo server export is NOT the Lambda handler.** There are two deployables with two verbs:
12
+
13
+ | Deployable | Contains | Verb | Frequency |
14
+ |---|---|---|---|
15
+ | Infrastructure + entrypoints | VPC, RDS, buckets, Router, the Lambda entry files in `server/` | `sst deploy` | Rarely (infra changes) |
16
+ | App code | Your entire Expo export: SSR pages, ALL `+api.ts` routes, client bundles | `everystack update --channel <name>` | Every ship (same verb as OTA) |
17
+
18
+ `everystack update` runs the expo export and publishes it as a compressed archive to the Updates bucket. At runtime, the Lambda's SSR fallback downloads that archive to `/tmp` (cached across warm invocations) and serves it with `expo-server` — Expo's own server runtime. Your app code never gets bundled into the Lambda; it ships like an OTA update.
19
+
20
+ ## Request Flow
21
+
22
+ ```
23
+ CloudFront Router
24
+ └─ Api Lambda (server/api.ts — createPluginLambdaHandler)
25
+ ├─ plugin routes claim their paths first (e.g. /api → everystack handler)
26
+ └─ everything else → ssrPlugin fallback → expo-server runs YOUR export:
27
+ ├─ SSR pages (with loader())
28
+ └─ your +api.ts routes (mcp+api.ts, app/games/[id]+api.ts, ...)
29
+ ```
30
+
31
+ **Your `+api.ts` routes ship and serve as-is.** They ride the published bundle and expo-server dispatches to them inside the Lambda. Nothing has to be collapsed into the everystack handler.
32
+
33
+ ## The Lambda Entrypoint
34
+
35
+ ```typescript
36
+ // server/api.ts — the Function handler for `sst deploy`
37
+ import { createPluginLambdaHandler, ssrPlugin } from '@everystack/server/plugin';
38
+ import { createAppContext, appPlugins } from './context';
39
+
40
+ export const handler = createPluginLambdaHandler({
41
+ context: () => createAppContext(),
42
+ plugins: appPlugins,
43
+ fallback: ssrPlugin(), // serves the published Expo export: SSR + your +api.ts routes
44
+ });
45
+ ```
46
+
47
+ ## One Plugin List, Two Venues
48
+
49
+ Define your plugins once; mount them in both places. Locally, Expo Router serves them through a catch-all route. Deployed, the Lambda mounts the same list and claims those paths BEFORE the fallback — so the deployed `/api` runs with SST Resource linking, never the bundle's copy.
50
+
51
+ ```typescript
52
+ // server/context.ts — shared by local dev and every Lambda
53
+ import { createDb } from '@everystack/server/db';
54
+ import { apiPlugin } from './plugins/api'; // your everystack createHandler, as a plugin
55
+ import { authPlugin } from '@everystack/auth/plugin';
56
+ import { schema } from '../db';
57
+
58
+ export async function createAppContext() {
59
+ const { db } = createDb(schema); // Resource-linked on Lambda, DATABASE_URL locally
60
+ return { db, schema, environment: process.env.ENVIRONMENT ?? 'dev', /* verifyToken, ... */ };
61
+ }
62
+
63
+ export const appPlugins = [authPlugin, apiPlugin];
64
+ ```
65
+
66
+ ```typescript
67
+ // server/plugins/api.ts — the everystack handler as ONE plugin among your routes
68
+ import { createHandler } from '@everystack/api';
69
+
70
+ export async function apiPlugin(ctx) {
71
+ const handler = createHandler(ctx.db, ctx.schema, { basePath: '/api', /* options */ });
72
+ return { routes: [{ path: '/api', handler }] };
73
+ }
74
+ ```
75
+
76
+ ```typescript
77
+ // app/api/[...path]+api.ts — LOCAL DEV venue (Expo Router serves this; the
78
+ // deployed Lambda claims /api first, so this copy never runs in production)
79
+ import { createPluginHandler } from '@everystack/server/plugin';
80
+ import { createAppContext, appPlugins } from '../../server/context';
81
+
82
+ const handler = createPluginHandler({ context: createAppContext, plugins: appPlugins });
83
+ export const GET = handler; export const POST = handler;
84
+ export const PATCH = handler; export const DELETE = handler;
85
+ ```
86
+
87
+ Your OTHER `+api.ts` routes (`app/mcp+api.ts`, `app/games/[id]+api.ts`, ...) need no counterpart: local dev serves them directly, deployed they serve through the fallback.
88
+
89
+ ## The Ops Entrypoint (db:migrate, db:seed, console)
90
+
91
+ `everystack db:migrate` invokes a dedicated, privileged Lambda over IAM — it has no public URL and holds the operator credential so the API Lambda never does.
92
+
93
+ ```typescript
94
+ // server/ops.ts
95
+ import { Resource } from 'sst';
96
+ import { createPluginLambdaHandler, dbPlugin } from '@everystack/server/plugin';
97
+ import { createAppContext, appPlugins } from './context';
98
+
99
+ export const handler = createPluginLambdaHandler({
100
+ http: false, // actions-only: rejects HTTP, answers IAM invokes
101
+ context: () => createAppContext({ admin: true }),
102
+ plugins: [
103
+ ...appPlugins,
104
+ dbPlugin({
105
+ migrationsFolder: 'drizzle',
106
+ seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
107
+ // ^ lazy import — a top-level import would run at Lambda INIT and crash the boot
108
+ backupBucket: Resource.Backups?.name,
109
+ // ^ registers db:backup / db:backups / db:restore. Omit it and those actions
110
+ // return "backup storage not configured"; omit the whole ops Lambda and the
111
+ // CLI can't reach them at all — it reports "Unknown action" (see Gotchas).
112
+ }),
113
+ ],
114
+ });
115
+ ```
116
+
117
+ ## Minimal sst.config.ts
118
+
119
+ The complete infrastructure for this app shape (V2: server app + PostgreSQL). Copy, rename, deploy.
120
+
121
+ ```typescript
122
+ /// <reference path="./.sst/platform/config.d.ts" />
123
+ export default $config({
124
+ app(input) {
125
+ return {
126
+ name: 'my-app',
127
+ removal: input?.stage === 'production' ? 'retain' : 'remove',
128
+ home: 'aws',
129
+ };
130
+ },
131
+ async run() {
132
+ const vpc = new sst.aws.Vpc('Vpc');
133
+ const database = new sst.aws.Postgres('Database', {
134
+ vpc,
135
+ // Local dev: `sst dev` uses this instead of RDS
136
+ dev: { host: 'localhost', port: 5432, username: 'postgres', password: 'postgres', database: 'my_app_dev' },
137
+ });
138
+
139
+ const updates = new sst.aws.Bucket('Updates'); // published Expo exports (server bundles)
140
+ const clientBundles = new sst.aws.Bucket('ClientBundles', { access: 'cloudfront' });
141
+ const backups = new sst.aws.Bucket('Backups'); // private — logical pg_dump backups (db:backup)
142
+
143
+ const jwtSecret = new sst.Secret('JwtSecret');
144
+ // Least-privilege API credential — written by `everystack db:provision`.
145
+ const databaseUrl = new sst.Secret('DATABASE_URL');
146
+
147
+ const router = new sst.aws.Router('Router', {
148
+ // Custom domain: Route 53 is the default DNS adapter. With your zone
149
+ // delegated to Route 53, this one line provisions the ACM certificate
150
+ // and DNS records automatically.
151
+ domain: $app.stage === 'production'
152
+ ? 'my-app.com'
153
+ : `${$app.stage}.my-app.com`,
154
+ });
155
+
156
+ const api = new sst.aws.Function('Api', {
157
+ handler: 'server/api.handler',
158
+ runtime: 'nodejs20.x',
159
+ timeout: '30 seconds',
160
+ vpc,
161
+ link: [databaseUrl, updates, clientBundles, jwtSecret],
162
+ url: { router: { instance: router, path: '/' } },
163
+ environment: { ENVIRONMENT: $app.stage, SITE_URL: router.url },
164
+ });
165
+
166
+ const ops = new sst.aws.Function('Ops', {
167
+ handler: 'server/ops.handler',
168
+ runtime: 'nodejs20.x',
169
+ timeout: '120 seconds',
170
+ vpc,
171
+ link: [database, databaseUrl, updates, clientBundles, jwtSecret, backups],
172
+ copyFiles: [{ from: 'drizzle', to: 'drizzle' }], // migrations must ship with the ops bundle
173
+ });
174
+
175
+ // The CLI discovers resources through these outputs. The first five are required;
176
+ // backupsBucket unlocks db:backup / db:swap / db:fork (also auto-discovered by bucket name).
177
+ return {
178
+ routerUrl: router.url,
179
+ apiFunctionName: api.name,
180
+ opsFunctionName: ops.name,
181
+ updatesBucket: updates.name,
182
+ clientBundlesBucket: clientBundles.name,
183
+ backupsBucket: backups.name,
184
+ };
185
+ },
186
+ });
187
+ ```
188
+
189
+ ## Local vs Deployed Database Connection
190
+
191
+ `createDb()` from `@everystack/server/db` serves both venues with one code path (requires `@everystack/server` >= 0.4.4):
192
+
193
+ - **Deployed:** the linked `DATABASE_URL` secret resolves via SST Resource; TLS defaults to `require` (RDS).
194
+ - **Local:** set `DATABASE_URL=postgres://user@localhost:5432/my_app_dev?sslmode=disable`. An explicit `sslmode` in the URL wins; the handler code does not change.
195
+
196
+ Do not hand-build a `postgres()` client from env vars to escape TLS — the URL is the contract.
197
+
198
+ ## Deploy Loop
199
+
200
+ ```bash
201
+ sst deploy --stage dev # infrastructure + entrypoints (rare)
202
+ everystack db:provision --stage dev # mint the least-privilege API credential (once per stage)
203
+ everystack update --channel dev # export + publish the app code (every ship)
204
+ everystack db:migrate --stage dev # run migrations via the ops Lambda
205
+ everystack db:seed --stage dev # dev only
206
+ ```
207
+
208
+ Subsequent app-code ships are `everystack update` alone — no `sst deploy`, no Lambda redeploy.
209
+
210
+ ## Gotchas
211
+
212
+ - **Nothing serves until the first `everystack update`** — the fallback has no bundle to download yet, so `/` returns 404 while `/api` (plugin-claimed) already works.
213
+ - **A path claimed by a plugin never reaches your bundle's route.** If you mount the everystack handler at `/api`, a bundle route at `app/api/foo+api.ts` is shadowed in production (it still serves in local dev unless the catch-all shadows it there too — keep the surfaces aligned).
214
+ - **Lazy-load your seed** (shown above). A top-level `import { runSeed }` in an entrypoint runs at Lambda INIT, tries to reach the database before configuration, and kills the boot.
215
+ - **`copyFiles` the migrations folder** into the ops function, or `db:migrate` fails with ENOENT.
216
+ - **`db:backup`/`db:export`/`db:restore` run in the ephemeral Fargate Task lane — wire `dbTaskPlugin({ backupBucket })` on the ops function.** They are pg_dump/pg_restore work; the ops Lambda holds no pg binaries. `everystack db:backup` reporting `Unknown action: db:backup` means the CLI reached a function with no `dbTaskPlugin` mounted (usually: no ops function at all, so `opsFunctionName` fell back to the Api Lambda). Deploy the `Ops` function above with `dbTaskPlugin`. If instead it reports `backup storage not configured`, `dbTaskPlugin` is mounted but `backupBucket` is unset — pass `() => Resource.Backups.name`. The Task image's `pg_dump`-vs-server compatibility is verified on demand by `everystack task:probe` (there is no Lambda pg_dump layer).
217
+ - **`+api.ts` routes run with the bundle's environment**, not per-route SST links. Resource-linked work (secrets, buckets) belongs in plugins on the Lambda side; keep bundle routes to app logic over the request.
package/dist/index.cjs CHANGED
@@ -21652,6 +21652,18 @@ var RESOURCES = [
21652
21652
  description: "SST deployment: sst.config.ts setup, secrets, stages (dev/production), CloudFront, Lambda configuration, VPC, RDS Aurora Serverless, resource linking.",
21653
21653
  filename: "deployment.md"
21654
21654
  },
21655
+ {
21656
+ uri: "everystack://caching",
21657
+ name: "Caching & WAF (CloudFront Edge)",
21658
+ description: "READ BEFORE enabling WAF on the Router. AWS managed rules 403 every PostgREST/JSON API request without the shipped exclusions transform (applyWafExclusions/applyWafRateLimit429 from @everystack/server/cdn). Also: edge auth, response caching by auth state, epoch invalidation, and debugging edge 403s.",
21659
+ filename: "caching.md"
21660
+ },
21661
+ {
21662
+ uri: "everystack://expo-server-deploy",
21663
+ name: "Deploying an Expo Router Server App",
21664
+ description: 'How web.output:"server" maps to AWS: the two-deployable model (sst deploy vs everystack update), where +api.ts routes run (the SSR fallback serves the published bundle), the one-plugin-list/two-venues pattern (local catch-all route + deployed Lambda entrypoint), the ops entrypoint for db:migrate, custom domains on the Router, and the local-vs-deployed database URL (sslmode). Read this when deploying any Expo Router app with SSR or +api.ts routes.',
21665
+ filename: "expo-server-deploy.md"
21666
+ },
21655
21667
  {
21656
21668
  uri: "everystack://admin",
21657
21669
  name: "Admin Dashboard",
@@ -22472,6 +22484,7 @@ function registerDeployPrompt(server) {
22472
22484
  "1. Read everystack://deployment for infrastructure setup.",
22473
22485
  "2. Read everystack://security for AWS credential setup and deployment checklist.",
22474
22486
  "3. Read everystack://database-operations for the SAFE schema-migration flow (db:plan \u2192 db:apply, snapshot-first) \u2014 a protected stage is NOT migrated with db:migrate.",
22487
+ " If the deploy enables WAF (`waf` on the Router), read everystack://caching first \u2014 the managed rules 403 every PostgREST request without the shipped exclusions transform, which must register BEFORE the Router.",
22475
22488
  projectPath ? `4. Run check_environment with projectPath="${projectPath}" to confirm prerequisites (Node, SST, AWS credentials) before deploying.` : "",
22476
22489
  "",
22477
22490
  "## Pre-Deploy Checklist",
@@ -22613,6 +22626,8 @@ function registerDebugPrompt(server) {
22613
22626
  "",
22614
22627
  "If the symptom involves authentication or authorization:",
22615
22628
  "",
22629
+ "- **First, edge vs handler:** a 403 with NO matching Lambda log entry was blocked at the edge (WAF or edge auth), not by your handler. This is common right after enabling `waf` managed rules \u2014 the AWS SQLi/body rules 403 every PostgREST request. Check the WAF sampled requests and read everystack://caching for the exclusions transform. A 403 that DOES reach Lambda is an application authz decision \u2014 continue below.",
22630
+ "",
22616
22631
  "- **Check token flow:**",
22617
22632
  " - Is the JWT being sent in the Authorization header?",
22618
22633
  " - Is the token expired? Decode at jwt.io and check `exp`",
@@ -22916,6 +22931,7 @@ function registerSecurePrompt(server) {
22916
22931
  "- Rejects expired or malformed tokens at the edge",
22917
22932
  "- Reduces Lambda invocations for unauthorized requests",
22918
22933
  "- See everystack://deployment for CloudFront function setup",
22934
+ "- WAF at the edge: everystack://caching \u2014 enabling AWS managed rules without the shipped exclusions 403s every PostgREST request",
22919
22935
  ""
22920
22936
  ].join("\n") : "",
22921
22937
  "## Verification",
@@ -23717,7 +23733,7 @@ async function runGovernanceCli(argv) {
23717
23733
  }
23718
23734
 
23719
23735
  // src/index.ts
23720
- var version2 = (true ? "0.4.0" : null) ?? "0.3.0-dev";
23736
+ var version2 = (true ? "0.4.2" : null) ?? "0.3.0-dev";
23721
23737
  var INSTRUCTIONS = [
23722
23738
  "You govern how any agent builds everystack \u2014 a self-hosted application stack for Expo apps on AWS.",
23723
23739
  "Your job is not only to advise but to keep the build on-script: the architecture the maintainer",
package/dist/security.md CHANGED
@@ -263,8 +263,8 @@ RESET ROLE;
263
263
  - [ ] Bcrypt cost >= 10
264
264
 
265
265
  ### Infrastructure
266
- - [ ] WAF managed rules (exclude SQLi_QUERYARGUMENTS on API paths for PostgREST syntax)
267
- - [ ] Rate limiting (1000 req/5 min per IP)
266
+ - [ ] WAF managed rules set the JSON-API false-positive BODY/HEADER rules (SQLi_BODY, SizeRestrictions_BODY, GenericRFI_BODY, Log4JRCE_HEADER) to COUNT via `applyWafExclusions`, or they 403 every PostgREST request. See everystack://caching (read BEFORE enabling `waf` on the Router).
267
+ - [ ] Rate limiting — 429 not 403 (applyWafRateLimit429); ~2000 req/5 min per IP. See everystack://caching.
268
268
  - [ ] CloudFront edge JWT verification for latency-sensitive paths
269
269
  - [ ] Database credentials via SST Resource linking
270
270
  - [ ] No debug endpoints or env dumps in production
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Governance layer that governs how any agent builds everystack — grounding, cheat gates, and Model-aware tooling over MCP",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -40,8 +40,9 @@
40
40
  "tsx": "4.21.0",
41
41
  "typescript": "5.9.3",
42
42
  "zod": "3.25.67",
43
- "@everystack/cli": "0.4.2",
44
- "@everystack/model": "0.4.1"
43
+ "@everystack/cli": "0.4.32",
44
+ "@everystack/model": "0.4.5",
45
+ "@everystack/server": "0.4.14"
45
46
  },
46
47
  "scripts": {
47
48
  "test": "jest",
@@ -33,6 +33,8 @@ export function registerDebugPrompt(server: McpServer): void {
33
33
  '',
34
34
  'If the symptom involves authentication or authorization:',
35
35
  '',
36
+ '- **First, edge vs handler:** a 403 with NO matching Lambda log entry was blocked at the edge (WAF or edge auth), not by your handler. This is common right after enabling `waf` managed rules — the AWS SQLi/body rules 403 every PostgREST request. Check the WAF sampled requests and read everystack://caching for the exclusions transform. A 403 that DOES reach Lambda is an application authz decision — continue below.',
37
+ '',
36
38
  '- **Check token flow:**',
37
39
  ' - Is the JWT being sent in the Authorization header?',
38
40
  ' - Is the token expired? Decode at jwt.io and check `exp`',
@@ -26,6 +26,7 @@ export function registerDeployPrompt(server: McpServer): void {
26
26
  '1. Read everystack://deployment for infrastructure setup.',
27
27
  '2. Read everystack://security for AWS credential setup and deployment checklist.',
28
28
  '3. Read everystack://database-operations for the SAFE schema-migration flow (db:plan → db:apply, snapshot-first) — a protected stage is NOT migrated with db:migrate.',
29
+ ' If the deploy enables WAF (`waf` on the Router), read everystack://caching first — the managed rules 403 every PostgREST request without the shipped exclusions transform, which must register BEFORE the Router.',
29
30
  projectPath ? `4. Run check_environment with projectPath="${projectPath}" to confirm prerequisites (Node, SST, AWS credentials) before deploying.` : '',
30
31
  '',
31
32
  '## Pre-Deploy Checklist',
@@ -208,6 +208,7 @@ export function registerSecurePrompt(server: McpServer): void {
208
208
  '- Rejects expired or malformed tokens at the edge',
209
209
  '- Reduces Lambda invocations for unauthorized requests',
210
210
  '- See everystack://deployment for CloudFront function setup',
211
+ '- WAF at the edge: everystack://caching — enabling AWS managed rules without the shipped exclusions 403s every PostgREST request',
211
212
  '',
212
213
  ].join('\n') : '',
213
214
 
@@ -0,0 +1,78 @@
1
+ # Caching & WAF (CloudFront Edge)
2
+
3
+ > The edge pipeline: WAF filters malicious traffic, edge auth verifies JWTs, CloudFront caches public GETs and SSR HTML, the handler sets `Cache-Control` by auth state, and mutations bump epoch keys that invalidate stale entries. Read the WAF section BEFORE enabling managed rules — the default managed rules 403 every PostgREST/JSON API request without the shipped exclusions.
4
+
5
+ ## THE TRAP — AWS managed rules 403 your JSON API
6
+
7
+ AWS WAF managed rules inspect the **request body and headers**, not just the URL. The moment you enable them on a PostgREST/JSON API, several rules fire on legitimate traffic and return **403 before the request ever reaches Lambda**:
8
+
9
+ - `AWSManagedRulesSQLiRuleSet` → **`SQLi_BODY`**: PostgREST JSON keys (`limit`, `select`, `order`, `sort`) read as SQL keywords. Every `POST`/RPC and every `select()` with those keys 403s.
10
+ - `AWSManagedRulesCommonRuleSet` → **`SizeRestrictions_BODY`** (large JSON payloads) and **`GenericRFI_BODY`** (URLs inside JSON values look like remote-file-inclusion).
11
+ - `AWSManagedRulesKnownBadInputsRuleSet` → **`Log4JRCE_HEADER`**: the JWT in the `Authorization` header trips the Log4Shell signature.
12
+
13
+ These are false positives — Drizzle parameterizes every query server-side, so none of it is real injection. But WAF has no application context and blocks anyway.
14
+
15
+ > Do NOT "exclude `SQLi_QUERYARGUMENTS`." That rule lives in `AWSManagedRulesSQLiRuleSet`, and a `ruleActionOverride` naming a rule that isn't in the group you attach it to **silently no-ops** — the exclusion looks configured while the BODY rules keep 403-ing. Exclude the BODY/HEADER rules named above.
16
+
17
+ ### The fix — two composed transforms
18
+
19
+ everystack ships the exclusion set and a rate-limit-429 transform in `@everystack/server/cdn`. Set the false-positive rules to **COUNT** mode (they still log to CloudWatch `AWS/WAFV2`, they just stop blocking) and answer rate limits with **429** instead of 403:
20
+
21
+ ```ts
22
+ import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
23
+
24
+ // MUST be registered BEFORE `new sst.aws.Router(...)`. A $transform only applies to
25
+ // resources created AFTER it, and the Router is what creates the WebAcl — register it
26
+ // after the Router and it silently no-ops.
27
+ $transform(aws.wafv2.WebAcl, (args: any) => {
28
+ if (!args.rules) return;
29
+ args.rules = $resolve(args.rules).apply(
30
+ (rules: any[]) => applyWafRateLimit429(applyWafExclusions(rules)),
31
+ );
32
+ });
33
+
34
+ const router = new sst.aws.Router('Router', {
35
+ // ...
36
+ waf: {
37
+ rateLimitPerIp: 2000,
38
+ managedRules: { common: true, sqli: true, knownBadInputs: true },
39
+ },
40
+ });
41
+ ```
42
+
43
+ - **COUNT ≠ disabled.** The rule still evaluates and records to CloudWatch — you keep visibility, you just stop the false-positive block. Nothing is turned off.
44
+ - **`applyWafExclusions(rules, overrides?)`** defaults to the shipped `WAF_JSON_API_OVERRIDES` map. Pass a second argument (rule group → rule names) to extend it if you hit a new false positive.
45
+ - **`applyWafRateLimit429`** rewrites rate-based block actions to `429 + Retry-After`. This is SEO-load-bearing, not cosmetic: Google reads any 4xx except 429 as "gone" and deindexes the URL over days; 429 means "back off and retry." A rate-limited public SSR site returning 403 becomes a deindexing engine.
46
+
47
+ ### GET-filter false positives (diagnosing new ones)
48
+
49
+ PostgREST GET syntax (`?title=eq.hello`, `?or=(status.eq.draft,status.eq.review)`) can trip query-argument SQLi signatures that are NOT in the default map. Don't guess — turn on `waf.logging`, read the WAF **sampled requests** in the console to find the exact rule that matched, then add it via the second argument to `applyWafExclusions`. Set the offending rule to COUNT, confirm the request passes, and keep watching the count metric.
50
+
51
+ ## WAF configuration
52
+
53
+ `sst.aws.Router`'s `waf` block configures rate limiting and managed rule sets:
54
+
55
+ | Field | Purpose |
56
+ | --- | --- |
57
+ | `rateLimitPerIp` | Per-IP request cap over a 5-minute sliding window. On exceed, WAF 403s (429 with the transform above) until the window resets. `2000` (~6.7 req/s sustained) suits most APIs. |
58
+ | `managedRules.common` | `AWSManagedRulesCommonRuleSet` — broad OWASP-style protections. |
59
+ | `managedRules.sqli` | `AWSManagedRulesSQLiRuleSet` — SQL-injection signatures. |
60
+ | `managedRules.knownBadInputs` | `AWSManagedRulesKnownBadInputsRuleSet` — known exploit payloads (Log4Shell, etc.). |
61
+
62
+ All three managed sets are AWS Free-Tier rules (~$1/rule-group/month + per-request); no per-request cost beyond base WAF pricing.
63
+
64
+ **What WAF does NOT cover** (it sees IPs and HTTP, not application state):
65
+ - Per-account auth brute force — that belongs in `@everystack/auth` (account lockout, progressive delays). WAF rate limiting is per-IP.
66
+ - Bot quality — AWS Bot Control (Targeted) can fingerprint sophisticated bots but costs $10/M requests; managed rules + rate limiting suffice for most apps.
67
+
68
+ ## The rest of the edge pipeline
69
+
70
+ **Edge auth.** For latency-sensitive authenticated paths, a CloudFront function verifies the JWT signature at the edge before the request reaches Lambda. See `everystack://auth` for `authFeature` / `composeViewerRequest` and edge token lifetimes (keep edge access tokens short, ~15 min).
71
+
72
+ **Response caching (Cache-Control by auth state).** The handler sets headers based on authentication:
73
+ - Public GETs and SSR HTML → cacheable at the edge (`public, max-age=...`). SSR HTML is the same for every visitor (good for SEO); user-specific content loads on the client during rehydration.
74
+ - Authenticated API responses → `private, no-store`, bypassing the CDN cache entirely.
75
+
76
+ **Epoch invalidation.** Mutations bump an epoch-based version key (in the CloudFront KVS), which naturally invalidates stale public cache entries — no manual purging. Reads carry the current epoch; a write advances it so subsequent reads miss the old cached copy.
77
+
78
+ **Debugging an edge 403.** A 403 with **no corresponding Lambda log entry** was blocked at the edge (WAF or edge auth), not by your handler. Check the WAF sampled requests for the matched rule, confirm with `curl -sI` against the API path, and reach for the exclusions transform above. A 403 that DOES reach Lambda is an application authz decision, not WAF.
@@ -5,6 +5,8 @@
5
5
  ## When to Use
6
6
  Read this when deploying an everystack app to AWS for the first time or adding a new stage.
7
7
 
8
+ If your app.json has `web.output: "server"` (SSR pages or `+api.ts` routes — most Expo Router apps), read everystack://expo-server-deploy next: it covers what deploys where for that shape, including where your `+api.ts` routes run.
9
+
8
10
  ## Prerequisites
9
11
 
10
12
  - Node.js 20+, pnpm
@@ -57,6 +59,9 @@ export default $config({
57
59
  const router = new sst.aws.Router('Router', {
58
60
  routes: { '/*': api.url },
59
61
  });
62
+ // Adding WAF (`waf: { managedRules, rateLimitPerIp }`)? Read everystack://caching
63
+ // FIRST — AWS managed rules 403 every PostgREST request without the shipped
64
+ // exclusions transform, which must register BEFORE this Router.
60
65
 
61
66
  // CLI needs these outputs
62
67
  return {
@@ -0,0 +1,217 @@
1
+ # Deploying an Expo Router Server App
2
+
3
+ > How `web.output: "server"` maps to AWS: what deploys where, where your `+api.ts` routes run, and where the everystack handler sits among them.
4
+
5
+ ## When to Use
6
+
7
+ Read this when your app.json has `web.output: "server"` (SSR pages, `+api.ts` API routes) and you are deploying to AWS. This is the deploy chapter for the most common Expo Router app shape — if your app is fully static (`web.output: "static"`), everystack://deployment alone is enough.
8
+
9
+ ## The Two-Deployable Model
10
+
11
+ The single most important fact: **the Expo server export is NOT the Lambda handler.** There are two deployables with two verbs:
12
+
13
+ | Deployable | Contains | Verb | Frequency |
14
+ |---|---|---|---|
15
+ | Infrastructure + entrypoints | VPC, RDS, buckets, Router, the Lambda entry files in `server/` | `sst deploy` | Rarely (infra changes) |
16
+ | App code | Your entire Expo export: SSR pages, ALL `+api.ts` routes, client bundles | `everystack update --channel <name>` | Every ship (same verb as OTA) |
17
+
18
+ `everystack update` runs the expo export and publishes it as a compressed archive to the Updates bucket. At runtime, the Lambda's SSR fallback downloads that archive to `/tmp` (cached across warm invocations) and serves it with `expo-server` — Expo's own server runtime. Your app code never gets bundled into the Lambda; it ships like an OTA update.
19
+
20
+ ## Request Flow
21
+
22
+ ```
23
+ CloudFront Router
24
+ └─ Api Lambda (server/api.ts — createPluginLambdaHandler)
25
+ ├─ plugin routes claim their paths first (e.g. /api → everystack handler)
26
+ └─ everything else → ssrPlugin fallback → expo-server runs YOUR export:
27
+ ├─ SSR pages (with loader())
28
+ └─ your +api.ts routes (mcp+api.ts, app/games/[id]+api.ts, ...)
29
+ ```
30
+
31
+ **Your `+api.ts` routes ship and serve as-is.** They ride the published bundle and expo-server dispatches to them inside the Lambda. Nothing has to be collapsed into the everystack handler.
32
+
33
+ ## The Lambda Entrypoint
34
+
35
+ ```typescript
36
+ // server/api.ts — the Function handler for `sst deploy`
37
+ import { createPluginLambdaHandler, ssrPlugin } from '@everystack/server/plugin';
38
+ import { createAppContext, appPlugins } from './context';
39
+
40
+ export const handler = createPluginLambdaHandler({
41
+ context: () => createAppContext(),
42
+ plugins: appPlugins,
43
+ fallback: ssrPlugin(), // serves the published Expo export: SSR + your +api.ts routes
44
+ });
45
+ ```
46
+
47
+ ## One Plugin List, Two Venues
48
+
49
+ Define your plugins once; mount them in both places. Locally, Expo Router serves them through a catch-all route. Deployed, the Lambda mounts the same list and claims those paths BEFORE the fallback — so the deployed `/api` runs with SST Resource linking, never the bundle's copy.
50
+
51
+ ```typescript
52
+ // server/context.ts — shared by local dev and every Lambda
53
+ import { createDb } from '@everystack/server/db';
54
+ import { apiPlugin } from './plugins/api'; // your everystack createHandler, as a plugin
55
+ import { authPlugin } from '@everystack/auth/plugin';
56
+ import { schema } from '../db';
57
+
58
+ export async function createAppContext() {
59
+ const { db } = createDb(schema); // Resource-linked on Lambda, DATABASE_URL locally
60
+ return { db, schema, environment: process.env.ENVIRONMENT ?? 'dev', /* verifyToken, ... */ };
61
+ }
62
+
63
+ export const appPlugins = [authPlugin, apiPlugin];
64
+ ```
65
+
66
+ ```typescript
67
+ // server/plugins/api.ts — the everystack handler as ONE plugin among your routes
68
+ import { createHandler } from '@everystack/api';
69
+
70
+ export async function apiPlugin(ctx) {
71
+ const handler = createHandler(ctx.db, ctx.schema, { basePath: '/api', /* options */ });
72
+ return { routes: [{ path: '/api', handler }] };
73
+ }
74
+ ```
75
+
76
+ ```typescript
77
+ // app/api/[...path]+api.ts — LOCAL DEV venue (Expo Router serves this; the
78
+ // deployed Lambda claims /api first, so this copy never runs in production)
79
+ import { createPluginHandler } from '@everystack/server/plugin';
80
+ import { createAppContext, appPlugins } from '../../server/context';
81
+
82
+ const handler = createPluginHandler({ context: createAppContext, plugins: appPlugins });
83
+ export const GET = handler; export const POST = handler;
84
+ export const PATCH = handler; export const DELETE = handler;
85
+ ```
86
+
87
+ Your OTHER `+api.ts` routes (`app/mcp+api.ts`, `app/games/[id]+api.ts`, ...) need no counterpart: local dev serves them directly, deployed they serve through the fallback.
88
+
89
+ ## The Ops Entrypoint (db:migrate, db:seed, console)
90
+
91
+ `everystack db:migrate` invokes a dedicated, privileged Lambda over IAM — it has no public URL and holds the operator credential so the API Lambda never does.
92
+
93
+ ```typescript
94
+ // server/ops.ts
95
+ import { Resource } from 'sst';
96
+ import { createPluginLambdaHandler, dbPlugin } from '@everystack/server/plugin';
97
+ import { createAppContext, appPlugins } from './context';
98
+
99
+ export const handler = createPluginLambdaHandler({
100
+ http: false, // actions-only: rejects HTTP, answers IAM invokes
101
+ context: () => createAppContext({ admin: true }),
102
+ plugins: [
103
+ ...appPlugins,
104
+ dbPlugin({
105
+ migrationsFolder: 'drizzle',
106
+ seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
107
+ // ^ lazy import — a top-level import would run at Lambda INIT and crash the boot
108
+ backupBucket: Resource.Backups?.name,
109
+ // ^ registers db:backup / db:backups / db:restore. Omit it and those actions
110
+ // return "backup storage not configured"; omit the whole ops Lambda and the
111
+ // CLI can't reach them at all — it reports "Unknown action" (see Gotchas).
112
+ }),
113
+ ],
114
+ });
115
+ ```
116
+
117
+ ## Minimal sst.config.ts
118
+
119
+ The complete infrastructure for this app shape (V2: server app + PostgreSQL). Copy, rename, deploy.
120
+
121
+ ```typescript
122
+ /// <reference path="./.sst/platform/config.d.ts" />
123
+ export default $config({
124
+ app(input) {
125
+ return {
126
+ name: 'my-app',
127
+ removal: input?.stage === 'production' ? 'retain' : 'remove',
128
+ home: 'aws',
129
+ };
130
+ },
131
+ async run() {
132
+ const vpc = new sst.aws.Vpc('Vpc');
133
+ const database = new sst.aws.Postgres('Database', {
134
+ vpc,
135
+ // Local dev: `sst dev` uses this instead of RDS
136
+ dev: { host: 'localhost', port: 5432, username: 'postgres', password: 'postgres', database: 'my_app_dev' },
137
+ });
138
+
139
+ const updates = new sst.aws.Bucket('Updates'); // published Expo exports (server bundles)
140
+ const clientBundles = new sst.aws.Bucket('ClientBundles', { access: 'cloudfront' });
141
+ const backups = new sst.aws.Bucket('Backups'); // private — logical pg_dump backups (db:backup)
142
+
143
+ const jwtSecret = new sst.Secret('JwtSecret');
144
+ // Least-privilege API credential — written by `everystack db:provision`.
145
+ const databaseUrl = new sst.Secret('DATABASE_URL');
146
+
147
+ const router = new sst.aws.Router('Router', {
148
+ // Custom domain: Route 53 is the default DNS adapter. With your zone
149
+ // delegated to Route 53, this one line provisions the ACM certificate
150
+ // and DNS records automatically.
151
+ domain: $app.stage === 'production'
152
+ ? 'my-app.com'
153
+ : `${$app.stage}.my-app.com`,
154
+ });
155
+
156
+ const api = new sst.aws.Function('Api', {
157
+ handler: 'server/api.handler',
158
+ runtime: 'nodejs20.x',
159
+ timeout: '30 seconds',
160
+ vpc,
161
+ link: [databaseUrl, updates, clientBundles, jwtSecret],
162
+ url: { router: { instance: router, path: '/' } },
163
+ environment: { ENVIRONMENT: $app.stage, SITE_URL: router.url },
164
+ });
165
+
166
+ const ops = new sst.aws.Function('Ops', {
167
+ handler: 'server/ops.handler',
168
+ runtime: 'nodejs20.x',
169
+ timeout: '120 seconds',
170
+ vpc,
171
+ link: [database, databaseUrl, updates, clientBundles, jwtSecret, backups],
172
+ copyFiles: [{ from: 'drizzle', to: 'drizzle' }], // migrations must ship with the ops bundle
173
+ });
174
+
175
+ // The CLI discovers resources through these outputs. The first five are required;
176
+ // backupsBucket unlocks db:backup / db:swap / db:fork (also auto-discovered by bucket name).
177
+ return {
178
+ routerUrl: router.url,
179
+ apiFunctionName: api.name,
180
+ opsFunctionName: ops.name,
181
+ updatesBucket: updates.name,
182
+ clientBundlesBucket: clientBundles.name,
183
+ backupsBucket: backups.name,
184
+ };
185
+ },
186
+ });
187
+ ```
188
+
189
+ ## Local vs Deployed Database Connection
190
+
191
+ `createDb()` from `@everystack/server/db` serves both venues with one code path (requires `@everystack/server` >= 0.4.4):
192
+
193
+ - **Deployed:** the linked `DATABASE_URL` secret resolves via SST Resource; TLS defaults to `require` (RDS).
194
+ - **Local:** set `DATABASE_URL=postgres://user@localhost:5432/my_app_dev?sslmode=disable`. An explicit `sslmode` in the URL wins; the handler code does not change.
195
+
196
+ Do not hand-build a `postgres()` client from env vars to escape TLS — the URL is the contract.
197
+
198
+ ## Deploy Loop
199
+
200
+ ```bash
201
+ sst deploy --stage dev # infrastructure + entrypoints (rare)
202
+ everystack db:provision --stage dev # mint the least-privilege API credential (once per stage)
203
+ everystack update --channel dev # export + publish the app code (every ship)
204
+ everystack db:migrate --stage dev # run migrations via the ops Lambda
205
+ everystack db:seed --stage dev # dev only
206
+ ```
207
+
208
+ Subsequent app-code ships are `everystack update` alone — no `sst deploy`, no Lambda redeploy.
209
+
210
+ ## Gotchas
211
+
212
+ - **Nothing serves until the first `everystack update`** — the fallback has no bundle to download yet, so `/` returns 404 while `/api` (plugin-claimed) already works.
213
+ - **A path claimed by a plugin never reaches your bundle's route.** If you mount the everystack handler at `/api`, a bundle route at `app/api/foo+api.ts` is shadowed in production (it still serves in local dev unless the catch-all shadows it there too — keep the surfaces aligned).
214
+ - **Lazy-load your seed** (shown above). A top-level `import { runSeed }` in an entrypoint runs at Lambda INIT, tries to reach the database before configuration, and kills the boot.
215
+ - **`copyFiles` the migrations folder** into the ops function, or `db:migrate` fails with ENOENT.
216
+ - **`db:backup`/`db:export`/`db:restore` run in the ephemeral Fargate Task lane — wire `dbTaskPlugin({ backupBucket })` on the ops function.** They are pg_dump/pg_restore work; the ops Lambda holds no pg binaries. `everystack db:backup` reporting `Unknown action: db:backup` means the CLI reached a function with no `dbTaskPlugin` mounted (usually: no ops function at all, so `opsFunctionName` fell back to the Api Lambda). Deploy the `Ops` function above with `dbTaskPlugin`. If instead it reports `backup storage not configured`, `dbTaskPlugin` is mounted but `backupBucket` is unset — pass `() => Resource.Backups.name`. The Task image's `pg_dump`-vs-server compatibility is verified on demand by `everystack task:probe` (there is no Lambda pg_dump layer).
217
+ - **`+api.ts` routes run with the bundle's environment**, not per-route SST links. Resource-linked work (secrets, buckets) belongs in plugins on the Lambda side; keep bundle routes to app logic over the request.
@@ -140,6 +140,20 @@ const RESOURCES: ResourceDef[] = [
140
140
  'SST deployment: sst.config.ts setup, secrets, stages (dev/production), CloudFront, Lambda configuration, VPC, RDS Aurora Serverless, resource linking.',
141
141
  filename: 'deployment.md',
142
142
  },
143
+ {
144
+ uri: 'everystack://caching',
145
+ name: 'Caching & WAF (CloudFront Edge)',
146
+ description:
147
+ 'READ BEFORE enabling WAF on the Router. AWS managed rules 403 every PostgREST/JSON API request without the shipped exclusions transform (applyWafExclusions/applyWafRateLimit429 from @everystack/server/cdn). Also: edge auth, response caching by auth state, epoch invalidation, and debugging edge 403s.',
148
+ filename: 'caching.md',
149
+ },
150
+ {
151
+ uri: 'everystack://expo-server-deploy',
152
+ name: 'Deploying an Expo Router Server App',
153
+ description:
154
+ 'How web.output:"server" maps to AWS: the two-deployable model (sst deploy vs everystack update), where +api.ts routes run (the SSR fallback serves the published bundle), the one-plugin-list/two-venues pattern (local catch-all route + deployed Lambda entrypoint), the ops entrypoint for db:migrate, custom domains on the Router, and the local-vs-deployed database URL (sslmode). Read this when deploying any Expo Router app with SSR or +api.ts routes.',
155
+ filename: 'expo-server-deploy.md',
156
+ },
143
157
  {
144
158
  uri: 'everystack://admin',
145
159
  name: 'Admin Dashboard',
@@ -263,8 +263,8 @@ RESET ROLE;
263
263
  - [ ] Bcrypt cost >= 10
264
264
 
265
265
  ### Infrastructure
266
- - [ ] WAF managed rules (exclude SQLi_QUERYARGUMENTS on API paths for PostgREST syntax)
267
- - [ ] Rate limiting (1000 req/5 min per IP)
266
+ - [ ] WAF managed rules set the JSON-API false-positive BODY/HEADER rules (SQLi_BODY, SizeRestrictions_BODY, GenericRFI_BODY, Log4JRCE_HEADER) to COUNT via `applyWafExclusions`, or they 403 every PostgREST request. See everystack://caching (read BEFORE enabling `waf` on the Router).
267
+ - [ ] Rate limiting — 429 not 403 (applyWafRateLimit429); ~2000 req/5 min per IP. See everystack://caching.
268
268
  - [ ] CloudFront edge JWT verification for latency-sensitive paths
269
269
  - [ ] Database credentials via SST Resource linking
270
270
  - [ ] No debug endpoints or env dumps in production