@everystack/api 0.4.8 → 0.4.9
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 +16 -0
- package/package.json +1 -1
- package/src/handler/index.ts +57 -46
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ 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.9] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- PATCH and DELETE refuse a bad filter before running beforeUpdate or beforeDelete
|
|
12
|
+
- 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.
|
|
13
|
+
- a repeated or= or and= now applies every group, ANDed, so a read or write that sent a second group matches fewer rows
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- with hideSchemaErrors, an RPC 42501 and the role gate answer Forbidden without database text
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
- upgrade notes for strict filters, hook order and hidden RPC error text
|
|
22
|
+
|
|
7
23
|
## [0.4.8] - 2026-10-18
|
|
8
24
|
|
|
9
25
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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
|
@@ -557,6 +557,7 @@ async function checkRoleAuth(
|
|
|
557
557
|
requiredRole: string,
|
|
558
558
|
request: Request,
|
|
559
559
|
auth: AuthConfig,
|
|
560
|
+
hideSchemaErrors?: boolean,
|
|
560
561
|
): Promise<AuthResult> {
|
|
561
562
|
if (requiredRole === 'public') {
|
|
562
563
|
return { user: null, error: null };
|
|
@@ -577,7 +578,7 @@ async function checkRoleAuth(
|
|
|
577
578
|
if (!hasRole(userRole, requiredRole, hierarchy)) {
|
|
578
579
|
return {
|
|
579
580
|
user: result.user,
|
|
580
|
-
error: json({ message: `Insufficient permissions: requires ${requiredRole} role` }, 403),
|
|
581
|
+
error: json({ message: hideSchemaErrors ? 'Forbidden' : `Insufficient permissions: requires ${requiredRole} role` }, 403),
|
|
581
582
|
};
|
|
582
583
|
}
|
|
583
584
|
|
|
@@ -1251,7 +1252,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1251
1252
|
|
|
1252
1253
|
// Per-RPC role check (object syntax: { fn, role })
|
|
1253
1254
|
if (isObjectForm && entry.role && options?.auth) {
|
|
1254
|
-
const roleResult = await checkRoleAuth(entry.role, request, options.auth);
|
|
1255
|
+
const roleResult = await checkRoleAuth(entry.role, request, options.auth, options.hideSchemaErrors);
|
|
1255
1256
|
if (roleResult.error) {
|
|
1256
1257
|
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
|
|
1257
1258
|
return roleResult.error;
|
|
@@ -1361,6 +1362,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1361
1362
|
if (options?.hideSchemaErrors && status === 500) {
|
|
1362
1363
|
return json({ message: 'Internal Server Error' }, 500);
|
|
1363
1364
|
}
|
|
1365
|
+
// PostgreSQL's 42501 text names functions and tables; 22023 and P0001 are the SQL author's channel and stay raw.
|
|
1366
|
+
if (options?.hideSchemaErrors && pgCode === '42501') {
|
|
1367
|
+
return json({ message: 'Forbidden', code: pgCode }, 403);
|
|
1368
|
+
}
|
|
1364
1369
|
return json({ message, ...(pgCode ? { code: pgCode } : {}) }, status);
|
|
1365
1370
|
}
|
|
1366
1371
|
}
|
|
@@ -2159,7 +2164,7 @@ function requestUsesHiddenColumn(
|
|
|
2159
2164
|
return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
|
|
2160
2165
|
})) return true;
|
|
2161
2166
|
} else if (key === 'or' || key === 'and') {
|
|
2162
|
-
if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
|
|
2167
|
+
if (parseLogicalGroup(value).filters.some(filter => isHidden(tableName, filter.column))) return true;
|
|
2163
2168
|
} else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
|
|
2164
2169
|
return true;
|
|
2165
2170
|
}
|
|
@@ -2563,6 +2568,12 @@ async function handlePatch(
|
|
|
2563
2568
|
if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
|
|
2564
2569
|
}
|
|
2565
2570
|
|
|
2571
|
+
// Before the hook, so a request that will answer 400 never runs app side effects.
|
|
2572
|
+
const filterResult = buildAllConditions(table, params);
|
|
2573
|
+
if ('error' in filterResult) {
|
|
2574
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2566
2577
|
const hook = options?.hooks?.[tableName];
|
|
2567
2578
|
|
|
2568
2579
|
if (hook?.beforeUpdate) {
|
|
@@ -2584,10 +2595,6 @@ async function handlePatch(
|
|
|
2584
2595
|
if ('refusal' in sanitized) return sanitized.refusal;
|
|
2585
2596
|
body = sanitized.body as Record<string, unknown>;
|
|
2586
2597
|
|
|
2587
|
-
const filterResult = buildAllConditions(table, params);
|
|
2588
|
-
if ('error' in filterResult) {
|
|
2589
|
-
return sanitizeError(filterResult.error, 400, options);
|
|
2590
|
-
}
|
|
2591
2598
|
const conditions = filterResult.conditions;
|
|
2592
2599
|
|
|
2593
2600
|
// Soft-delete: update live records, unless a restore role sent ?deleted=include|only
|
|
@@ -2750,6 +2757,12 @@ async function handleDelete(
|
|
|
2750
2757
|
return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
|
|
2751
2758
|
}
|
|
2752
2759
|
|
|
2760
|
+
// Before the hook, so a request that will answer 400 never runs app side effects.
|
|
2761
|
+
const filterResult = buildAllConditions(table, params);
|
|
2762
|
+
if ('error' in filterResult) {
|
|
2763
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2753
2766
|
const hook = options?.hooks?.[tableName];
|
|
2754
2767
|
|
|
2755
2768
|
if (hook?.beforeDelete) {
|
|
@@ -2760,10 +2773,6 @@ async function handleDelete(
|
|
|
2760
2773
|
}
|
|
2761
2774
|
}
|
|
2762
2775
|
|
|
2763
|
-
const filterResult = buildAllConditions(table, params);
|
|
2764
|
-
if ('error' in filterResult) {
|
|
2765
|
-
return sanitizeError(filterResult.error, 400, options);
|
|
2766
|
-
}
|
|
2767
2776
|
const conditions = filterResult.conditions;
|
|
2768
2777
|
|
|
2769
2778
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
@@ -3089,6 +3098,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
3089
3098
|
case 'is':
|
|
3090
3099
|
if (filter.value === 'null') condition = isNull(column);
|
|
3091
3100
|
else if (filter.value === 'not.null') condition = isNotNull(column);
|
|
3101
|
+
else return { __filterError: `Invalid value for 'is': expected null or not.null` };
|
|
3092
3102
|
break;
|
|
3093
3103
|
case 'in':
|
|
3094
3104
|
condition = inArray(column, values ?? parseInList(filter.value));
|
|
@@ -3162,7 +3172,7 @@ function buildOrderBy(
|
|
|
3162
3172
|
|
|
3163
3173
|
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
3164
3174
|
|
|
3165
|
-
function parseFilters(params: URLSearchParams): Filter[] {
|
|
3175
|
+
function parseFilters(params: URLSearchParams): { filters: Filter[]; error?: string } {
|
|
3166
3176
|
const filters: Filter[] = [];
|
|
3167
3177
|
|
|
3168
3178
|
for (const [key, value] of params.entries()) {
|
|
@@ -3178,7 +3188,9 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
3178
3188
|
|
|
3179
3189
|
// PostgREST format: column=operator.value
|
|
3180
3190
|
const dotIndex = remaining.indexOf('.');
|
|
3181
|
-
if (dotIndex === -1)
|
|
3191
|
+
if (dotIndex === -1) {
|
|
3192
|
+
return { filters, error: `Invalid filter for '${key.slice(0, 100)}': expected operator.value` };
|
|
3193
|
+
}
|
|
3182
3194
|
|
|
3183
3195
|
const operator = remaining.substring(0, dotIndex);
|
|
3184
3196
|
const filterValue = remaining.substring(dotIndex + 1);
|
|
@@ -3193,7 +3205,7 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
3193
3205
|
});
|
|
3194
3206
|
}
|
|
3195
3207
|
|
|
3196
|
-
return filters;
|
|
3208
|
+
return { filters };
|
|
3197
3209
|
}
|
|
3198
3210
|
|
|
3199
3211
|
/** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
|
|
@@ -3238,16 +3250,22 @@ function splitLogicalGroup(content: string): string[] {
|
|
|
3238
3250
|
* Parse a logical group value into individual filters.
|
|
3239
3251
|
* Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
|
|
3240
3252
|
*/
|
|
3241
|
-
function parseLogicalGroup(value: string): Filter[] {
|
|
3253
|
+
function parseLogicalGroup(value: string): { filters: Filter[]; error?: string } {
|
|
3242
3254
|
let content = value;
|
|
3243
3255
|
if (content.startsWith('(') && content.endsWith(')')) {
|
|
3244
3256
|
content = content.slice(1, -1);
|
|
3245
3257
|
}
|
|
3246
3258
|
|
|
3247
3259
|
const filters: Filter[] = [];
|
|
3260
|
+
if (content === '') return { filters };
|
|
3261
|
+
let error: string | undefined;
|
|
3262
|
+
const memberError = 'Invalid filter group: every member must be column.operator.value';
|
|
3248
3263
|
for (const part of splitLogicalGroup(content)) {
|
|
3249
3264
|
const dotIndex = part.indexOf('.');
|
|
3250
|
-
if (dotIndex === -1)
|
|
3265
|
+
if (dotIndex === -1) {
|
|
3266
|
+
error = memberError;
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3251
3269
|
|
|
3252
3270
|
const column = part.substring(0, dotIndex);
|
|
3253
3271
|
const rest = part.substring(dotIndex + 1);
|
|
@@ -3260,7 +3278,10 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
3260
3278
|
}
|
|
3261
3279
|
|
|
3262
3280
|
const opDot = remaining.indexOf('.');
|
|
3263
|
-
if (opDot === -1)
|
|
3281
|
+
if (opDot === -1) {
|
|
3282
|
+
error = memberError;
|
|
3283
|
+
continue;
|
|
3284
|
+
}
|
|
3264
3285
|
|
|
3265
3286
|
const operator = remaining.substring(0, opDot);
|
|
3266
3287
|
const filterValue = remaining.substring(opDot + 1);
|
|
@@ -3273,7 +3294,7 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
3273
3294
|
...(jsonPath ? { jsonPath } : {}),
|
|
3274
3295
|
});
|
|
3275
3296
|
}
|
|
3276
|
-
return filters;
|
|
3297
|
+
return error ? { filters, error } : { filters };
|
|
3277
3298
|
}
|
|
3278
3299
|
|
|
3279
3300
|
function isFilterError(x: unknown): x is { __filterError: string } {
|
|
@@ -3286,36 +3307,27 @@ function isFilterError(x: unknown): x is { __filterError: string } {
|
|
|
3286
3307
|
* Returns { conditions } on success, { error } on validation failure.
|
|
3287
3308
|
*/
|
|
3288
3309
|
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { conditions: any[] } | { error: string } {
|
|
3289
|
-
const
|
|
3290
|
-
|
|
3310
|
+
const parsed = parseFilters(params);
|
|
3311
|
+
if (parsed.error) return { error: parsed.error };
|
|
3312
|
+
const rawResults = parsed.filters.map(f => buildCondition(table, f));
|
|
3291
3313
|
for (const r of rawResults) {
|
|
3292
3314
|
if (isFilterError(r)) return { error: r.__filterError };
|
|
3293
3315
|
}
|
|
3294
3316
|
const conditions = rawResults.filter(Boolean);
|
|
3295
3317
|
|
|
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));
|
|
3318
|
+
// getAll: the client appends, so `.or(a).or(b)` sends the key twice; dropping one widens the request.
|
|
3319
|
+
for (const [key, combine] of [['or', or], ['and', and]] as const) {
|
|
3320
|
+
for (const value of params.getAll(key)) {
|
|
3321
|
+
const group = parseLogicalGroup(value);
|
|
3322
|
+
if (group.error) return { error: group.error };
|
|
3323
|
+
const groupRawResults = group.filters.map(f => buildCondition(table, f));
|
|
3324
|
+
for (const r of groupRawResults) {
|
|
3325
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
3326
|
+
}
|
|
3327
|
+
const groupConditions = groupRawResults.filter(Boolean);
|
|
3328
|
+
if (groupConditions.length > 0) {
|
|
3329
|
+
conditions.push(combine(...groupConditions));
|
|
3330
|
+
}
|
|
3319
3331
|
}
|
|
3320
3332
|
}
|
|
3321
3333
|
|
|
@@ -3324,9 +3336,8 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
|
|
|
3324
3336
|
|
|
3325
3337
|
/**
|
|
3326
3338
|
* 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
|
|
3339
|
+
* with no WHERE. An empty group (`or=`, `or=()`) is not a filter. Only user filters
|
|
3340
|
+
* count: the soft-delete and ownership clauses the handler adds are not part of this test. A filter error is left to the
|
|
3330
3341
|
* caller's own buildAllConditions, which reports it.
|
|
3331
3342
|
*/
|
|
3332
3343
|
function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
|