@everystack/api 0.4.0 → 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.4.0",
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,6 +536,56 @@ function transformBodyKeys(body: Record<string, unknown>, table: DrizzleTable):
522
536
  return result;
523
537
  }
524
538
 
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
+
525
589
  /** The name a drizzle pgTable knows itself by in SQL, or null for a non-table export. */
526
590
  function sqlNameOf(value: unknown): string | null {
527
591
  if (!value || typeof value !== 'object') return null;
@@ -1515,14 +1579,20 @@ async function handlePost(
1515
1579
  return json({ message: 'Invalid JSON body' }, 400);
1516
1580
  }
1517
1581
 
1518
- // Map body keys to Drizzle property names when naming convention is active
1519
- if (options?.naming === 'snake_case') {
1520
- 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;
1521
1594
  }
1522
1595
 
1523
- // Strip protected fields before mutation (mass assignment prevention)
1524
- body = stripProtectedFields(body, tableName, options);
1525
-
1526
1596
  const hook = options?.hooks?.[tableName];
1527
1597
 
1528
1598
  if (hook?.beforeCreate) {
@@ -1536,7 +1606,17 @@ async function handlePost(
1536
1606
  }
1537
1607
  }
1538
1608
 
1539
- 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();
1540
1620
 
1541
1621
  if (hook?.afterCreate && rows.length > 0) {
1542
1622
  try { await hook.afterCreate(rows[0], user, client); } catch {}
@@ -1562,14 +1642,20 @@ async function handlePatch(
1562
1642
  return json({ message: 'Invalid JSON body' }, 400);
1563
1643
  }
1564
1644
 
1565
- // Map body keys to Drizzle property names when naming convention is active
1566
- if (options?.naming === 'snake_case') {
1567
- 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;
1568
1657
  }
1569
1658
 
1570
- // Strip protected fields before mutation (mass assignment prevention)
1571
- body = stripProtectedFields(body, tableName, options);
1572
-
1573
1659
  const hook = options?.hooks?.[tableName];
1574
1660
 
1575
1661
  if (hook?.beforeUpdate) {
@@ -1605,7 +1691,17 @@ async function handlePatch(
1605
1691
  }
1606
1692
  }
1607
1693
 
1608
- 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>);
1609
1705
 
1610
1706
  if (conditions.length === 1) {
1611
1707
  query = query.where(conditions[0]);