@everystack/api 0.2.12 → 0.4.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@everystack/api` are documented here.
4
+
5
+ Format based on [Keep a Changelog](https://keepachangelog.com/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - auto-generated changelogs from conventional commits
12
+ - automate the version-drift check — a manual note is not a gate
13
+
14
+ ## [0.4.0] - 2026-07-14
15
+
16
+ ### Added
17
+
18
+ - forward-port afterRpc + read-replica routing from the 0.2.x line
19
+
20
+ ### Fixed
21
+
22
+ - surface caught 5xx exceptions to the observability sink
23
+ - verify tokens opportunistically on public RPCs, like public table routes
24
+ - RPC responses carry the same Cache-Control discipline as table GETs
25
+ - multi-word tables route under both naming domains
26
+
27
+ ## [0.2.8] - 2026-06-18
28
+
29
+ ### Added
30
+
31
+ - forensic audit capture for every non-GET request (trace-id spine)
32
+
33
+ ## [0.2.7] - 2026-06-18
34
+
35
+ ### Added
36
+
37
+ - forensic audit capture, public-surface guard, honest verifyClient framing
38
+
39
+ ## [0.2.6] - 2026-06-17
40
+
41
+ ### Removed
42
+
43
+ - remove onMutation callback — events replaces it
44
+
45
+ ### Added
46
+
47
+ - add Drizzle relational query (JOIN) support for relation embedding
48
+ - add CloudFront KVS cache versioning with CLI purge and auto-invalidation
49
+ - add pgSettings for PostgreSQL RLS context injection
50
+ - add not operator negation and or()/and() logical groups
51
+ - add aggregate queries with GROUP BY support
52
+ - add per-table lifecycle hooks for mutations
53
+ - add optional two-tier client credentials auth
54
+ - add JSON path filtering for JSONB columns
55
+ - add negation and logical group helpers
56
+ - add RBAC and snake_case naming convention
57
+ - add CloudFront edge auth, response caching, and CDN docs
58
+ - configurable per-origin cache headers + image EXIF fix
59
+ - add exposedTables and hiddenColumns options
60
+ - support referer-based client auth for web apps
61
+ - RPC-backed row actions on the show view
62
+ - extensibility, unified view, observability + Track B rollup
63
+
64
+ ### Fixed
65
+
66
+ - add SET LOCAL ROLE to executeWithSettings for RLS enforcement
67
+ - resolve 5 pre-production security blockers from red team audit
68
+ - resolve 7 high-priority security findings from red team audit
69
+ - resolve 7 medium-priority security findings from red team audit
70
+ - resolve 9 low/medium-priority security findings from red team audit
71
+ - skip client auth for publicRoutes methods
72
+ - pin all dependency versions to lockfile-resolved values
73
+ - pgSettings on RPC, publicRpc client auth bypass, logs:tail log group resolution
74
+ - catch PostgreSQL errors in RPC execution path
75
+ - opportunistic auth on public routes, hiddenColumns ordering
76
+ - enforce client auth on publicRpc endpoints
77
+ - RPCs run unwrapped by default; opt into pgSettings via settings:true
78
+
79
+ ### Changed
80
+
81
+ - split expo-stack into discrete packages and remove @jest/globals imports
82
+ - consolidate authorization to PostgreSQL as single source of truth
83
+ - drop type module from all packages, switch Jest to CJS mode
84
+ - consolidate Jest configs into shared base
85
+
86
+ ### Documentation
87
+
88
+ - rewrite documentation around V1→V2→V3 progressive complexity model
89
+ - update all documentation to reflect full API surface
90
+ - prepare documentation for open-source release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.2.12",
3
+ "version": "0.4.1",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -18,14 +18,17 @@
18
18
  },
19
19
  "files": [
20
20
  "src",
21
- "README.md"
21
+ "README.md",
22
+ "CHANGELOG.md"
22
23
  ],
23
24
  "exports": {
24
25
  "./handler": {
26
+ "source": "./src/handler/index.ts",
25
27
  "types": "./src/handler/index.ts",
26
28
  "default": "./src/handler/index.ts"
27
29
  },
28
30
  "./client": {
31
+ "source": "./src/client/index.ts",
29
32
  "types": "./src/client/index.ts",
30
33
  "default": "./src/client/index.ts"
31
34
  }
@@ -38,6 +41,7 @@
38
41
  "@types/node": "22.19.18",
39
42
  "drizzle-orm": "0.41.0",
40
43
  "jest": "29.7.0",
44
+ "postgres": "3.4.9",
41
45
  "ts-jest": "29.4.9",
42
46
  "typescript": "5.9.3"
43
47
  },
@@ -471,15 +471,24 @@ function transformResponse(data: unknown, options?: HandlerOptions): unknown {
471
471
  return transformKeys(data, toSnakeCase);
472
472
  }
473
473
 
474
- /** Strip protected fields from a request body before insert/update (mass assignment prevention). */
474
+ /**
475
+ * Strip protected fields from a request body before insert/update (mass assignment prevention).
476
+ * Recurses per-row on an array body (bulk insert — db.insert(table).values() accepts one) so it
477
+ * comes back as an array, not `{...body}`'s indexed-object flattening of it (mirrors
478
+ * stripHiddenColumns below, which already gets this right on the response side).
479
+ */
475
480
  function stripProtectedFields(
476
- body: Record<string, unknown>,
481
+ body: unknown,
477
482
  tableName: string,
478
483
  options?: HandlerOptions,
479
- ): Record<string, unknown> {
484
+ ): unknown {
480
485
  const fields = options?.protectedFields?.[tableName];
481
486
  if (!fields || fields.length === 0) return body;
482
- const result = { ...body };
487
+ if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
488
+ if (body === null || typeof body !== 'object') {
489
+ throw new HandlerError(400, `Invalid row in request body: expected an object, got ${JSON.stringify(body)}`);
490
+ }
491
+ const result = { ...(body as Record<string, unknown>) };
483
492
  for (const field of fields) {
484
493
  delete result[field];
485
494
  }
@@ -507,9 +516,14 @@ function stripHiddenColumns(
507
516
  return data;
508
517
  }
509
518
 
510
- function transformBodyKeys(body: Record<string, unknown>, table: DrizzleTable): Record<string, unknown> {
519
+ /** Same array-preserving recursion as stripProtectedFields, for the same reason. */
520
+ function transformBodyKeys(body: unknown, table: DrizzleTable): unknown {
521
+ if (Array.isArray(body)) return body.map(row => transformBodyKeys(row, table));
522
+ if (body === null || typeof body !== 'object') {
523
+ throw new HandlerError(400, `Invalid row in request body: expected an object, got ${JSON.stringify(body)}`);
524
+ }
511
525
  const result: Record<string, unknown> = {};
512
- for (const [key, value] of Object.entries(body)) {
526
+ for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
513
527
  if (table[key]) {
514
528
  result[key] = value;
515
529
  } else if (key.includes('_')) {
@@ -522,8 +536,131 @@ function transformBodyKeys(body: Record<string, unknown>, table: DrizzleTable):
522
536
  return result;
523
537
  }
524
538
 
525
- export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOptions): Handler {
539
+ /**
540
+ * A JSON request body carries dates as plain strings (or epoch-millis numbers) — JSON has no
541
+ * Date type. Drizzle's date/timestamp/timestamptz columns default to "date" mode, where
542
+ * `mapToDriverValue` calls `value.toISOString()` unconditionally (see node_modules/drizzle-orm/
543
+ * pg-core/columns/{date,timestamp}.js) — anything else blows up there with `TypeError:
544
+ * value.toISOString is not a function`, surfacing as an opaque 500. Converts the value to a
545
+ * Date for exactly those columns before it ever reaches Drizzle; reads are untouched. Throws a
546
+ * HandlerError (the same convention hooks below already use) naming the field when the value
547
+ * doesn't parse, so the caller gets a 400 instead of that 500.
548
+ *
549
+ * A row of a bulk-insert array body is coerced independently, recursively — the shape (array
550
+ * or single row) must come back exactly as it went in, since `db.insert(table).values(...)`
551
+ * accepts either and a naive `{ ...body }` on an array silently corrupts it into
552
+ * `{"0": row0, "1": row1, ...}` (JS object-spread of an array indexes by position).
553
+ *
554
+ * String values are required to be ISO 8601, WITH an explicit offset/Z whenever a time-of-day is
555
+ * present — the bare `new Date(str)` constructor also accepts two ambiguous shapes that would
556
+ * silently store the WRONG instant with no error raised (the one outcome worse than the crash
557
+ * this function replaces): locale-dependent formats (`new Date('01/02/2020')` is 2 Jan, not the
558
+ * 1 Feb a DD/MM caller meant), and an offset-less datetime, which the spec parses as the
559
+ * SERVER's local time rather than UTC (verified: `TZ=America/New_York new
560
+ * Date('2020-01-01T00:00:00').toISOString()` → `2020-01-01T05:00:00.000Z`, 5 hours off, with no
561
+ * error). A bare date has no time-of-day to be ambiguous about, so it stays offset-optional. A
562
+ * number is unambiguous (epoch millis) and skips this check entirely.
563
+ */
564
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
565
+
566
+ function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
567
+ if (Array.isArray(body)) return body.map(row => coerceDateColumnValues(row, table));
568
+ if (body === null || typeof body !== 'object') {
569
+ throw new HandlerError(400, `Invalid row in request body: expected an object, got ${JSON.stringify(body)}`);
570
+ }
571
+
572
+ const result: Record<string, unknown> = { ...(body as Record<string, unknown>) };
573
+ for (const [key, value] of Object.entries(result)) {
574
+ if (typeof value !== 'string' && typeof value !== 'number') continue;
575
+ const column = getColumn(table, key);
576
+ if (!column || column.dataType !== 'date') continue;
577
+ if (typeof value === 'string' && !ISO_DATE_RE.test(value)) {
578
+ throw new HandlerError(400, `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}`);
579
+ }
580
+ const parsed = new Date(value);
581
+ if (Number.isNaN(parsed.getTime())) {
582
+ throw new HandlerError(400, `Invalid date value for "${key}": ${JSON.stringify(value)}`);
583
+ }
584
+ result[key] = parsed;
585
+ }
586
+ return result;
587
+ }
588
+
589
+ /** The name a drizzle pgTable knows itself by in SQL, or null for a non-table export. */
590
+ function sqlNameOf(value: unknown): string | null {
591
+ if (!value || typeof value !== 'object') return null;
592
+ const name = (value as Record<symbol, unknown>)[Symbol.for('drizzle:Name')];
593
+ return typeof name === 'string' ? name : null;
594
+ }
595
+
596
+ /** Export-key ↔ SQL-name pairs for every table whose two spellings differ. */
597
+ function tableNamePairs(schema: Schema): Array<{ exportKey: string; sqlName: string }> {
598
+ const pairs: Array<{ exportKey: string; sqlName: string }> = [];
599
+ for (const [exportKey, value] of Object.entries(schema)) {
600
+ const sqlName = sqlNameOf(value);
601
+ if (sqlName && sqlName !== exportKey) pairs.push({ exportKey, sqlName });
602
+ }
603
+ return pairs;
604
+ }
605
+
606
+ /** The schema with each table reachable under BOTH spellings (existing keys win). */
607
+ function expandSchema(schema: Schema, pairs: Array<{ exportKey: string; sqlName: string }>): Schema {
608
+ if (pairs.length === 0) return schema;
609
+ const expanded: Schema = { ...schema };
610
+ for (const { exportKey, sqlName } of pairs) {
611
+ if (!(sqlName in expanded)) expanded[sqlName] = schema[exportKey];
612
+ }
613
+ return expanded;
614
+ }
615
+
616
+ /**
617
+ * Table-keyed options written in EITHER naming domain apply to both spellings.
618
+ * `deriveHandlerConfig` emits SQL names; hand-written configs use export keys —
619
+ * each key gains its twin (same value reference), so neither authoring style
620
+ * can strand a table behind the other domain's gate.
621
+ */
622
+ function expandTableKeyedOptions(
623
+ options: HandlerOptions | undefined,
624
+ pairs: Array<{ exportKey: string; sqlName: string }>,
625
+ ): HandlerOptions | undefined {
626
+ if (!options || pairs.length === 0) return options;
627
+ const twin = (name: string): string | undefined =>
628
+ pairs.find((p) => p.exportKey === name)?.sqlName ?? pairs.find((p) => p.sqlName === name)?.exportKey;
629
+ const expandList = (list: string[]): string[] =>
630
+ [...new Set(list.flatMap((name) => { const t = twin(name); return t ? [name, t] : [name]; }))];
631
+ const expandMap = <T,>(map: Record<string, T>): Record<string, T> => {
632
+ const out: Record<string, T> = { ...map };
633
+ for (const key of Object.keys(map)) {
634
+ const t = twin(key);
635
+ if (t && !(t in out)) out[t] = map[key];
636
+ }
637
+ return out;
638
+ };
639
+ return {
640
+ ...options,
641
+ ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
642
+ ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
643
+ ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
644
+ ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
645
+ ...(options.relations ? { relations: expandMap(options.relations) } : {}),
646
+ ...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
647
+ ...(options.softDelete?.tables
648
+ ? { softDelete: { ...options.softDelete, tables: expandList(options.softDelete.tables) } }
649
+ : {}),
650
+ };
651
+ }
652
+
653
+ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
654
+ // The naming seam: URLs, exposedTables, and relation targets historically mixed
655
+ // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
656
+ // multi-word table failed one gate or the other — both spellings 404'd. Collapse
657
+ // the two domains here, once: both spellings route, and options in either domain
658
+ // apply. The root listing below stays one entry per table (the SQL name).
659
+ const namePairs = tableNamePairs(rawSchema);
660
+ const schema = expandSchema(rawSchema, namePairs);
661
+ const options = expandTableKeyedOptions(rawOptions, namePairs);
526
662
  const tableNames = new Set(Object.keys(schema));
663
+ const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
527
664
 
528
665
  return async (request: Request): Promise<Response> => {
529
666
  const url = new URL(request.url);
@@ -583,11 +720,12 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
583
720
  }
584
721
  }
585
722
 
586
- // Root path — table listing, always public
723
+ // Root path — table listing, always public. One entry per table (SQL name),
724
+ // regardless of how many spellings route to it.
587
725
  if (pathSegments.length === 0) {
588
726
  const tables = options?.exposedTables
589
- ? Array.from(tableNames).filter(t => options.exposedTables!.includes(t))
590
- : Array.from(tableNames);
727
+ ? canonicalTables.filter(t => options.exposedTables!.includes(t))
728
+ : canonicalTables;
591
729
  return json({ tables }, 200);
592
730
  }
593
731
 
@@ -1441,14 +1579,20 @@ async function handlePost(
1441
1579
  return json({ message: 'Invalid JSON body' }, 400);
1442
1580
  }
1443
1581
 
1444
- // Map body keys to Drizzle property names when naming convention is active
1445
- if (options?.naming === 'snake_case') {
1446
- body = transformBodyKeys(body, table);
1582
+ try {
1583
+ // Map body keys to Drizzle property names when naming convention is active
1584
+ if (options?.naming === 'snake_case') {
1585
+ body = transformBodyKeys(body, table) as Record<string, unknown>;
1586
+ }
1587
+ // Strip protected fields before mutation (mass assignment prevention)
1588
+ body = stripProtectedFields(body, tableName, options) as Record<string, unknown>;
1589
+ } catch (err) {
1590
+ if (err instanceof HandlerError) {
1591
+ return json({ message: err.message }, err.status);
1592
+ }
1593
+ throw err;
1447
1594
  }
1448
1595
 
1449
- // Strip protected fields before mutation (mass assignment prevention)
1450
- body = stripProtectedFields(body, tableName, options);
1451
-
1452
1596
  const hook = options?.hooks?.[tableName];
1453
1597
 
1454
1598
  if (hook?.beforeCreate) {
@@ -1462,7 +1606,17 @@ async function handlePost(
1462
1606
  }
1463
1607
  }
1464
1608
 
1465
- const rows = await db.insert(table).values(body).returning();
1609
+ let coerced: unknown;
1610
+ try {
1611
+ coerced = coerceDateColumnValues(body, table);
1612
+ } catch (err) {
1613
+ if (err instanceof HandlerError) {
1614
+ return json({ message: err.message }, err.status);
1615
+ }
1616
+ throw err;
1617
+ }
1618
+
1619
+ const rows = await db.insert(table).values(coerced as Record<string, unknown>).returning();
1466
1620
 
1467
1621
  if (hook?.afterCreate && rows.length > 0) {
1468
1622
  try { await hook.afterCreate(rows[0], user, client); } catch {}
@@ -1488,14 +1642,20 @@ async function handlePatch(
1488
1642
  return json({ message: 'Invalid JSON body' }, 400);
1489
1643
  }
1490
1644
 
1491
- // Map body keys to Drizzle property names when naming convention is active
1492
- if (options?.naming === 'snake_case') {
1493
- body = transformBodyKeys(body, table);
1645
+ try {
1646
+ // Map body keys to Drizzle property names when naming convention is active
1647
+ if (options?.naming === 'snake_case') {
1648
+ body = transformBodyKeys(body, table) as Record<string, unknown>;
1649
+ }
1650
+ // Strip protected fields before mutation (mass assignment prevention)
1651
+ body = stripProtectedFields(body, tableName, options) as Record<string, unknown>;
1652
+ } catch (err) {
1653
+ if (err instanceof HandlerError) {
1654
+ return json({ message: err.message }, err.status);
1655
+ }
1656
+ throw err;
1494
1657
  }
1495
1658
 
1496
- // Strip protected fields before mutation (mass assignment prevention)
1497
- body = stripProtectedFields(body, tableName, options);
1498
-
1499
1659
  const hook = options?.hooks?.[tableName];
1500
1660
 
1501
1661
  if (hook?.beforeUpdate) {
@@ -1531,7 +1691,17 @@ async function handlePatch(
1531
1691
  }
1532
1692
  }
1533
1693
 
1534
- let query: any = db.update(table).set(body);
1694
+ let coerced: unknown;
1695
+ try {
1696
+ coerced = coerceDateColumnValues(body, table);
1697
+ } catch (err) {
1698
+ if (err instanceof HandlerError) {
1699
+ return json({ message: err.message }, err.status);
1700
+ }
1701
+ throw err;
1702
+ }
1703
+
1704
+ let query: any = db.update(table).set(coerced as Record<string, unknown>);
1535
1705
 
1536
1706
  if (conditions.length === 1) {
1537
1707
  query = query.where(conditions[0]);