@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 +90 -0
- package/package.json +6 -2
- package/src/handler/index.ts +116 -20
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.
|
|
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
|
},
|
package/src/handler/index.ts
CHANGED
|
@@ -471,15 +471,24 @@ function transformResponse(data: unknown, options?: HandlerOptions): unknown {
|
|
|
471
471
|
return transformKeys(data, toSnakeCase);
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
/**
|
|
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:
|
|
481
|
+
body: unknown,
|
|
477
482
|
tableName: string,
|
|
478
483
|
options?: HandlerOptions,
|
|
479
|
-
):
|
|
484
|
+
): unknown {
|
|
480
485
|
const fields = options?.protectedFields?.[tableName];
|
|
481
486
|
if (!fields || fields.length === 0) return body;
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
|
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]);
|