@everystack/api 0.4.1 → 0.4.3

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.
@@ -7,8 +7,10 @@
7
7
  * // Mount as Expo Router API route or any Web Standard handler
8
8
  */
9
9
 
10
- import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql } from 'drizzle-orm';
10
+ import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql, getTableColumns, type SQL } from 'drizzle-orm';
11
11
  import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
12
+ import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
13
+ import { isPermissionDenied, safeRead, UNREADABLE } from './thrown';
12
14
  export type { AuditConfig, AuditRecord, RequestContext, AuditOutcome, PrincipalKind } from './audit';
13
15
  export { getPublicSurface, assertPublicSurface } from './public-surface';
14
16
  export type { PublicSurface } from './public-surface';
@@ -41,12 +43,21 @@ export class HandlerError extends Error {
41
43
  */
42
44
  export interface HandlerFault {
43
45
  requestId?: string;
44
- /** 'rpc' for /rpc/<name>; 'rest' for a table operation. */
45
- kind: 'rpc' | 'rest';
46
- /** RPC function name or table name. */
46
+ /**
47
+ * 'rpc' for /rpc/<name>; 'rest' for a table operation; 'hook' for an
48
+ * after-hook that threw after its write (the response stayed 2xx).
49
+ */
50
+ kind: 'rpc' | 'rest' | 'hook';
51
+ /**
52
+ * RPC function name or table name. For 'hook': '<table>.afterCreate',
53
+ * '<table>.afterUpdate', '<table>.afterDelete' or 'rpc.<fn>.afterRpc'.
54
+ */
47
55
  name: string;
48
56
  method: string;
49
- /** Always >= 500 here — refusals (4xx) never raise a fault. */
57
+ /**
58
+ * Always >= 500 here — refusals (4xx) never raise a fault. For 'hook' it is
59
+ * the fault's severity (500), not the response status.
60
+ */
50
61
  status: number;
51
62
  message: string;
52
63
  stack?: string;
@@ -68,6 +79,99 @@ async function emitFault(
68
79
  }
69
80
  }
70
81
 
82
+ /**
83
+ * Report an after-hook that threw. It ran after its write was committed, so
84
+ * the caller still answers 2xx; the hook's failure goes to onError only.
85
+ * Never throws.
86
+ */
87
+ async function reportHookFault(
88
+ onError: HandlerOptions['onError'],
89
+ at: { requestId?: string; name: string; method: string },
90
+ error: unknown,
91
+ ): Promise<void> {
92
+ const { message, ...rest } = describeError(error);
93
+ await emitFault(onError, {
94
+ ...at, kind: 'hook', status: 500, ...rest,
95
+ message: `after-hook failed after the write: ${message}`,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Work a write handler queues to run once executeWithSettings has returned: with
101
+ * pgSettings, after the transaction has ended, so nothing here holds the connection.
102
+ * `always` runs for every outcome, a failed commit included; it is a before-hook
103
+ * fault, and the hook did fail. Otherwise it is a table after-hook and runs only
104
+ * for a 2xx.
105
+ */
106
+ interface Deferred {
107
+ always: boolean;
108
+ run: () => Promise<void>;
109
+ }
110
+
111
+ /** Queue a table after-hook. Its throw is reported as kind 'hook', never re-thrown. */
112
+ function deferAfterHook(
113
+ deferred: Deferred[],
114
+ onError: HandlerOptions['onError'],
115
+ at: { requestId?: string; name: string; method: string },
116
+ run: () => Promise<void>,
117
+ ): void {
118
+ deferred.push({
119
+ always: false,
120
+ run: async () => {
121
+ try {
122
+ await run();
123
+ } catch (err) {
124
+ await reportHookFault(onError, at, err);
125
+ }
126
+ },
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Look up a config entry by a name the request supplied. Only an own key
132
+ * counts: a plain object also answers `constructor`, `toString` and
133
+ * `__proto__` from Object.prototype.
134
+ */
135
+ function ownEntry<T>(obj: Record<string, T> | undefined, key: string): T | undefined {
136
+ return obj && Object.hasOwn(obj, key) ? obj[key] : undefined;
137
+ }
138
+
139
+ /** Name a fault after a declared RPC only; any other path segment is caller text. */
140
+ function declaredRpcName(rpc: HandlerOptions['rpc'], name: string | undefined): string {
141
+ return name && rpc && Object.hasOwn(rpc, name) ? name : '';
142
+ }
143
+
144
+ /**
145
+ * Report an auth outage (the token or client verifier could not reach its
146
+ * backing service). It answers a returned 503, so no catch reports it. The
147
+ * client gets the fixed body; the driver's message goes here only.
148
+ * A no-op when `outage` is undefined (the auth failure was a 401/403).
149
+ * Never throws: the 503 must not turn into a rejection.
150
+ */
151
+ async function emitAuthOutage(
152
+ onError: HandlerOptions['onError'],
153
+ where: Pick<HandlerFault, 'requestId' | 'kind' | 'name' | 'method'>,
154
+ outage: unknown,
155
+ ): Promise<void> {
156
+ if (!onError || outage === undefined) return;
157
+ await emitFault(onError, { ...where, status: 503, ...describeError(outage) });
158
+ }
159
+
160
+ /**
161
+ * The message, stack and code of anything thrown, for a fault. Never throws:
162
+ * String() throws on a null-prototype object, and a getter can throw.
163
+ */
164
+ function describeError(error: unknown): Pick<HandlerFault, 'message' | 'stack' | 'pgCode'> {
165
+ const [message, stack, code] = [safeRead(error, 'message'), safeRead(error, 'stack'), safeRead(error, 'code')];
166
+ let text: string;
167
+ try { text = typeof message === 'string' ? message : String(error); } catch { text = 'unreadable error'; }
168
+ return {
169
+ message: text,
170
+ stack: typeof stack === 'string' ? stack : undefined,
171
+ pgCode: typeof code === 'string' ? code : undefined,
172
+ };
173
+ }
174
+
71
175
  /** Replace schema-revealing error details with a generic message when hideSchemaErrors is enabled. */
72
176
  function sanitizeError(message: string, status: number, options?: HandlerOptions): Response {
73
177
  if (options?.hideSchemaErrors) {
@@ -244,8 +348,8 @@ export interface HandlerOptions {
244
348
  * If omitted, all tables in the schema are exposed (current behavior). */
245
349
  exposedTables?: string[];
246
350
  /** Per-table list of column names to strip from all responses.
247
- * Columns are removed after query execution, before JSON serialization.
248
- * Does NOT affect query filtering — hidden columns can still be used in WHERE clauses. */
351
+ * Explicit references in query parameters are refused before database access.
352
+ * Does not prevent writes to these columns; use protectedFields for that. */
249
353
  hiddenColumns?: Record<string, string[]>;
250
354
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
251
355
  * Prevents mass assignment / privilege escalation on sensitive columns. */
@@ -259,8 +363,14 @@ export interface HandlerOptions {
259
363
  /** Per-table row ownership for IDOR protection on PATCH/DELETE.
260
364
  * Auto-appends WHERE <column> = user[<userField>] to mutations.
261
365
  * GET/POST are unaffected — reads use RLS, creates use hooks.
262
- * Example: { posts: { column: 'authorId', userField: 'sub' } } */
263
- rowOwnership?: Record<string, { column: string; userField: string }>;
366
+ * Example: { posts: { column: 'authorId', userField: 'sub' } }
367
+ *
368
+ * `exceptRoles` lists roles that skip the clause, e.g. an admin RLS lets write any row.
369
+ * It mirrors an RLS policy and never stands alone: RLS still decides the write.
370
+ * The caller's role is read from `auth.roleField` (default `role`) and must match a
371
+ * listed role exactly. No hierarchy, no case folding. A missing role keeps the clause.
372
+ * The role claim must be trusted: keep `role` out of anything a user can write. */
373
+ rowOwnership?: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
264
374
  /** When true, error messages hide internal details (table names, column names, operators).
265
375
  * Enable in production to prevent schema enumeration. Default: false. */
266
376
  hideSchemaErrors?: boolean;
@@ -294,19 +404,6 @@ function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: Han
294
404
  return getSoftDeleteColumn(table, options) != null;
295
405
  }
296
406
 
297
- /**
298
- * Detect PostgreSQL permission denied errors (code 42501).
299
- * Used to convert Postgres GRANT/RLS denials into clean 403 responses.
300
- */
301
- function isPermissionDenied(error: unknown): boolean {
302
- if (error && typeof error === 'object') {
303
- const e = error as Record<string, unknown>;
304
- if (e.code === '42501') return true;
305
- if (typeof e.message === 'string' && e.message.includes('permission denied')) return true;
306
- }
307
- return false;
308
- }
309
-
310
407
  /**
311
408
  * Execute a function with pgSettings applied in the same transaction.
312
409
  *
@@ -345,6 +442,16 @@ async function executeWithSettings<T>(
345
442
  }
346
443
 
347
444
 
445
+ /**
446
+ * The caller's role claim, or '' when it is missing or not a string. pgSettings compares
447
+ * the claim strictly, so a claim such as ['admin'] must never read as 'admin' here.
448
+ * Every role gate reads the claim through this one function.
449
+ */
450
+ function readRoleClaim(user: Record<string, unknown>, roleField: string): string {
451
+ const claim = user[roleField];
452
+ return typeof claim === 'string' ? claim : '';
453
+ }
454
+
348
455
  /**
349
456
  * Check whether userRole meets or exceeds requiredRole in the hierarchy.
350
457
  * Both in hierarchy → index comparison. Otherwise → exact string match.
@@ -354,6 +461,7 @@ function hasRole(
354
461
  requiredRole: string,
355
462
  hierarchy: string[],
356
463
  ): boolean {
464
+ if (!userRole || !requiredRole) return false;
357
465
  const requiredIndex = hierarchy.indexOf(requiredRole);
358
466
  const userIndex = hierarchy.indexOf(userRole);
359
467
  if (requiredIndex !== -1 && userIndex !== -1) {
@@ -362,6 +470,17 @@ function hasRole(
362
470
  return userRole === requiredRole;
363
471
  }
364
472
 
473
+ /**
474
+ * The result of an auth check. `outage` is the caught error when the verifier
475
+ * was unreachable (error is then the 503); a caller that returns `error`
476
+ * passes it to emitAuthOutage.
477
+ */
478
+ interface AuthResult {
479
+ user: Record<string, unknown> | null;
480
+ error: Response | null;
481
+ outage?: unknown;
482
+ }
483
+
365
484
  /**
366
485
  * Extract Bearer token from request and verify it.
367
486
  * Returns { user, error } — error is a Response (401) if auth fails.
@@ -369,7 +488,7 @@ function hasRole(
369
488
  async function verifyUserToken(
370
489
  request: Request,
371
490
  auth: AuthConfig,
372
- ): Promise<{ user: Record<string, unknown> | null; error: Response | null }> {
491
+ ): Promise<AuthResult> {
373
492
  const authHeader = request.headers.get('Authorization');
374
493
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
375
494
 
@@ -380,7 +499,11 @@ async function verifyUserToken(
380
499
  let user: Record<string, unknown> | null;
381
500
  try {
382
501
  user = await auth.verifyToken(token);
383
- } catch {
502
+ } catch (err) {
503
+ // An unreachable verifier is not a bad token: 401 would log the user out.
504
+ if (isServiceUnavailable(err)) {
505
+ return { user: null, error: serviceUnavailableResponse(), outage: err };
506
+ }
384
507
  return { user: null, error: json({ message: 'Authentication failed: invalid or expired token' }, 401) };
385
508
  }
386
509
 
@@ -402,7 +525,7 @@ async function checkRoleAuth(
402
525
  requiredRole: string,
403
526
  request: Request,
404
527
  auth: AuthConfig,
405
- ): Promise<{ user: Record<string, unknown> | null; error: Response | null }> {
528
+ ): Promise<AuthResult> {
406
529
  if (requiredRole === 'public') {
407
530
  return { user: null, error: null };
408
531
  }
@@ -417,8 +540,7 @@ async function checkRoleAuth(
417
540
 
418
541
  // Named role: check hierarchy
419
542
  const hierarchy = auth.roleHierarchy ?? ['public', 'authenticated', 'admin'];
420
- const roleField = auth.roleField ?? 'role';
421
- const userRole = String(result.user![roleField] ?? '');
543
+ const userRole = readRoleClaim(result.user!, auth.roleField ?? 'role');
422
544
 
423
545
  if (!hasRole(userRole, requiredRole, hierarchy)) {
424
546
  return {
@@ -608,7 +730,7 @@ function expandSchema(schema: Schema, pairs: Array<{ exportKey: string; sqlName:
608
730
  if (pairs.length === 0) return schema;
609
731
  const expanded: Schema = { ...schema };
610
732
  for (const { exportKey, sqlName } of pairs) {
611
- if (!(sqlName in expanded)) expanded[sqlName] = schema[exportKey];
733
+ if (!Object.hasOwn(expanded, sqlName)) expanded[sqlName] = schema[exportKey];
612
734
  }
613
735
  return expanded;
614
736
  }
@@ -632,7 +754,7 @@ function expandTableKeyedOptions(
632
754
  const out: Record<string, T> = { ...map };
633
755
  for (const key of Object.keys(map)) {
634
756
  const t = twin(key);
635
- if (t && !(t in out)) out[t] = map[key];
757
+ if (t && !Object.hasOwn(out, t)) out[t] = map[key];
636
758
  }
637
759
  return out;
638
760
  };
@@ -650,6 +772,60 @@ function expandTableKeyedOptions(
650
772
  };
651
773
  }
652
774
 
775
+ /**
776
+ * Every table the options name outside rowOwnership, in snake_case so that `userPosts`
777
+ * and `user_posts` in two maps of the same options match.
778
+ */
779
+ function declaredTables(options: HandlerOptions): Set<string> {
780
+ const names = [
781
+ ...(options.exposedTables ?? []),
782
+ ...Object.keys(options.hiddenColumns ?? {}),
783
+ ...Object.keys(options.protectedFields ?? {}),
784
+ ...Object.keys(options.hooks ?? {}),
785
+ ...Object.keys(options.relations ?? {}),
786
+ ...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
787
+ ...(options.softDelete?.tables ?? []),
788
+ ];
789
+ return new Set(names.map(toSnakeCase));
790
+ }
791
+
792
+ /**
793
+ * Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
794
+ * needs a user, so rowOwnership without auth is an error. An entry whose column does not
795
+ * resolve (same resolver as request time) is an error. An entry for a table this handler
796
+ * does not serve is accepted only when the options declare that table elsewhere: derived
797
+ * configs name every table while a handler serves a few. Otherwise it is a typo that would
798
+ * leave the real table unscoped, and an error. An entry with exceptRoles needs pgSettings:
799
+ * without SET LOCAL ROLE no RLS policy backs the exemption, only the JWT role claim.
800
+ */
801
+ function validateRowOwnership(schema: Schema, options: HandlerOptions | undefined): void {
802
+ const entries = Object.entries(options?.rowOwnership ?? {});
803
+ if (entries.length === 0) return;
804
+ if (!options?.auth) {
805
+ throw new Error('[everystack/api] rowOwnership requires auth: the owner clause needs an authenticated user');
806
+ }
807
+ const declared = declaredTables(options);
808
+ for (const [tableName, entry] of entries) {
809
+ const table = schema[tableName];
810
+ if (!table) {
811
+ if (declared.has(toSnakeCase(tableName))) continue;
812
+ throw new Error(
813
+ `[everystack/api] rowOwnership.${tableName}: table "${tableName}" is not in the schema and is not declared anywhere else in the handler options (exposedTables, relations, hiddenColumns, protectedFields, softDelete, hooks). Fix the name or remove the entry.`,
814
+ );
815
+ }
816
+ if (getColumn(table, entry.column) === null) {
817
+ throw new Error(
818
+ `[everystack/api] rowOwnership.${tableName}.column "${entry.column}" does not resolve to a column of table "${tableName}"`,
819
+ );
820
+ }
821
+ if ((entry.exceptRoles?.length ?? 0) > 0 && !options.pgSettings) {
822
+ throw new Error(
823
+ `[everystack/api] rowOwnership.${tableName}.exceptRoles requires options.pgSettings: without SET LOCAL ROLE the exemption trusts the JWT role claim alone and no RLS policy backs it. Add pgSettings or remove exceptRoles.`,
824
+ );
825
+ }
826
+ }
827
+ }
828
+
653
829
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
654
830
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
655
831
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
@@ -659,6 +835,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
659
835
  const namePairs = tableNamePairs(rawSchema);
660
836
  const schema = expandSchema(rawSchema, namePairs);
661
837
  const options = expandTableKeyedOptions(rawOptions, namePairs);
838
+ // Validate configured entries, not their generated table-name twins.
839
+ for (const [tableName, columns] of Object.entries(rawOptions?.hiddenColumns ?? {})) {
840
+ for (const column of columns) {
841
+ if (columnPropertyName(schema[tableName], column) === undefined) {
842
+ console.warn(
843
+ `[everystack/api] invalid hiddenColumns entry: table=${tableName} column=${column}; `
844
+ + 'JOIN-path embeds of this table return 400 until the entry is fixed.',
845
+ );
846
+ }
847
+ }
848
+ }
849
+ validateRowOwnership(schema, rawOptions);
662
850
  const tableNames = new Set(Object.keys(schema));
663
851
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
664
852
 
@@ -707,7 +895,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
707
895
 
708
896
  try {
709
897
  client = await options.auth.verifyClient(clientId ?? '', clientSecret ?? '', referer ?? undefined);
710
- } catch {
898
+ } catch (err) {
899
+ if (isServiceUnavailable(err)) {
900
+ // The route is not resolved yet: name what the path asks for, but
901
+ // only a declared table or RPC. Any other segment is caller text.
902
+ const [first, second] = pathSegments;
903
+ const where = first === 'rpc'
904
+ ? { kind: 'rpc' as const, name: declaredRpcName(options.rpc, second) }
905
+ : { kind: 'rest' as const, name: first && tableNames.has(first) ? first : '' };
906
+ await emitAuthOutage(options.onError, { requestId, method, ...where }, err);
907
+ return serviceUnavailableResponse();
908
+ }
711
909
  return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
712
910
  }
713
911
 
@@ -737,14 +935,23 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
737
935
  && options.auth.publicRpc?.includes(pathSegments[1]);
738
936
  if (!isPublicMethod && !isPublicRpc) {
739
937
  const result = await verifyUserToken(request, options.auth);
740
- if (result.error) return result.error;
938
+ if (result.error) {
939
+ await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
940
+ return result.error;
941
+ }
741
942
  user = result.user;
742
943
  } else if (request.headers.get('authorization')) {
743
944
  // Public RPC with token present — verify opportunistically, same
744
945
  // policy as public table routes below. If valid, the request runs
745
946
  // as authenticated (RLS self-visibility, PII self-tiers). If
746
- // invalid, ignore and fall through as anonymous.
947
+ // invalid, ignore and fall through as anonymous. If the verifier
948
+ // is down, answer 503: running a signed-in caller as anonymous
949
+ // would hand them anonymous data as if it were theirs.
747
950
  const result = await verifyUserToken(request, options.auth);
951
+ if (result.error && result.outage !== undefined) {
952
+ await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
953
+ return result.error;
954
+ }
748
955
  if (!result.error) user = result.user;
749
956
  }
750
957
  if (user && options.auth.onAuthenticated) {
@@ -753,14 +960,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
753
960
  }
754
961
 
755
962
  const fnName = pathSegments[1];
756
- if (options?.rpc && fnName && options.rpc[fnName]) {
757
- const entry = options.rpc[fnName];
963
+ const entry = fnName ? ownEntry(options?.rpc, fnName) : undefined;
964
+ if (fnName && entry) {
758
965
  const isObjectForm = typeof entry !== 'function';
759
966
 
760
967
  // Per-RPC role check (object syntax: { fn, role })
761
968
  if (isObjectForm && entry.role && options?.auth) {
762
969
  const roleResult = await checkRoleAuth(entry.role, request, options.auth);
763
- if (roleResult.error) return roleResult.error;
970
+ if (roleResult.error) {
971
+ await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
972
+ return roleResult.error;
973
+ }
764
974
  user = roleResult.user;
765
975
  } else if (isObjectForm && entry.role && !options?.auth) {
766
976
  // Role required but no auth configured — deny
@@ -802,7 +1012,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
802
1012
  // webhooks). Swallow errors — a side-effect failure must not fail the
803
1013
  // write, exactly like the after* REST hooks. Success path only.
804
1014
  if (options?.afterRpc) {
805
- try { await options.afterRpc(result, fnName, body, user, client, ctx); } catch {}
1015
+ try {
1016
+ await options.afterRpc(result, fnName, body, user, client, ctx);
1017
+ } catch (err) {
1018
+ await reportHookFault(options.onError, { requestId, name: `rpc.${fnName}.afterRpc`, method }, err);
1019
+ }
806
1020
  }
807
1021
  // Same cache discipline as table GETs: with pgSettings configured the
808
1022
  // response can vary by identity (opportunistic auth on public RPCs),
@@ -813,9 +1027,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
813
1027
  const rpcCacheControl = getCacheControl(options);
814
1028
  return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
815
1029
  } catch (error) {
816
- const pgCode = (error as any)?.code;
1030
+ const code = safeRead(error, 'code');
1031
+ const text = safeRead(error, 'message');
1032
+ // A value whose getter throws is hostile: classify nothing from it
1033
+ // and echo nothing of it. It answers the generic 500 (or an outage's 503).
1034
+ const hostile = code === UNREADABLE || text === UNREADABLE || safeRead(error, 'cause') === UNREADABLE;
1035
+ const pgCode = hostile ? undefined : code;
1036
+ // An outage: answer 503 with a fixed body. The driver's message
1037
+ // carries host and port; it goes to onError only. A HandlerError's
1038
+ // own answer still wins: its return below runs first.
1039
+ const unavailable = isServiceUnavailable(error);
817
1040
  let status = 500;
818
- if (pgCode === '28P01') status = 401;
1041
+ if (unavailable) status = 503;
819
1042
  else if (pgCode === '42501') status = 403;
820
1043
  else if (pgCode === '23505') status = 409;
821
1044
  else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
@@ -833,15 +1056,14 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
833
1056
  await emitFault(options?.onError, {
834
1057
  requestId, kind: 'rpc', name: fnName, method,
835
1058
  status: error instanceof HandlerError ? error.status : status,
836
- message: error instanceof Error ? error.message : String(error),
837
- stack: error instanceof Error ? error.stack : undefined,
838
- pgCode,
1059
+ ...describeError(error),
839
1060
  });
840
1061
  }
841
1062
  if (error instanceof HandlerError) {
842
1063
  return json({ message: error.message }, error.status);
843
1064
  }
844
- const message = error instanceof Error ? error.message : 'RPC execution failed';
1065
+ if (unavailable) return serviceUnavailableResponse();
1066
+ const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
845
1067
  if (options?.hideSchemaErrors && status === 500) {
846
1068
  return json({ message: 'Internal Server Error' }, 500);
847
1069
  }
@@ -867,13 +1089,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
867
1089
  const isPublicMethod = options.auth.publicRoutes.includes(method);
868
1090
  if (!isPublicMethod) {
869
1091
  const result = await verifyUserToken(request, options.auth);
870
- if (result.error) return result.error;
1092
+ if (result.error) {
1093
+ await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
1094
+ return result.error;
1095
+ }
871
1096
  user = result.user;
872
1097
  } else if (request.headers.get('authorization')) {
873
1098
  // Public route with token present — verify opportunistically.
874
1099
  // If valid, the request runs as authenticated (better RLS access).
875
- // If invalid, ignore and fall through as anonymous.
1100
+ // If invalid, ignore and fall through as anonymous. If the verifier
1101
+ // is down, answer 503, as the public RPC branch above does.
876
1102
  const result = await verifyUserToken(request, options.auth);
1103
+ if (result.error && result.outage !== undefined) {
1104
+ await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
1105
+ return result.error;
1106
+ }
877
1107
  if (!result.error) user = result.user;
878
1108
  }
879
1109
 
@@ -882,16 +1112,15 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
882
1112
  }
883
1113
  }
884
1114
 
885
- // Compute pgSettings after auth (user now resolved)
886
- const settings = options?.pgSettings?.(user, client);
887
1115
  const table = schema[tableName];
888
1116
 
889
1117
  // Forensic audit of the non-GET table surface (writes). Capture the query, not
890
1118
  // the response: the operation, the WHERE predicate, and the body's column NAMES
891
- // (never values — values can be PII). GET is delegated to RLS, never audited.
1119
+ // (never values — values can be PII). Unresolved embed policies also record
1120
+ // GET denials; other reads are delegated to RLS.
892
1121
  const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE';
893
- const auditOp: 'insert' | 'update' | 'delete' =
894
- method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
1122
+ const auditOp: 'insert' | 'update' | 'delete' | 'select' =
1123
+ method === 'GET' ? 'select' : method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
895
1124
  const auditFilter = url.searchParams.toString() || undefined;
896
1125
  let auditColumns: string[] | undefined;
897
1126
  if (isWrite && options?.audit?.sink && method !== 'DELETE') {
@@ -909,27 +1138,89 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
909
1138
  const routedDb = method === 'GET' && !user && options?.readDb ? options.readDb : db;
910
1139
 
911
1140
  try {
912
- const response = await executeWithSettings(routedDb, settings, async (dbOrTx) => {
913
- if (method === 'GET') {
914
- const cacheControl = getCacheControl(options);
915
- return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
1141
+ // Bound every select before the hidden-column guard walks it. The guard
1142
+ // reads duplicates and runs for writes too, so a GET-only check is too late.
1143
+ let queryError: string | undefined;
1144
+ let auditReadDenial = false;
1145
+ for (const selectParam of url.searchParams.getAll('select')) {
1146
+ const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
1147
+ const depth = getEmbedDepth(selectParam);
1148
+ if (depth > maxEmbedDepth) {
1149
+ queryError = `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}`;
1150
+ break;
916
1151
  }
1152
+ }
917
1153
 
918
- if (method === 'POST') {
919
- return handlePost(dbOrTx, table, tableName, request, options, user, client);
1154
+ // Refusals share the response audit below without opening a transaction.
1155
+ // Response stripping cannot prevent value oracles or aggregate alias reads.
1156
+ if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
1157
+ queryError = 'Invalid query';
1158
+ }
1159
+ // Refuse projections we cannot enforce on the common pre-DB audit path.
1160
+ const tableRelations = ownEntry(options?.relations, tableName);
1161
+ if (!queryError && options?.drizzleRelations && tableRelations) {
1162
+ for (const selectParam of url.searchParams.getAll('select')) {
1163
+ const { embeds } = parseSelectParam(selectParam, tableRelations, options.relations);
1164
+ const withClause = buildWithClause(embeds, tableRelations, schema, options);
1165
+ if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1166
+ queryError = 'Invalid query';
1167
+ auditReadDenial = method === 'GET' && withClause === 'unresolvedHiddenColumns';
1168
+ break;
1169
+ }
920
1170
  }
1171
+ }
921
1172
 
922
- if (method === 'PATCH') {
923
- return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
924
- }
1173
+ // pgSettings runs once per request. The settings map is known only here, so the
1174
+ // check that it backs an ownership exemption runs here, before any transaction.
1175
+ const settings = queryError ? undefined : options?.pgSettings?.(user, client);
1176
+ const unbacked = queryError ? null : unbackedExemptionRefusal(method, tableName, options, user, settings);
1177
+ // A returned 500 skips the catch below, so report it to onError here.
1178
+ if (unbacked) {
1179
+ await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method, status: 500, message: unbacked });
1180
+ }
1181
+ // Work queued by the write. It runs only once executeWithSettings has
1182
+ // returned or thrown (with pgSettings, after the transaction has ended).
1183
+ // A before-hook fault runs in the finally, for any outcome: the hook failed
1184
+ // even when the commit then fails too. A table after-hook runs only for a
1185
+ // 2xx; a throw (a failed commit) skips it, since the write did not happen.
1186
+ // A retry wrapper around db.transaction must reset `deferred` per attempt,
1187
+ // or the hooks of an aborted attempt would run too.
1188
+ const deferred: Deferred[] = [];
1189
+ let response: Response;
1190
+ try {
1191
+ response = queryError ? json({ message: queryError }, 400)
1192
+ : unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
1193
+ if (method === 'GET') {
1194
+ const cacheControl = getCacheControl(options);
1195
+ return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
1196
+ }
925
1197
 
926
- if (method === 'DELETE') {
927
- return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
928
- }
1198
+ if (method === 'POST') {
1199
+ return handlePost(dbOrTx, table, tableName, request, options, user, client, requestId, deferred);
1200
+ }
929
1201
 
930
- return json({ message: `Method ${method} not allowed` }, 405);
931
- });
932
- if (isWrite) {
1202
+ if (method === 'PATCH') {
1203
+ return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client, requestId, deferred);
1204
+ }
1205
+
1206
+ if (method === 'DELETE') {
1207
+ return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client, requestId, deferred);
1208
+ }
1209
+
1210
+ return json({ message: `Method ${method} not allowed` }, 405);
1211
+ });
1212
+ } finally {
1213
+ // emitFault never throws, so this cannot replace the error in flight.
1214
+ for (const item of deferred) if (item.always) await item.run();
1215
+ }
1216
+ if (response.status >= 200 && response.status < 300) {
1217
+ // The contract: a side effect fires only for a successful write. The guard is
1218
+ // unreachable today (every refusal returns before the write queues a hook) and
1219
+ // stays for the next write path. Each queued hook reports its own throw, so
1220
+ // none can fail the response.
1221
+ for (const item of deferred) if (!item.always) await item.run();
1222
+ }
1223
+ if (isWrite || auditReadDenial) {
933
1224
  await emitAudit(options?.audit, {
934
1225
  kind: 'rest', name: tableName, user, client,
935
1226
  result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
@@ -948,56 +1239,104 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
948
1239
  if (isPermissionDenied(error)) {
949
1240
  return sanitizeError('Permission denied', 403, options);
950
1241
  }
951
- // Table faults re-throw to the Lambda boundary (which logs to CloudWatch),
952
- // but that path never reaches the S3 archive. Surface the cause here too so
953
- // a write 5xx is in logs:errors. A HandlerError with a 4xx status is a
954
- // deliberate refusal from a hook, not a fault — leave it alone.
1242
+ // An outage answers 503 with a fixed body; the driver's message (host and
1243
+ // port) goes to onError only. A HandlerError is the app's own answer and
1244
+ // never an outage.
1245
+ const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
1246
+ // Other table faults re-throw to the Lambda boundary (which logs to
1247
+ // CloudWatch), but that path never reaches the S3 archive. Surface the
1248
+ // cause here too so a write 5xx is in logs:errors. A HandlerError with a
1249
+ // 4xx status is a deliberate refusal from a hook, not a fault — leave it alone.
955
1250
  if (!(error instanceof HandlerError) || error.status >= 500) {
956
1251
  await emitFault(options?.onError, {
957
1252
  requestId, kind: 'rest', name: tableName, method,
958
- status: error instanceof HandlerError ? error.status : 500,
959
- message: error instanceof Error ? error.message : String(error),
960
- stack: error instanceof Error ? error.stack : undefined,
961
- pgCode: (error as any)?.code,
1253
+ status: error instanceof HandlerError ? error.status : unavailable ? 503 : 500,
1254
+ ...describeError(error),
962
1255
  });
963
1256
  }
1257
+ if (unavailable) return serviceUnavailableResponse();
964
1258
  throw error;
965
1259
  }
966
1260
  };
967
1261
  }
968
1262
 
969
- /**
970
- * Convert parsed embeds to Drizzle's `with` clause format for relational queries.
971
- * { name: 'author', columns: null } → { author: true }
972
- * { name: 'author', columns: ['name'] } → { author: { columns: { name: true } } }
973
- */
1263
+ /** Drizzle selections use schema property names, even when the API uses SQL names. */
1264
+ function columnPropertyName(table: DrizzleTable | undefined, name: string): string | undefined {
1265
+ if (!table) return undefined;
1266
+ const column = getColumn(table, name);
1267
+ const columns = Object.entries(getTableColumns(table));
1268
+ return columns.find(([, value]) => value === column)?.[0]
1269
+ ?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
1270
+ }
1271
+
1272
+ /** Build each relation's projection before Drizzle reads its values. */
974
1273
  function buildWithClause(
975
1274
  embeds: EmbedRequest[],
976
1275
  tableRelations: Record<string, RelationConfig>,
977
- ): Record<string, any> | undefined {
1276
+ schema: Schema,
1277
+ options?: HandlerOptions,
1278
+ ): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
978
1279
  if (embeds.length === 0) return undefined;
979
1280
 
980
1281
  const withClause: Record<string, any> = {};
981
1282
  let hasEmbedded = false;
982
1283
 
983
1284
  for (const embed of embeds) {
984
- if (!tableRelations[embed.name]) continue;
1285
+ const relation = tableRelations[embed.name];
1286
+ if (!relation) continue;
985
1287
  hasEmbedded = true;
986
-
987
- if (embed.columns === null) {
988
- withClause[embed.name] = true;
989
- } else {
990
- const columns: Record<string, boolean> = {};
991
- for (const col of embed.columns) {
992
- columns[col] = true;
993
- }
994
- withClause[embed.name] = { columns };
1288
+ const table = schema[relation.table];
1289
+ const hidden = new Set<string>();
1290
+ for (const name of options?.hiddenColumns?.[relation.table] ?? []) {
1291
+ const property = columnPropertyName(table, name);
1292
+ if (property === undefined) return 'unresolvedHiddenColumns';
1293
+ hidden.add(property);
1294
+ }
1295
+ const config: Record<string, any> = {};
1296
+ if (embed.columns !== null) {
1297
+ config.columns = Object.fromEntries(embed.columns
1298
+ .map(name => columnPropertyName(table, name) ?? name)
1299
+ .filter(name => !hidden.has(name))
1300
+ .map(name => [name, true]));
1301
+ if (Object.keys(config.columns).length === 0) return null;
1302
+ } else if (hidden.size > 0) {
1303
+ config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
995
1304
  }
1305
+ withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
996
1306
  }
997
1307
 
998
1308
  return hasEmbedded ? withClause : undefined;
999
1309
  }
1000
1310
 
1311
+ /** Follow only parsed relations: a similarly named JSON key is not a table column. */
1312
+ function stripEmbeddedHiddenColumns(
1313
+ data: unknown,
1314
+ embeds: EmbedRequest[],
1315
+ tableName: string,
1316
+ schema: Schema,
1317
+ options?: HandlerOptions,
1318
+ ): unknown {
1319
+ if (embeds.length === 0 || data === null || typeof data !== 'object') return data;
1320
+ if (Array.isArray(data)) {
1321
+ return data.map(row => stripEmbeddedHiddenColumns(row, embeds, tableName, schema, options));
1322
+ }
1323
+ const result = { ...(data as Record<string, unknown>) };
1324
+ for (const embed of embeds) {
1325
+ const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
1326
+ const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
1327
+ if (!relation || !(key in result)) continue;
1328
+ const hidden = (options?.hiddenColumns?.[relation.table] ?? []).flatMap(name => {
1329
+ const property = columnPropertyName(schema[relation.table], name);
1330
+ return [name, toSnakeCase(name), ...(property ? [property, toSnakeCase(property)] : [])];
1331
+ });
1332
+ const related = stripHiddenColumns(result[key], relation.table, {
1333
+ hiddenColumns: { [relation.table]: hidden },
1334
+ });
1335
+ result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
1336
+ }
1337
+ return result;
1338
+ }
1339
+
1001
1340
  /**
1002
1341
  * Convert column names array to Drizzle's `columns` selection format.
1003
1342
  * ['id', 'title'] → { id: true, title: true }
@@ -1100,17 +1439,8 @@ async function handleGet(
1100
1439
  const prefer = request.headers.get('Prefer') || '';
1101
1440
  const wantExactCount = prefer.includes('count=exact');
1102
1441
 
1103
- // Enforce maximum embed depth to prevent DoS from deeply nested relation queries
1104
- if (selectParam) {
1105
- const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
1106
- const depth = getEmbedDepth(selectParam);
1107
- if (depth > maxEmbedDepth) {
1108
- return json({ message: `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}` }, 400);
1109
- }
1110
- }
1111
-
1112
1442
  // Parse select parameter for relation embedding and aggregates
1113
- let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName], options?.relations);
1443
+ let { columns, embeds, aggregates } = parseSelectParam(selectParam, ownEntry(options?.relations, tableName), options?.relations);
1114
1444
 
1115
1445
  // Resolve snake_case select column names to Drizzle camelCase properties
1116
1446
  if (options?.naming === 'snake_case' && columns) {
@@ -1197,7 +1527,7 @@ async function handleGet(
1197
1527
  }
1198
1528
 
1199
1529
  // Detect if Drizzle relational query (JOINs) should be used
1200
- const tableRelations = options?.relations?.[tableName];
1530
+ const tableRelations = ownEntry(options?.relations, tableName);
1201
1531
  const useRelationalQuery = embeds.length > 0
1202
1532
  && tableRelations
1203
1533
  && options?.drizzleRelations
@@ -1215,7 +1545,10 @@ async function handleGet(
1215
1545
  const colSelection = buildColumnsSelection(columns);
1216
1546
  if (colSelection) queryOptions.columns = colSelection;
1217
1547
 
1218
- const withClause = buildWithClause(embeds, tableRelations!);
1548
+ const withClause = buildWithClause(embeds, tableRelations!, schema, options);
1549
+ if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1550
+ return json({ message: 'Invalid query' }, 400);
1551
+ }
1219
1552
  if (withClause) queryOptions.with = withClause;
1220
1553
 
1221
1554
  if (conditions.length === 1) {
@@ -1304,7 +1637,8 @@ async function handleGet(
1304
1637
  };
1305
1638
  if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
1306
1639
 
1307
- return new Response(JSON.stringify(stripHiddenColumns(transformResponse(responseData, options), tableName, options)), {
1640
+ const transformed = stripHiddenColumns(transformResponse(responseData, options), tableName, options);
1641
+ return new Response(JSON.stringify(stripEmbeddedHiddenColumns(transformed, embeds, tableName, schema, options)), {
1308
1642
  status: 200,
1309
1643
  headers: getHeaders,
1310
1644
  });
@@ -1374,12 +1708,12 @@ function parseSelectParam(
1374
1708
  }
1375
1709
 
1376
1710
  const embedMatch = part.match(/^(\w+)\((.+)\)$/);
1377
- if (embedMatch && tableRelations && embedMatch[1] in tableRelations) {
1711
+ if (embedMatch && tableRelations && Object.hasOwn(tableRelations, embedMatch[1])) {
1378
1712
  const name = embedMatch[1];
1379
1713
  const innerSelect = embedMatch[2].trim();
1380
1714
  // Recursively parse sub-embeds using the target table's relations
1381
1715
  const targetTableName = tableRelations[name].table;
1382
- const targetRelations = allRelations?.[targetTableName];
1716
+ const targetRelations = ownEntry(allRelations, targetTableName);
1383
1717
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
1384
1718
  embeds.push({
1385
1719
  name,
@@ -1431,6 +1765,71 @@ function splitSelectParam(param: string): string[] {
1431
1765
  return parts;
1432
1766
  }
1433
1767
 
1768
+ /** Check explicit query references, including each related table's own policy. */
1769
+ function requestUsesHiddenColumn(
1770
+ schema: Schema,
1771
+ tableName: string,
1772
+ params: URLSearchParams,
1773
+ options?: HandlerOptions,
1774
+ ): boolean {
1775
+ if (!options?.hiddenColumns) return false;
1776
+
1777
+ const isHidden = (sourceTable: string, reference: string): boolean => {
1778
+ let targetName = sourceTable;
1779
+ let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
1780
+ // Prefer a real source column. The relation fallback only rejects hidden
1781
+ // references; the filter and order builders do not support relation paths.
1782
+ if (!getColumn(schema[targetName], columnName)) {
1783
+ const parts = columnName.split('.');
1784
+ while (parts.length > 1) {
1785
+ const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
1786
+ if (!relation || !schema[relation.table]) return false;
1787
+ targetName = relation.table;
1788
+ }
1789
+ columnName = parts[0];
1790
+ }
1791
+ const table = schema[targetName];
1792
+ const column = getColumn(table, columnName);
1793
+ return (options.hiddenColumns?.[targetName] ?? []).some(hidden => {
1794
+ const hiddenColumn = getColumn(table, hidden);
1795
+ return column && hiddenColumn
1796
+ ? column === hiddenColumn
1797
+ : toCamelCase(columnName) === toCamelCase(hidden);
1798
+ });
1799
+ };
1800
+
1801
+ const selectUsesHidden = (sourceTable: string, select: string): boolean => {
1802
+ // Walk the raw selection: parseSelectParam collapses `*,secret` to a
1803
+ // wildcard and discards aggregates inside embeds, losing explicit names.
1804
+ return splitSelectParam(select).some(part => {
1805
+ const aggregate = part.match(AGG_REGEX);
1806
+ if (aggregate) return aggregate[2] ? isHidden(sourceTable, aggregate[2]) : false;
1807
+ const embed = part.match(/^(\w+)\((.+)\)$/);
1808
+ const relation = embed && ownEntry(ownEntry(options.relations, sourceTable), embed[1]);
1809
+ if (embed && relation && schema[relation.table]) {
1810
+ return selectUsesHidden(relation.table, embed[2]);
1811
+ }
1812
+ return part !== '*' && isHidden(sourceTable, part);
1813
+ });
1814
+ };
1815
+
1816
+ for (const [key, value] of params) {
1817
+ if (key === 'select') {
1818
+ if (selectUsesHidden(tableName, value)) return true;
1819
+ } else if (key === 'order') {
1820
+ if (value.split(',').some(order => {
1821
+ const dot = order.lastIndexOf('.');
1822
+ return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
1823
+ })) return true;
1824
+ } else if (key === 'or' || key === 'and') {
1825
+ if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
1826
+ } else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
1827
+ return true;
1828
+ }
1829
+ }
1830
+ return false;
1831
+ }
1832
+
1434
1833
  /**
1435
1834
  * Resolve relation embeds by fetching related rows and nesting them.
1436
1835
  */
@@ -1442,7 +1841,7 @@ async function resolveEmbeds(
1442
1841
  embeds: EmbedRequest[],
1443
1842
  options: HandlerOptions,
1444
1843
  ): Promise<Record<string, unknown>[]> {
1445
- const tableRelations = options.relations?.[tableName];
1844
+ const tableRelations = ownEntry(options.relations, tableName);
1446
1845
  if (!tableRelations) return rows;
1447
1846
 
1448
1847
  // Clone rows to avoid mutation
@@ -1563,6 +1962,37 @@ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<st
1563
1962
  return result;
1564
1963
  }
1565
1964
 
1965
+ /**
1966
+ * The response for a before-hook that threw. A HandlerError keeps its own status and
1967
+ * message. An outage (the hook could not reach the database) answers the fixed 503,
1968
+ * like every other outage path. Anything else is a generic 500 so the hook's text
1969
+ * never reaches the client. A 5xx is a fault and goes to onError; a 4xx HandlerError
1970
+ * is a refusal and does not. The fault is queued, not sent: with pgSettings the hook
1971
+ * ran inside the transaction, and a slow onError must not hold it open.
1972
+ */
1973
+ function beforeHookFailure(
1974
+ err: unknown,
1975
+ options: HandlerOptions | undefined,
1976
+ at: { requestId?: string; name: string; method: string },
1977
+ deferred: Deferred[],
1978
+ ): Response {
1979
+ const report = (fault: HandlerFault): void => {
1980
+ deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
1981
+ };
1982
+ if (err instanceof HandlerError) {
1983
+ if (err.status >= 500) {
1984
+ report({ ...at, kind: 'rest', status: err.status, message: err.message });
1985
+ }
1986
+ return json({ message: err.message }, err.status);
1987
+ }
1988
+ if (isServiceUnavailable(err)) {
1989
+ report({ ...at, kind: 'rest', status: 503, ...describeError(err) });
1990
+ return serviceUnavailableResponse();
1991
+ }
1992
+ report({ ...at, kind: 'rest', status: 500, ...describeError(err) });
1993
+ return json({ message: 'Hook error' }, 500);
1994
+ }
1995
+
1566
1996
  async function handlePost(
1567
1997
  db: DrizzleDb,
1568
1998
  table: DrizzleTable,
@@ -1571,6 +2001,8 @@ async function handlePost(
1571
2001
  options?: HandlerOptions,
1572
2002
  user: Record<string, unknown> | null = null,
1573
2003
  client: Record<string, unknown> | null = null,
2004
+ requestId?: string,
2005
+ deferred: Deferred[] = [],
1574
2006
  ): Promise<Response> {
1575
2007
  let body: Record<string, unknown>;
1576
2008
  try {
@@ -1599,10 +2031,7 @@ async function handlePost(
1599
2031
  try {
1600
2032
  body = await hook.beforeCreate(body, user, client);
1601
2033
  } catch (err) {
1602
- if (err instanceof HandlerError) {
1603
- return json({ message: err.message }, err.status);
1604
- }
1605
- return json({ message: 'Hook error' }, 500);
2034
+ return beforeHookFailure(err, options, { requestId, name: tableName, method: 'POST' }, deferred);
1606
2035
  }
1607
2036
  }
1608
2037
 
@@ -1619,7 +2048,9 @@ async function handlePost(
1619
2048
  const rows = await db.insert(table).values(coerced as Record<string, unknown>).returning();
1620
2049
 
1621
2050
  if (hook?.afterCreate && rows.length > 0) {
1622
- try { await hook.afterCreate(rows[0], user, client); } catch {}
2051
+ const { afterCreate } = hook;
2052
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterCreate`, method: 'POST' },
2053
+ () => afterCreate.call(hook, rows[0], user, client));
1623
2054
  }
1624
2055
 
1625
2056
  return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201);
@@ -1634,13 +2065,20 @@ async function handlePatch(
1634
2065
  options?: HandlerOptions,
1635
2066
  user: Record<string, unknown> | null = null,
1636
2067
  client: Record<string, unknown> | null = null,
2068
+ requestId?: string,
2069
+ deferred: Deferred[] = [],
1637
2070
  ): Promise<Response> {
2071
+ // Ownership refusal first: before the body, the filter, or any hook is examined.
2072
+ const scope = ownershipScope(table, tableName, options, user);
2073
+ if ('refusal' in scope) return scope.refusal;
2074
+
1638
2075
  let body: Record<string, unknown>;
1639
2076
  try {
1640
2077
  body = await request.json();
1641
2078
  } catch {
1642
2079
  return json({ message: 'Invalid JSON body' }, 400);
1643
2080
  }
2081
+ const clientBody = body;
1644
2082
 
1645
2083
  try {
1646
2084
  // Map body keys to Drizzle property names when naming convention is active
@@ -1656,16 +2094,28 @@ async function handlePatch(
1656
2094
  throw err;
1657
2095
  }
1658
2096
 
2097
+ // With the ownership backstop lifted, an unfiltered PATCH would write every row the
2098
+ // role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
2099
+ const ownership = options?.rowOwnership?.[tableName];
2100
+ if (user && options && ownership && isOwnershipExempt(ownership, user, options)
2101
+ && buildsNoFilter(table, params)) {
2102
+ return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
2103
+ }
2104
+
2105
+ // The owner clause proves the caller owns the row, not that they may give it away.
2106
+ // A caller it scopes may not set the owner column to anyone else. RLS stays primary.
2107
+ if (scope.condition && user && ownership) {
2108
+ const key = ownerReassignmentKey(table, ownership, user, clientBody, body);
2109
+ if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
2110
+ }
2111
+
1659
2112
  const hook = options?.hooks?.[tableName];
1660
2113
 
1661
2114
  if (hook?.beforeUpdate) {
1662
2115
  try {
1663
2116
  body = await hook.beforeUpdate(body, user, client);
1664
2117
  } catch (err) {
1665
- if (err instanceof HandlerError) {
1666
- return json({ message: err.message }, err.status);
1667
- }
1668
- return json({ message: 'Hook error' }, 500);
2118
+ return beforeHookFailure(err, options, { requestId, name: tableName, method: 'PATCH' }, deferred);
1669
2119
  }
1670
2120
  }
1671
2121
 
@@ -1681,15 +2131,8 @@ async function handlePatch(
1681
2131
  if (sdCol) conditions.push(isNull(sdCol));
1682
2132
  }
1683
2133
 
1684
- // Row ownership: auto-append WHERE <column> = user[<userField>] for IDOR protection
1685
- if (user && options?.rowOwnership?.[tableName]) {
1686
- const ownership = options.rowOwnership[tableName];
1687
- const ownerCol = getColumn(table, ownership.column);
1688
- const userValue = user[ownership.userField];
1689
- if (ownerCol && userValue != null) {
1690
- conditions.push(eq(ownerCol, userValue));
1691
- }
1692
- }
2134
+ // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
2135
+ if (scope.condition) conditions.push(scope.condition);
1693
2136
 
1694
2137
  let coerced: unknown;
1695
2138
  try {
@@ -1712,12 +2155,115 @@ async function handlePatch(
1712
2155
  const rows = await query.returning();
1713
2156
 
1714
2157
  if (hook?.afterUpdate && rows.length > 0) {
1715
- try { await hook.afterUpdate(rows, user, client); } catch {}
2158
+ const { afterUpdate } = hook;
2159
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterUpdate`, method: 'PATCH' },
2160
+ () => afterUpdate.call(hook, rows, user, client));
1716
2161
  }
1717
2162
 
1718
2163
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
1719
2164
  }
1720
2165
 
2166
+ /**
2167
+ * True when the caller's role is an exact member of the entry's `exceptRoles`, so the
2168
+ * ownership clause is skipped. Fail closed: no list, an empty list, a missing or
2169
+ * non-string role claim, or any other role keeps the clause.
2170
+ */
2171
+ function isOwnershipExempt(
2172
+ ownership: { exceptRoles?: string[] },
2173
+ user: Record<string, unknown>,
2174
+ options: HandlerOptions,
2175
+ ): boolean {
2176
+ if (!ownership.exceptRoles?.length) return false;
2177
+ const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
2178
+ return role !== '' && ownership.exceptRoles.includes(role);
2179
+ }
2180
+
2181
+ /**
2182
+ * The refusal message (sent with a 500) for a PATCH or DELETE whose ownership exemption
2183
+ * fires while the request's pgSettings map has no role, or null. executeWithSettings runs
2184
+ * SET LOCAL ROLE only for a truthy `role`; without it the connection stays on the table
2185
+ * owner, RLS does not apply, and the JWT role claim alone would lift the owner clause. A
2186
+ * caller who is not excepted keeps the clause, so it is never refused here.
2187
+ */
2188
+ function unbackedExemptionRefusal(
2189
+ method: string,
2190
+ tableName: string,
2191
+ options: HandlerOptions | undefined,
2192
+ user: Record<string, unknown> | null,
2193
+ settings: Record<string, string> | undefined,
2194
+ ): string | null {
2195
+ if (method !== 'PATCH' && method !== 'DELETE') return null;
2196
+ const ownership = options?.rowOwnership?.[tableName];
2197
+ if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
2198
+ if (settings?.['role']) return null;
2199
+ const message = `rowOwnership.${tableName}.exceptRoles: pgSettings returned no role for this request, so SET LOCAL ROLE did not run and no RLS policy backs the exemption. Write refused.`;
2200
+ console.error(`[everystack/api] ${message}`);
2201
+ return message;
2202
+ }
2203
+
2204
+ /**
2205
+ * The key, as the client spelled it, that would set the owner column to a value other
2206
+ * than the caller's own, or null. A key matches when it resolves to the owner column
2207
+ * object, so either spelling counts. The decision reads `body` (transformed and
2208
+ * stripped: what would be written); the reported key comes from `clientBody`, so the
2209
+ * message echoes only what the client sent. Null, an object or any other value is a
2210
+ * change; the caller's own value, compared as a string, is a no-op.
2211
+ */
2212
+ function ownerReassignmentKey(
2213
+ table: DrizzleTable,
2214
+ ownership: { column: string; userField: string },
2215
+ user: Record<string, unknown>,
2216
+ clientBody: unknown,
2217
+ body: unknown,
2218
+ ): string | null {
2219
+ const ownerCol = getColumn(table, ownership.column);
2220
+ const owner = String(user[ownership.userField]);
2221
+ const changesOwner = ([key, value]: [string, unknown]): boolean =>
2222
+ getColumn(table, key) === ownerCol
2223
+ && (value === null || typeof value === 'object' || String(value) !== owner);
2224
+ const written = Object.entries(body ?? {}).find(changesOwner);
2225
+ if (!written) return null;
2226
+ return Object.entries(clientBody ?? {}).find(changesOwner)?.[0] ?? written[0];
2227
+ }
2228
+
2229
+ type OwnershipScope = { refusal: Response } | { condition: SQL | null };
2230
+
2231
+ /**
2232
+ * The owner clause for a PATCH or DELETE, or the refusal when it cannot be built. Fails
2233
+ * closed for a table with a rowOwnership entry and a caller who is not excepted: no
2234
+ * user is 401, a null, undefined or empty owner value is 403, an owner column that does not
2235
+ * resolve is 403 (startup validation makes that unreachable; logged, never echoed).
2236
+ * No entry, or an excepted role, yields no clause.
2237
+ */
2238
+ function ownershipScope(
2239
+ table: DrizzleTable,
2240
+ tableName: string,
2241
+ options: HandlerOptions | undefined,
2242
+ user: Record<string, unknown> | null,
2243
+ ): OwnershipScope {
2244
+ const ownership = options?.rowOwnership?.[tableName];
2245
+ if (!options || !ownership) return { condition: null };
2246
+ if (!user) {
2247
+ return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
2248
+ }
2249
+ if (isOwnershipExempt(ownership, user, options)) return { condition: null };
2250
+ const forbidden = (): OwnershipScope => ({
2251
+ refusal: json({ message: 'Forbidden: row ownership cannot be verified for this request' }, 403),
2252
+ });
2253
+ const userValue = user[ownership.userField];
2254
+ // An empty string is not an owner: it would scope the write to rows owned by ''.
2255
+ // A numeric 0 is a legal owner value, so only null, undefined and '' refuse.
2256
+ if (userValue == null || userValue === '') return forbidden();
2257
+ const ownerCol = getColumn(table, ownership.column);
2258
+ if (!ownerCol) {
2259
+ console.error(
2260
+ `[everystack/api] rowOwnership.${tableName}.column "${ownership.column}" does not resolve to a column of table "${tableName}"; write refused`,
2261
+ );
2262
+ return forbidden();
2263
+ }
2264
+ return { condition: eq(ownerCol, userValue) };
2265
+ }
2266
+
1721
2267
  async function handleDelete(
1722
2268
  db: DrizzleDb,
1723
2269
  table: DrizzleTable,
@@ -1726,12 +2272,15 @@ async function handleDelete(
1726
2272
  options?: HandlerOptions,
1727
2273
  user: Record<string, unknown> | null = null,
1728
2274
  client: Record<string, unknown> | null = null,
2275
+ requestId?: string,
2276
+ deferred: Deferred[] = [],
1729
2277
  ): Promise<Response> {
1730
- const filters = parseFilters(params);
1731
- const hasLogicalGroup = params.has('or') || params.has('and');
2278
+ // Ownership refusal first, so it never reveals whether the filter was valid.
2279
+ const scope = ownershipScope(table, tableName, options, user);
2280
+ if ('refusal' in scope) return scope.refusal;
1732
2281
 
1733
2282
  // Safety: require at least one filter to prevent accidental mass deletion
1734
- if (filters.length === 0 && !hasLogicalGroup) {
2283
+ if (buildsNoFilter(table, params)) {
1735
2284
  return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
1736
2285
  }
1737
2286
 
@@ -1741,10 +2290,7 @@ async function handleDelete(
1741
2290
  try {
1742
2291
  await hook.beforeDelete(user, client);
1743
2292
  } catch (err) {
1744
- if (err instanceof HandlerError) {
1745
- return json({ message: err.message }, err.status);
1746
- }
1747
- return json({ message: 'Hook error' }, 500);
2293
+ return beforeHookFailure(err, options, { requestId, name: tableName, method: 'DELETE' }, deferred);
1748
2294
  }
1749
2295
  }
1750
2296
 
@@ -1754,15 +2300,8 @@ async function handleDelete(
1754
2300
  }
1755
2301
  const conditions = filterResult.conditions;
1756
2302
 
1757
- // Row ownership: auto-append WHERE <column> = user[<userField>] for IDOR protection
1758
- if (user && options?.rowOwnership?.[tableName]) {
1759
- const ownership = options.rowOwnership[tableName];
1760
- const ownerCol = getColumn(table, ownership.column);
1761
- const userValue = user[ownership.userField];
1762
- if (ownerCol && userValue != null) {
1763
- conditions.push(eq(ownerCol, userValue));
1764
- }
1765
- }
2303
+ // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
2304
+ if (scope.condition) conditions.push(scope.condition);
1766
2305
 
1767
2306
  // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
1768
2307
  if (isSoftDeleteTable(tableName, table, options)) {
@@ -1782,7 +2321,9 @@ async function handleDelete(
1782
2321
  const rows = await query.returning();
1783
2322
 
1784
2323
  if (hook?.afterDelete && rows.length > 0) {
1785
- try { await hook.afterDelete(rows, user, client); } catch {}
2324
+ const { afterDelete } = hook;
2325
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
2326
+ () => afterDelete.call(hook, rows, user, client));
1786
2327
  }
1787
2328
 
1788
2329
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
@@ -1801,7 +2342,9 @@ async function handleDelete(
1801
2342
  const rows = await query.returning();
1802
2343
 
1803
2344
  if (hook?.afterDelete && rows.length > 0) {
1804
- try { await hook.afterDelete(rows, user, client); } catch {}
2345
+ const { afterDelete } = hook;
2346
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
2347
+ () => afterDelete.call(hook, rows, user, client));
1805
2348
  }
1806
2349
 
1807
2350
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
@@ -2049,12 +2592,13 @@ function buildOrderBy(table: DrizzleTable, orderParam: string): any {
2049
2592
  return direction === 'desc' ? desc(column) : asc(column);
2050
2593
  }
2051
2594
 
2595
+ const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
2596
+
2052
2597
  function parseFilters(params: URLSearchParams): Filter[] {
2053
2598
  const filters: Filter[] = [];
2054
- const reserved = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
2055
2599
 
2056
2600
  for (const [key, value] of params.entries()) {
2057
- if (reserved.has(key)) continue;
2601
+ if (RESERVED_QUERY_PARAMS.has(key)) continue;
2058
2602
 
2059
2603
  // Check for NOT modifier: not.eq.value → negated=true, operator=eq
2060
2604
  let negated = false;
@@ -2172,6 +2716,27 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
2172
2716
  return { conditions };
2173
2717
  }
2174
2718
 
2719
+ /**
2720
+ * True when the request's own filters build no SQL condition, so the write would run
2721
+ * with no WHERE. A param that parses but builds nothing (`or=`, `or=()`, `and=junk`,
2722
+ * `id=is.x`) is not a filter. Only user filters count: the soft-delete and ownership
2723
+ * clauses the handler adds are not part of this test. A filter error is left to the
2724
+ * caller's own buildAllConditions, which reports it.
2725
+ */
2726
+ function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
2727
+ const result = buildAllConditions(table, params);
2728
+ return !('error' in result) && result.conditions.length === 0;
2729
+ }
2730
+
2731
+ /**
2732
+ * The outage answer. no-store keeps a CDN from caching it: CloudFront holds an
2733
+ * origin 5xx for about 10 seconds, so a protected GET could get a stale 503
2734
+ * after the database recovers.
2735
+ */
2736
+ function serviceUnavailableResponse(): Response {
2737
+ return json({ message: SERVICE_UNAVAILABLE_MESSAGE }, 503, { 'Cache-Control': 'no-store' });
2738
+ }
2739
+
2175
2740
  function json(data: unknown, status: number, headers?: Record<string, string>): Response {
2176
2741
  return new Response(JSON.stringify(data), {
2177
2742
  status,