@everystack/server 0.3.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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>",
@@ -74,6 +74,11 @@
74
74
  "default": "./src/media.ts"
75
75
  }
76
76
  },
77
+ "scripts": {
78
+ "test": "jest",
79
+ "build": "tsc --build",
80
+ "lint": "tsc --noEmit"
81
+ },
77
82
  "peerDependencies": {
78
83
  "esbuild": ">=0.20.0",
79
84
  "@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
@@ -129,6 +134,8 @@
129
134
  "@aws-sdk/client-s3": "3.1053.0",
130
135
  "@aws-sdk/lib-storage": "3.1053.0",
131
136
  "@aws-sdk/signature-v4a": "3.1063.0",
137
+ "@everystack/auth": "workspace:*",
138
+ "@everystack/cli": "workspace:*",
132
139
  "@types/aws-lambda": "8.10.161",
133
140
  "@types/jest": "29.5.14",
134
141
  "@types/node": "22.19.18",
@@ -138,13 +145,6 @@
138
145
  "postgres": "3.4.9",
139
146
  "sst": "4.13.1",
140
147
  "ts-jest": "29.4.9",
141
- "typescript": "5.9.3",
142
- "@everystack/auth": "0.2.6",
143
- "@everystack/cli": "0.3.0"
144
- },
145
- "scripts": {
146
- "test": "jest",
147
- "build": "tsc --build",
148
- "lint": "tsc --noEmit"
148
+ "typescript": "5.9.3"
149
149
  }
150
- }
150
+ }
package/src/db-doctor.ts CHANGED
@@ -62,6 +62,20 @@ export interface DoctorProbe {
62
62
  * `USING(true)` role like the master). Absent when there is no RLS table to probe.
63
63
  */
64
64
  apiAmbientRead?: { table: string; accessible: boolean };
65
+ /**
66
+ * Every SECURITY DEFINER function (schema, name, whether search_path is pinned). The
67
+ * `functions` teeth read this: for each schema that owns a `writtenBy: 'functions'` table
68
+ * (passed as `functionSchemas`), the package's SECDEF functions must exist and each must
69
+ * pin its search_path. Absent on an older probe (the check is then skipped).
70
+ */
71
+ secdefFunctions?: SecdefFn[];
72
+ }
73
+
74
+ /** A SECURITY DEFINER function and whether its `search_path` is pinned. */
75
+ export interface SecdefFn {
76
+ schema: string;
77
+ name: string;
78
+ hasSearchPath: boolean;
65
79
  }
66
80
 
67
81
  export type CheckStatus = 'pass' | 'fail' | 'warn';
@@ -106,6 +120,29 @@ WHERE c.relkind = 'r'
106
120
  ORDER BY n.nspname, c.relname
107
121
  `.trim();
108
122
 
123
+ /**
124
+ * Every SECURITY DEFINER function with whether its `search_path` is pinned. A SECDEF
125
+ * function runs as its owner (above RLS), so an unpinned `search_path` is a hijack vector:
126
+ * a caller can shadow an unqualified name the body calls and run code as the owner. This is
127
+ * the exact bug class that bit dev (migration 0022 went unapplied via a journal gap, leaving
128
+ * the auth SECDEF functions mutable) — the `functions` teeth read this to catch it on the
129
+ * deployed database, not just in the migration SQL (which `security:audit` already covers).
130
+ */
131
+ export const SECDEF_FUNCTIONS_SQL = `
132
+ SELECT
133
+ n.nspname AS schema,
134
+ p.proname AS name,
135
+ EXISTS (
136
+ SELECT 1 FROM unnest(COALESCE(p.proconfig, '{}'::text[]) ) cfg WHERE cfg LIKE 'search_path=%'
137
+ ) AS has_search_path
138
+ FROM pg_proc p
139
+ JOIN pg_namespace n ON n.oid = p.pronamespace
140
+ WHERE p.prosecdef
141
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
142
+ AND n.nspname NOT LIKE 'pg_%'
143
+ ORDER BY n.nspname, p.proname
144
+ `.trim();
145
+
109
146
  /** Coerce a Postgres boolean (true | 't' | 'true' | 1) to a JS boolean. */
110
147
  export function pgBool(v: unknown): boolean {
111
148
  return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
@@ -170,12 +207,20 @@ ORDER BY n.nspname, c.relname;
170
207
  /** Options for the report builder. */
171
208
  export interface DoctorReportOptions {
172
209
  /**
173
- * App-owned tables that are intentionally ENABLE-not-FORCE: an operational table whose
174
- * owner (e.g. the worker, or a SECURITY DEFINER function) must read/write it, where FORCE
175
- * would block the owner. These are added to the framework default (`auth.users`) and not
176
- * flagged by the FORCE check. Example: `['ops.runs']` (the jobs queue the worker writes).
210
+ * Tables that are intentionally ENABLE-not-FORCE: a table whose owner (the worker, or a
211
+ * SECURITY DEFINER function) must read/write it, where FORCE would block the owner. These
212
+ * are not flagged by the FORCE check. Read from each model's `writtenBy` declaration via
213
+ * `deriveForceRlsCarveouts(models)` including `auth.users` and the six other `auth.*`
214
+ * tables (all `writtenBy: 'functions'`), `ops.runs`, `dist.blobs`, and the notification
215
+ * tables — so the list can't drift from the contract.
177
216
  */
178
217
  forceRlsCarveouts?: string[];
218
+ /**
219
+ * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`), via
220
+ * `deriveFunctionSchemas(models)`. The `functions` teeth assert each such schema has
221
+ * SECDEF functions and that every one pins its `search_path`. Omitted/empty skips the check.
222
+ */
223
+ functionSchemas?: string[];
179
224
  }
