@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.
Files changed (3) hide show
  1. package/LICENSE +681 -0
  2. package/package.json +10 -11
  3. package/src/handler/index.ts +328 -120
@@ -18,11 +18,24 @@ type Schema = Record<string, DrizzleTable>;
18
18
 
19
19
  type Handler = (request: Request) => Promise<Response>;
20
20
 
21
- export interface RoleConfig {
22
- /** Role required for GET requests on this table */
23
- read: string;
24
- /** Role required for POST/PATCH/DELETE requests on this table */
25
- write: string;
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, client headers are required on every request (including public routes). */
39
- verifyClient?: (clientId: string, clientSecret: string) => Promise<Record<string, unknown> | null>;
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, (body: unknown, user?: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Promise<unknown>>;
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 based on role config.
277
- * - pgSettings configured → private header (per-user data via RLS)
278
- * - read: 'public' → public header
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
- 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 JSON.stringify(manifest);
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
- if (options?.auth?.verifyClient) {
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 || !clientSecret) {
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
- return json({ tables: Array.from(tableNames) }, 200);
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 result = await options.rpc[fnName](body, user, client);
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 json({ message: `Table '${tableName}' not found` }, 404);
518
+ return sanitizeError(`Table '${tableName}' not found`, 404, options);
434
519
  }
435
520
 
436
- // Phase 2: User auth (role-aware) — now we know the table name
437
- if (options?.auth) {
438
- const requiredRole = getRequiredRole(tableName, method, options.auth);
521
+ if (options?.exposedTables && !options.exposedTables.includes(tableName)) {
522
+ return sanitizeError(`Table '${tableName}' not found`, 404, options);
523
+ }
439
524
 
440
- if (requiredRole !== null) {
441
- // Role-based path
442
- const result = await checkRoleAuth(requiredRole, request, options.auth);
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
- return executeWithSettings(db, settings, async (dbOrTx) => {
465
- if (method === 'GET') {
466
- const cacheControl = getCacheControl(tableName, options);
467
- return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
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
- if (method === 'POST') {
471
- return handlePost(dbOrTx, table, tableName, request, options, user, client);
472
- }
551
+ if (method === 'POST') {
552
+ return handlePost(dbOrTx, table, tableName, request, options, user, client);
553
+ }
473
554
 
474
- if (method === 'PATCH') {
475
- return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
476
- }
555
+ if (method === 'PATCH') {
556
+ return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
557
+ }
477
558
 
478
- if (method === 'DELETE') {
479
- return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
480
- }
559
+ if (method === 'DELETE') {
560
+ return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
561
+ }
481
562
 
482
- return json({ message: `Method ${method} not allowed` }, 405);
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 limitVal = params.has('limit') ? parseInt(params.get('limit')!, 10) : 1000;
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 conditions = buildAllConditions(table, params);
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: innerSelect === '*' ? null : innerSelect.split(',').map(c => c.trim()),
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
- const relatedRows: Record<string, unknown>[] = await db
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
- const relatedRows: Record<string, unknown>[] = await db
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 conditions = buildAllConditions(table, params);
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 conditions = buildAllConditions(table, params);
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 null;
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 null;
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 conditions = filters.map(f => buildCondition(table, f)).filter(Boolean);
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 orConditions = orFilters.map(f => buildCondition(table, f)).filter(Boolean);
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 andConditions = andFilters.map(f => buildCondition(table, f)).filter(Boolean);
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 {