@everystack/api 0.4.9 → 0.4.10
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 +11 -0
- package/package.json +1 -1
- package/src/handler/index.ts +75 -33
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to `@everystack/api` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.10] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- an aggregate inside an embed (author(count())) answers 400 instead of being dropped, on every verb including writes that carry such a select
|
|
12
|
+
- a nested embed on the join path (drizzleRelations) answers 400 instead of being dropped, on every verb including writes that carry such a select; the batch path now keeps nested embeds and accepts snake_case embed column names
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- table-keyed option maps are read by own key, so a table named constructor reads no prototype value
|
|
17
|
+
|
|
7
18
|
## [0.4.9] - 2026-10-18
|
|
8
19
|
|
|
9
20
|
### BREAKING CHANGES
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
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>",
|
package/src/handler/index.ts
CHANGED
|
@@ -216,7 +216,11 @@ export interface AuthConfig {
|
|
|
216
216
|
/** Ordered role hierarchy, lowest to highest privilege.
|
|
217
217
|
* Used by RPC role gating. Default: ['public', 'authenticated', 'admin'] */
|
|
218
218
|
roleHierarchy?: string[];
|
|
219
|
-
/** Field in the user payload containing the role string.
|
|
219
|
+
/** Field in the user payload containing the role string. Default: 'role'
|
|
220
|
+
* `roleField` must hold one string. A missing, non-string or array value is treated as
|
|
221
|
+
* no role and fails every gate; if your tokens carry several roles, collapse them to one
|
|
222
|
+
* in `auth.verifyToken` before the payload reaches the handler, so the RPC, ownership,
|
|
223
|
+
* restore (`PATCH ?deleted=`) gates and your `pgSettings` all read the same value. */
|
|
220
224
|
roleField?: string;
|
|
221
225
|
}
|
|
222
226
|
|
|
@@ -339,7 +343,8 @@ export interface HandlerOptions {
|
|
|
339
343
|
drizzleRelations?: Record<string, unknown>;
|
|
340
344
|
/** Compute PostgreSQL session variables from the authenticated user.
|
|
341
345
|
* When provided, queries are wrapped in a transaction with set_config() calls
|
|
342
|
-
* so RLS policies can reference the user context.
|
|
346
|
+
* so RLS policies can reference the user context.
|
|
347
|
+
* Read the same field `roleField` names. Do not derive the database role from a different claim. */
|
|
343
348
|
pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
|
|
344
349
|
/** Per-table lifecycle hooks for mutations. */
|
|
345
350
|
hooks?: Record<string, HookConfig>;
|
|
@@ -637,7 +642,7 @@ function stripProtectedFields(
|
|
|
637
642
|
tableName: string,
|
|
638
643
|
options?: HandlerOptions,
|
|
639
644
|
): unknown {
|
|
640
|
-
const fields = options?.protectedFields
|
|
645
|
+
const fields = ownEntry(options?.protectedFields, tableName);
|
|
641
646
|
if (!fields || fields.length === 0) return body;
|
|
642
647
|
if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
|
|
643
648
|
if (body === null || typeof body !== 'object') {
|
|
@@ -655,7 +660,7 @@ function stripHiddenColumns(
|
|
|
655
660
|
tableName: string,
|
|
656
661
|
options?: HandlerOptions,
|
|
657
662
|
): unknown {
|
|
658
|
-
const columns = options?.hiddenColumns
|
|
663
|
+
const columns = ownEntry(options?.hiddenColumns, tableName);
|
|
659
664
|
if (!columns || columns.length === 0) return data;
|
|
660
665
|
|
|
661
666
|
const strip = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
@@ -928,7 +933,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
928
933
|
const sameColumn = (field: string): boolean => (table
|
|
929
934
|
? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
|
|
930
935
|
: toSnakeCase(field) === toSnakeCase(colName));
|
|
931
|
-
if (roles.length > 0 && (options?.protectedFields
|
|
936
|
+
if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
|
|
932
937
|
throw new Error(
|
|
933
938
|
`[everystack/api] softDelete.restore.${tableName}: the soft-delete column "${colName}" is in protectedFields.${tableName}, so every restore body is stripped to {}. Remove it from protectedFields.${tableName} or remove the restore entry.`,
|
|
934
939
|
);
|
|
@@ -942,7 +947,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
942
947
|
* non-string role claim is not a member.
|
|
943
948
|
*/
|
|
944
949
|
function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
|
|
945
|
-
const roles = options?.softDelete?.restore
|
|
950
|
+
const roles = ownEntry(options?.softDelete?.restore, tableName);
|
|
946
951
|
if (!roles?.length || !user) return false;
|
|
947
952
|
const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
|
|
948
953
|
return role !== '' && roles.includes(role);
|
|
@@ -1453,15 +1458,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1453
1458
|
|
|
1454
1459
|
// A part with parentheses is an aggregate or a declared embed. Anything
|
|
1455
1460
|
// else cannot be honored, so refuse it rather than drop it from the rows.
|
|
1461
|
+
// An aggregate inside an embed cannot be honored either, nor can a nested
|
|
1462
|
+
// embed on the JOIN path: its `with` clause is one level deep.
|
|
1456
1463
|
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1457
1464
|
if (!queryError) {
|
|
1465
|
+
const joinPath = relationalQueryAvailable(routedDb, tableName, options);
|
|
1458
1466
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1459
|
-
const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1467
|
+
const { undeclared, embedAggregate, nestedEmbed } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1460
1468
|
if (undeclared !== undefined) {
|
|
1461
1469
|
queryError = undeclaredSelectPartMessage(undeclared);
|
|
1462
1470
|
queryErrorEchoesInput = true;
|
|
1463
1471
|
break;
|
|
1464
1472
|
}
|
|
1473
|
+
if (embedAggregate || (nestedEmbed && joinPath)) {
|
|
1474
|
+
queryError = 'Invalid query';
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1465
1477
|
}
|
|
1466
1478
|
}
|
|
1467
1479
|
|
|
@@ -1583,6 +1595,14 @@ function columnPropertyName(table: DrizzleTable | undefined, name: string): stri
|
|
|
1583
1595
|
?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
|
|
1584
1596
|
}
|
|
1585
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* A read with embeds takes the JOIN path when this holds. The pre-DB refusal of
|
|
1600
|
+
* a nested embed and handleGet both ask it, so the two cannot disagree.
|
|
1601
|
+
*/
|
|
1602
|
+
function relationalQueryAvailable(db: DrizzleDb, tableName: string, options?: HandlerOptions): boolean {
|
|
1603
|
+
return Boolean(ownEntry(options?.relations, tableName) && options?.drizzleRelations && (db as any).query?.[tableName]);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1586
1606
|
/** Build each relation's projection before Drizzle reads its values. */
|
|
1587
1607
|
function buildWithClause(
|
|
1588
1608
|
embeds: EmbedRequest[],
|
|
@@ -1600,7 +1620,7 @@ function buildWithClause(
|
|
|
1600
1620
|
if (!relation) continue;
|
|
1601
1621
|
hasEmbedded = true;
|
|
1602
1622
|
const table = schema[relation.table];
|
|
1603
|
-
const names = new Set(options?.hiddenColumns
|
|
1623
|
+
const names = new Set(ownEntry(options?.hiddenColumns, relation.table) ?? []);
|
|
1604
1624
|
const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
|
|
1605
1625
|
const config: Record<string, any> = {};
|
|
1606
1626
|
if (embed.columns !== null) {
|
|
@@ -1829,10 +1849,7 @@ async function handleGet(
|
|
|
1829
1849
|
|
|
1830
1850
|
// Detect if Drizzle relational query (JOINs) should be used
|
|
1831
1851
|
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1832
|
-
const useRelationalQuery = embeds.length > 0
|
|
1833
|
-
&& tableRelations
|
|
1834
|
-
&& options?.drizzleRelations
|
|
1835
|
-
&& (db as any).query?.[tableName];
|
|
1852
|
+
const useRelationalQuery = embeds.length > 0 && relationalQueryAvailable(db, tableName, options);
|
|
1836
1853
|
|
|
1837
1854
|
let rows: unknown[];
|
|
1838
1855
|
const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
|
|
@@ -1983,7 +2000,7 @@ function parseSelectParam(
|
|
|
1983
2000
|
selectParam: string | null,
|
|
1984
2001
|
tableRelations?: Record<string, RelationConfig>,
|
|
1985
2002
|
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
1986
|
-
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
|
|
2003
|
+
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string; embedAggregate?: true; nestedEmbed?: true } {
|
|
1987
2004
|
if (!selectParam) {
|
|
1988
2005
|
return { columns: null, embeds: [], aggregates: [] };
|
|
1989
2006
|
}
|
|
@@ -1995,6 +2012,11 @@ function parseSelectParam(
|
|
|
1995
2012
|
// The first part with parentheses that is neither an aggregate nor a declared
|
|
1996
2013
|
// embed, at any depth. The caller refuses the select; it is never a column.
|
|
1997
2014
|
let undeclared: string | undefined;
|
|
2015
|
+
// Aggregates exist at the top level only. One inside an embed, at any depth,
|
|
2016
|
+
// cannot be honored, so the caller refuses the select.
|
|
2017
|
+
let embedAggregate = false;
|
|
2018
|
+
// An embed inside an embed, at any depth. The JOIN path cannot honour one yet.
|
|
2019
|
+
let nestedEmbed = false;
|
|
1998
2020
|
|
|
1999
2021
|
// Parse: split on commas, but respect parentheses
|
|
2000
2022
|
const parts = splitSelectParam(selectParam);
|
|
@@ -2019,6 +2041,8 @@ function parseSelectParam(
|
|
|
2019
2041
|
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
2020
2042
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
2021
2043
|
undeclared ??= inner.undeclared;
|
|
2044
|
+
if (inner.aggregates.length > 0 || inner.embedAggregate) embedAggregate = true;
|
|
2045
|
+
if (inner.embeds.length > 0) nestedEmbed = true;
|
|
2022
2046
|
embeds.push({
|
|
2023
2047
|
name,
|
|
2024
2048
|
columns: inner.columns,
|
|
@@ -2038,6 +2062,8 @@ function parseSelectParam(
|
|
|
2038
2062
|
embeds,
|
|
2039
2063
|
aggregates,
|
|
2040
2064
|
...(undeclared === undefined ? {} : { undeclared }),
|
|
2065
|
+
...(embedAggregate ? { embedAggregate: true as const } : {}),
|
|
2066
|
+
...(nestedEmbed ? { nestedEmbed: true as const } : {}),
|
|
2041
2067
|
};
|
|
2042
2068
|
}
|
|
2043
2069
|
|
|
@@ -2172,6 +2198,20 @@ function requestUsesHiddenColumn(
|
|
|
2172
2198
|
return false;
|
|
2173
2199
|
}
|
|
2174
2200
|
|
|
2201
|
+
/** The relation an embed names, when its target table and key exist. resolveEmbeds skips any other. */
|
|
2202
|
+
function resolveEmbedTarget(
|
|
2203
|
+
schema: Schema,
|
|
2204
|
+
tableRelations: Record<string, RelationConfig> | undefined,
|
|
2205
|
+
name: string,
|
|
2206
|
+
): { relationConfig: RelationConfig; targetTable: DrizzleTable; targetColumn: DrizzleColumn } | null {
|
|
2207
|
+
const relationConfig = ownEntry(tableRelations, name);
|
|
2208
|
+
if (!relationConfig) return null;
|
|
2209
|
+
const targetTable = schema[relationConfig.table];
|
|
2210
|
+
if (!targetTable) return null;
|
|
2211
|
+
const targetColumn = getColumn(targetTable, relationConfig.to);
|
|
2212
|
+
return targetColumn ? { relationConfig, targetTable, targetColumn } : null;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2175
2215
|
/**
|
|
2176
2216
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
2177
2217
|
*/
|
|
@@ -2190,14 +2230,18 @@ async function resolveEmbeds(
|
|
|
2190
2230
|
const result = rows.map(r => ({ ...r }));
|
|
2191
2231
|
|
|
2192
2232
|
for (const embed of embeds) {
|
|
2193
|
-
const
|
|
2194
|
-
if (!
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2233
|
+
const target = resolveEmbedTarget(schema, tableRelations, embed.name);
|
|
2234
|
+
if (!target) continue;
|
|
2235
|
+
const { relationConfig, targetTable, targetColumn } = target;
|
|
2236
|
+
|
|
2237
|
+
// Rows are keyed by schema property, so pick by property name, as
|
|
2238
|
+
// buildWithClause does. A nested embed is kept only when it resolves: an
|
|
2239
|
+
// unresolved one is skipped below, and its name may be a real column.
|
|
2240
|
+
const nestedRelations = ownEntry(options.relations, relationConfig.table);
|
|
2241
|
+
const picked = embed.columns && [
|
|
2242
|
+
...embed.columns.map(name => columnPropertyName(targetTable, name)).filter((name): name is string => name !== undefined),
|
|
2243
|
+
...embed.embeds.filter(nested => resolveEmbedTarget(schema, nestedRelations, nested.name)).map(nested => nested.name),
|
|
2244
|
+
];
|
|
2201
2245
|
|
|
2202
2246
|
// Build soft-delete filter for the target table
|
|
2203
2247
|
const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
|
|
@@ -2238,9 +2282,7 @@ async function resolveEmbeds(
|
|
|
2238
2282
|
for (const row of result) {
|
|
2239
2283
|
const sourceVal = row[relationConfig.from];
|
|
2240
2284
|
const matched = relatedRows.filter(r => r[relationConfig.to] === sourceVal);
|
|
2241
|
-
row[embed.name] =
|
|
2242
|
-
? matched.map(r => pickColumns(r, embed.columns!))
|
|
2243
|
-
: matched;
|
|
2285
|
+
row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
|
|
2244
2286
|
}
|
|
2245
2287
|
} else {
|
|
2246
2288
|
// Many-to-one: source.from is the FK, target.to is the PK
|
|
@@ -2285,7 +2327,7 @@ async function resolveEmbeds(
|
|
|
2285
2327
|
row[embed.name] = null;
|
|
2286
2328
|
} else {
|
|
2287
2329
|
const related = index.get(fkVal) || null;
|
|
2288
|
-
row[embed.name] = related &&
|
|
2330
|
+
row[embed.name] = related && picked ? pickColumns(related, picked) : related;
|
|
2289
2331
|
}
|
|
2290
2332
|
}
|
|
2291
2333
|
}
|
|
@@ -2371,10 +2413,10 @@ function sanitizeWriteBody(
|
|
|
2371
2413
|
at: { requestId?: string; name: string; method: string },
|
|
2372
2414
|
deferred: Deferred[],
|
|
2373
2415
|
): { body: unknown; changed: string[] } | { refusal: Response } {
|
|
2374
|
-
const columns = options?.sanitizeColumns
|
|
2416
|
+
const columns = ownEntry(options?.sanitizeColumns, tableName);
|
|
2375
2417
|
if (!columns?.length) return { body, changed: [] };
|
|
2376
2418
|
const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
|
|
2377
|
-
const hidden = new Set((options?.hiddenColumns
|
|
2419
|
+
const hidden = new Set((ownEntry(options?.hiddenColumns, tableName) ?? []).map(name => columnPropertyName(table, name)));
|
|
2378
2420
|
const refuse = (message: string, column: string): { refusal: Response } => {
|
|
2379
2421
|
const fault: HandlerFault = {
|
|
2380
2422
|
...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
|
|
@@ -2456,7 +2498,7 @@ async function handlePost(
|
|
|
2456
2498
|
throw err;
|
|
2457
2499
|
}
|
|
2458
2500
|
|
|
2459
|
-
const hook = options?.hooks
|
|
2501
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2460
2502
|
|
|
2461
2503
|
if (hook?.beforeCreate) {
|
|
2462
2504
|
try {
|
|
@@ -2550,7 +2592,7 @@ async function handlePatch(
|
|
|
2550
2592
|
|
|
2551
2593
|
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
2552
2594
|
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
2553
|
-
const ownership = options?.rowOwnership
|
|
2595
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2554
2596
|
if (user && options && ownership && isOwnershipExempt(ownership, user, options)
|
|
2555
2597
|
&& buildsNoFilter(table, params)) {
|
|
2556
2598
|
return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
|
|
@@ -2574,7 +2616,7 @@ async function handlePatch(
|
|
|
2574
2616
|
return sanitizeError(filterResult.error, 400, options);
|
|
2575
2617
|
}
|
|
2576
2618
|
|
|
2577
|
-
const hook = options?.hooks
|
|
2619
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2578
2620
|
|
|
2579
2621
|
if (hook?.beforeUpdate) {
|
|
2580
2622
|
try {
|
|
@@ -2666,7 +2708,7 @@ function unbackedExemptionRefusal(
|
|
|
2666
2708
|
settings: Record<string, string> | undefined,
|
|
2667
2709
|
): string | null {
|
|
2668
2710
|
if (method !== 'PATCH' && method !== 'DELETE') return null;
|
|
2669
|
-
const ownership = options?.rowOwnership
|
|
2711
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2670
2712
|
if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
|
|
2671
2713
|
if (settings?.['role']) return null;
|
|
2672
2714
|
const message = `rowOwnership.${tableName}.exceptRoles: pgSettings returned no role for this request, so SET LOCAL ROLE did not run and no RLS policy backs the exemption. Write refused.`;
|
|
@@ -2714,7 +2756,7 @@ function ownershipScope(
|
|
|
2714
2756
|
options: HandlerOptions | undefined,
|
|
2715
2757
|
user: Record<string, unknown> | null,
|
|
2716
2758
|
): OwnershipScope {
|
|
2717
|
-
const ownership = options?.rowOwnership
|
|
2759
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2718
2760
|
if (!options || !ownership) return { condition: null };
|
|
2719
2761
|
if (!user) {
|
|
2720
2762
|
return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
|
|
@@ -2763,7 +2805,7 @@ async function handleDelete(
|
|
|
2763
2805
|
return sanitizeError(filterResult.error, 400, options);
|
|
2764
2806
|
}
|
|
2765
2807
|
|
|
2766
|
-
const hook = options?.hooks
|
|
2808
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2767
2809
|
|
|
2768
2810
|
if (hook?.beforeDelete) {
|
|
2769
2811
|
try {
|