180
225
 
181
226
  /** Build the green/red report from a gathered probe. Pure. */
@@ -272,9 +317,11 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
272
317
  // ENABLE-but-not-FORCE: the API/SSR roles are non-owners and are still gated by its
273
318
  // policies regardless of FORCE. `auth.users` (the @everystack/auth credential table) is
274
319
  // the framework's one such table.
275
- // auth.users is the framework's own credential carve-out (always); apps add operational
276
- // tables whose owner must write them (e.g. ops.runs, the worker-written jobs queue).
277
- const FORCE_RLS_CARVEOUTS = new Set(['auth.users', ...(options.forceRlsCarveouts ?? [])]);
320
+ // The carveouts come entirely from the models' `writtenBy` declarations
321
+ // (deriveForceRlsCarveouts): auth.users and the six other auth.* tables (functions),
322
+ // ops.runs and dist.blobs (worker), the notification tables. No hand-list, no framework
323
+ // default that could drift from — or fall short of — the modeled set.
324
+ const FORCE_RLS_CARVEOUTS = new Set(options.forceRlsCarveouts ?? []);
278
325
  const allUnforced = probe.forceRls.filter((r) => r.enabled && !r.forced);
279
326
  const carved = allUnforced.filter((r) => FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
280
327
  const unforced = allUnforced.filter((r) => !FORCE_RLS_CARVEOUTS.has(`${r.schema}.${r.table}`));
@@ -302,6 +349,42 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
302
349
  });
303
350
  }
304
351
 
352
+ // The `functions` teeth — the other half of `writtenBy: 'functions'`. A table written only
353
+ // by SECURITY DEFINER functions is ENABLE-not-FORCE (carved out above) precisely BECAUSE the
354
+ // package's functions run as the owner. That bargain is only safe if those functions exist
355
+ // and each pins its search_path; an unpinned one is an owner-privilege hijack vector. This
356
+ // asserts it on the deployed database — the gate that was missing when 0022 silently went
357
+ // unapplied and left the auth SECDEF functions mutable.
358
+ if (probe.secdefFunctions && (options.functionSchemas?.length ?? 0) > 0) {
359
+ const schemas = new Set(options.functionSchemas);
360
+ const relevant = probe.secdefFunctions.filter((f) => schemas.has(f.schema));
361
+ const unpinned = relevant.filter((f) => !f.hasSearchPath);
362
+ const present = new Set(relevant.map((f) => f.schema));
363
+ const missing = [...schemas].filter((s) => !present.has(s)).sort();
364
+ if (unpinned.length > 0) {
365
+ checks.push({
366
+ name: 'SECURITY DEFINER functions pin search_path',
367
+ status: 'fail',
368
+ detail: `SECDEF function(s) in a functions-written schema have a mutable search_path (owner-privilege hijack vector): ${unpinned.map((f) => `${f.schema}.${f.name}`).join(', ')}`,
369
+ remediation: unpinned.map((f) => `ALTER FUNCTION ${f.schema}.${f.name} SET search_path = ${f.schema}, public, pg_temp;`).join(' ')
370
+ + ' (Then redeploy so the migration that defines them carries the pin.)',
371
+ });
372
+ } else if (missing.length > 0) {
373
+ checks.push({
374
+ name: 'SECURITY DEFINER functions exist for functions-written tables',
375
+ status: 'fail',
376
+ detail: `no SECDEF functions found in schema(s) that own a writtenBy:'functions' table: ${missing.join(', ')} — the package SQL that writes those tables was not applied`,
377
+ remediation: `Apply the package's functions migration (e.g. everystack db:migrate). A functions-written table with no SECDEF functions cannot be written at all.`,
378
+ });
379
+ } else {
380
+ checks.push({
381
+ name: 'SECURITY DEFINER functions pin search_path',
382
+ status: 'pass',
383
+ detail: `all ${relevant.length} SECDEF function(s) in ${[...present].sort().join(', ')} pin their search_path`,
384
+ });
385
+ }
386
+ }
387
+
305
388
  return {
306
389
  api,
307
390
  admin,
@@ -310,6 +393,15 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
310
393
  };
