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