@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.
Files changed (2) hide show
  1. package/package.json +15 -16
  2. 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.0",
3
+ "version": "0.2.1",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
- "license": "MIT",
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": "NODE_OPTIONS='--experimental-vm-modules' jest",
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
+ }
@@ -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'
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(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" }, ... }
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 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,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 result = await options.rpc[fnName](body, user, client);
425
- return json(result, 200);
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 json({ message: `Table '${tableName}' not found` }, 404);
535
+ return sanitizeError(`Table '${tableName}' not found`, 404, options);
434
536
  }
435
537
 
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);
538
+ if (options?.exposedTables && !options.exposedTables.includes(tableName)) {
539
+ return sanitizeError(`Table '${tableName}' not found`, 404, options);
540
+ }
439
541
 
440
- if (requiredRole !== null) {
441
- // Role-based path
442
- const result = await checkRoleAuth(requiredRole, request, options.auth);
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
- 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
- }
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
- if (method === 'POST') {
471
- return handlePost(dbOrTx, table, tableName, request, options, user, client);
472
- }
568
+ if (method === 'POST') {
569
+ return handlePost(dbOrTx, table, tableName, request, options, user, client);
570
+ }
473
571
 
474
- if (method === 'PATCH') {
475
- return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
476
- }
572
+ if (method === 'PATCH') {
573
+ return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
574
+ }
477
575
 
478
- if (method === 'DELETE') {
479
- return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
480
- }
576
+ if (method === 'DELETE') {
577
+ return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
578
+ }
481
579
 
482
- return json({ message: `Method ${method} not allowed` }, 405);
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 limitVal = params.has('limit') ? parseInt(params.get('limit')!, 10) : 1000;
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 conditions = buildAllConditions(table, params);
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: innerSelect === '*' ? null : innerSelect.split(',').map(c => c.trim()),
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
- const relatedRows: Record<string, unknown>[] = await db
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
- const relatedRows: Record<string, unknown>[] = await db
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 conditions = buildAllConditions(table, params);
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 conditions = buildAllConditions(table, params);
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 null;
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 null;
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 conditions = filters.map(f => buildCondition(table, f)).filter(Boolean);
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 orConditions = orFilters.map(f => buildCondition(table, f)).filter(Boolean);
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 andConditions = andFilters.map(f => buildCondition(table, f)).filter(Boolean);
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 {