@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 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.8",
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>",
@@ -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) continue;
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) continue;
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) continue;
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 filters = parseFilters(params);
3290
- const rawResults = filters.map(f => buildCondition(table, f));
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
- const orParam = params.get('or');
3297
- if (orParam) {
3298
- const orFilters = parseLogicalGroup(orParam);
3299
- const orRawResults = orFilters.map(f => buildCondition(table, f));
3300
- for (const r of orRawResults) {
3301
- if (isFilterError(r)) return { error: r.__filterError };
3302
- }
3303
- const orConditions = orRawResults.filter(Boolean);
3304
- if (orConditions.length > 0) {
3305
- conditions.push(or(...orConditions));
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. A param that parses but builds nothing (`or=`, `or=()`, `and=junk`,
3328
- * `id=is.x`) is not a filter. Only user filters count: the soft-delete and ownership
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 {