@everystack/api 0.4.8 → 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 +27 -0
- package/package.json +1 -1
- package/src/handler/index.ts +132 -79
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ 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
|
+
|
|
18
|
+
## [0.4.9] - 2026-10-18
|
|
19
|
+
|
|
20
|
+
### BREAKING CHANGES
|
|
21
|
+
|
|
22
|
+
- PATCH and DELETE refuse a bad filter before running beforeUpdate or beforeDelete
|
|
23
|
+
- a filter the handler cannot parse or build answers 400 instead of being dropped; a bare value, is.true, is.false and in.(..) inside an or() or and() group are refused, so @everystack/query raw maps need eq.
|
|
24
|
+
- a repeated or= or and= now applies every group, ANDed, so a read or write that sent a second group matches fewer rows
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- with hideSchemaErrors, an RPC 42501 and the role gate answer Forbidden without database text
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- upgrade notes for strict filters, hook order and hidden RPC error text
|
|
33
|
+
|
|
7
34
|
## [0.4.8] - 2026-10-18
|
|
8
35
|
|
|
9
36
|
### Fixed
|
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>;
|
|
@@ -557,6 +562,7 @@ async function checkRoleAuth(
|
|
|
557
562
|
requiredRole: string,
|
|
558
563
|
request: Request,
|
|
559
564
|
auth: AuthConfig,
|
|
565
|
+
hideSchemaErrors?: boolean,
|
|
560
566
|
): Promise<AuthResult> {
|
|
561
567
|
if (requiredRole === 'public') {
|
|
562
568
|
return { user: null, error: null };
|
|
@@ -577,7 +583,7 @@ async function checkRoleAuth(
|
|
|
577
583
|
if (!hasRole(userRole, requiredRole, hierarchy)) {
|
|
578
584
|
return {
|
|
579
585
|
user: result.user,
|
|
580
|
-
error: json({ message: `Insufficient permissions: requires ${requiredRole} role` }, 403),
|
|
586
|
+
error: json({ message: hideSchemaErrors ? 'Forbidden' : `Insufficient permissions: requires ${requiredRole} role` }, 403),
|
|
581
587
|
};
|
|
582
588
|
}
|
|
583
589
|
|
|
@@ -636,7 +642,7 @@ function stripProtectedFields(
|
|
|
636
642
|
tableName: string,
|
|
637
643
|
options?: HandlerOptions,
|
|
638
644
|
): unknown {
|
|
639
|
-
const fields = options?.protectedFields
|
|
645
|
+
const fields = ownEntry(options?.protectedFields, tableName);
|
|
640
646
|
if (!fields || fields.length === 0) return body;
|
|
641
647
|
if (Array.isArray(body)) return body.map(row => stripProtectedFields(row, tableName, options));
|
|
642
648
|
if (body === null || typeof body !== 'object') {
|
|
@@ -654,7 +660,7 @@ function stripHiddenColumns(
|
|
|
654
660
|
tableName: string,
|
|
655
661
|
options?: HandlerOptions,
|
|
656
662
|
): unknown {
|
|
657
|
-
const columns = options?.hiddenColumns
|
|
663
|
+
const columns = ownEntry(options?.hiddenColumns, tableName);
|
|
658
664
|
if (!columns || columns.length === 0) return data;
|
|
659
665
|
|
|
660
666
|
const strip = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
@@ -927,7 +933,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
927
933
|
const sameColumn = (field: string): boolean => (table
|
|
928
934
|
? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
|
|
929
935
|
: toSnakeCase(field) === toSnakeCase(colName));
|
|
930
|
-
if (roles.length > 0 && (options?.protectedFields
|
|
936
|
+
if (roles.length > 0 && (ownEntry(options?.protectedFields, tableName) ?? []).some(sameColumn)) {
|
|
931
937
|
throw new Error(
|
|
932
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.`,
|
|
933
939
|
);
|
|
@@ -941,7 +947,7 @@ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | und
|
|
|
941
947
|
* non-string role claim is not a member.
|
|
942
948
|
*/
|
|
943
949
|
function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
|
|
944
|
-
const roles = options?.softDelete?.restore
|
|
950
|
+
const roles = ownEntry(options?.softDelete?.restore, tableName);
|
|
945
951
|
if (!roles?.length || !user) return false;
|
|
946
952
|
const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
|
|
947
953
|
return role !== '' && roles.includes(role);
|
|
@@ -1251,7 +1257,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1251
1257
|
|
|
1252
1258
|
// Per-RPC role check (object syntax: { fn, role })
|
|
1253
1259
|
if (isObjectForm && entry.role && options?.auth) {
|
|
1254
|
-
const roleResult = await checkRoleAuth(entry.role, request, options.auth);
|
|
1260
|
+
const roleResult = await checkRoleAuth(entry.role, request, options.auth, options.hideSchemaErrors);
|
|
1255
1261
|
if (roleResult.error) {
|
|
1256
1262
|
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
|
|
1257
1263
|
return roleResult.error;
|
|
@@ -1361,6 +1367,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1361
1367
|
if (options?.hideSchemaErrors && status === 500) {
|
|
1362
1368
|
return json({ message: 'Internal Server Error' }, 500);
|
|
1363
1369
|
}
|
|
1370
|
+
// PostgreSQL's 42501 text names functions and tables; 22023 and P0001 are the SQL author's channel and stay raw.
|
|
1371
|
+
if (options?.hideSchemaErrors && pgCode === '42501') {
|
|
1372
|
+
return json({ message: 'Forbidden', code: pgCode }, 403);
|
|
1373
|
+
}
|
|
1364
1374
|
return json({ message, ...(pgCode ? { code: pgCode } : {}) }, status);
|
|
1365
1375
|
}
|
|
1366
1376
|
}
|
|
@@ -1448,15 +1458,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1448
1458
|
|
|
1449
1459
|
// A part with parentheses is an aggregate or a declared embed. Anything
|
|
1450
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.
|
|
1451
1463
|
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1452
1464
|
if (!queryError) {
|
|
1465
|
+
const joinPath = relationalQueryAvailable(routedDb, tableName, options);
|
|
1453
1466
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1454
|
-
const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1467
|
+
const { undeclared, embedAggregate, nestedEmbed } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1455
1468
|
if (undeclared !== undefined) {
|
|
1456
1469
|
queryError = undeclaredSelectPartMessage(undeclared);
|
|
1457
1470
|
queryErrorEchoesInput = true;
|
|
1458
1471
|
break;
|
|
1459
1472
|
}
|
|
1473
|
+
if (embedAggregate || (nestedEmbed && joinPath)) {
|
|
1474
|
+
queryError = 'Invalid query';
|
|
1475
|
+
break;
|
|
1476
|
+
}
|
|
1460
1477
|
}
|
|
1461
1478
|
}
|
|
1462
1479
|
|
|
@@ -1578,6 +1595,14 @@ function columnPropertyName(table: DrizzleTable | undefined, name: string): stri
|
|
|
1578
1595
|
?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
|
|
1579
1596
|
}
|
|
1580
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
|
+
|
|
1581
1606
|
/** Build each relation's projection before Drizzle reads its values. */
|
|
1582
1607
|
function buildWithClause(
|
|
1583
1608
|
embeds: EmbedRequest[],
|
|
@@ -1595,7 +1620,7 @@ function buildWithClause(
|
|
|
1595
1620
|
if (!relation) continue;
|
|
1596
1621
|
hasEmbedded = true;
|
|
1597
1622
|
const table = schema[relation.table];
|
|
1598
|
-
const names = new Set(options?.hiddenColumns
|
|
1623
|
+
const names = new Set(ownEntry(options?.hiddenColumns, relation.table) ?? []);
|
|
1599
1624
|
const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
|
|
1600
1625
|
const config: Record<string, any> = {};
|
|
1601
1626
|
if (embed.columns !== null) {
|
|
@@ -1824,10 +1849,7 @@ async function handleGet(
|
|
|
1824
1849
|
|
|
1825
1850
|
// Detect if Drizzle relational query (JOINs) should be used
|
|
1826
1851
|
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1827
|
-
const useRelationalQuery = embeds.length > 0
|
|
1828
|
-
&& tableRelations
|
|
1829
|
-
&& options?.drizzleRelations
|
|
1830
|
-
&& (db as any).query?.[tableName];
|
|
1852
|
+
const useRelationalQuery = embeds.length > 0 && relationalQueryAvailable(db, tableName, options);
|
|
1831
1853
|
|
|
1832
1854
|
let rows: unknown[];
|
|
1833
1855
|
const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
|
|
@@ -1978,7 +2000,7 @@ function parseSelectParam(
|
|
|
1978
2000
|
selectParam: string | null,
|
|
1979
2001
|
tableRelations?: Record<string, RelationConfig>,
|
|
1980
2002
|
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
1981
|
-
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
|
|
2003
|
+
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string; embedAggregate?: true; nestedEmbed?: true } {
|
|
1982
2004
|
if (!selectParam) {
|
|
1983
2005
|
return { columns: null, embeds: [], aggregates: [] };
|
|
1984
2006
|
}
|
|
@@ -1990,6 +2012,11 @@ function parseSelectParam(
|
|
|
1990
2012
|
// The first part with parentheses that is neither an aggregate nor a declared
|
|
1991
2013
|
// embed, at any depth. The caller refuses the select; it is never a column.
|
|
1992
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;
|
|
1993
2020
|
|
|
1994
2021
|
// Parse: split on commas, but respect parentheses
|
|
1995
2022
|
const parts = splitSelectParam(selectParam);
|
|
@@ -2014,6 +2041,8 @@ function parseSelectParam(
|
|
|
2014
2041
|
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
2015
2042
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
2016
2043
|
undeclared ??= inner.undeclared;
|
|
2044
|
+
if (inner.aggregates.length > 0 || inner.embedAggregate) embedAggregate = true;
|
|
2045
|
+
if (inner.embeds.length > 0) nestedEmbed = true;
|
|
2017
2046
|
embeds.push({
|
|
2018
2047
|
name,
|
|
2019
2048
|
columns: inner.columns,
|
|
@@ -2033,6 +2062,8 @@ function parseSelectParam(
|
|
|
2033
2062
|
embeds,
|
|
2034
2063
|
aggregates,
|
|
2035
2064
|
...(undeclared === undefined ? {} : { undeclared }),
|
|
2065
|
+
...(embedAggregate ? { embedAggregate: true as const } : {}),
|
|
2066
|
+
...(nestedEmbed ? { nestedEmbed: true as const } : {}),
|
|
2036
2067
|
};
|
|
2037
2068
|
}
|
|
2038
2069
|
|
|
@@ -2159,7 +2190,7 @@ function requestUsesHiddenColumn(
|
|
|
2159
2190
|
return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
|
|
2160
2191
|
})) return true;
|
|
2161
2192
|
} else if (key === 'or' || key === 'and') {
|
|
2162
|
-
if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
|
|
2193
|
+
if (parseLogicalGroup(value).filters.some(filter => isHidden(tableName, filter.column))) return true;
|
|
2163
2194
|
} else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
|
|
2164
2195
|
return true;
|
|
2165
2196
|
}
|
|
@@ -2167,6 +2198,20 @@ function requestUsesHiddenColumn(
|
|
|
2167
2198
|
return false;
|
|
2168
2199
|
}
|
|
2169
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
|
+
|
|
2170
2215
|
/**
|
|
2171
2216
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
2172
2217
|
*/
|
|
@@ -2185,14 +2230,18 @@ async function resolveEmbeds(
|
|
|
2185
2230
|
const result = rows.map(r => ({ ...r }));
|
|
2186
2231
|
|
|
2187
2232
|
for (const embed of embeds) {
|
|
2188
|
-
const
|
|
2189
|
-
if (!
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
+
];
|
|
2196
2245
|
|
|
2197
2246
|
// Build soft-delete filter for the target table
|
|
2198
2247
|
const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
|
|
@@ -2233,9 +2282,7 @@ async function resolveEmbeds(
|
|
|
2233
2282
|
for (const row of result) {
|
|
2234
2283
|
const sourceVal = row[relationConfig.from];
|
|
2235
2284
|
const matched = relatedRows.filter(r => r[relationConfig.to] === sourceVal);
|
|
2236
|
-
row[embed.name] =
|
|
2237
|
-
? matched.map(r => pickColumns(r, embed.columns!))
|
|
2238
|
-
: matched;
|
|
2285
|
+
row[embed.name] = picked ? matched.map(r => pickColumns(r, picked)) : matched;
|
|
2239
2286
|
}
|
|
2240
2287
|
} else {
|
|
2241
2288
|
// Many-to-one: source.from is the FK, target.to is the PK
|
|
@@ -2280,7 +2327,7 @@ async function resolveEmbeds(
|
|
|
2280
2327
|
row[embed.name] = null;
|
|
2281
2328
|
} else {
|
|
2282
2329
|
const related = index.get(fkVal) || null;
|
|
2283
|
-
row[embed.name] = related &&
|
|
2330
|
+
row[embed.name] = related && picked ? pickColumns(related, picked) : related;
|
|
2284
2331
|
}
|
|
2285
2332
|
}
|
|
2286
2333
|
}
|
|
@@ -2366,10 +2413,10 @@ function sanitizeWriteBody(
|
|
|
2366
2413
|
at: { requestId?: string; name: string; method: string },
|
|
2367
2414
|
deferred: Deferred[],
|
|
2368
2415
|
): { body: unknown; changed: string[] } | { refusal: Response } {
|
|
2369
|
-
const columns = options?.sanitizeColumns
|
|
2416
|
+
const columns = ownEntry(options?.sanitizeColumns, tableName);
|
|
2370
2417
|
if (!columns?.length) return { body, changed: [] };
|
|
2371
2418
|
const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
|
|
2372
|
-
const hidden = new Set((options?.hiddenColumns
|
|
2419
|
+
const hidden = new Set((ownEntry(options?.hiddenColumns, tableName) ?? []).map(name => columnPropertyName(table, name)));
|
|
2373
2420
|
const refuse = (message: string, column: string): { refusal: Response } => {
|
|
2374
2421
|
const fault: HandlerFault = {
|
|
2375
2422
|
...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
|
|
@@ -2451,7 +2498,7 @@ async function handlePost(
|
|
|
2451
2498
|
throw err;
|
|
2452
2499
|
}
|
|
2453
2500
|
|
|
2454
|
-
const hook = options?.hooks
|
|
2501
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2455
2502
|
|
|
2456
2503
|
if (hook?.beforeCreate) {
|
|
2457
2504
|
try {
|
|
@@ -2545,7 +2592,7 @@ async function handlePatch(
|
|
|
2545
2592
|
|
|
2546
2593
|
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
2547
2594
|
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
2548
|
-
const ownership = options?.rowOwnership
|
|
2595
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2549
2596
|
if (user && options && ownership && isOwnershipExempt(ownership, user, options)
|
|
2550
2597
|
&& buildsNoFilter(table, params)) {
|
|
2551
2598
|
return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
|
|
@@ -2563,7 +2610,13 @@ async function handlePatch(
|
|
|
2563
2610
|
if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
|
|
2564
2611
|
}
|
|
2565
2612
|
|
|
2566
|
-
|
|
2613
|
+
// Before the hook, so a request that will answer 400 never runs app side effects.
|
|
2614
|
+
const filterResult = buildAllConditions(table, params);
|
|
2615
|
+
if ('error' in filterResult) {
|
|
2616
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2567
2620
|
|
|
2568
2621
|
if (hook?.beforeUpdate) {
|
|
2569
2622
|
try {
|
|
@@ -2584,10 +2637,6 @@ async function handlePatch(
|
|
|
2584
2637
|
if ('refusal' in sanitized) return sanitized.refusal;
|
|
2585
2638
|
body = sanitized.body as Record<string, unknown>;
|
|
2586
2639
|
|
|
2587
|
-
const filterResult = buildAllConditions(table, params);
|
|
2588
|
-
if ('error' in filterResult) {
|
|
2589
|
-
return sanitizeError(filterResult.error, 400, options);
|
|
2590
|
-
}
|
|
2591
2640
|
const conditions = filterResult.conditions;
|
|
2592
2641
|
|
|
2593
2642
|
// Soft-delete: update live records, unless a restore role sent ?deleted=include|only
|
|
@@ -2659,7 +2708,7 @@ function unbackedExemptionRefusal(
|
|
|
2659
2708
|
settings: Record<string, string> | undefined,
|
|
2660
2709
|
): string | null {
|
|
2661
2710
|
if (method !== 'PATCH' && method !== 'DELETE') return null;
|
|
2662
|
-
const ownership = options?.rowOwnership
|
|
2711
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2663
2712
|
if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
|
|
2664
2713
|
if (settings?.['role']) return null;
|
|
2665
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.`;
|
|
@@ -2707,7 +2756,7 @@ function ownershipScope(
|
|
|
2707
2756
|
options: HandlerOptions | undefined,
|
|
2708
2757
|
user: Record<string, unknown> | null,
|
|
2709
2758
|
): OwnershipScope {
|
|
2710
|
-
const ownership = options?.rowOwnership
|
|
2759
|
+
const ownership = ownEntry(options?.rowOwnership, tableName);
|
|
2711
2760
|
if (!options || !ownership) return { condition: null };
|
|
2712
2761
|
if (!user) {
|
|
2713
2762
|
return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
|
|
@@ -2750,7 +2799,13 @@ async function handleDelete(
|
|
|
2750
2799
|
return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
|
|
2751
2800
|
}
|
|
2752
2801
|
|
|
2753
|
-
|
|
2802
|
+
// Before the hook, so a request that will answer 400 never runs app side effects.
|
|
2803
|
+
const filterResult = buildAllConditions(table, params);
|
|
2804
|
+
if ('error' in filterResult) {
|
|
2805
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
const hook = ownEntry(options?.hooks, tableName);
|
|
2754
2809
|
|
|
2755
2810
|
if (hook?.beforeDelete) {
|
|
2756
2811
|
try {
|
|
@@ -2760,10 +2815,6 @@ async function handleDelete(
|
|
|
2760
2815
|
}
|
|
2761
2816
|
}
|
|
2762
2817
|
|
|
2763
|
-
const filterResult = buildAllConditions(table, params);
|
|
2764
|
-
if ('error' in filterResult) {
|
|
2765
|
-
return sanitizeError(filterResult.error, 400, options);
|
|
2766
|
-
}
|
|
2767
2818
|
const conditions = filterResult.conditions;
|
|
2768
2819
|
|
|
2769
2820
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
@@ -3089,6 +3140,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
3089
3140
|
case 'is':
|
|
3090
3141
|
if (filter.value === 'null') condition = isNull(column);
|
|
3091
3142
|
else if (filter.value === 'not.null') condition = isNotNull(column);
|
|
3143
|
+
else return { __filterError: `Invalid value for 'is': expected null or not.null` };
|
|
3092
3144
|
break;
|
|
3093
3145
|
case 'in':
|
|
3094
3146
|
condition = inArray(column, values ?? parseInList(filter.value));
|
|
@@ -3162,7 +3214,7 @@ function buildOrderBy(
|
|
|
3162
3214
|
|
|
3163
3215
|
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
3164
3216
|
|
|
3165
|
-
function parseFilters(params: URLSearchParams): Filter[] {
|
|
3217
|
+
function parseFilters(params: URLSearchParams): { filters: Filter[]; error?: string } {
|
|
3166
3218
|
const filters: Filter[] = [];
|
|
3167
3219
|
|
|
3168
3220
|
for (const [key, value] of params.entries()) {
|
|
@@ -3178,7 +3230,9 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
3178
3230
|
|
|
3179
3231
|
// PostgREST format: column=operator.value
|
|
3180
3232
|
const dotIndex = remaining.indexOf('.');
|
|
3181
|
-
if (dotIndex === -1)
|
|
3233
|
+
if (dotIndex === -1) {
|
|
3234
|
+
return { filters, error: `Invalid filter for '${key.slice(0, 100)}': expected operator.value` };
|
|
3235
|
+
}
|
|
3182
3236
|
|
|
3183
3237
|
const operator = remaining.substring(0, dotIndex);
|
|
3184
3238
|
const filterValue = remaining.substring(dotIndex + 1);
|
|
@@ -3193,7 +3247,7 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
3193
3247
|
});
|
|
3194
3248
|
}
|
|
3195
3249
|
|
|
3196
|
-
return filters;
|
|
3250
|
+
return { filters };
|
|
3197
3251
|
}
|
|
3198
3252
|
|
|
3199
3253
|
/** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
|
|
@@ -3238,16 +3292,22 @@ function splitLogicalGroup(content: string): string[] {
|
|
|
3238
3292
|
* Parse a logical group value into individual filters.
|
|
3239
3293
|
* Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
|
|
3240
3294
|
*/
|
|
3241
|
-
function parseLogicalGroup(value: string): Filter[] {
|
|
3295
|
+
function parseLogicalGroup(value: string): { filters: Filter[]; error?: string } {
|
|
3242
3296
|
let content = value;
|
|
3243
3297
|
if (content.startsWith('(') && content.endsWith(')')) {
|
|
3244
3298
|
content = content.slice(1, -1);
|
|
3245
3299
|
}
|
|
3246
3300
|
|
|
3247
3301
|
const filters: Filter[] = [];
|
|
3302
|
+
if (content === '') return { filters };
|
|
3303
|
+
let error: string | undefined;
|
|
3304
|
+
const memberError = 'Invalid filter group: every member must be column.operator.value';
|
|
3248
3305
|
for (const part of splitLogicalGroup(content)) {
|
|
3249
3306
|
const dotIndex = part.indexOf('.');
|
|
3250
|
-
if (dotIndex === -1)
|
|
3307
|
+
if (dotIndex === -1) {
|
|
3308
|
+
error = memberError;
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3251
3311
|
|
|
3252
3312
|
const column = part.substring(0, dotIndex);
|
|
3253
3313
|
const rest = part.substring(dotIndex + 1);
|
|
@@ -3260,7 +3320,10 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
3260
3320
|
}
|
|
3261
3321
|
|
|
3262
3322
|
const opDot = remaining.indexOf('.');
|
|
3263
|
-
if (opDot === -1)
|
|
3323
|
+
if (opDot === -1) {
|
|
3324
|
+
error = memberError;
|
|
3325
|
+
continue;
|
|
3326
|
+
}
|
|
3264
3327
|
|
|
3265
3328
|
const operator = remaining.substring(0, opDot);
|
|
3266
3329
|
const filterValue = remaining.substring(opDot + 1);
|
|
@@ -3273,7 +3336,7 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
3273
3336
|
...(jsonPath ? { jsonPath } : {}),
|
|
3274
3337
|
});
|
|
3275
3338
|
}
|
|
3276
|
-
return filters;
|
|
3339
|
+
return error ? { filters, error } : { filters };
|
|
3277
3340
|
}
|
|
3278
3341
|
|
|
3279
3342
|
function isFilterError(x: unknown): x is { __filterError: string } {
|
|
@@ -3286,36 +3349,27 @@ function isFilterError(x: unknown): x is { __filterError: string } {
|
|
|
3286
3349
|
* Returns { conditions } on success, { error } on validation failure.
|
|
3287
3350
|
*/
|
|
3288
3351
|
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { conditions: any[] } | { error: string } {
|
|
3289
|
-
const
|
|
3290
|
-
|
|
3352
|
+
const parsed = parseFilters(params);
|
|
3353
|
+
if (parsed.error) return { error: parsed.error };
|
|
3354
|
+
const rawResults = parsed.filters.map(f => buildCondition(table, f));
|
|
3291
3355
|
for (const r of rawResults) {
|
|
3292
3356
|
if (isFilterError(r)) return { error: r.__filterError };
|
|
3293
3357
|
}
|
|
3294
3358
|
const conditions = rawResults.filter(Boolean);
|
|
3295
3359
|
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
const
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
const andParam = params.get('and');
|
|
3310
|
-
if (andParam) {
|
|
3311
|
-
const andFilters = parseLogicalGroup(andParam);
|
|
3312
|
-
const andRawResults = andFilters.map(f => buildCondition(table, f));
|
|
3313
|
-
for (const r of andRawResults) {
|
|
3314
|
-
if (isFilterError(r)) return { error: r.__filterError };
|
|
3315
|
-
}
|
|
3316
|
-
const andConditions = andRawResults.filter(Boolean);
|
|
3317
|
-
if (andConditions.length > 0) {
|
|
3318
|
-
conditions.push(and(...andConditions));
|
|
3360
|
+
// getAll: the client appends, so `.or(a).or(b)` sends the key twice; dropping one widens the request.
|
|
3361
|
+
for (const [key, combine] of [['or', or], ['and', and]] as const) {
|
|
3362
|
+
for (const value of params.getAll(key)) {
|
|
3363
|
+
const group = parseLogicalGroup(value);
|
|
3364
|
+
if (group.error) return { error: group.error };
|
|
3365
|
+
const groupRawResults = group.filters.map(f => buildCondition(table, f));
|
|
3366
|
+
for (const r of groupRawResults) {
|
|
3367
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
3368
|
+
}
|
|
3369
|
+
const groupConditions = groupRawResults.filter(Boolean);
|
|
3370
|
+
if (groupConditions.length > 0) {
|
|
3371
|
+
conditions.push(combine(...groupConditions));
|
|
3372
|
+
}
|
|
3319
3373
|
}
|
|
3320
3374
|
}
|
|
3321
3375
|
|
|
@@ -3324,9 +3378,8 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
|
|
|
3324
3378
|
|
|
3325
3379
|
/**
|
|
3326
3380
|
* True when the request's own filters build no SQL condition, so the write would run
|
|
3327
|
-
* with no WHERE.
|
|
3328
|
-
*
|
|
3329
|
-
* clauses the handler adds are not part of this test. A filter error is left to the
|
|
3381
|
+
* with no WHERE. An empty group (`or=`, `or=()`) is not a filter. Only user filters
|
|
3382
|
+
* count: the soft-delete and ownership clauses the handler adds are not part of this test. A filter error is left to the
|
|
3330
3383
|
* caller's own buildAllConditions, which reports it.
|
|
3331
3384
|
*/
|
|
3332
3385
|
function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
|