@everystack/server 0.3.5 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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>",
@@ -143,8 +143,8 @@
143
143
  "sst": "4.13.1",
144
144
  "ts-jest": "29.4.9",
145
145
  "typescript": "5.9.3",
146
- "@everystack/cli": "0.3.31",
147
- "@everystack/auth": "0.3.1"
146
+ "@everystack/auth": "0.4.0",
147
+ "@everystack/cli": "0.4.3"
148
148
  },
149
149
  "scripts": {
150
150
  "test": "jest",
package/src/cdn/index.ts CHANGED
@@ -38,6 +38,7 @@ export {
38
38
  export {
39
39
  stripSstDeadCode,
40
40
  applyWafExclusions,
41
+ applyWafRateLimit429,
41
42
  WAF_JSON_API_OVERRIDES,
42
43
  } from './transforms';
43
44
 
@@ -155,3 +155,60 @@ export function applyWafExclusions(
155
155
  return rule;
156
156
  });
157
157
  }
158
+
159
+ /**
160
+ * Rewrite rate-based-rule block actions to answer 429 + Retry-After instead
161
+ * of WAF's default 403.
162
+ *
163
+ * Google is explicit that the status code matters for SEO ("Don't use 403s
164
+ * or 404s for rate limiting"): every 4xx except 429 signals the content no
165
+ * longer exists and deindexes the URL over time, while 429/503 mean "back
166
+ * off and retry" — crawling slows almost immediately and Google retries for
167
+ * ~2 days before dropping anything. A per-IP rate rule clears within
168
+ * minutes of the burst ending, so a legitimate crawler never sees multi-day
169
+ * 429s. Getting this wrong turns a load shield into a deindexing engine.
170
+ *
171
+ * Only rules with a rateBasedStatement AND a block action are touched;
172
+ * managed rule groups (overrideAction) and count-mode rules pass through.
173
+ *
174
+ * @param rules Resolved WAF rules array from Pulumi.
175
+ * @param retryAfterSeconds Retry-After header value. Default 120.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * // In sst.config.ts — composes with applyWafExclusions in one transform.
180
+ * // MUST be registered BEFORE `new sst.aws.Router` (a $transform only
181
+ * // applies to resources created after it, and the Router creates the WebAcl).
182
+ * import { applyWafExclusions, applyWafRateLimit429 } from '@everystack/server/cdn';
183
+ *
184
+ * $transform(aws.wafv2.WebAcl, (args: any) => {
185
+ * if (!args.rules) return;
186
+ * args.rules = $resolve(args.rules).apply(
187
+ * (rules) => applyWafRateLimit429(applyWafExclusions(rules)),
188
+ * );
189
+ * });
190
+ * ```
191
+ */
192
+ export function applyWafRateLimit429(
193
+ rules: any[],
194
+ retryAfterSeconds = 120,
195
+ ): any[] {
196
+ return rules.map((rule: any) => {
197
+ if (!rule.statement?.rateBasedStatement || !rule.action?.block) return rule;
198
+ return {
199
+ ...rule,
200
+ action: {
201
+ ...rule.action,
202
+ block: {
203
+ ...rule.action.block,
204
+ customResponse: {
205
+ responseCode: 429,
206
+ responseHeaders: [
207
+ { name: 'Retry-After', value: String(retryAfterSeconds) },
208
+ ],
209
+ },
210
+ },
211
+ },
212
+ };
213
+ });
214
+ }
package/src/db-doctor.ts CHANGED
@@ -67,8 +67,40 @@ export interface DoctorProbe {
67
67
  * `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
68
68
  * (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
69
69
  * pin its search_path. Absent on an older probe (the check is then skipped).
70
+ *
71
+ * B4 also reads it globally: an unpinned SECDEF ANYWHERE is the runtime echo of the
72
+ * compile gate (defineFunction refuses to compile one) — warn-level outside the
73
+ * functions-teeth schemas, which keep their fail.
70
74
  */
71
75
  secdefFunctions?: SecdefFn[];
76
+ /**
77
+ * Functions PUBLIC can EXECUTE — PostgreSQL's silent-open default (a NULL proacl MEANS
78
+ * EXECUTE to PUBLIC; the query expands acldefault to see it). The declared derived layer
79
+ * revokes it unconditionally; anything here was created outside it or hand-granted back.
80
+ * Absent on an older probe (the check is then skipped). B4.
81
+ */
82
+ publicExecutableFunctions?: PublicExecutableFn[];
83
+ /**
84
+ * Views/matviews NO app role can read (no non-owner ACL entries) — superuser-only
85
+ * relations, the runtime echo of the derived read-gap gate. Declared-dark descriptors
86
+ * (`private: true`) also land here by design; the warn names both resolutions. Absent on
87
+ * an older probe (the check is then skipped). B4.
88
+ */
89
+ darkRelations?: DarkRelation[];
90
+ }
91
+
92
+ /** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
93
+ export interface PublicExecutableFn {
94
+ schema: string;
95
+ name: string;
96
+ secdef: boolean;
97
+ }
98
+
99
+ /** A view ('v') or matview ('m') with no grants to any non-owner role. */
100
+ export interface DarkRelation {
101
+ schema: string;
102
+ name: string;
103
+ kind: string;
72
104
  }
73
105
 
74
106
  /** A SECURITY DEFINER function and whether its `search_path` is pinned. */
@@ -143,6 +175,59 @@ WHERE p.prosecdef
143
175
  ORDER BY n.nspname, p.proname
144
176
  `.trim();
145
177
 
178
+ /**
179
+ * Functions PUBLIC can EXECUTE. The trap this exists for: a NULL `proacl` is not "no
180
+ * grants" — it is PostgreSQL's BUILT-IN DEFAULT, which includes EXECUTE to PUBLIC, so the
181
+ * query expands `acldefault('f', proowner)` before exploding. Trigger-returning functions
182
+ * are excluded (not directly callable — PUBLIC EXECUTE on them is inert), as are
183
+ * extension-owned functions (not ours to judge) and system schemas. `secdef` marks the
184
+ * dangerous half: PUBLIC-callable code that runs as its OWNER.
185
+ */
186
+ export const PUBLIC_EXECUTABLE_FUNCTIONS_SQL = `
187
+ SELECT
188
+ n.nspname AS schema,
189
+ p.proname AS name,
190
+ p.prosecdef AS secdef
191
+ FROM pg_proc p
192
+ JOIN pg_namespace n ON n.oid = p.pronamespace
193
+ CROSS JOIN LATERAL aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
194
+ WHERE p.prokind IN ('f', 'p')
195
+ AND a.grantee = 0
196
+ AND a.privilege_type = 'EXECUTE'
197
+ AND p.prorettype <> 'trigger'::regtype
198
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
199
+ AND n.nspname NOT LIKE 'pg_%'
200
+ AND NOT EXISTS (
201
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = p.oid AND dep.deptype = 'e'
202
+ )
203
+ GROUP BY n.nspname, p.proname, p.prosecdef
204
+ ORDER BY n.nspname, p.proname
205
+ `.trim();
206
+
207
+ /**
208
+ * Views/matviews with no ACL entry for any non-owner role — readable by the superuser
209
+ * and nobody else. The runtime echo of the derived read-gap gate: on a modeled project
210
+ * this is either an object created outside the read model or a declared-dark descriptor.
211
+ */
212
+ export const DARK_RELATIONS_SQL = `
213
+ SELECT
214
+ n.nspname AS schema,
215
+ c.relname AS name,
216
+ c.relkind AS kind
217
+ FROM pg_class c
218
+ JOIN pg_namespace n ON n.oid = c.relnamespace
219
+ WHERE c.relkind IN ('v', 'm')
220
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
221
+ AND n.nspname NOT LIKE 'pg_%'
222
+ AND NOT EXISTS (
223
+ SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee <> c.relowner
224
+ )
225
+ AND NOT EXISTS (
226
+ SELECT 1 FROM pg_depend dep WHERE dep.objid = c.oid AND dep.deptype = 'e'
227
+ )
228
+ ORDER BY n.nspname, c.relname
229
+ `.trim();
230
+
146
231
  /** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
147
232
  export function pgBool(v: unknown): boolean {
148
233
  return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
@@ -385,6 +470,75 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
385
470
  }
386
471
  }
387
472
 
473
+ // B4 — the derived layer's runtime echoes (read-model-everywhere). Each check echoes a
474
+ // compile/check gate on the LIVE database: the gates cover what the models declare;
475
+ // these cover what actually exists, however it got there.
476
+
477
+ // PUBLIC-executable functions: fail when any runs as its owner (SECDEF — PUBLIC-callable
478
+ // privileged code), warn otherwise (invoker functions still run with the caller's
479
+ // rights, but PUBLIC EXECUTE is never a decision someone reviewed).
480
+ if (probe.publicExecutableFunctions) {
481
+ const offenders = probe.publicExecutableFunctions;
482
+ if (offenders.length > 0) {
483
+ const label = (f: PublicExecutableFn): string => `${f.schema}.${f.name}${f.secdef ? ' (SECURITY DEFINER)' : ''}`;
484
+ checks.push({
485
+ name: 'Functions are not PUBLIC-executable',
486
+ status: offenders.some((f) => f.secdef) ? 'fail' : 'warn',
487
+ detail: `PUBLIC can EXECUTE (PostgreSQL's silent-open default — a NULL proacl grants it): ${offenders.map(label).join(', ')}`,
488
+ remediation: offenders.map((f) => `REVOKE ALL ON FUNCTION ${f.schema}.${f.name} FROM PUBLIC;`).join(' ')
489
+ + ` Then grant the intended callers — or declare the function with defineFunction + can('execute', { role }) so the compiler emits both.`,
490
+ });
491
+ } else {
492
+ checks.push({
493
+ name: 'Functions are not PUBLIC-executable',
494
+ status: 'pass',
495
+ detail: 'no function is executable by PUBLIC',
496
+ });
497
+ }
498
+ }
499
+
500
+ // Dark relations: readable by the superuser and nobody else — either created outside
501
+ // the read model, or declared dark on purpose. Warn, because only the author knows.
502
+ if (probe.darkRelations) {
503
+ if (probe.darkRelations.length > 0) {
504
+ checks.push({
505
+ name: 'Derived relations are readable or declared dark',
506
+ status: 'warn',
507
+ detail: `no app role can read: ${probe.darkRelations.map((r) => `${r.schema}.${r.name}`).join(', ')} — superuser-only relation(s)`,
508
+ remediation: `Declare can('read') on the descriptor (the compiler emits the GRANT), or private: true if it is intentionally dark — either way the decision lands in source.`,
509
+ });
510
+ } else {
511
+ checks.push({
512
+ name: 'Derived relations are readable or declared dark',
513
+ status: 'pass',
514
+ detail: 'every view/matview is granted to at least one role',
515
+ });
516
+ }
517
+ }
518
+
519
+ // The global SECDEF-pin echo: the compile gate refuses to DECLARE an unpinned SECDEF;
520
+ // this catches the ones that exist anyway (hand-created, pre-descriptor). The
521
+ // functions-teeth schemas keep their fail above — excluded here, never double-reported.
522
+ if (probe.secdefFunctions) {
523
+ const teeth = new Set(options.functionSchemas ?? []);
524
+ const unpinned = probe.secdefFunctions.filter((f) => !f.hasSearchPath && !teeth.has(f.schema));
525
+ if (unpinned.length > 0) {
526
+ checks.push({
527
+ name: 'Every SECURITY DEFINER function pins search_path',
528
+ status: 'warn',
529
+ detail: `unpinned SECDEF function(s) — owner-privilege hijack vector: ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
530
+ remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = pg_catalog;`).join(' ')
531
+ + ` Better: declare it with defineFunction({ security: 'definer', searchPath: [...] }) — the compiler refuses an unpinned one.`,
532
+ });
533
+ } else {
534
+ checks.push({
535
+ name: 'Every SECURITY DEFINER function pins search_path',
536
+ status: 'pass',
537
+ detail: `all ${probe.secdefFunctions.length} SECURITY DEFINER function(s) pin their search_path`,
538
+ });
539
+ }
540
+ }
541
+
388
542
  return {
389
543
  api,
390
544
  admin,
@@ -393,6 +547,24 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
393
547
  };
394
548
  }
395
549
 
550
+ /** Map a PUBLIC_EXECUTABLE_FUNCTIONS_SQL row to PublicExecutableFn. */
551
+ export function rowToPublicExecutableFn(row: any): PublicExecutableFn {
552
+ return {
553
+ schema: String(row.schema),
554
+ name: String(row.name),
555
+ secdef: pgBool(row.secdef),
556
+ };
557
+ }
558
+
559
+ /** Map a DARK_RELATIONS_SQL row to DarkRelation. */
560
+ export function rowToDarkRelation(row: any): DarkRelation {
561
+ return {
562
+ schema: String(row.schema),
563
+ name: String(row.name),
564
+ kind: String(row.kind),
565
+ };
566
+ }
567
+
396
568
  /** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
397
569
  export function rowToSecdefFn(row: any): SecdefFn {
398
570
  return {
package/src/plugin.ts CHANGED
@@ -186,6 +186,18 @@ export function createPluginLambdaHandler(
186
186
  ): Promise<APIGatewayProxyStructuredResultV2 | unknown> => {
187
187
  // Direct CLI invoke via Lambda Invoke — IAM is the auth layer.
188
188
  if ('_action' in event && typeof event._action === 'string') {
189
+ // Built-in health probe — `everystack deploy` invokes it right after a deploy so a bundle
190
+ // that can't initialize is caught at deploy time, not at the next db verb. Handled before
191
+ // the action lookup (it must answer even when initialization itself is the failure) and
192
+ // reserved: an app action named `_health` is never reached.
193
+ if (event._action === '_health') {
194
+ try {
195
+ const { actions } = await ensureInitialized();
196
+ return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort() };
197
+ } catch (error) {
198
+ return { ok: false, error: String(error) };
199
+ }
200
+ }
189
201
  const { ctx, actions } = await ensureInitialized();
190
202
  const actionName = event._action;
191
203
  const actionHandler = actions[actionName];
@@ -505,7 +517,14 @@ export interface DbPluginOptions {
505
517
  * Contributes: migrate, seed, db:reset, db:psql, db:query, console.
506
518
  *
507
519
  * Usage:
508
- * dbPlugin({ migrationsFolder: join(__dirname, '..', 'drizzle'), seed: runSeed })
520
+ * dbPlugin({
521
+ * migrationsFolder: join(__dirname, '..', 'drizzle'),
522
+ * // Load the seed module LAZILY. Seed scripts are often also runnable directly
523
+ * // (`node db/seed.ts`) and may connect or process.exit at import time — a static
524
+ * // `import { runSeed }` runs that at Lambda INIT and crashes the ops plane before
525
+ * // any handler exists. The dynamic import defers it to the moment db:seed runs.
526
+ * seed: async (db, schema) => (await import('../db/seed.js')).runSeed(db, schema),
527
+ * })
509
528
  */
510
529
  export function dbPlugin(options: DbPluginOptions): Plugin {
511
530
  return async (ctx: PluginContext): Promise<PluginContribution> => {
@@ -662,7 +681,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
662
681
  const { sql } = await import('drizzle-orm');
663
682
  const {
664
683
  buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
684
+ rowToPublicExecutableFn, rowToDarkRelation,
665
685
  CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
686
+ PUBLIC_EXECUTABLE_FUNCTIONS_SQL, DARK_RELATIONS_SQL,
666
687
  } = await import('./db-doctor');
667
688
  const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
668
689
 
@@ -686,6 +707,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
686
707
  // SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
687
708
  // to assert every SECDEF function in a functions-written schema pins its search_path.
688
709
  const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
710
+ // B4 — the derived layer's runtime echoes: PUBLIC-executable functions (NULL proacl
711
+ // included via acldefault) and superuser-only views/matviews.
712
+ const publicExecutableFunctions = rowsOf(await opsDb.execute(sql.raw(PUBLIC_EXECUTABLE_FUNCTIONS_SQL))).map(rowToPublicExecutableFn);
713
+ const darkRelations = rowsOf(await opsDb.execute(sql.raw(DARK_RELATIONS_SQL))).map(rowToDarkRelation);
689
714
 
690
715
  // Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
691
716
  let apiAmbientRead: { table: string; accessible: boolean } | undefined;
@@ -701,7 +726,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
701
726
  }
702
727
 
703
728
  return buildDoctorReport(
704
- { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
729
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
705
730
  { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
706
731
  );
707
732
  };