@everystack/api 0.1.0 → 0.2.0
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/LICENSE +681 -0
- package/package.json +10 -11
- package/src/handler/index.ts +328 -120
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'
|
|
285
|
-
*/
|
|
286
|
-
function getCacheControl(tableName: string, options?: HandlerOptions): string | null {
|
|
287
|
-
const publicHeader = options?.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5';
|
|
288
|
-
const privateHeader = options?.cache?.private ?? 'private, no-store';
|
|
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" }, ... }
|
|
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)
|
|
301
326
|
*/
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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,8 +490,23 @@ 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
|
|
508
|
+
const settings = options?.pgSettings?.(user, client);
|
|
509
|
+
const result = await executeWithSettings(db, settings, () => fn(body, user, client));
|
|
425
510
|
return json(result, 200);
|
|
426
511
|
}
|
|
427
512
|
return json({ message: 'Function not found' }, 404);
|
|
@@ -430,26 +515,21 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
430
515
|
const tableName = pathSegments[0];
|
|
431
516
|
|
|
432
517
|
if (!tableNames.has(tableName)) {
|
|
433
|
-
return
|
|
518
|
+
return sanitizeError(`Table '${tableName}' not found`, 404, options);
|
|
434
519
|
}
|
|
435
520
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
521
|
+
if (options?.exposedTables && !options.exposedTables.includes(tableName)) {
|
|
522
|
+
return sanitizeError(`Table '${tableName}' not found`, 404, options);
|
|
523
|
+
}
|
|
439
524
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
525
|
+
// Phase 2: User authentication — verify JWT if required
|
|
526
|
+
// Authorization (table/row access) is handled by PostgreSQL GRANTs + RLS.
|
|
527
|
+
if (options?.auth) {
|
|
528
|
+
const isPublicMethod = options.auth.publicRoutes.includes(method);
|
|
529
|
+
if (!isPublicMethod) {
|
|
530
|
+
const result = await verifyUserToken(request, options.auth);
|
|
443
531
|
if (result.error) return result.error;
|
|
444
532
|
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
533
|
}
|
|
454
534
|
|
|
455
535
|
if (user && options.auth.onAuthenticated) {
|
|
@@ -461,26 +541,33 @@ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOp
|
|
|
461
541
|
const settings = options?.pgSettings?.(user, client);
|
|
462
542
|
const table = schema[tableName];
|
|
463
543
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
544
|
+
try {
|
|
545
|
+
return await executeWithSettings(db, settings, async (dbOrTx) => {
|
|
546
|
+
if (method === 'GET') {
|
|
547
|
+
const cacheControl = getCacheControl(options);
|
|
548
|
+
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|
|
549
|
+
}
|
|
469
550
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
551
|
+
if (method === 'POST') {
|
|
552
|
+
return handlePost(dbOrTx, table, tableName, request, options, user, client);
|
|
553
|
+
}
|
|
473
554
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
555
|
+
if (method === 'PATCH') {
|
|
556
|
+
return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
|
|
557
|
+
}
|
|
477
558
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
559
|
+
if (method === 'DELETE') {
|
|
560
|
+
return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
|
|
561
|
+
}
|
|
481
562
|
|
|
482
|
-
|
|
483
|
-
|
|
563
|
+
return json({ message: `Method ${method} not allowed` }, 405);
|
|
564
|
+
});
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (isPermissionDenied(error)) {
|
|
567
|
+
return sanitizeError('Permission denied', 403, options);
|
|
568
|
+
}
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
484
571
|
};
|
|
485
572
|
}
|
|
486
573
|
|
|
@@ -607,7 +694,9 @@ async function handleGet(
|
|
|
607
694
|
options?: HandlerOptions,
|
|
608
695
|
cacheControl?: string | null,
|
|
609
696
|
): Promise<Response> {
|
|
610
|
-
const
|
|
697
|
+
const maxLimit = options?.maxLimit ?? 10000;
|
|
698
|
+
const parsedLimit = params.has('limit') ? parseInt(params.get('limit')!, 10) : 1000;
|
|
699
|
+
const limitVal = Math.min(parsedLimit, maxLimit);
|
|
611
700
|
const offsetVal = params.has('offset') ? parseInt(params.get('offset')!, 10) : 0;
|
|
612
701
|
const orderParam = params.get('order');
|
|
613
702
|
const selectParam = params.get('select');
|
|
@@ -616,8 +705,17 @@ async function handleGet(
|
|
|
616
705
|
const prefer = request.headers.get('Prefer') || '';
|
|
617
706
|
const wantExactCount = prefer.includes('count=exact');
|
|
618
707
|
|
|
708
|
+
// Enforce maximum embed depth to prevent DoS from deeply nested relation queries
|
|
709
|
+
if (selectParam) {
|
|
710
|
+
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
711
|
+
const depth = getEmbedDepth(selectParam);
|
|
712
|
+
if (depth > maxEmbedDepth) {
|
|
713
|
+
return json({ message: `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}` }, 400);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
619
717
|
// Parse select parameter for relation embedding and aggregates
|
|
620
|
-
let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName]);
|
|
718
|
+
let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName], options?.relations);
|
|
621
719
|
|
|
622
720
|
// Resolve snake_case select column names to Drizzle camelCase properties
|
|
623
721
|
if (options?.naming === 'snake_case' && columns) {
|
|
@@ -629,7 +727,11 @@ async function handleGet(
|
|
|
629
727
|
}
|
|
630
728
|
|
|
631
729
|
// Parse PostgREST filters (including or()/and() logical groups and not. negation)
|
|
632
|
-
const
|
|
730
|
+
const filterResult = buildAllConditions(table, params);
|
|
731
|
+
if ('error' in filterResult) {
|
|
732
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
733
|
+
}
|
|
734
|
+
const conditions = filterResult.conditions;
|
|
633
735
|
|
|
634
736
|
// Soft-delete: filter deleted records unless ?deleted=include|only
|
|
635
737
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -693,7 +795,7 @@ async function handleGet(
|
|
|
693
795
|
};
|
|
694
796
|
if (cacheControl) aggHeaders['Cache-Control'] = cacheControl;
|
|
695
797
|
|
|
696
|
-
return new Response(JSON.stringify(transformResponse(serialized, options)), {
|
|
798
|
+
return new Response(JSON.stringify(transformResponse(stripHiddenColumns(serialized, tableName, options), options)), {
|
|
697
799
|
status: 200,
|
|
698
800
|
headers: aggHeaders,
|
|
699
801
|
});
|
|
@@ -807,7 +909,7 @@ async function handleGet(
|
|
|
807
909
|
};
|
|
808
910
|
if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
|
|
809
911
|
|
|
810
|
-
return new Response(JSON.stringify(transformResponse(responseData, options)), {
|
|
912
|
+
return new Response(JSON.stringify(transformResponse(stripHiddenColumns(responseData, tableName, options), options)), {
|
|
811
913
|
status: 200,
|
|
812
914
|
headers: getHeaders,
|
|
813
915
|
});
|
|
@@ -822,6 +924,7 @@ async function handleGet(
|
|
|
822
924
|
interface EmbedRequest {
|
|
823
925
|
name: string;
|
|
824
926
|
columns: string[] | null; // null = all columns (*)
|
|
927
|
+
embeds: EmbedRequest[]; // nested relation embeds
|
|
825
928
|
}
|
|
826
929
|
|
|
827
930
|
type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
@@ -836,9 +939,21 @@ interface AggregateField {
|
|
|
836
939
|
// Matches: count(), amount.sum(), total:amount.sum(), row_count:count()
|
|
837
940
|
const AGG_REGEX = /^(?:(\w+):)?(?:(\w+)\.)?(count|sum|avg|min|max)\(\)$/;
|
|
838
941
|
|
|
942
|
+
/** Count the maximum parenthesis nesting depth in a select parameter (embed depth). */
|
|
943
|
+
function getEmbedDepth(selectParam: string): number {
|
|
944
|
+
let depth = 0;
|
|
945
|
+
let max = 0;
|
|
946
|
+
for (const ch of selectParam) {
|
|
947
|
+
if (ch === '(') { depth++; max = Math.max(max, depth); }
|
|
948
|
+
else if (ch === ')') { depth--; }
|
|
949
|
+
}
|
|
950
|
+
return max;
|
|
951
|
+
}
|
|
952
|
+
|
|
839
953
|
function parseSelectParam(
|
|
840
954
|
selectParam: string | null,
|
|
841
955
|
tableRelations?: Record<string, RelationConfig>,
|
|
956
|
+
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
842
957
|
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
|
|
843
958
|
if (!selectParam) {
|
|
844
959
|
return { columns: null, embeds: [], aggregates: [] };
|
|
@@ -867,9 +982,14 @@ function parseSelectParam(
|
|
|
867
982
|
if (embedMatch && tableRelations && embedMatch[1] in tableRelations) {
|
|
868
983
|
const name = embedMatch[1];
|
|
869
984
|
const innerSelect = embedMatch[2].trim();
|
|
985
|
+
// Recursively parse sub-embeds using the target table's relations
|
|
986
|
+
const targetTableName = tableRelations[name].table;
|
|
987
|
+
const targetRelations = allRelations?.[targetTableName];
|
|
988
|
+
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
870
989
|
embeds.push({
|
|
871
990
|
name,
|
|
872
|
-
columns:
|
|
991
|
+
columns: inner.columns,
|
|
992
|
+
embeds: inner.embeds,
|
|
873
993
|
});
|
|
874
994
|
} else if (part === '*') {
|
|
875
995
|
hasWildcard = true;
|
|
@@ -967,10 +1087,16 @@ async function resolveEmbeds(
|
|
|
967
1087
|
const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[])];
|
|
968
1088
|
if (targetSdCol) embedConditions.push(isNull(targetSdCol));
|
|
969
1089
|
|
|
970
|
-
|
|
1090
|
+
let relatedRows: Record<string, unknown>[] = await db
|
|
971
1091
|
.select()
|
|
972
1092
|
.from(targetTable)
|
|
973
1093
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
1094
|
+
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
1095
|
+
|
|
1096
|
+
// Recursively resolve nested embeds
|
|
1097
|
+
if (embed.embeds.length > 0) {
|
|
1098
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
1099
|
+
}
|
|
974
1100
|
|
|
975
1101
|
// Group by FK value and assign to each source row
|
|
976
1102
|
for (const row of result) {
|
|
@@ -999,10 +1125,16 @@ async function resolveEmbeds(
|
|
|
999
1125
|
const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
|
|
1000
1126
|
if (targetSdCol) embedConditions.push(isNull(targetSdCol));
|
|
1001
1127
|
|
|
1002
|
-
|
|
1128
|
+
let relatedRows: Record<string, unknown>[] = await db
|
|
1003
1129
|
.select()
|
|
1004
1130
|
.from(targetTable)
|
|
1005
1131
|
.where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
|
|
1132
|
+
relatedRows = stripHiddenColumns(relatedRows, relationConfig.table, options) as Record<string, unknown>[];
|
|
1133
|
+
|
|
1134
|
+
// Recursively resolve nested embeds
|
|
1135
|
+
if (embed.embeds.length > 0) {
|
|
1136
|
+
relatedRows = await resolveEmbeds(db, schema, relationConfig.table, relatedRows, embed.embeds, options);
|
|
1137
|
+
}
|
|
1006
1138
|
|
|
1007
1139
|
// Index by target key for O(1) lookup
|
|
1008
1140
|
const index = new Map<unknown, Record<string, unknown>>();
|
|
@@ -1057,12 +1189,18 @@ async function handlePost(
|
|
|
1057
1189
|
body = transformBodyKeys(body, table);
|
|
1058
1190
|
}
|
|
1059
1191
|
|
|
1192
|
+
// Strip protected fields before mutation (mass assignment prevention)
|
|
1193
|
+
body = stripProtectedFields(body, tableName, options);
|
|
1194
|
+
|
|
1060
1195
|
const hook = options?.hooks?.[tableName];
|
|
1061
1196
|
|
|
1062
1197
|
if (hook?.beforeCreate) {
|
|
1063
1198
|
try {
|
|
1064
1199
|
body = await hook.beforeCreate(body, user, client);
|
|
1065
|
-
} catch {
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
if (err instanceof HandlerError) {
|
|
1202
|
+
return json({ message: err.message }, err.status);
|
|
1203
|
+
}
|
|
1066
1204
|
return json({ message: 'Hook error' }, 500);
|
|
1067
1205
|
}
|
|
1068
1206
|
}
|
|
@@ -1073,7 +1211,7 @@ async function handlePost(
|
|
|
1073
1211
|
try { await hook.afterCreate(rows[0], user, client); } catch {}
|
|
1074
1212
|
}
|
|
1075
1213
|
|
|
1076
|
-
return json(transformResponse(rows[0], options), 201);
|
|
1214
|
+
return json(transformResponse(stripHiddenColumns(rows[0], tableName, options), options), 201);
|
|
1077
1215
|
}
|
|
1078
1216
|
|
|
1079
1217
|
async function handlePatch(
|
|
@@ -1098,17 +1236,27 @@ async function handlePatch(
|
|
|
1098
1236
|
body = transformBodyKeys(body, table);
|
|
1099
1237
|
}
|
|
1100
1238
|
|
|
1239
|
+
// Strip protected fields before mutation (mass assignment prevention)
|
|
1240
|
+
body = stripProtectedFields(body, tableName, options);
|
|
1241
|
+
|
|
1101
1242
|
const hook = options?.hooks?.[tableName];
|
|
1102
1243
|
|
|
1103
1244
|
if (hook?.beforeUpdate) {
|
|
1104
1245
|
try {
|
|
1105
1246
|
body = await hook.beforeUpdate(body, user, client);
|
|
1106
|
-
} catch {
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
if (err instanceof HandlerError) {
|
|
1249
|
+
return json({ message: err.message }, err.status);
|
|
1250
|
+
}
|
|
1107
1251
|
return json({ message: 'Hook error' }, 500);
|
|
1108
1252
|
}
|
|
1109
1253
|
}
|
|
1110
1254
|
|
|
1111
|
-
const
|
|
1255
|
+
const filterResult = buildAllConditions(table, params);
|
|
1256
|
+
if ('error' in filterResult) {
|
|
1257
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
1258
|
+
}
|
|
1259
|
+
const conditions = filterResult.conditions;
|
|
1112
1260
|
|
|
1113
1261
|
// Soft-delete: exclude deleted records from updates
|
|
1114
1262
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -1116,6 +1264,16 @@ async function handlePatch(
|
|
|
1116
1264
|
if (sdCol) conditions.push(isNull(sdCol));
|
|
1117
1265
|
}
|
|
1118
1266
|
|
|
1267
|
+
// Row ownership: auto-append WHERE <column> = user[<userField>] for IDOR protection
|
|
1268
|
+
if (user && options?.rowOwnership?.[tableName]) {
|
|
1269
|
+
const ownership = options.rowOwnership[tableName];
|
|
1270
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
1271
|
+
const userValue = user[ownership.userField];
|
|
1272
|
+
if (ownerCol && userValue != null) {
|
|
1273
|
+
conditions.push(eq(ownerCol, userValue));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1119
1277
|
let query: any = db.update(table).set(body);
|
|
1120
1278
|
|
|
1121
1279
|
if (conditions.length === 1) {
|
|
@@ -1130,7 +1288,7 @@ async function handlePatch(
|
|
|
1130
1288
|
try { await hook.afterUpdate(rows, user, client); } catch {}
|
|
1131
1289
|
}
|
|
1132
1290
|
|
|
1133
|
-
return json(transformResponse(rows, options), 200);
|
|
1291
|
+
return json(transformResponse(stripHiddenColumns(rows, tableName, options), options), 200);
|
|
1134
1292
|
}
|
|
1135
1293
|
|
|
1136
1294
|
async function handleDelete(
|
|
@@ -1155,12 +1313,29 @@ async function handleDelete(
|
|
|
1155
1313
|
if (hook?.beforeDelete) {
|
|
1156
1314
|
try {
|
|
1157
1315
|
await hook.beforeDelete(user, client);
|
|
1158
|
-
} catch {
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
if (err instanceof HandlerError) {
|
|
1318
|
+
return json({ message: err.message }, err.status);
|
|
1319
|
+
}
|
|
1159
1320
|
return json({ message: 'Hook error' }, 500);
|
|
1160
1321
|
}
|
|
1161
1322
|
}
|
|
1162
1323
|
|
|
1163
|
-
const
|
|
1324
|
+
const filterResult = buildAllConditions(table, params);
|
|
1325
|
+
if ('error' in filterResult) {
|
|
1326
|
+
return sanitizeError(filterResult.error, 400, options);
|
|
1327
|
+
}
|
|
1328
|
+
const conditions = filterResult.conditions;
|
|
1329
|
+
|
|
1330
|
+
// Row ownership: auto-append WHERE <column> = user[<userField>] for IDOR protection
|
|
1331
|
+
if (user && options?.rowOwnership?.[tableName]) {
|
|
1332
|
+
const ownership = options.rowOwnership[tableName];
|
|
1333
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
1334
|
+
const userValue = user[ownership.userField];
|
|
1335
|
+
if (ownerCol && userValue != null) {
|
|
1336
|
+
conditions.push(eq(ownerCol, userValue));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1164
1339
|
|
|
1165
1340
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
1166
1341
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -1183,7 +1358,7 @@ async function handleDelete(
|
|
|
1183
1358
|
try { await hook.afterDelete(rows, user, client); } catch {}
|
|
1184
1359
|
}
|
|
1185
1360
|
|
|
1186
|
-
return json(transformResponse(rows, options), 200);
|
|
1361
|
+
return json(transformResponse(stripHiddenColumns(rows, tableName, options), options), 200);
|
|
1187
1362
|
}
|
|
1188
1363
|
}
|
|
1189
1364
|
|
|
@@ -1202,7 +1377,7 @@ async function handleDelete(
|
|
|
1202
1377
|
try { await hook.afterDelete(rows, user, client); } catch {}
|
|
1203
1378
|
}
|
|
1204
1379
|
|
|
1205
|
-
return json(transformResponse(rows, options), 200);
|
|
1380
|
+
return json(transformResponse(stripHiddenColumns(rows, tableName, options), options), 200);
|
|
1206
1381
|
}
|
|
1207
1382
|
|
|
1208
1383
|
interface JsonPath {
|
|
@@ -1287,6 +1462,9 @@ function parseJsonPath(column: string): JsonPath | null {
|
|
|
1287
1462
|
return { baseColumn, steps };
|
|
1288
1463
|
}
|
|
1289
1464
|
|
|
1465
|
+
// JSON path keys must be safe identifiers — no SQL injection via sql.raw()
|
|
1466
|
+
const SAFE_JSON_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
1467
|
+
|
|
1290
1468
|
/**
|
|
1291
1469
|
* Build a SQL condition for a JSON path filter.
|
|
1292
1470
|
* Generates expressions like: "metadata"->>'theme' = 'dark'
|
|
@@ -1294,7 +1472,14 @@ function parseJsonPath(column: string): JsonPath | null {
|
|
|
1294
1472
|
function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
1295
1473
|
const jp = filter.jsonPath!;
|
|
1296
1474
|
const baseCol = getColumn(table, jp.baseColumn);
|
|
1297
|
-
if (!baseCol) return
|
|
1475
|
+
if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
|
|
1476
|
+
|
|
1477
|
+
// Validate all JSON path keys before interpolation
|
|
1478
|
+
for (const step of jp.steps) {
|
|
1479
|
+
if (!SAFE_JSON_KEY.test(step.key)) {
|
|
1480
|
+
return { __filterError: `Invalid JSON path key: '${step.key}'` };
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1298
1483
|
|
|
1299
1484
|
// Build the JSON path expression
|
|
1300
1485
|
let expr: any = sql`${baseCol}`;
|
|
@@ -1355,13 +1540,19 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
1355
1540
|
return condition;
|
|
1356
1541
|
}
|
|
1357
1542
|
|
|
1543
|
+
const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in']);
|
|
1544
|
+
|
|
1358
1545
|
function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
1359
1546
|
if (filter.jsonPath) {
|
|
1360
1547
|
return buildJsonCondition(table, filter);
|
|
1361
1548
|
}
|
|
1362
1549
|
|
|
1363
1550
|
const column = getColumn(table, filter.column);
|
|
1364
|
-
if (!column) return
|
|
1551
|
+
if (!column) return { __filterError: `Unknown column: '${filter.column}'` };
|
|
1552
|
+
|
|
1553
|
+
if (!VALID_OPERATORS.has(filter.operator)) {
|
|
1554
|
+
return { __filterError: `Unknown filter operator: '${filter.operator}'` };
|
|
1555
|
+
}
|
|
1365
1556
|
|
|
1366
1557
|
let condition: any = null;
|
|
1367
1558
|
|
|
@@ -1508,18 +1699,31 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
1508
1699
|
return filters;
|
|
1509
1700
|
}
|
|
1510
1701
|
|
|
1702
|
+
function isFilterError(x: unknown): x is { __filterError: string } {
|
|
1703
|
+
return typeof x === 'object' && x !== null && '__filterError' in x;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1511
1706
|
/**
|
|
1512
1707
|
* Build all WHERE conditions from query params, including individual filters
|
|
1513
1708
|
* and or()/and() logical groups.
|
|
1709
|
+
* Returns { conditions } on success, { error } on validation failure.
|
|
1514
1710
|
*/
|
|
1515
|
-
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): any[] {
|
|
1711
|
+
function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { conditions: any[] } | { error: string } {
|
|
1516
1712
|
const filters = parseFilters(params);
|
|
1517
|
-
const
|
|
1713
|
+
const rawResults = filters.map(f => buildCondition(table, f));
|
|
1714
|
+
for (const r of rawResults) {
|
|
1715
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
1716
|
+
}
|
|
1717
|
+
const conditions = rawResults.filter(Boolean);
|
|
1518
1718
|
|
|
1519
1719
|
const orParam = params.get('or');
|
|
1520
1720
|
if (orParam) {
|
|
1521
1721
|
const orFilters = parseLogicalGroup(orParam);
|
|
1522
|
-
const
|
|
1722
|
+
const orRawResults = orFilters.map(f => buildCondition(table, f));
|
|
1723
|
+
for (const r of orRawResults) {
|
|
1724
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
1725
|
+
}
|
|
1726
|
+
const orConditions = orRawResults.filter(Boolean);
|
|
1523
1727
|
if (orConditions.length > 0) {
|
|
1524
1728
|
conditions.push(or(...orConditions));
|
|
1525
1729
|
}
|
|
@@ -1528,13 +1732,17 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): any[]
|
|
|
1528
1732
|
const andParam = params.get('and');
|
|
1529
1733
|
if (andParam) {
|
|
1530
1734
|
const andFilters = parseLogicalGroup(andParam);
|
|
1531
|
-
const
|
|
1735
|
+
const andRawResults = andFilters.map(f => buildCondition(table, f));
|
|
1736
|
+
for (const r of andRawResults) {
|
|
1737
|
+
if (isFilterError(r)) return { error: r.__filterError };
|
|
1738
|
+
}
|
|
1739
|
+
const andConditions = andRawResults.filter(Boolean);
|
|
1532
1740
|
if (andConditions.length > 0) {
|
|
1533
1741
|
conditions.push(and(...andConditions));
|
|
1534
1742
|
}
|
|
1535
1743
|
}
|
|
1536
1744
|
|
|
1537
|
-
return conditions;
|
|
1745
|
+
return { conditions };
|
|
1538
1746
|
}
|
|
1539
1747
|
|
|
1540
1748
|
function json(data: unknown, status: number): Response {
|