@everystack/api 0.1.0 → 0.2.1
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/package.json +15 -16
- package/src/handler/index.ts +346 -121
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"src",
|
|
11
11
|
"README.md"
|
|
12
12
|
],
|
|
13
|
-
"type": "module",
|
|
14
13
|
"exports": {
|
|
15
14
|
"./handler": {
|
|
16
15
|
"types": "./src/handler/index.ts",
|
|
@@ -21,20 +20,20 @@
|
|
|
21
20
|
"default": "./src/client/index.ts"
|
|
22
21
|
}
|
|
23
22
|
},
|
|
24
|
-
"peerDependencies": {
|
|
25
|
-
"drizzle-orm": ">=0.35.0"
|
|
26
|
-
},
|
|
27
|
-
"devDependencies": {
|
|
28
|
-
"@types/jest": "^29.5.14",
|
|
29
|
-
"@types/node": "^22.0.0",
|
|
30
|
-
"drizzle-orm": "^0.41.0",
|
|
31
|
-
"jest": "^29.7.0",
|
|
32
|
-
"ts-jest": "^29.3.0",
|
|
33
|
-
"typescript": "^5.7.0"
|
|
34
|
-
},
|
|
35
23
|
"scripts": {
|
|
36
|
-
"test": "
|
|
24
|
+
"test": "jest",
|
|
37
25
|
"build": "tsc --build",
|
|
38
26
|
"lint": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"drizzle-orm": "0.41.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/jest": "29.5.14",
|
|
33
|
+
"@types/node": "22.19.18",
|
|
34
|
+
"drizzle-orm": "0.41.0",
|
|
35
|
+
"jest": "29.7.0",
|
|
36
|
+
"ts-jest": "29.4.9",
|
|
37
|
+
"typescript": "5.9.3"
|
|
39
38
|
}
|
|
40
|
-
}
|
|
39
|
+
}
|
package/src/handler/index.ts
CHANGED
|
@@ -18,11 +18,24 @@ type Schema = Record<string, DrizzleTable>;
|
|
|
18
18
|
|
|
19
19
|
type Handler = (request: Request) => Promise<Response>;
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
/** Throw from hooks to return a custom status code and message to the client. */
|
|
22
|
+
export class HandlerError extends Error {
|
|
23
|
+
status: number;
|
|
24
|
+
constructor(status: number, message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.name = 'HandlerError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Replace schema-revealing error details with a generic message when hideSchemaErrors is enabled. */
|
|
32
|
+
function sanitizeError(message: string, status: number, options?: HandlerOptions): Response {
|
|
33
|
+
if (options?.hideSchemaErrors) {
|
|
34
|
+
if (status === 404) return json({ message: 'Not found' }, 404);
|
|
35
|
+
if (status === 403) return json({ message: 'Forbidden' }, 403);
|
|
36
|
+
if (status === 400) return json({ message: 'Bad request' }, 400);
|
|
37
|
+
}
|
|
38
|
+
return json({ message }, status);
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export interface AuthConfig {
|
|
@@ -35,20 +48,20 @@ export interface AuthConfig {
|
|
|
35
48
|
/** Called after successful authentication with the user payload */
|
|
36
49
|
onAuthenticated?: (user: Record<string, unknown>) => void;
|
|
37
50
|
/** Verify client credentials and return client metadata, or null if invalid.
|
|
38
|
-
* When configured,
|
|
39
|
-
|
|
51
|
+
* When configured, every request must provide client headers OR a Referer/Origin header.
|
|
52
|
+
* Called with (clientId, clientSecret, referer) — the callback decides which path to validate:
|
|
53
|
+
* - Secret-based (native/server): clientId + clientSecret present
|
|
54
|
+
* - Referer-based (web): referer present, no secret */
|
|
55
|
+
verifyClient?: (clientId: string, clientSecret: string, referer?: string) => Promise<Record<string, unknown> | null>;
|
|
40
56
|
/** Custom header names for client credentials.
|
|
41
57
|
* Defaults: { id: 'X-Client-Id', secret: 'X-Client-Secret' } */
|
|
42
58
|
clientHeaders?: { id: string; secret: string };
|
|
43
59
|
/** Called after successful client authentication with the client payload */
|
|
44
60
|
onClientAuthenticated?: (client: Record<string, unknown>) => void;
|
|
45
|
-
/** Per-table role requirements. Key is table name or '*' for wildcard default.
|
|
46
|
-
* When set, overrides publicRoutes for tables that have an entry. */
|
|
47
|
-
roles?: Record<string, RoleConfig>;
|
|
48
61
|
/** Ordered role hierarchy, lowest to highest privilege.
|
|
49
|
-
* Default: ['public', 'authenticated', 'admin'] */
|
|
62
|
+
* Used by RPC role gating. Default: ['public', 'authenticated', 'admin'] */
|
|
50
63
|
roleHierarchy?: string[];
|
|
51
|
-
/** Field in the user payload containing the role string. Default: 'role' */
|
|
64
|
+
/** Field in the user payload containing the role string. Used by RPC role gating. Default: 'role' */
|
|
52
65
|
roleField?: string;
|
|
53
66
|
}
|
|
54
67
|
|
|
@@ -95,9 +108,19 @@ export interface CacheConfig {
|
|
|
95
108
|
private?: string;
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
/** RPC handler — plain function (backward compatible) */
|
|
112
|
+
export type RpcHandler = (body: unknown, user?: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Promise<unknown>;
|
|
113
|
+
|
|
114
|
+
/** RPC handler with per-function role requirement */
|
|
115
|
+
export interface RpcHandlerWithRole {
|
|
116
|
+
fn: RpcHandler;
|
|
117
|
+
/** Role required to call this RPC (e.g., 'admin', 'authenticated') */
|
|
118
|
+
role: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
98
121
|
export interface HandlerOptions {
|
|
99
122
|
auth?: AuthConfig;
|
|
100
|
-
rpc?: Record<string,
|
|
123
|
+
rpc?: Record<string, RpcHandler | RpcHandlerWithRole>;
|
|
101
124
|
/** Relation definitions for resource embedding: { [sourceTable]: { [relationName]: RelationConfig } } */
|
|
102
125
|
relations?: Record<string, Record<string, RelationConfig>>;
|
|
103
126
|
/** Base path to strip from request URL (e.g., '/api' when mounted at /api/[...path]) */
|
|
@@ -118,6 +141,31 @@ export interface HandlerOptions {
|
|
|
118
141
|
naming?: 'camelCase' | 'snake_case';
|
|
119
142
|
/** Override default Cache-Control headers for API responses. */
|
|
120
143
|
cache?: CacheConfig;
|
|
144
|
+
/** Whitelist of table names accessible via REST.
|
|
145
|
+
* If provided, only these tables are served — all others return 404.
|
|
146
|
+
* If omitted, all tables in the schema are exposed (current behavior). */
|
|
147
|
+
exposedTables?: string[];
|
|
148
|
+
/** Per-table list of column names to strip from all responses.
|
|
149
|
+
* Columns are removed after query execution, before JSON serialization.
|
|
150
|
+
* Does NOT affect query filtering — hidden columns can still be used in WHERE clauses. */
|
|
151
|
+
hiddenColumns?: Record<string, string[]>;
|
|
152
|
+
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
153
|
+
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
154
|
+
protectedFields?: Record<string, string[]>;
|
|
155
|
+
/** Maximum nesting depth for relation embedding via ?select=rel(sub_rel(...)).
|
|
156
|
+
* Prevents DoS from deeply nested relation queries. Default: 3. */
|
|
157
|
+
maxEmbedDepth?: number;
|
|
158
|
+
/** Maximum value for ?limit= parameter. Prevents unbounded result sets.
|
|
159
|
+
* User-supplied limits above this are clamped. Default: 10000. */
|
|
160
|
+
maxLimit?: number;
|
|
161
|
+
/** Per-table row ownership for IDOR protection on PATCH/DELETE.
|
|
162
|
+
* Auto-appends WHERE <column> = user[<userField>] to mutations.
|
|
163
|
+
* GET/POST are unaffected — reads use RLS, creates use hooks.
|
|
164
|
+
* Example: { posts: { column: 'authorId', userField: 'sub' } } */
|
|
165
|
+
rowOwnership?: Record<string, { column: string; userField: string }>;
|
|
166
|
+
/** When true, error messages hide internal details (table names, column names, operators).
|
|
167
|
+
* Enable in production to prevent schema enumeration. Default: false. */
|
|
168
|
+
hideSchemaErrors?: boolean;
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
|
|
@@ -135,6 +183,19 @@ function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: Han
|
|
|
135
183
|
return getSoftDeleteColumn(table, options) != null;
|
|
136
184
|
}
|
|
137
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Detect PostgreSQL permission denied errors (code 42501).
|
|
188
|
+
* Used to convert Postgres GRANT/RLS denials into clean 403 responses.
|
|
189
|
+
*/
|
|
190
|
+
function isPermissionDenied(error: unknown): boolean {
|
|
191
|
+
if (error && typeof error === 'object') {
|
|
192
|
+
const e = error as Record<string, unknown>;
|
|
193
|
+
if (e.code === '42501') return true;
|
|
194
|
+
if (typeof e.message === 'string' && e.message.includes('permission denied')) return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
138
199
|
/**
|
|
139
200
|
* Execute a function with pgSettings applied in the same transaction.
|
|
140
201
|
*
|
|
@@ -172,20 +233,6 @@ async function executeWithSettings<T>(
|
|
|
172
233
|
return fn(db);
|
|
173
234
|
}
|
|
174
235
|
|
|
175
|
-
/**
|
|
176
|
-
* Determine the role required for a given table + HTTP method.
|
|
177
|
-
* Resolution: roles[tableName] → roles['*'] → null (use publicRoutes fallback).
|
|
178
|
-
*/
|
|
179
|
-
function getRequiredRole(
|
|
180
|
-
tableName: string,
|
|
181
|
-
method: string,
|
|
182
|
-
auth?: AuthConfig,
|
|
183
|
-
): string | null {
|
|
184
|
-
if (!auth?.roles) return null;
|
|
185
|
-
const config = auth.roles[tableName] ?? auth.roles['*'];
|
|
186
|
-
if (!config) return null;
|
|
187
|
-
return method === 'GET' ? config.read : config.write;
|
|
188
|
-
}
|
|
189
236
|
|
|
190
237
|
/**
|
|
191
238
|
* Check whether userRole meets or exceeds requiredRole in the hierarchy.
|
|
@@ -273,41 +320,15 @@ async function checkRoleAuth(
|
|
|
273
320
|
}
|
|
274
321
|
|
|
275
322
|
/**
|
|
276
|
-
* Determine the Cache-Control header for a GET response
|
|
277
|
-
* - pgSettings configured → private
|
|
278
|
-
* -
|
|
279
|
-
* - Any other role → private header
|
|
280
|
-
* - No roles config → null (let server package set its default)
|
|
281
|
-
*
|
|
282
|
-
* Headers are configurable via options.cache. Defaults:
|
|
283
|
-
* - public: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5'
|
|
284
|
-
* - private: 'private, no-store'
|
|
323
|
+
* Determine the Cache-Control header for a GET response.
|
|
324
|
+
* - pgSettings configured → private (per-user data via RLS)
|
|
325
|
+
* - No pgSettings → null (let server package set its default)
|
|
285
326
|
*/
|
|
286
|
-
function getCacheControl(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (options?.pgSettings) return privateHeader;
|
|
291
|
-
if (!options?.auth?.roles) return null;
|
|
292
|
-
const requiredRole = getRequiredRole(tableName, 'GET', options.auth);
|
|
293
|
-
if (requiredRole === null) return null;
|
|
294
|
-
if (requiredRole === 'public') return publicHeader;
|
|
295
|
-
return privateHeader;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Serialize roles config into a compact JSON manifest for CloudFront KVS.
|
|
300
|
-
* Format: { "__tables__": ["posts", ...], "*": { "r": "public", "w": "authenticated" }, ... }
|
|
301
|
-
*/
|
|
302
|
-
export function serializeRolesManifest(
|
|
303
|
-
schema: Schema,
|
|
304
|
-
roles: Record<string, RoleConfig>,
|
|
305
|
-
): string {
|
|
306
|
-
const manifest: Record<string, unknown> = { __tables__: Object.keys(schema) };
|
|
307
|
-
for (const [key, config] of Object.entries(roles)) {
|
|
308
|
-
manifest[key] = { r: config.read, w: config.write };
|
|
327
|
+
function getCacheControl(options?: HandlerOptions): string | null {
|
|
328
|
+
if (options?.pgSettings) {
|
|
329
|
+
return options?.cache?.private ?? 'private, no-store';
|
|
309
330
|
}
|
|
310
|
-
return
|
|
331
|
+
return null;
|
|
311
332
|
}
|
|
312
333
|
|
|
313
334
|
// --- Naming convention helpers ---
|
|
@@ -339,6 +360,42 @@ function transformResponse(data: unknown, options?: HandlerOptions): unknown {
|
|
|
339
360
|
return transformKeys(data, toSnakeCase);
|
|
340
361
|
}
|
|
341
362
|
|
|
363
|
+
/** Strip protected fields from a request body before insert/update (mass assignment prevention). */
|
|
364
|
+
function stripProtectedFields(
|
|
365
|
+
body: Record<string, unknown>,
|
|
366
|
+
tableName: string,
|
|
367
|
+
options?: HandlerOptions,
|
|
368
|
+
): Record<string, unknown> {
|
|
369
|
+
const fields = options?.protectedFields?.[tableName];
|
|
370
|
+
if (!fields || fields.length === 0) return body;
|
|
371
|
+
const result = { ...body };
|
|
372
|
+
for (const field of fields) {
|
|
373
|
+
delete result[field];
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function stripHiddenColumns(
|
|
379
|
+
data: unknown,
|
|
380
|
+
tableName: string,
|
|
381
|
+
options?: HandlerOptions,
|
|
382
|
+
): unknown {
|
|
383
|
+
const columns = options?.hiddenColumns?.[tableName];
|
|
384
|
+
if (!columns || columns.length === 0) return data;
|
|
385
|
+
|
|
386
|
+
const strip = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
387
|
+
const result = { ...row };
|
|
388
|
+
for (const col of columns) {
|
|
389
|
+
delete result[col];
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
if (Array.isArray(data)) return data.map(strip);
|
|
395
|
+
if (data !== null && typeof data === 'object') return strip(data as Record<string, unknown>);
|
|
396
|
+
return data;
|
|
397
|
+
}
|
|
398
|
+
|
|
342
399
|
function transformBodyKeys(body: Record<string, unknown>, table: DrizzleTable): Record<string, unknown> {
|
|
343
400
|
const result: Record<string, unknown> = {};
|
|
344
401
|
for (const [key, value] of Object.entries(body)) {
|
|
@@ -370,20 +427,30 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
370
427
|
const method = request.method.toUpperCase();
|
|
371
428
|
|
|
372
429
|
// Phase 1: Client auth (if configured) — identifies which app is calling
|
|
430
|
+
// Supports two validation paths:
|
|
431
|
+
// 1. Header-based (native/server): X-Client-Id + X-Client-Secret
|
|
432
|
+
// 2. Referer-based (web): Referer or Origin header (no secrets in browser source)
|
|
433
|
+
// The verifyClient callback receives all three and decides which path to validate.
|
|
434
|
+
// Skipped for publicRoutes methods (e.g. GET) and publicRpc functions — no credentials needed.
|
|
373
435
|
let user: Record<string, unknown> | null = null;
|
|
374
436
|
let client: Record<string, unknown> | null = null;
|
|
375
|
-
|
|
437
|
+
const isPublicRoute = options?.auth?.publicRoutes?.includes(method) ?? false;
|
|
438
|
+
const isPublicRpc = pathSegments[0] === 'rpc'
|
|
439
|
+
&& !!pathSegments[1]
|
|
440
|
+
&& (options?.auth?.publicRpc?.includes(pathSegments[1]) ?? false);
|
|
441
|
+
if (options?.auth?.verifyClient && !isPublicRoute && !isPublicRpc) {
|
|
376
442
|
const idHeader = options.auth.clientHeaders?.id ?? 'X-Client-Id';
|
|
377
443
|
const secretHeader = options.auth.clientHeaders?.secret ?? 'X-Client-Secret';
|
|
378
444
|
const clientId = request.headers.get(idHeader);
|
|
379
445
|
const clientSecret = request.headers.get(secretHeader);
|
|
446
|
+
const referer = request.headers.get('referer') || request.headers.get('origin');
|
|
380
447
|
|
|
381
|
-
if (!clientId
|
|
448
|
+
if (!clientId && !clientSecret && !referer) {
|
|
382
449
|
return json({ message: 'Client authentication required: missing client credentials' }, 401);
|
|
383
450
|
}
|
|
384
451
|
|
|
385
452
|
try {
|
|
386
|
-
client = await options.auth.verifyClient(clientId, clientSecret);
|
|
453
|
+
client = await options.auth.verifyClient(clientId ?? '', clientSecret ?? '', referer ?? undefined);
|
|
387
454
|
} catch {
|
|
388
455
|
return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
|
|
389
456
|
}
|
|
@@ -399,7 +466,10 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
399
466
|
|
|
400
467
|
// Root path — table listing, always public
|
|
401
468
|
if (pathSegments.length === 0) {
|
|
402
|
-
|
|
469
|
+
const tables = options?.exposedTables
|
|
470
|
+
? Array.from(tableNames).filter(t => options.exposedTables!.includes(t))
|
|
471
|
+
: Array.from(tableNames);
|
|
472
|
+
return json({ tables }, 200);
|
|
403
473
|
}
|
|
404
474
|
|
|
405
475
|
// RPC path — uses publicRoutes/publicRpc for auth, not table roles
|
|
@@ -420,9 +490,41 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
420
490
|
|
|
421
491
|
const fnName = pathSegments[1];
|
|
422
492
|
if (options?.rpc && fnName && options.rpc[fnName]) {
|
|
493
|
+
const entry = options.rpc[fnName];
|
|
494
|
+
const isObjectForm = typeof entry !== 'function';
|
|
495
|
+
|
|
496
|
+
// Per-RPC role check (object syntax: { fn, role })
|
|
497
|
+
if (isObjectForm && entry.role && options?.auth) {
|
|
498
|
+
const roleResult = await checkRoleAuth(entry.role, request, options.auth);
|
|
499
|
+
if (roleResult.error) return roleResult.error;
|
|
500
|
+
user = roleResult.user;
|
|
501
|
+
} else if (isObjectForm && entry.role && !options?.auth) {
|
|
502
|
+
// Role required but no auth configured — deny
|
|
503
|
+
return json({ message: 'Authentication required: no auth token provided' }, 401);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const fn = isObjectForm ? entry.fn : entry;
|
|
423
507
|
const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
424
|
-
const
|
|
425
|
-
|
|
508
|
+
const settings = options?.pgSettings?.(user, client);
|
|
509
|
+
try {
|
|
510
|
+
const result = await executeWithSettings(db, settings, () => fn(body, user, client));
|
|
511
|
+
return json(result, 200);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof HandlerError) {
|
|
514
|
+
return json({ message: error.message }, error.status);
|
|
515
|
+
}
|
|
516
|
+
const message = error instanceof Error ? error.message : 'RPC execution failed';
|
|
517
|
+
const pgCode = (error as any)?.code;
|
|
518
|
+
let status = 500;
|
|
519
|
+
if (pgCode === '28P01') status = 401;
|
|
520
|
+
else if (pgCode === '42501') status = 403;
|
|
521
|
+
else if (pgCode === '23505') status = 409;
|
|
522
|
+
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
523
|
+
if (options?.hideSchemaErrors && status === 500) {
|
|
524
|
+
return json({ message: 'Internal Server Error' }, 500);
|
|
525
|
+
}
|
|
526
|
+
return json({ message, ...(pgCode ? { code: pgCode } : {}) }, status);
|
|
527
|
+
}
|
|
426
528
|
}
|
|
427
529
|
return json({ message: 'Function not found' }, 404);
|
|
428
530
|
}
|
|
@@ -430,26 +532,21 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
430
532
|
const tableName = pathSegments[0];
|
|
431
533
|
|
|
432
534
|
if (!tableNames.has(tableName)) {
|
|
433
|
-
return
|
|
535
|
+
return sanitizeError(`Table '${tableName}' not found`, 404, options);
|
|
434
536
|
}
|
|
435
537
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
538
|
+
if (options?.exposedTables && !options.exposedTables.includes(tableName)) {
|
|
539
|
+
return sanitizeError(`Table '${tableName}' not found`, 404, options);
|
|
540
|
+
}
|
|
439
541
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
542
|
+
// Phase 2: User authentication — verify JWT if required
|
|
543
|
+
// Authorization (table/row access) is handled by PostgreSQL GRANTs + RLS.
|
|
544
|
+
if (options?.auth) {
|
|
545
|
+
const isPublicMethod = options.auth.publicRoutes.includes(method);
|
|
546
|
+
if (!isPublicMethod) {
|
|
547
|
+
const result = await verifyUserToken(request, options.auth);
|
|
443
548
|
if (result.error) return result.error;
|
|
444
549
|
user = result.user;
|
|
445
|
-
} else {
|
|
446
|
-
// Legacy path: fall back to publicRoutes
|
|
447
|
-
const isPublicMethod = options.auth.publicRoutes.includes(method);
|
|
448
|
-
if (!isPublicMethod) {
|
|
449
|
-
const result = await verifyUserToken(request, options.auth);
|
|
450
|
-
if (result.error) return result.error;
|
|
451
|
-
user = result.user;
|
|
452
|
-
}
|
|
453
550
|
}
|
|
454
551
|
|
|
455
552
|
if (user && options.auth.onAuthenticated) {
|
|
@@ -461,26 +558,33 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
461
558
|
const settings = options?.pgSettings?.(user, client);
|
|
462
559
|
const table = schema[tableName];
|
|
463
560
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
561
|
+
try {
|
|
562
|
+
return await executeWithSettings(db, settings, async (dbOrTx) => {
|
|
563
|
+
if (method === 'GET') {
|
|
564
|
+
const cacheControl = getCacheControl(options);
|
|
565
|
+
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|
|
566
|
+
}
|
|
469
567
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
568
|
+
if (method === 'POST') {
|
|
569
|
+
return handlePost(dbOrTx, table, tableName, request, options, user, client);
|
|
570
|
+
}
|
|
473
571
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
572
|
+
if (method === 'PATCH') {
|
|
573
|
+
return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
|
|
574
|
+
}
|
|
477
575
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
576
|
+
if (method === 'DELETE') {
|
|
577
|
+
return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
|
|
578
|
+
}
|
|
481
579
|
|
|
482
|
-
|
|
483
|
-
|
|
580
|
+
return json({ message: `Method ${method} not allowed` }, 405);
|
|
581
|
+
});
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (isPermissionDenied(error)) {
|
|
584
|
+
return sanitizeError('Permission denied', 403, options);
|
|
585
|
+
}
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
484
588
|
};
|
|
485
589
|
}
|
|
486
590
|
|
|
@@ -607,7 +711,9 @@ async function handleGet(
|
|
|
607
711
|
options?: HandlerOptions,
|
|
608
712
|
cacheControl?: string | null,
|
|
609
713
|
): Promise<Response> {
|
|
610
|
-
const
|
|
714
|
+
const maxLimit = options?.maxLimit ?? 10000;
|
|
715
|
+
const parsedLimit = params.has('limit') ? parseInt(params.get('limit')!, 10) : 1000;
|
|
716
|
+
const limitVal = Math.min(parsedLimit, maxLimit);
|
|
611
717
|
const offsetVal = params.has('offset') ? parseInt(params.get('offset')!, 10) : 0;
|
|
612
718
|
const orderParam = params.get('order');
|
|
613
719
|
const selectParam = params.get('select');
|
|
@@ -616,8 +722,17 @@ async function handleGet(
|
|
|
616
722
|
const prefer = request.headers.get('Prefer') || '';
|
|
617
723
|
const wantExactCount = prefer.includes('count=exact');
|
|
618
724
|
|
|
725
|
+
// Enforce maximum embed depth to prevent DoS from deeply nested relation queries
|
|
726
|
+
if (selectParam) {
|
|
727
|
+
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
728
|
+
const depth = getEmbedDepth(selectParam);
|
|
729
|
+
if (depth > maxEmbedDepth) {
|
|
730
|
+
return json({ message: `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}` }, 400);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
619
734
|
// Parse select parameter for relation embedding and aggregates
|
|
620
|
-
let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName]);
|
|
735
|
+
let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName], options?.relations);
|
|
621
736
|
|
|
622
737
|
// Resolve snake_case select column names to Drizzle camelCase properties
|
|
623
738
|
if (options?.naming === 'snake_case' && columns) {
|
|
@@ -629,7 +744,11 @@ async function handleGet(
|
|
|
629
744
|
}
|
|
630
745
|
|
|
631
746
|
// Parse PostgREST filters (including or()/and() logical groups and not. negation)
|
|
632
|
-
const
|
|
747
|
+
const filterResult = buildAllConditions(table, params);
|
|
748
|
+
if ('error' in filterResult) {
|
|
749
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
750
|
+
}
|
|
751
|
+
const conditions = filterResult.conditions;
|
|
633
752
|
|
|
634
753
|
// Soft-delete: filter deleted records unless ?deleted=include|only
|
|
635
754
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -693,7 +812,7 @@ async function handleGet(
|
|
|
693
812
|
};
|
|
694
813
|
if (cacheControl) aggHeaders['Cache-Control'] = cacheControl;
|
|
695
814
|
|
|
696
|
-
return new Response(JSON.stringify(transformResponse(serialized, options)), {
|
|
815
|
+
return new Response(JSON.stringify(transformResponse(stripHiddenColumns(serialized, tableName, options), options)), {
|
|
697
816
|
status: 200,
|
|
698
817
|
headers: aggHeaders,
|
|
699
818
|
});
|
|
@@ -807,7 +926,7 @@ async function handleGet(
|
|
|
807
926
|
};
|
|
808
927
|
if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
|
|
809
928
|
|
|
810
|
-
return new Response(JSON.stringify(transformResponse(responseData, options)), {
|
|
929
|
+
return new Response(JSON.stringify(transformResponse(stripHiddenColumns(responseData, tableName, options), options)), {
|
|
811
930
|
status: 200,
|
|
812
931
|
headers: getHeaders,
|
|
813
932
|
});
|
|
@@ -822,6 +941,7 @@ async function handleGet(
|
|
|
822
941
|
interface EmbedRequest {
|
|
823
942
|
name: string;
|
|
824
943
|
columns: string[] | null; // null = all columns (*)
|
|
944
|
+
embeds: EmbedRequest[]; // nested relation embeds
|
|
825
945
|
}
|
|
826
946
|
|
|
827
947
|
type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
@@ -836,9 +956,21 @@ interface AggregateField {
|
|
|
836
956
|
// Matches: count(), amount.sum(), total:amount.sum(), row_count:count()
|
|
837
957
|
const AGG_REGEX = /^(?:(\w+):)?(?:(\w+)\.)?(count|sum|avg|min|max)\(\)$/;
|
|
838
958
|
|
|
959
|
+
/** Count the maximum parenthesis nesting depth in a select parameter (embed depth). */
|
|
960
|
+
function getEmbedDepth(selectParam: string): number {
|
|
961
|
+
let depth = 0;
|
|
962
|
+
let max = 0;
|
|
963
|
+
for (const ch of selectParam) {
|
|
964
|
+
if (ch === '(') { depth++; max = Math.max(max, depth); }
|
|
965
|
+
else if (ch === ')') { depth--; }
|
|
966
|
+
}
|
|
967
|
+
return max;
|
|
968
|
+
}
|
|
969
|
+
|
|
839
970
|
function parseSelectParam(
|
|
840
971
|
selectParam: string | null,
|
|
841
972
|
tableRelations?: Record<string, RelationConfig>,
|
|
973
|
+
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
842
974
|
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
|
|
843
975
|
if (!selectParam) {
|
|
844
976
|
return { columns: null, embeds: [], aggregates: [] };
|
|
@@ -867,9 +999,14 @@ function parseSelectParam(
|
|
|
867
999
|
if (embedMatch && tableRelations && embedMatch[1] in tableRelations) {
|
|
868
1000
|
const name = embedMatch[1];
|
|
869
1001
|
const innerSelect = embedMatch[2].trim();
|
|
1002
|
+
// Recursively parse sub-embeds using the target table's relations
|
|
1003
|
+
const targetTableName = tableRelations[name].table;
|
|
1004
|
+
const targetRelations = allRelations?.[targetTableName];
|
|
1005
|
+
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
870
1006
|
embeds.push({
|
|
871
1007
|
name,
|
|
872
|
-
columns:
|
|
1008
|
+
columns: inner.columns,
|
|
1009
|
+
embeds: inner.embeds,
|
|
873
1010
|
});
|
|
874
1011
|
} else if (part === '*') {
|
|
875
1012
|
hasWildcard = true;
|
|
@@ -967,10 +1104,16 @@ async function resolveEmbeds(
|
|
|
967
1104
|
const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[])];
|
|
968
1105
|
if (targetSdCol) embedConditions.push(isNull(targetSdCol));
|
|
969
1106
|
|
|
970
|
-
|
|
1107
|
+
let relatedRows: Record<string, unknown>[] = await db
|
|
971
1108
|
.select()
|
|
972
1109
|
.from(targetTable)
|
|
973
1110
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
1111
|
+
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
1112
|
+
|
|
1113
|
+
// Recursively resolve nested embeds
|
|
1114
|
+
if (embed.embeds.length > 0) {
|
|
1115
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
1116
|
+
}
|
|
974
1117
|
|
|
975
1118
|
// Group by FK value and assign to each source row
|
|
976
1119
|
for (const row of result) {
|
|
@@ -999,10 +1142,16 @@ async function resolveEmbeds(
|
|
|
999
1142
|
const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
|
|
1000
1143
|
if (targetSdCol) embedConditions.push(isNull(targetSdCol));
|
|
1001
1144
|
|
|
1002
|
-
|
|
1145
|
+
let relatedRows: Record<string, unknown>[] = await db
|
|
1003
1146
|
.select()
|
|
1004
1147
|
.from(targetTable)
|
|
1005
1148
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
1149
|
+
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
1150
|
+
|
|
1151
|
+
// Recursively resolve nested embeds
|
|
1152
|
+
if (embed.embeds.length > 0) {
|
|
1153
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
1154
|
+
}
|
|
1006
1155
|
|
|
1007
1156
|
// Index by target key for O(1) lookup
|
|
1008
1157
|
const index = new Map<unknown, Record<string, unknown>>();
|
|
@@ -1057,12 +1206,18 @@ async function handlePost(
|
|
|
1057
1206
|
body = transformBodyKeys(body, table);
|
|
1058
1207
|
}
|
|
1059
1208
|
|
|
1209
|
+
// Strip protected fields before mutation (mass assignment prevention)
|
|
1210
|
+
body = stripProtectedFields(body, tableName, options);
|
|
1211
|
+
|
|
1060
1212
|
const hook = options?.hooks?.[tableName];
|
|
1061
1213
|
|
|
1062
1214
|
if (hook?.beforeCreate) {
|
|
1063
1215
|
try {
|
|
1064
1216
|
body = await hook.beforeCreate(body, user, client);
|
|
1065
|
-
} catch {
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
if (err instanceof HandlerError) {
|
|
1219
|
+
return json({ message: err.message }, err.status);
|
|
1220
|
+
}
|
|
1066
1221
|
return json({ message: 'Hook error' }, 500);
|
|
1067
1222
|
}
|
|
1068
1223
|
}
|
|
@@ -1073,7 +1228,7 @@ async function handlePost(
|
|
|
1073
1228
|
try { await hook.afterCreate(rows[0], user, client); } catch {}
|
|
1074
1229
|
}
|
|
1075
1230
|
|
|
1076
|
-
return json(transformResponse(rows[0], options), 201);
|
|
1231
|
+
return json(transformResponse(stripHiddenColumns(rows[0], tableName, options), options), 201);
|
|
1077
1232
|
}
|
|
1078
1233
|
|
|
1079
1234
|
async function handlePatch(
|
|
@@ -1098,17 +1253,27 @@ async function handlePatch(
|
|
|
1098
1253
|
body = transformBodyKeys(body, table);
|
|
1099
1254
|
}
|
|
1100
1255
|
|
|
1256
|
+
// Strip protected fields before mutation (mass assignment prevention)
|
|
1257
|
+
body = stripProtectedFields(body, tableName, options);
|
|
1258
|
+
|
|
1101
1259
|
const hook = options?.hooks?.[tableName];
|
|
1102
1260
|
|
|
1103
1261
|
if (hook?.beforeUpdate) {
|
|
1104
1262
|
try {
|
|
1105
1263
|
body = await hook.beforeUpdate(body, user, client);
|
|
1106
|
-
} catch {
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
if (err instanceof HandlerError) {
|
|
1266
|
+
return json({ message: err.message }, err.status);
|
|
1267
|
+
}
|
|
1107
1268
|
return json({ message: 'Hook error' }, 500);
|
|
1108
1269
|
}
|
|
1109
1270
|
}
|
|
1110
1271
|
|
|
1111
|
-
const
|
|
1272
|
+
const filterResult = buildAllConditions(table, params);
|
|
1273
|
+
if ('error' in filterResult) {
|
|
1274
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
1275
|
+
}
|
|
1276
|
+
const conditions = filterResult.conditions;
|
|
1112
1277
|
|
|
1113
1278
|
// Soft-delete: exclude deleted records from updates
|
|
1114
1279
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -1116,6 +1281,16 @@ async function handlePatch(
|
|
|
1116
1281
|
if (sdCol) conditions.push(isNull(sdCol));
|
|
1117
1282
|
}
|
|
1118
1283
|
|
|
1284
|
+
// Row ownership: auto-append WHERE <column> = user[<userField>] for IDOR protection
|
|
1285
|
+
if (user && options?.rowOwnership?.[tableName]) {
|
|
1286
|
+
const ownership = options.rowOwnership[tableName];
|
|
1287
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
1288
|
+
const userValue = user[ownership.userField];
|
|
1289
|
+
if (ownerCol && userValue != null) {
|
|
1290
|
+
conditions.push(eq(ownerCol, userValue));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1119
1294
|
let query: any = db.update(table).set(body);
|
|
1120
1295
|
|
|
1121
1296
|
if (conditions.length === 1) {
|
|
@@ -1130,7 +1305,7 @@ async function handlePatch(
|
|
|
1130
1305
|
try { await hook.afterUpdate(rows, user, client); } catch {}
|
|
1131
1306
|
}
|
|
1132
1307
|
|
|
1133
|
-
return json(transformResponse(rows, options), 200);
|
|
1308
|
+
return json(transformResponse(stripHiddenColumns(rows, tableName, options), options), 200);
|
|
1134
1309
|
}
|
|
1135
1310
|
|
|
1136
1311
|
async function handleDelete(
|
|
@@ -1155,12 +1330,29 @@ async function handleDelete(
|
|
|
1155
1330
|
if (hook?.beforeDelete) {
|
|
1156
1331
|
try {
|
|
1157
1332
|
await hook.beforeDelete(user, client);
|
|
1158
|
-
} catch {
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
if (err instanceof HandlerError) {
|
|
1335
|
+
return json({ message: err.message }, err.status);
|
|
1336
|
+
}
|
|
1159
1337
|
return json({ message: 'Hook error' }, 500);
|
|
1160
1338
|
}
|
|
1161
1339
|
}
|
|
1162
1340
|
|
|
1163
|
-
const
|
|
1341
|
+
const filterResult = buildAllConditions(table, params);
|
|
1342
|
+
if ('error' in filterResult) {
|
|
1343
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
1344
|
+
}
|
|
1345
|
+
const conditions = filterResult.conditions;
|
|
1346
|
+
|
|
1347
|
+
// Row ownership: auto-append WHERE <column> = user[<userField>] for IDOR protection
|
|
1348
|
+
if (user && options?.rowOwnership?.[tableName]) {
|
|
1349
|
+
const ownership = options.rowOwnership[tableName];
|
|
1350
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
1351
|
+
const userValue = user[ownership.userField];
|
|
1352
|
+
if (ownerCol && userValue != null) {
|
|
1353
|
+
conditions.push(eq(ownerCol, userValue));
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1164
1356
|
|
|
1165
1357
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
1166
1358
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -1183,7 +1375,7 @@ async function handleDelete(
|
|
|
1183
1375
|
try { await hook.afterDelete(rows, user, client); } catch {}
|
|
1184
1376
|
}
|
|
1185
1377
|
|
|
1186
|
-
return json(transformResponse(rows, options), 200);
|
|
1378
|
+
return json(transformResponse(stripHiddenColumns(rows, tableName, options), options), 200);
|
|
1187
1379
|
}
|
|
1188
1380
|
}
|
|
1189
1381
|
|
|
@@ -1202,7 +1394,7 @@ async function handleDelete(
|
|
|
1202
1394
|
try { await hook.afterDelete(rows, user, client); } catch {}
|
|
1203
1395
|
}
|
|
1204
1396
|
|
|
1205
|
-
return json(transformResponse(rows, options), 200);
|
|
1397
|
+
return json(transformResponse(stripHiddenColumns(rows, tableName, options), options), 200);
|
|
1206
1398
|
}
|
|
1207
1399
|
|
|
1208
1400
|
interface JsonPath {
|
|
@@ -1287,6 +1479,9 @@ function parseJsonPath(column: string): JsonPath | null {
|
|
|
1287
1479
|
return { baseColumn, steps };
|
|
1288
1480
|
}
|
|
1289
1481
|
|
|
1482
|
+
// JSON path keys must be safe identifiers — no SQL injection via sql.raw()
|
|
1483
|
+
const SAFE_JSON_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
1484
|
+
|
|
1290
1485
|
/**
|
|
1291
1486
|
* Build a SQL condition for a JSON path filter.
|
|
1292
1487
|
* Generates expressions like: "metadata"->>'theme' = 'dark'
|
|
@@ -1294,7 +1489,14 @@ function parseJsonPath(column: string): JsonPath | null {
|
|
|
1294
1489
|
function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
1295
1490
|
const jp = filter.jsonPath!;
|
|
1296
1491
|
const baseCol = getColumn(table, jp.baseColumn);
|
|
1297
|
-
if (!baseCol) return
|
|
1492
|
+
if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
|
|
1493
|
+
|
|
1494
|
+
// Validate all JSON path keys before interpolation
|
|
1495
|
+
for (const step of jp.steps) {
|
|
1496
|
+
if (!SAFE_JSON_KEY.test(step.key)) {
|
|
1497
|
+
return { __filterError: `Invalid JSON path key: '${step.key}'` };
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1298
1500
|
|
|
1299
1501
|
// Build the JSON path expression
|
|
1300
1502
|
let expr: any = sql`${baseCol}`;
|
|
@@ -1355,13 +1557,19 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
1355
1557
|
return condition;
|
|
1356
1558
|
}
|
|
1357
1559
|
|
|
1560
|
+
const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in']);
|
|
1561
|
+
|
|
1358
1562
|
function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
1359
1563
|
if (filter.jsonPath) {
|
|
1360
1564
|
return buildJsonCondition(table, filter);
|
|
1361
1565
|
}
|
|
1362
1566
|
|
|
1363
1567
|
const column = getColumn(table, filter.column);
|
|
1364
|
-
if (!column) return
|
|
1568
|
+
if (!column) return { __filterError: `Unknown column: '${filter.column}'` };
|
|
1569
|
+
|
|
1570
|
+
if (!VALID_OPERATORS.has(filter.operator)) {
|
|
1571
|
+
return { __filterError: `Unknown filter operator: '${filter.operator}'` };
|
|
1572
|
+
}
|
|
1365
1573
|
|
|
1366
1574
|
let condition: any = null;
|
|
1367
1575
|
|
|
@@ -1508,18 +1716,31 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
1508
1716
|
return filters;
|
|
1509
1717
|
}
|
|
1510
1718
|
|
|
1719
|
+
function isFilterError(x: unknown): x is { __filterError: string } {
|
|
1720
|
+
return typeof x === 'object' && x !== null && '__filterError' in x;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1511
1723
|
/**
|
|
1512
1724
|
* Build all WHERE conditions from query params, including individual filters
|
|
1513
1725
|
* and or()/and() logical groups.
|
|
1726
|
+
* Returns { conditions } on success, { error } on validation failure.
|
|
1514
1727
|
*/
|
|
1515
|
-
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): any[] {
|
|
1728
|
+
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { conditions: any[] } | { error: string } {
|
|
1516
1729
|
const filters = parseFilters(params);
|
|
1517
|
-
const
|
|
1730
|
+
const rawResults = filters.map(f => buildCondition(table, f));
|
|
1731
|
+
for (const r of rawResults) {
|
|
1732
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
1733
|
+
}
|
|
1734
|
+
const conditions = rawResults.filter(Boolean);
|
|
1518
1735
|
|
|
1519
1736
|
const orParam = params.get('or');
|
|
1520
1737
|
if (orParam) {
|
|
1521
1738
|
const orFilters = parseLogicalGroup(orParam);
|
|
1522
|
-
const
|
|
1739
|
+
const orRawResults = orFilters.map(f => buildCondition(table, f));
|
|
1740
|
+
for (const r of orRawResults) {
|
|
1741
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
1742
|
+
}
|
|
1743
|
+
const orConditions = orRawResults.filter(Boolean);
|
|
1523
1744
|
if (orConditions.length > 0) {
|
|
1524
1745
|
conditions.push(or(...orConditions));
|
|
1525
1746
|
}
|
|
@@ -1528,13 +1749,17 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): any[]
|
|
|
1528
1749
|
const andParam = params.get('and');
|
|
1529
1750
|
if (andParam) {
|
|
1530
1751
|
const andFilters = parseLogicalGroup(andParam);
|
|
1531
|
-
const
|
|
1752
|
+
const andRawResults = andFilters.map(f => buildCondition(table, f));
|
|
1753
|
+
for (const r of andRawResults) {
|
|
1754
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
1755
|
+
}
|
|
1756
|
+
const andConditions = andRawResults.filter(Boolean);
|
|
1532
1757
|
if (andConditions.length > 0) {
|
|
1533
1758
|
conditions.push(and(...andConditions));
|
|
1534
1759
|
}
|
|
1535
1760
|
}
|
|
1536
1761
|
|
|
1537
|
-
return conditions;
|
|
1762
|
+
return { conditions };
|
|
1538
1763
|
}
|
|
1539
1764
|
|
|
1540
1765
|
function json(data: unknown, status: number): Response {
|