@everystack/api 0.4.7 → 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 +22 -0
- package/package.json +1 -1
- package/src/handler/index.ts +64 -49
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ 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
|
+
|
|
23
|
+
## [0.4.8] - 2026-10-18
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- compile under NodeNext; the primary-key tiebreak no longer hands a pg-core column to asc()
|
|
28
|
+
|
|
7
29
|
## [0.4.7] - 2026-10-18
|
|
8
30
|
|
|
9
31
|
### 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.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));
|
|
@@ -3141,11 +3151,15 @@ function buildOrderBy(
|
|
|
3141
3151
|
|
|
3142
3152
|
// Aggregate groups do not represent individual rows, so their order cannot use the row key.
|
|
3143
3153
|
if (tieBreak && is(table, PgTable)) {
|
|
3154
|
+
// Resolve the key columns from `columns` (getTableColumns), the same typing asc() gets for
|
|
3155
|
+
// the named columns above. getTableConfig's pg-core column types are a second Column class
|
|
3156
|
+
// under NodeNext (drizzle's .d.cts imports both .js and .cjs), so they must not reach asc().
|
|
3144
3157
|
const config = getTableConfig(table);
|
|
3158
|
+
const byName = new Map(columns.map(column => [column.name, column]));
|
|
3145
3159
|
const primaryColumns = [
|
|
3146
|
-
...
|
|
3147
|
-
...config.primaryKeys.flatMap(key => key.columns),
|
|
3148
|
-
];
|
|
3160
|
+
...columns.filter(column => column.primary),
|
|
3161
|
+
...config.primaryKeys.flatMap(key => key.columns.map(keyColumn => byName.get(keyColumn.name))),
|
|
3162
|
+
].filter((column): column is DrizzleColumn => column !== undefined);
|
|
3149
3163
|
for (const column of primaryColumns) {
|
|
3150
3164
|
if (!named.has(column.name) && !isHidden?.(column.name)) {
|
|
3151
3165
|
expressions.push(asc(column));
|
|
@@ -3158,7 +3172,7 @@ function buildOrderBy(
|
|
|
3158
3172
|
|
|
3159
3173
|
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
3160
3174
|
|
|
3161
|
-
function parseFilters(params: URLSearchParams): Filter[] {
|
|
3175
|
+
function parseFilters(params: URLSearchParams): { filters: Filter[]; error?: string } {
|
|
3162
3176
|
const filters: Filter[] = [];
|
|
3163
3177
|
|
|
3164
3178
|
for (const [key, value] of params.entries()) {
|
|
@@ -3174,7 +3188,9 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
3174
3188
|
|
|
3175
3189
|
// PostgREST format: column=operator.value
|
|
3176
3190
|
const dotIndex = remaining.indexOf('.');
|
|
3177
|
-
if (dotIndex === -1)
|
|
3191
|
+
if (dotIndex === -1) {
|
|
3192
|
+
return { filters, error: `Invalid filter for '${key.slice(0, 100)}': expected operator.value` };
|
|
3193
|
+
}
|
|
3178
3194
|
|
|
3179
3195
|
const operator = remaining.substring(0, dotIndex);
|
|
3180
3196
|
const filterValue = remaining.substring(dotIndex + 1);
|
|
@@ -3189,7 +3205,7 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
3189
3205
|
});
|
|
3190
3206
|
}
|
|
3191
3207
|
|
|
3192
|
-
return filters;
|
|
3208
|
+
return { filters };
|
|
3193
3209
|
}
|
|
3194
3210
|
|
|
3195
3211
|
/** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
|
|
@@ -3234,16 +3250,22 @@ function splitLogicalGroup(content: string): string[] {
|
|
|
3234
3250
|
* Parse a logical group value into individual filters.
|
|
3235
3251
|
* Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
|
|
3236
3252
|
*/
|
|
3237
|
-
function parseLogicalGroup(value: string): Filter[] {
|
|
3253
|
+
function parseLogicalGroup(value: string): { filters: Filter[]; error?: string } {
|
|
3238
3254
|
let content = value;
|
|
3239
3255
|
if (content.startsWith('(') && content.endsWith(')')) {
|
|
3240
3256
|
content = content.slice(1, -1);
|
|
3241
3257
|
}
|
|
3242
3258
|
|
|
3243
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';
|
|
3244
3263
|
for (const part of splitLogicalGroup(content)) {
|
|
3245
3264
|
const dotIndex = part.indexOf('.');
|
|
3246
|
-
if (dotIndex === -1)
|
|
3265
|
+
if (dotIndex === -1) {
|
|
3266
|
+
error = memberError;
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3247
3269
|
|
|
3248
3270
|
const column = part.substring(0, dotIndex);
|
|
3249
3271
|
const rest = part.substring(dotIndex + 1);
|
|
@@ -3256,7 +3278,10 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
3256
3278
|
}
|
|
3257
3279
|
|
|
3258
3280
|
const opDot = remaining.indexOf('.');
|
|
3259
|
-
if (opDot === -1)
|
|
3281
|
+
if (opDot === -1) {
|
|
3282
|
+
error = memberError;
|
|
3283
|
+
continue;
|
|
3284
|
+
}
|
|
3260
3285
|
|
|
3261
3286
|
const operator = remaining.substring(0, opDot);
|
|
3262
3287
|
const filterValue = remaining.substring(opDot + 1);
|
|
@@ -3269,7 +3294,7 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
3269
3294
|
...(jsonPath ? { jsonPath } : {}),
|
|
3270
3295
|
});
|
|
3271
3296
|
}
|
|
3272
|
-
return filters;
|
|
3297
|
+
return error ? { filters, error } : { filters };
|
|
3273
3298
|
}
|
|
3274
3299
|
|
|
3275
3300
|
function isFilterError(x: unknown): x is { __filterError: string } {
|
|
@@ -3282,36 +3307,27 @@ function isFilterError(x: unknown): x is { __filterError: string } {
|
|
|
3282
3307
|
* Returns { conditions } on success, { error } on validation failure.
|
|
3283
3308
|
*/
|
|
3284
3309
|
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { conditions: any[] } | { error: string } {
|
|
3285
|
-
const
|
|
3286
|
-
|
|
3310
|
+
const parsed = parseFilters(params);
|
|
3311
|
+
if (parsed.error) return { error: parsed.error };
|
|
3312
|
+
const rawResults = parsed.filters.map(f => buildCondition(table, f));
|
|
3287
3313
|
for (const r of rawResults) {
|
|
3288
3314
|
if (isFilterError(r)) return { error: r.__filterError };
|
|
3289
3315
|
}
|
|
3290
3316
|
const conditions = rawResults.filter(Boolean);
|
|
3291
3317
|
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
const
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
const andParam = params.get('and');
|
|
3306
|
-
if (andParam) {
|
|
3307
|
-
const andFilters = parseLogicalGroup(andParam);
|
|
3308
|
-
const andRawResults = andFilters.map(f => buildCondition(table, f));
|
|
3309
|
-
for (const r of andRawResults) {
|
|
3310
|
-
if (isFilterError(r)) return { error: r.__filterError };
|
|
3311
|
-
}
|
|
3312
|
-
const andConditions = andRawResults.filter(Boolean);
|
|
3313
|
-
if (andConditions.length > 0) {
|
|
3314
|
-
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
|
+
}
|
|
3315
3331
|
}
|
|
3316
3332
|
}
|
|
3317
3333
|
|
|
@@ -3320,9 +3336,8 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
|
|
|
3320
3336
|
|
|
3321
3337
|
/**
|
|
3322
3338
|
* True when the request's own filters build no SQL condition, so the write would run
|
|
3323
|
-
* with no WHERE.
|
|
3324
|
-
*
|
|
3325
|
-
* 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
|
|
3326
3341
|
* caller's own buildAllConditions, which reports it.
|
|
3327
3342
|
*/
|
|
3328
3343
|
function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
|