311
394
  }
312
395
 
396
+ /** Map a SECDEF_FUNCTIONS_SQL row to SecdefFn. */
397
+ export function rowToSecdefFn(row: any): SecdefFn {
398
+ return {
399
+ schema: String(row.schema),
400
+ name: String(row.name),
401
+ hasSearchPath: pgBool(row.has_search_path),
402
+ };
403
+ }
404
+
313
405
  /** Map a CONN_SQL row to ConnInfo (tolerant of pg driver boolean encodings). */
314
406
  export function rowToConnInfo(row: any): ConnInfo {
315
407
  return {
package/src/image.ts CHANGED
@@ -155,6 +155,63 @@ export function parseParams(query: Record<string, string | undefined>): Transfor
155
155
  return params;
156
156
  }
157
157
 
158
+ /**
159
+ * ISOBMFF `ftyp` brands that mean "HEVC-coded HEIF" — i.e. a HEIC still image
160
+ * (iPhones shoot these by default). Sharp reports all of these as format
161
+ * `'heif'`, and our HEIC rescue path (heic-decode) can decode them.
162
+ */
163
+ const HEIC_BRANDS = new Set([
164
+ 'heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs', 'mif1', 'msf1',
165
+ ]);
166
+ const AVIF_BRANDS = new Set(['avif', 'avis']);
167
+
168
+ /**
169
+ * Identify an image format from its magic bytes, independently of Sharp.
170
+ *
171
+ * Sharp builds without libheif cannot even *identify* a HEIF container —
172
+ * `sharp(buf).metadata()` throws, format detection yields `undefined`, and a
173
+ * real HEIC photo is misrouted down the non-image passthrough path (413 when
174
+ * over the passthrough cap, or raw `image/heic` bytes that only Safari renders).
175
+ * Trusting the bytes instead of Sharp makes HEIC detection build-independent and
176
+ * lets the existing `heif` rescue path fire.
177
+ *
178
+ * Returns a Sharp-compatible format string (`jpeg`, `png`, `gif`, `webp`,
179
+ * `heif`, `avif`) or `undefined` when the bytes aren't a recognized image.
180
+ */
181
+ export function sniffImageFormat(data: Buffer): string | undefined {
182
+ if (data.length < 3) return undefined;
183
+
184
+ // JPEG: FF D8 FF
185
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'jpeg';
186
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
187
+ if (
188
+ data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 &&
189
+ data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a
190
+ ) return 'png';
191
+ // GIF: "GIF87a" / "GIF89a"
192
+ if (data.toString('ascii', 0, 3) === 'GIF') return 'gif';
193
+ // WebP: "RIFF"...."WEBP"
194
+ if (data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'webp';
195
+ // TIFF: "II*\0" / "MM\0*"
196
+ if (
197
+ (data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2a && data[3] === 0x00) ||
198
+ (data[0] === 0x4d && data[1] === 0x4d && data[2] === 0x00 && data[3] === 0x2a)
199
+ ) return 'tiff';
200
+
201
+ // ISOBMFF (HEIC/HEIF/AVIF): [4-byte size]["ftyp"][major brand][minor][compatible…]
202
+ if (data.toString('ascii', 4, 8) === 'ftyp') {
203
+ const boxSize = Math.min(data.readUInt32BE(0) || data.length, data.length);
204
+ const brands: string[] = [data.toString('ascii', 8, 12)];
205
+ for (let off = 16; off + 4 <= boxSize; off += 4) {
206
+ brands.push(data.toString('ascii', off, off + 4));
207
+ }
208
+ if (brands.some((b) => HEIC_BRANDS.has(b))) return 'heif';
209
+ if (brands.some((b) => AVIF_BRANDS.has(b))) return 'avif';
210
+ }
211
+
212
+ return undefined;
213
+ }
214
+
158
215
  function isNotFound(error: any): boolean {
159
216
  return (
160
217
  error.name === 'NotFound' ||
@@ -367,7 +424,15 @@ export function createImageHandler(
367
424
  const meta = await sharp(originalData).metadata();
368
425
  detectedFormat = meta.format; // jpeg, png, webp, gif, tiff, heif, etc.
369
426
  } catch {
370
- // Sharp can't parse it — not an image
427
+ // Sharp can't parse it — not an image, OR a HEIF container this Sharp
428
+ // build has no libheif to identify. Fall through to the magic-byte sniff.
429
+ }
430
+
431
+ // A Sharp build without libheif throws on HEIF and leaves detectedFormat
432
+ // undefined, misrouting real HEIC photos to the non-image passthrough
433
+ // below. Sniff the bytes so `heif` is detected and the rescue path fires.
434
+ if (!detectedFormat) {
435
+ detectedFormat = sniffImageFormat(originalData);
371
436
  }
372
437
 
373
438
  if (!detectedFormat) {
package/src/plugin.ts CHANGED
@@ -463,11 +463,18 @@ export interface DbPluginOptions {
463
463
  /** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
464
464
  pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
465
465
  /**
466
- * App-owned tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor
467
- * does not flag them. The framework always carves out `auth.users`; add operational tables
468
- * whose owner must write them, e.g. `['ops.runs']` (the worker-written jobs queue).
466
+ * Tables that are intentionally ENABLE-not-FORCE row-level security, so db:doctor does not
467
+ * flag them. Pass `deriveForceRlsCarveouts(models)` it reads each model's `writtenBy`
468
+ * declaration (auth.users + the six other auth.* tables, ops.runs, dist.blobs, the
469
+ * notification tables), so the list can't drift from the contract.
469
470
  */
470
471
  forceRlsCarveouts?: string[];
472
+ /**
473
+ * Schemas that own a `writtenBy: 'functions'` table (e.g. `['auth']`). Pass
474
+ * `deriveFunctionSchemas(models)`. db:doctor's `functions` teeth assert each such schema has
475
+ * SECDEF functions and that every one pins its search_path (the unpinned-search_path gate).
476
+ */
477
+ functionSchemas?: string[];
471
478
  /** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
472
479
  consoleAuth?: {
473
480
  usersTable?: string;
@@ -628,7 +635,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
628
635
  actions['db:doctor'] = async (_payload, ctx) => {
629
636
  const { sql } = await import('drizzle-orm');
630
637
  const {
631
- buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql,
638
+ buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
639
+ CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
632
640
  } = await import('./db-doctor');
633
641
  const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
634
642
 
@@ -649,6 +657,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
649
657
  // The "we require RLS" set — every app-reachable table + its RLS posture, so a table
650
658
  // granted to a request role with RLS off (the PostgREST fail-open shape) is caught.
651
659
  const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
660
+ // SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
661
+ // to assert every SECDEF function in a functions-written schema pins its search_path.
662
+ const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
652
663
 
653
664
  // Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
654
665
  let apiAmbientRead: { table: string; accessible: boolean } | undefined;
@@ -664,8 +675,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
664
675
  }
665
676
 
666
677
  return buildDoctorReport(
667
- { api, admin, forceRls, appGrantedRls, apiAmbientRead },
668
- { forceRlsCarveouts: options.forceRlsCarveouts },
678
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions },
679
+ { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
669
680
  );
670
681
  };
671
682