@everystack/server 0.2.26 → 0.2.28

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.2.26",
3
+ "version": "0.2.28",
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>",
@@ -134,7 +134,7 @@
134
134
  "ts-jest": "29.4.9",
135
135
  "typescript": "5.9.3",
136
136
  "@everystack/auth": "0.2.6",
137
- "@everystack/cli": "0.2.39"
137
+ "@everystack/cli": "0.2.40"
138
138
  },
139
139
  "scripts": {
140
140
  "test": "jest",
package/src/db.ts CHANGED
@@ -262,3 +262,75 @@ export function getAdminSql(): ReturnType<typeof postgres> {
262
262
  if (adminSqlClient) return adminSqlClient;
263
263
  return getSql();
264
264
  }
265
+
266
+ /**
267
+ * Resolve the read-replica database URL, if configured.
268
+ *
269
+ * The third credential slot: DATABASE_URL (api), ADMIN_DATABASE_URL (operator),
270
+ * READ_DATABASE_URL (replica). Point it at a physical read replica's endpoint
271
+ * with the SAME least-privilege role as DATABASE_URL — same credential,
272
+ * different host. Returns null when no replica is configured.
273
+ */
274
+ export function getReadDatabaseUrl(): string | null {
275
+ // SST Secret — PascalCase (Resource.ReadDatabaseUrl.value)
276
+ const pascal = tryResource(() => (Resource as any).ReadDatabaseUrl?.value as string);
277
+ if (pascal) return pascal;
278
+ // SST Secret — raw env name (Resource.READ_DATABASE_URL.value)
279
+ const raw = tryResource(() => (Resource as any).READ_DATABASE_URL?.value as string);
280
+ if (raw) return raw;
281
+ // process.env fallback
282
+ if (process.env.READ_DATABASE_URL) return process.env.READ_DATABASE_URL;
283
+ return null;
284
+ }
285
+
286
+ // Lazy singleton read-replica connection (separate from the API connection)
287
+ let readDbInstance: ReturnType<typeof drizzle> | null = null;
288
+ let readSqlClient: ReturnType<typeof postgres> | null = null;
289
+
290
+ /**
291
+ * Read-replica database connection for anonymous read traffic.
292
+ *
293
+ * Connects with READ_DATABASE_URL when configured. When it isn't, returns the
294
+ * primary connection (createDb) with NO warning — unlike the admin split this
295
+ * is a scaling knob, not a security boundary: absent is normal, not a
296
+ * misconfiguration. Apps without a replica keep working with zero config.
297
+ *
298
+ * `withRole` works unchanged against a hot standby: SET LOCAL ROLE and
299
+ * set_config(…, true) are permitted in read-only transactions, and physical
300
+ * replication carries roles, GRANTs, and RLS policies with the catalog. A
301
+ * write through this connection fails loudly on a real replica ("cannot
302
+ * execute … in a read-only transaction") — a routing bug can never silently
303
+ * diverge. See docs/plans/read-replica-routing.md.
304
+ */
305
+ export function createReadDb<T extends Record<string, unknown>>(
306
+ schema: T,
307
+ options?: { maxConnections?: number }
308
+ ): {
309
+ db: ReturnType<typeof drizzle>;
310
+ schema: T;
311
+ } {
312
+ if (readDbInstance) return { db: readDbInstance, schema };
313
+
314
+ const url = getReadDatabaseUrl();
315
+ if (!url) return createDb(schema, options);
316
+
317
+ readSqlClient = postgres(url, {
318
+ max: options?.maxConnections ?? 1,
319
+ idle_timeout: 60,
320
+ connect_timeout: 5,
321
+ max_lifetime: 60 * 5,
322
+ ssl: 'require',
323
+ });
324
+ readDbInstance = drizzle(readSqlClient, { schema });
325
+ return { db: readDbInstance, schema };
326
+ }
327
+
328
+ /**
329
+ * Raw postgres.js client for the read-replica connection.
330
+ * Must be called after createReadDb(). Falls back to the API client when no
331
+ * replica is configured (same fallback as createReadDb).
332
+ */
333
+ export function getReadSql(): ReturnType<typeof postgres> {
334
+ if (readSqlClient) return readSqlClient;
335
+ return getSql();
336
+ }
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
@@ -582,6 +582,36 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
582
582
  }
583
583
  };
584
584
 
585
+ // --- db:authz:probe — the authorization red-team's execution path ---
586
+ // Runs the CLI-built probe SQL (SET ROLE + attempt per role/table/command, each in
587
+ // its own savepoint) inside ONE transaction that ALWAYS rolls back, then returns the
588
+ // outcome rows. Unlike db:query this permits writes — they are required to test
589
+ // INSERT/UPDATE/DELETE privileges — but the forced rollback guarantees nothing
590
+ // persists. IAM-gated like every action. The CLI owns the SQL (authz-redteam.ts);
591
+ // this is the thin, transactional, self-reverting runner it needs.
592
+ actions['db:authz:probe'] = async (payload, _ctx) => {
593
+ const { setup, read } = (payload ?? {}) as { setup?: string; read?: string };
594
+ if (!setup || !read) {
595
+ return { error: 'db:authz:probe requires { setup, read } SQL strings' };
596
+ }
597
+ const { sql } = await import('drizzle-orm');
598
+ const PROBE_ROLLBACK = Symbol('authz_probe_rollback');
599
+ let rows: any[] = [];
600
+ try {
601
+ await opsDb.transaction(async (tx: any) => {
602
+ await tx.execute(sql.raw(setup));
603
+ const result: any = await tx.execute(sql.raw(read));
604
+ rows = Array.isArray(result) ? result : result?.rows ?? [];
605
+ throw PROBE_ROLLBACK; // discard every probe write
606
+ });
607
+ } catch (err: unknown) {
608
+ if (err !== PROBE_ROLLBACK) {
609
+ return { error: (err as any)?.message || String(err) };
610
+ }
611
+ }
612
+ return { rows };
613
+ };
614
+
585
615
  // --- db:doctor — "is my database actually secure?" ---
586
616
  // Probes the api connection (createDb / DATABASE_URL) and the operator connection
587
617
  // (opsDb / ADMIN_DATABASE_URL) from inside the VPC and reports whether the api path