@everystack/api 0.4.2 → 0.4.4

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) {
@@ -129,6 +233,13 @@ export interface SoftDeleteConfig {
129
233
  column?: string;
130
234
  /** Tables with soft-delete enabled. If omitted, all tables with the column are treated as soft-delete. */
131
235
  tables?: string[];
236
+ /** Who may restore through PATCH, per table: table name -> role names. Keyed like `tables`.
237
+ * PATCH honours `?deleted=include|only` only when the caller's role claim (`auth.roleField`,
238
+ * default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
239
+ * Anyone else gets live rows only, as if the param were absent. A restore body must be
240
+ * exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
241
+ * This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md). */
242
+ restore?: Record<string, string[]>;
132
243
  }
133
244
 
134
245
  export interface HookConfig {
@@ -244,8 +355,8 @@ export interface HandlerOptions {
244
355
  * If omitted, all tables in the schema are exposed (current behavior). */
245
356
  exposedTables?: string[];
246
357
  /** 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. */
358
+ * Explicit references in query parameters are refused before database access.
359
+ * Does not prevent writes to these columns; use protectedFields for that. */
249
360
  hiddenColumns?: Record<string, string[]>;
250
361
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
251
362
  * Prevents mass assignment / privilege escalation on sensitive columns. */
@@ -259,8 +370,14 @@ export interface HandlerOptions {
259
370
  /** Per-table row ownership for IDOR protection on PATCH/DELETE.
260
371
  * Auto-appends WHERE <column> = user[<userField>] to mutations.
261
372
  * 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 }>;
373
+ * Example: { posts: { column: 'authorId', userField: 'sub' } }
374
+ *
375
+ * `exceptRoles` lists roles that skip the clause, e.g. an admin RLS lets write any row.
376
+ * It mirrors an RLS policy and never stands alone: RLS still decides the write.
377
+ * The caller's role is read from `auth.roleField` (default `role`) and must match a
378
+ * listed role exactly. No hierarchy, no case folding. A missing role keeps the clause.
379
+ * The role claim must be trusted: keep `role` out of anything a user can write. */
380
+ rowOwnership?: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
264
381
  /** When true, error messages hide internal details (table names, column names, operators).
265
382
  * Enable in production to prevent schema enumeration. Default: false. */
266
383
  hideSchemaErrors?: boolean;
@@ -285,6 +402,17 @@ function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): Dri
285
402
  return getColumn(table, colName);
286
403
  }
287
404
 
405
+ /**
406
+ * The soft-delete condition for a GET or PATCH: ?deleted=include adds none, ?deleted=only
407
+ * matches deleted rows, anything else (absent or unknown) matches live rows. DELETE does
408
+ * not use it: it always matches live rows only.
409
+ */
410
+ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL | null {
411
+ const mode = params.get('deleted');
412
+ if (mode === 'include') return null;
413
+ return mode === 'only' ? isNotNull(sdCol) : isNull(sdCol);
414
+ }
415
+
288
416
  function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
289
417
  if (!options?.softDelete) return false;
290
418
  if (options.softDelete.tables) {
@@ -294,19 +422,6 @@ function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: Han
294
422
  return getSoftDeleteColumn(table, options) != null;
295
423
  }
296
424
 
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
425
  /**
311
426
  * Execute a function with pgSettings applied in the same transaction.
312
427
  *
@@ -345,6 +460,16 @@ async function executeWithSettings<T>(
345
460
  }
346
461
 
347
462
 
463
+ /**
464
+ * The caller's role claim, or '' when it is missing or not a string. pgSettings compares
465
+ * the claim strictly, so a claim such as ['admin'] must never read as 'admin' here.
466
+ * Every role gate reads the claim through this one function.
467
+ */
468
+ function readRoleClaim(user: Record<string, unknown>, roleField: string): string {
469
+ const claim = user[roleField];
470
+ return typeof claim === 'string' ? claim : '';
471
+ }
472
+
348
473
  /**
349
474
  * Check whether userRole meets or exceeds requiredRole in the hierarchy.
350
475
  * Both in hierarchy → index comparison. Otherwise → exact string match.
@@ -354,6 +479,7 @@ function hasRole(
354
479
  requiredRole: string,
355
480
  hierarchy: string[],
356
481
  ): boolean {
482
+ if (!userRole || !requiredRole) return false;
357
483
  const requiredIndex = hierarchy.indexOf(requiredRole);
358
484
  const userIndex = hierarchy.indexOf(userRole);
359
485
  if (requiredIndex !== -1 && userIndex !== -1) {
@@ -362,6 +488,17 @@ function hasRole(
362
488
  return userRole === requiredRole;
363
489
  }
364
490
 
491
+ /**
492
+ * The result of an auth check. `outage` is the caught error when the verifier
493
+ * was unreachable (error is then the 503); a caller that returns `error`
494
+ * passes it to emitAuthOutage.
495
+ */
496
+ interface AuthResult {
497
+ user: Record<string, unknown> | null;
498
+ error: Response | null;
499
+ outage?: unknown;
500
+ }
501
+
365
502
  /**
366
503
  * Extract Bearer token from request and verify it.
367
504
  * Returns { user, error } — error is a Response (401) if auth fails.
@@ -369,7 +506,7 @@ function hasRole(
369
506
  async function verifyUserToken(
370
507
  request: Request,
371
508
  auth: AuthConfig,
372
- ): Promise<{ user: Record<string, unknown> | null; error: Response | null }> {
509
+ ): Promise<AuthResult> {
373
510
  const authHeader = request.headers.get('Authorization');
374
511
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
375
512
 
@@ -380,7 +517,11 @@ async function verifyUserToken(
380
517
  let user: Record<string, unknown> | null;
381
518
  try {
382
519
  user = await auth.verifyToken(token);
383
- } catch {
520
+ } catch (err) {
521
+ // An unreachable verifier is not a bad token: 401 would log the user out.
522
+ if (isServiceUnavailable(err)) {
523
+ return { user: null, error: serviceUnavailableResponse(), outage: err };
524
+ }
384
525
  return { user: null, error: json({ message: 'Authentication failed: invalid or expired token' }, 401) };
385
526
  }
386
527
 
@@ -402,7 +543,7 @@ async function checkRoleAuth(
402
543
  requiredRole: string,
403
544
  request: Request,
404
545
  auth: AuthConfig,
405
- ): Promise<{ user: Record<string, unknown> | null; error: Response | null }> {
546
+ ): Promise<AuthResult> {
406
547
  if (requiredRole === 'public') {
407
548
  return { user: null, error: null };
408
549
  }
@@ -417,8 +558,7 @@ async function checkRoleAuth(
417
558
 
418
559
  // Named role: check hierarchy
419
560
  const hierarchy = auth.roleHierarchy ?? ['public', 'authenticated', 'admin'];
420
- const roleField = auth.roleField ?? 'role';
421
- const userRole = String(result.user![roleField] ?? '');
561
+ const userRole = readRoleClaim(result.user!, auth.roleField ?? 'role');
422
562
 
423
563
  if (!hasRole(userRole, requiredRole, hierarchy)) {
424
564
  return {
@@ -608,7 +748,7 @@ function expandSchema(schema: Schema, pairs: Array<{ exportKey: string; sqlName:
608
748
  if (pairs.length === 0) return schema;
609
749
  const expanded: Schema = { ...schema };
610
750
  for (const { exportKey, sqlName } of pairs) {
611
- if (!(sqlName in expanded)) expanded[sqlName] = schema[exportKey];
751
+ if (!Object.hasOwn(expanded, sqlName)) expanded[sqlName] = schema[exportKey];
612
752
  }
613
753
  return expanded;
614
754
  }
@@ -632,7 +772,7 @@ function expandTableKeyedOptions(
632
772
  const out: Record<string, T> = { ...map };
633
773
  for (const key of Object.keys(map)) {
634
774
  const t = twin(key);
635
- if (t && !(t in out)) out[t] = map[key];
775
+ if (t && !Object.hasOwn(out, t)) out[t] = map[key];
636
776
  }
637
777
  return out;
638
778
  };
@@ -644,12 +784,114 @@ function expandTableKeyedOptions(
644
784
  ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
645
785
  ...(options.relations ? { relations: expandMap(options.relations) } : {}),
646
786
  ...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
647
- ...(options.softDelete?.tables
648
- ? { softDelete: { ...options.softDelete, tables: expandList(options.softDelete.tables) } }
787
+ ...(options.softDelete
788
+ ? {
789
+ softDelete: {
790
+ ...options.softDelete,
791
+ ...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
792
+ ...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
793
+ },
794
+ }
649
795
  : {}),
650
796
  };
651
797
  }
652
798
 
799
+ /**
800
+ * Every table the options name outside rowOwnership, in snake_case so that `userPosts`
801
+ * and `user_posts` in two maps of the same options match.
802
+ */
803
+ function declaredTables(options: HandlerOptions): Set<string> {
804
+ const names = [
805
+ ...(options.exposedTables ?? []),
806
+ ...Object.keys(options.hiddenColumns ?? {}),
807
+ ...Object.keys(options.protectedFields ?? {}),
808
+ ...Object.keys(options.hooks ?? {}),
809
+ ...Object.keys(options.relations ?? {}),
810
+ ...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
811
+ ...(options.softDelete?.tables ?? []),
812
+ ];
813
+ return new Set(names.map(toSnakeCase));
814
+ }
815
+
816
+ /**
817
+ * Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
818
+ * needs a user, so rowOwnership without auth is an error. An entry whose column does not
819
+ * resolve (same resolver as request time) is an error. An entry for a table this handler
820
+ * does not serve is accepted only when the options declare that table elsewhere: derived
821
+ * configs name every table while a handler serves a few. Otherwise it is a typo that would
822
+ * leave the real table unscoped, and an error. An entry with exceptRoles needs pgSettings:
823
+ * without SET LOCAL ROLE no RLS policy backs the exemption, only the JWT role claim.
824
+ */
825
+ function validateRowOwnership(schema: Schema, options: HandlerOptions | undefined): void {
826
+ const entries = Object.entries(options?.rowOwnership ?? {});
827
+ if (entries.length === 0) return;
828
+ if (!options?.auth) {
829
+ throw new Error('[everystack/api] rowOwnership requires auth: the owner clause needs an authenticated user');
830
+ }
831
+ const declared = declaredTables(options);
832
+ for (const [tableName, entry] of entries) {
833
+ const table = schema[tableName];
834
+ if (!table) {
835
+ if (declared.has(toSnakeCase(tableName))) continue;
836
+ throw new Error(
837
+ `[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.`,
838
+ );
839
+ }
840
+ if (getColumn(table, entry.column) === null) {
841
+ throw new Error(
842
+ `[everystack/api] rowOwnership.${tableName}.column "${entry.column}" does not resolve to a column of table "${tableName}"`,
843
+ );
844
+ }
845
+ if ((entry.exceptRoles?.length ?? 0) > 0 && !options.pgSettings) {
846
+ throw new Error(
847
+ `[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.`,
848
+ );
849
+ }
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Refuse to start on a softDelete.restore config the handler cannot honour. Every entry
855
+ * must name a soft-delete table. A restorable table must not list the soft-delete column
856
+ * in protectedFields: the strip would leave every restore body empty. Takes the expanded
857
+ * options, so both spellings of a table are checked.
858
+ */
859
+ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | undefined): void {
860
+ const softDelete = options?.softDelete;
861
+ const colName = softDelete?.column || 'deletedAt';
862
+ for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
863
+ const table = schema[tableName];
864
+ const isSoftDelete = table
865
+ ? isSoftDeleteTable(tableName, table, options)
866
+ : (softDelete?.tables?.includes(tableName) ?? false);
867
+ if (!isSoftDelete) {
868
+ throw new Error(
869
+ `[everystack/api] softDelete.restore.${tableName}: table "${tableName}" is not a soft-delete table. Add it to softDelete.tables or remove the entry.`,
870
+ );
871
+ }
872
+ const sameColumn = (field: string): boolean => (table
873
+ ? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
874
+ : toSnakeCase(field) === toSnakeCase(colName));
875
+ if (roles.length > 0 && (options?.protectedFields?.[tableName] ?? []).some(sameColumn)) {
876
+ throw new Error(
877
+ `[everystack/api] softDelete.restore.${tableName}: the soft-delete column "${colName}" is in protectedFields.${tableName}, so every restore body is stripped to {}. Remove it from protectedFields.${tableName} or remove the restore entry.`,
878
+ );
879
+ }
880
+ }
881
+ }
882
+
883
+ /**
884
+ * True when the caller may restore on this table: its role claim is an exact member of
885
+ * softDelete.restore[tableName]. Fail closed: no option, no list, no user, or a missing or
886
+ * non-string role claim is not a member.
887
+ */
888
+ function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
889
+ const roles = options?.softDelete?.restore?.[tableName];
890
+ if (!roles?.length || !user) return false;
891
+ const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
892
+ return role !== '' && roles.includes(role);
893
+ }
894
+
653
895
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
654
896
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
655
897
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
@@ -659,6 +901,19 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
659
901
  const namePairs = tableNamePairs(rawSchema);
660
902
  const schema = expandSchema(rawSchema, namePairs);
661
903
  const options = expandTableKeyedOptions(rawOptions, namePairs);
904
+ // Validate configured entries, not their generated table-name twins.
905
+ for (const [tableName, columns] of Object.entries(rawOptions?.hiddenColumns ?? {})) {
906
+ for (const column of columns) {
907
+ if (columnPropertyName(schema[tableName], column) === undefined) {
908
+ console.warn(
909
+ `[everystack/api] invalid hiddenColumns entry: table=${tableName} column=${column}; `
910
+ + 'JOIN-path embeds of this table return 400 until the entry is fixed.',
911
+ );
912
+ }
913
+ }
914
+ }
915
+ validateRowOwnership(schema, rawOptions);
916
+ validateSoftDeleteRestore(schema, options);
662
917
  const tableNames = new Set(Object.keys(schema));
663
918
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
664
919
 
@@ -707,7 +962,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
707
962
 
708
963
  try {
709
964
  client = await options.auth.verifyClient(clientId ?? '', clientSecret ?? '', referer ?? undefined);
710
- } catch {
965
+ } catch (err) {
966
+ if (isServiceUnavailable(err)) {
967
+ // The route is not resolved yet: name what the path asks for, but
968
+ // only a declared table or RPC. Any other segment is caller text.
969
+ const [first, second] = pathSegments;
970
+ const where = first === 'rpc'
971
+ ? { kind: 'rpc' as const, name: declaredRpcName(options.rpc, second) }
972
+ : { kind: 'rest' as const, name: first && tableNames.has(first) ? first : '' };
973
+ await emitAuthOutage(options.onError, { requestId, method, ...where }, err);
974
+ return serviceUnavailableResponse();
975
+ }
711
976
  return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
712
977
  }
713
978
 
@@ -737,14 +1002,23 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
737
1002
  && options.auth.publicRpc?.includes(pathSegments[1]);
738
1003
  if (!isPublicMethod && !isPublicRpc) {
739
1004
  const result = await verifyUserToken(request, options.auth);
740
- if (result.error) return result.error;
1005
+ if (result.error) {
1006
+ await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
1007
+ return result.error;
1008
+ }
741
1009
  user = result.user;
742
1010
  } else if (request.headers.get('authorization')) {
743
1011
  // Public RPC with token present — verify opportunistically, same
744
1012
  // policy as public table routes below. If valid, the request runs
745
1013
  // as authenticated (RLS self-visibility, PII self-tiers). If
746
- // invalid, ignore and fall through as anonymous.
1014
+ // invalid, ignore and fall through as anonymous. If the verifier
1015
+ // is down, answer 503: running a signed-in caller as anonymous
1016
+ // would hand them anonymous data as if it were theirs.
747
1017
  const result = await verifyUserToken(request, options.auth);
1018
+ if (result.error && result.outage !== undefined) {
1019
+ await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
1020
+ return result.error;
1021
+ }
748
1022
  if (!result.error) user = result.user;
749
1023
  }
750
1024
  if (user && options.auth.onAuthenticated) {
@@ -753,14 +1027,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
753
1027
  }
754
1028
 
755
1029
  const fnName = pathSegments[1];
756
- if (options?.rpc && fnName && options.rpc[fnName]) {
757
- const entry = options.rpc[fnName];
1030
+ const entry = fnName ? ownEntry(options?.rpc, fnName) : undefined;
1031
+ if (fnName && entry) {
758
1032
  const isObjectForm = typeof entry !== 'function';
759
1033
 
760
1034
  // Per-RPC role check (object syntax: { fn, role })
761
1035
  if (isObjectForm && entry.role && options?.auth) {
762
1036
  const roleResult = await checkRoleAuth(entry.role, request, options.auth);
763
- if (roleResult.error) return roleResult.error;
1037
+ if (roleResult.error) {
1038
+ await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
1039
+ return roleResult.error;
1040
+ }
764
1041
  user = roleResult.user;
765
1042
  } else if (isObjectForm && entry.role && !options?.auth) {
766
1043
  // Role required but no auth configured — deny
@@ -802,7 +1079,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
802
1079
  // webhooks). Swallow errors — a side-effect failure must not fail the
803
1080
  // write, exactly like the after* REST hooks. Success path only.
804
1081
  if (options?.afterRpc) {
805
- try { await options.afterRpc(result, fnName, body, user, client, ctx); } catch {}
1082
+ try {
1083
+ await options.afterRpc(result, fnName, body, user, client, ctx);
1084
+ } catch (err) {
1085
+ await reportHookFault(options.onError, { requestId, name: `rpc.${fnName}.afterRpc`, method }, err);
1086
+ }
806
1087
  }
807
1088
  // Same cache discipline as table GETs: with pgSettings configured the
808
1089
  // response can vary by identity (opportunistic auth on public RPCs),
@@ -813,9 +1094,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
813
1094
  const rpcCacheControl = getCacheControl(options);
814
1095
  return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
815
1096
  } catch (error) {
816
- const pgCode = (error as any)?.code;
1097
+ const code = safeRead(error, 'code');
1098
+ const text = safeRead(error, 'message');
1099
+ // A value whose getter throws is hostile: classify nothing from it
1100
+ // and echo nothing of it. It answers the generic 500 (or an outage's 503).
1101
+ const hostile = code === UNREADABLE || text === UNREADABLE || safeRead(error, 'cause') === UNREADABLE;
1102
+ const pgCode = hostile ? undefined : code;
1103
+ // An outage: answer 503 with a fixed body. The driver's message
1104
+ // carries host and port; it goes to onError only. A HandlerError's
1105
+ // own answer still wins: its return below runs first.
1106
+ const unavailable = isServiceUnavailable(error);
817
1107
  let status = 500;
818
- if (pgCode === '28P01') status = 401;
1108
+ if (unavailable) status = 503;
819
1109
  else if (pgCode === '42501') status = 403;
820
1110
  else if (pgCode === '23505') status = 409;
821
1111
  else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
@@ -833,15 +1123,14 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
833
1123
  await emitFault(options?.onError, {
834
1124
  requestId, kind: 'rpc', name: fnName, method,
835
1125
  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,
1126
+ ...describeError(error),
839
1127
  });
840
1128
  }
841
1129
  if (error instanceof HandlerError) {
842
1130
  return json({ message: error.message }, error.status);
843
1131
  }
844
- const message = error instanceof Error ? error.message : 'RPC execution failed';
1132
+ if (unavailable) return serviceUnavailableResponse();
1133
+ const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
845
1134
  if (options?.hideSchemaErrors && status === 500) {
846
1135
  return json({ message: 'Internal Server Error' }, 500);
847
1136
  }
@@ -867,13 +1156,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
867
1156
  const isPublicMethod = options.auth.publicRoutes.includes(method);
868
1157
  if (!isPublicMethod) {
869
1158
  const result = await verifyUserToken(request, options.auth);
870
- if (result.error) return result.error;
1159
+ if (result.error) {
1160
+ await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
1161
+ return result.error;
1162
+ }
871
1163
  user = result.user;
872
1164
  } else if (request.headers.get('authorization')) {
873
1165
  // Public route with token present — verify opportunistically.
874
1166
  // If valid, the request runs as authenticated (better RLS access).
875
- // If invalid, ignore and fall through as anonymous.
1167
+ // If invalid, ignore and fall through as anonymous. If the verifier
1168
+ // is down, answer 503, as the public RPC branch above does.
876
1169
  const result = await verifyUserToken(request, options.auth);
1170
+ if (result.error && result.outage !== undefined) {
1171
+ await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
1172
+ return result.error;
1173
+ }
877
1174
  if (!result.error) user = result.user;
878
1175
  }
879
1176
 
@@ -882,16 +1179,15 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
882
1179
  }
883
1180
  }
884
1181
 
885
- // Compute pgSettings after auth (user now resolved)
886
- const settings = options?.pgSettings?.(user, client);
887
1182
  const table = schema[tableName];
888
1183
 
889
1184
  // Forensic audit of the non-GET table surface (writes). Capture the query, not
890
1185
  // 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.
1186
+ // (never values — values can be PII). Unresolved embed policies also record
1187
+ // GET denials; other reads are delegated to RLS.
892
1188
  const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE';
893
- const auditOp: 'insert' | 'update' | 'delete' =
894
- method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
1189
+ const auditOp: 'insert' | 'update' | 'delete' | 'select' =
1190
+ method === 'GET' ? 'select' : method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
895
1191
  const auditFilter = url.searchParams.toString() || undefined;
896
1192
  let auditColumns: string[] | undefined;
897
1193
  if (isWrite && options?.audit?.sink && method !== 'DELETE') {
@@ -909,27 +1205,89 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
909
1205
  const routedDb = method === 'GET' && !user && options?.readDb ? options.readDb : db;
910
1206
 
911
1207
  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);
1208
+ // Bound every select before the hidden-column guard walks it. The guard
1209
+ // reads duplicates and runs for writes too, so a GET-only check is too late.
1210
+ let queryError: string | undefined;
1211
+ let auditReadDenial = false;
1212
+ for (const selectParam of url.searchParams.getAll('select')) {
1213
+ const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
1214
+ const depth = getEmbedDepth(selectParam);
1215
+ if (depth > maxEmbedDepth) {
1216
+ queryError = `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}`;
1217
+ break;
916
1218
  }
1219
+ }
917
1220
 
918
- if (method === 'POST') {
919
- return handlePost(dbOrTx, table, tableName, request, options, user, client);
1221
+ // Refusals share the response audit below without opening a transaction.
1222
+ // Response stripping cannot prevent value oracles or aggregate alias reads.
1223
+ if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
1224
+ queryError = 'Invalid query';
1225
+ }
1226
+ // Refuse projections we cannot enforce on the common pre-DB audit path.
1227
+ const tableRelations = ownEntry(options?.relations, tableName);
1228
+ if (!queryError && options?.drizzleRelations && tableRelations) {
1229
+ for (const selectParam of url.searchParams.getAll('select')) {
1230
+ const { embeds } = parseSelectParam(selectParam, tableRelations, options.relations);
1231
+ const withClause = buildWithClause(embeds, tableRelations, schema, options);
1232
+ if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1233
+ queryError = 'Invalid query';
1234
+ auditReadDenial = method === 'GET' && withClause === 'unresolvedHiddenColumns';
1235
+ break;
1236
+ }
920
1237
  }
1238
+ }
921
1239
 
922
- if (method === 'PATCH') {
923
- return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
924
- }
1240
+ // pgSettings runs once per request. The settings map is known only here, so the
1241
+ // check that it backs an ownership exemption runs here, before any transaction.
1242
+ const settings = queryError ? undefined : options?.pgSettings?.(user, client);
1243
+ const unbacked = queryError ? null : unbackedExemptionRefusal(method, tableName, options, user, settings);
1244
+ // A returned 500 skips the catch below, so report it to onError here.
1245
+ if (unbacked) {
1246
+ await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method, status: 500, message: unbacked });
1247
+ }
1248
+ // Work queued by the write. It runs only once executeWithSettings has
1249
+ // returned or thrown (with pgSettings, after the transaction has ended).
1250
+ // A before-hook fault runs in the finally, for any outcome: the hook failed
1251
+ // even when the commit then fails too. A table after-hook runs only for a
1252
+ // 2xx; a throw (a failed commit) skips it, since the write did not happen.
1253
+ // A retry wrapper around db.transaction must reset `deferred` per attempt,
1254
+ // or the hooks of an aborted attempt would run too.
1255
+ const deferred: Deferred[] = [];
1256
+ let response: Response;
1257
+ try {
1258
+ response = queryError ? json({ message: queryError }, 400)
1259
+ : unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
1260
+ if (method === 'GET') {
1261
+ const cacheControl = getCacheControl(options);
1262
+ return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
1263
+ }
925
1264
 
926
- if (method === 'DELETE') {
927
- return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
928
- }
1265
+ if (method === 'POST') {
1266
+ return handlePost(dbOrTx, table, tableName, request, options, user, client, requestId, deferred);
1267
+ }
929
1268
 
930
- return json({ message: `Method ${method} not allowed` }, 405);
931
- });
932
- if (isWrite) {
1269
+ if (method === 'PATCH') {
1270
+ return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client, requestId, deferred);
1271
+ }
1272
+
1273
+ if (method === 'DELETE') {
1274
+ return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client, requestId, deferred);
1275
+ }
1276
+
1277
+ return json({ message: `Method ${method} not allowed` }, 405);
1278
+ });
1279
+ } finally {
1280
+ // emitFault never throws, so this cannot replace the error in flight.
1281
+ for (const item of deferred) if (item.always) await item.run();
1282
+ }
1283
+ if (response.status >= 200 && response.status < 300) {
1284
+ // The contract: a side effect fires only for a successful write. The guard is
1285
+ // unreachable today (every refusal returns before the write queues a hook) and
1286
+ // stays for the next write path. Each queued hook reports its own throw, so
1287
+ // none can fail the response.
1288
+ for (const item of deferred) if (!item.always) await item.run();
1289
+ }
1290
+ if (isWrite || auditReadDenial) {
933
1291
  await emitAudit(options?.audit, {
934
1292
  kind: 'rest', name: tableName, user, client,
935
1293
  result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
@@ -948,56 +1306,106 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
948
1306
  if (isPermissionDenied(error)) {
949
1307
  return sanitizeError('Permission denied', 403, options);
950
1308
  }
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.
1309
+ // An outage answers 503 with a fixed body; the driver's message (host and
1310
+ // port) goes to onError only. A HandlerError is the app's own answer and
1311
+ // never an outage.
1312
+ const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
1313
+ // Other table faults re-throw to the Lambda boundary (which logs to
1314
+ // CloudWatch), but that path never reaches the S3 archive. Surface the
1315
+ // cause here too so a write 5xx is in logs:errors. A HandlerError with a
1316
+ // 4xx status is a deliberate refusal from a hook, not a fault — leave it alone.
955
1317
  if (!(error instanceof HandlerError) || error.status >= 500) {
956
1318
  await emitFault(options?.onError, {
957
1319
  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,
1320
+ status: error instanceof HandlerError ? error.status : unavailable ? 503 : 500,
1321
+ ...describeError(error),
962
1322
  });
963
1323
  }
1324
+ if (unavailable) return serviceUnavailableResponse();
964
1325
  throw error;
965
1326
  }
966
1327
  };
967
1328
  }
968
1329
 
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
- */
1330
+ /** Drizzle selections use schema property names, even when the API uses SQL names. */
1331
+ function columnPropertyName(table: DrizzleTable | undefined, name: string): string | undefined {
1332
+ if (!table) return undefined;
1333
+ const column = getColumn(table, name);
1334
+ const columns = Object.entries(getTableColumns(table));
1335
+ return columns.find(([, value]) => value === column)?.[0]
1336
+ ?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
1337
+ }
1338
+
1339
+ /** Build each relation's projection before Drizzle reads its values. */
974
1340
  function buildWithClause(
975
1341
  embeds: EmbedRequest[],
976
1342
  tableRelations: Record<string, RelationConfig>,
977
- ): Record<string, any> | undefined {
1343
+ schema: Schema,
1344
+ options?: HandlerOptions,
1345
+ ): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
978
1346
  if (embeds.length === 0) return undefined;
979
1347
 
980
1348
  const withClause: Record<string, any> = {};
981
1349
  let hasEmbedded = false;
982
1350
 
983
1351
  for (const embed of embeds) {
984
- if (!tableRelations[embed.name]) continue;
1352
+ const relation = tableRelations[embed.name];
1353
+ if (!relation) continue;
985
1354
  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 };
1355
+ const table = schema[relation.table];
1356
+ const hidden = new Set<string>();
1357
+ for (const name of options?.hiddenColumns?.[relation.table] ?? []) {
1358
+ const property = columnPropertyName(table, name);
1359
+ if (property === undefined) return 'unresolvedHiddenColumns';
1360
+ hidden.add(property);
1361
+ }
1362
+ const config: Record<string, any> = {};
1363
+ if (embed.columns !== null) {
1364
+ // Drop names that are not columns of a known table: Drizzle ignores them,
1365
+ // and throws when nothing else is left. An emptied list is the 400 below.
1366
+ config.columns = Object.fromEntries(embed.columns
1367
+ .map(name => (table ? columnPropertyName(table, name) : name))
1368
+ .filter((name): name is string => name !== undefined && !hidden.has(name))
1369
+ .map(name => [name, true]));
1370
+ if (Object.keys(config.columns).length === 0) return null;
1371
+ } else if (hidden.size > 0) {
1372
+ config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
995
1373
  }
1374
+ withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
996
1375
  }
997
1376
 
998
1377
  return hasEmbedded ? withClause : undefined;
999
1378
  }
1000
1379
 
1380
+ /** Follow only parsed relations: a similarly named JSON key is not a table column. */
1381
+ function stripEmbeddedHiddenColumns(
1382
+ data: unknown,
1383
+ embeds: EmbedRequest[],
1384
+ tableName: string,
1385
+ schema: Schema,
1386
+ options?: HandlerOptions,
1387
+ ): unknown {
1388
+ if (embeds.length === 0 || data === null || typeof data !== 'object') return data;
1389
+ if (Array.isArray(data)) {
1390
+ return data.map(row => stripEmbeddedHiddenColumns(row, embeds, tableName, schema, options));
1391
+ }
1392
+ const result = { ...(data as Record<string, unknown>) };
1393
+ for (const embed of embeds) {
1394
+ const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
1395
+ const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
1396
+ if (!relation || !(key in result)) continue;
1397
+ const hidden = (options?.hiddenColumns?.[relation.table] ?? []).flatMap(name => {
1398
+ const property = columnPropertyName(schema[relation.table], name);
1399
+ return [name, toSnakeCase(name), ...(property ? [property, toSnakeCase(property)] : [])];
1400
+ });
1401
+ const related = stripHiddenColumns(result[key], relation.table, {
1402
+ hiddenColumns: { [relation.table]: hidden },
1403
+ });
1404
+ result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
1405
+ }
1406
+ return result;
1407
+ }
1408
+
1001
1409
  /**
1002
1410
  * Convert column names array to Drizzle's `columns` selection format.
1003
1411
  * ['id', 'title'] → { id: true, title: true }
@@ -1100,17 +1508,8 @@ async function handleGet(
1100
1508
  const prefer = request.headers.get('Prefer') || '';
1101
1509
  const wantExactCount = prefer.includes('count=exact');
1102
1510
 
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
1511
  // Parse select parameter for relation embedding and aggregates
1113
- let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName], options?.relations);
1512
+ let { columns, embeds, aggregates } = parseSelectParam(selectParam, ownEntry(options?.relations, tableName), options?.relations);
1114
1513
 
1115
1514
  // Resolve snake_case select column names to Drizzle camelCase properties
1116
1515
  if (options?.naming === 'snake_case' && columns) {
@@ -1132,13 +1531,8 @@ async function handleGet(
1132
1531
  if (isSoftDeleteTable(tableName, table, options)) {
1133
1532
  const sdCol = getSoftDeleteColumn(table, options);
1134
1533
  if (sdCol) {
1135
- const deletedParam = params.get('deleted');
1136
- if (deletedParam === 'only') {
1137
- conditions.push(isNotNull(sdCol));
1138
- } else if (deletedParam !== 'include') {
1139
- // Default: exclude deleted records
1140
- conditions.push(isNull(sdCol));
1141
- }
1534
+ const condition = softDeleteCondition(sdCol, params);
1535
+ if (condition) conditions.push(condition);
1142
1536
  }
1143
1537
  }
1144
1538
 
@@ -1197,7 +1591,7 @@ async function handleGet(
1197
1591
  }
1198
1592
 
1199
1593
  // Detect if Drizzle relational query (JOINs) should be used
1200
- const tableRelations = options?.relations?.[tableName];
1594
+ const tableRelations = ownEntry(options?.relations, tableName);
1201
1595
  const useRelationalQuery = embeds.length > 0
1202
1596
  && tableRelations
1203
1597
  && options?.drizzleRelations
@@ -1215,7 +1609,10 @@ async function handleGet(
1215
1609
  const colSelection = buildColumnsSelection(columns);
1216
1610
  if (colSelection) queryOptions.columns = colSelection;
1217
1611
 
1218
- const withClause = buildWithClause(embeds, tableRelations!);
1612
+ const withClause = buildWithClause(embeds, tableRelations!, schema, options);
1613
+ if (withClause === null || withClause === 'unresolvedHiddenColumns') {
1614
+ return json({ message: 'Invalid query' }, 400);
1615
+ }
1219
1616
  if (withClause) queryOptions.with = withClause;
1220
1617
 
1221
1618
  if (conditions.length === 1) {
@@ -1304,7 +1701,8 @@ async function handleGet(
1304
1701
  };
1305
1702
  if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
1306
1703
 
1307
- return new Response(JSON.stringify(stripHiddenColumns(transformResponse(responseData, options), tableName, options)), {
1704
+ const transformed = stripHiddenColumns(transformResponse(responseData, options), tableName, options);
1705
+ return new Response(JSON.stringify(stripEmbeddedHiddenColumns(transformed, embeds, tableName, schema, options)), {
1308
1706
  status: 200,
1309
1707
  headers: getHeaders,
1310
1708
  });
@@ -1374,12 +1772,12 @@ function parseSelectParam(
1374
1772
  }
1375
1773
 
1376
1774
  const embedMatch = part.match(/^(\w+)\((.+)\)$/);
1377
- if (embedMatch && tableRelations && embedMatch[1] in tableRelations) {
1775
+ if (embedMatch && tableRelations && Object.hasOwn(tableRelations, embedMatch[1])) {
1378
1776
  const name = embedMatch[1];
1379
1777
  const innerSelect = embedMatch[2].trim();
1380
1778
  // Recursively parse sub-embeds using the target table's relations
1381
1779
  const targetTableName = tableRelations[name].table;
1382
- const targetRelations = allRelations?.[targetTableName];
1780
+ const targetRelations = ownEntry(allRelations, targetTableName);
1383
1781
  const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
1384
1782
  embeds.push({
1385
1783
  name,
@@ -1431,6 +1829,71 @@ function splitSelectParam(param: string): string[] {
1431
1829
  return parts;
1432
1830
  }
1433
1831
 
1832
+ /** Check explicit query references, including each related table's own policy. */
1833
+ function requestUsesHiddenColumn(
1834
+ schema: Schema,
1835
+ tableName: string,
1836
+ params: URLSearchParams,
1837
+ options?: HandlerOptions,
1838
+ ): boolean {
1839
+ if (!options?.hiddenColumns) return false;
1840
+
1841
+ const isHidden = (sourceTable: string, reference: string): boolean => {
1842
+ let targetName = sourceTable;
1843
+ let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
1844
+ // Prefer a real source column. The relation fallback only rejects hidden
1845
+ // references; the filter and order builders do not support relation paths.
1846
+ if (!getColumn(schema[targetName], columnName)) {
1847
+ const parts = columnName.split('.');
1848
+ while (parts.length > 1) {
1849
+ const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
1850
+ if (!relation || !schema[relation.table]) return false;
1851
+ targetName = relation.table;
1852
+ }
1853
+ columnName = parts[0];
1854
+ }
1855
+ const table = schema[targetName];
1856
+ const column = getColumn(table, columnName);
1857
+ return (options.hiddenColumns?.[targetName] ?? []).some(hidden => {
1858
+ const hiddenColumn = getColumn(table, hidden);
1859
+ return column && hiddenColumn
1860
+ ? column === hiddenColumn
1861
+ : toCamelCase(columnName) === toCamelCase(hidden);
1862
+ });
1863
+ };
1864
+
1865
+ const selectUsesHidden = (sourceTable: string, select: string): boolean => {
1866
+ // Walk the raw selection: parseSelectParam collapses `*,secret` to a
1867
+ // wildcard and discards aggregates inside embeds, losing explicit names.
1868
+ return splitSelectParam(select).some(part => {
1869
+ const aggregate = part.match(AGG_REGEX);
1870
+ if (aggregate) return aggregate[2] ? isHidden(sourceTable, aggregate[2]) : false;
1871
+ const embed = part.match(/^(\w+)\((.+)\)$/);
1872
+ const relation = embed && ownEntry(ownEntry(options.relations, sourceTable), embed[1]);
1873
+ if (embed && relation && schema[relation.table]) {
1874
+ return selectUsesHidden(relation.table, embed[2]);
1875
+ }
1876
+ return part !== '*' && isHidden(sourceTable, part);
1877
+ });
1878
+ };
1879
+
1880
+ for (const [key, value] of params) {
1881
+ if (key === 'select') {
1882
+ if (selectUsesHidden(tableName, value)) return true;
1883
+ } else if (key === 'order') {
1884
+ if (value.split(',').some(order => {
1885
+ const dot = order.lastIndexOf('.');
1886
+ return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
1887
+ })) return true;
1888
+ } else if (key === 'or' || key === 'and') {
1889
+ if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
1890
+ } else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
1891
+ return true;
1892
+ }
1893
+ }
1894
+ return false;
1895
+ }
1896
+
1434
1897
  /**
1435
1898
  * Resolve relation embeds by fetching related rows and nesting them.
1436
1899
  */
@@ -1442,7 +1905,7 @@ async function resolveEmbeds(
1442
1905
  embeds: EmbedRequest[],
1443
1906
  options: HandlerOptions,
1444
1907
  ): Promise<Record<string, unknown>[]> {
1445
- const tableRelations = options.relations?.[tableName];
1908
+ const tableRelations = ownEntry(options.relations, tableName);
1446
1909
  if (!tableRelations) return rows;
1447
1910
 
1448
1911
  // Clone rows to avoid mutation
@@ -1563,6 +2026,37 @@ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<st
1563
2026
  return result;
1564
2027
  }
1565
2028
 
2029
+ /**
2030
+ * The response for a before-hook that threw. A HandlerError keeps its own status and
2031
+ * message. An outage (the hook could not reach the database) answers the fixed 503,
2032
+ * like every other outage path. Anything else is a generic 500 so the hook's text
2033
+ * never reaches the client. A 5xx is a fault and goes to onError; a 4xx HandlerError
2034
+ * is a refusal and does not. The fault is queued, not sent: with pgSettings the hook
2035
+ * ran inside the transaction, and a slow onError must not hold it open.
2036
+ */
2037
+ function beforeHookFailure(
2038
+ err: unknown,
2039
+ options: HandlerOptions | undefined,
2040
+ at: { requestId?: string; name: string; method: string },
2041
+ deferred: Deferred[],
2042
+ ): Response {
2043
+ const report = (fault: HandlerFault): void => {
2044
+ deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
2045
+ };
2046
+ if (err instanceof HandlerError) {
2047
+ if (err.status >= 500) {
2048
+ report({ ...at, kind: 'rest', status: err.status, message: err.message });
2049
+ }
2050
+ return json({ message: err.message }, err.status);
2051
+ }
2052
+ if (isServiceUnavailable(err)) {
2053
+ report({ ...at, kind: 'rest', status: 503, ...describeError(err) });
2054
+ return serviceUnavailableResponse();
2055
+ }
2056
+ report({ ...at, kind: 'rest', status: 500, ...describeError(err) });
2057
+ return json({ message: 'Hook error' }, 500);
2058
+ }
2059
+
1566
2060
  async function handlePost(
1567
2061
  db: DrizzleDb,
1568
2062
  table: DrizzleTable,
@@ -1571,6 +2065,8 @@ async function handlePost(
1571
2065
  options?: HandlerOptions,
1572
2066
  user: Record<string, unknown> | null = null,
1573
2067
  client: Record<string, unknown> | null = null,
2068
+ requestId?: string,
2069
+ deferred: Deferred[] = [],
1574
2070
  ): Promise<Response> {
1575
2071
  let body: Record<string, unknown>;
1576
2072
  try {
@@ -1599,10 +2095,7 @@ async function handlePost(
1599
2095
  try {
1600
2096
  body = await hook.beforeCreate(body, user, client);
1601
2097
  } catch (err) {
1602
- if (err instanceof HandlerError) {
1603
- return json({ message: err.message }, err.status);
1604
- }
1605
- return json({ message: 'Hook error' }, 500);
2098
+ return beforeHookFailure(err, options, { requestId, name: tableName, method: 'POST' }, deferred);
1606
2099
  }
1607
2100
  }
1608
2101
 
@@ -1619,7 +2112,9 @@ async function handlePost(
1619
2112
  const rows = await db.insert(table).values(coerced as Record<string, unknown>).returning();
1620
2113
 
1621
2114
  if (hook?.afterCreate && rows.length > 0) {
1622
- try { await hook.afterCreate(rows[0], user, client); } catch {}
2115
+ const { afterCreate } = hook;
2116
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterCreate`, method: 'POST' },
2117
+ () => afterCreate.call(hook, rows[0], user, client));
1623
2118
  }
1624
2119
 
1625
2120
  return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201);
@@ -1634,13 +2129,20 @@ async function handlePatch(
1634
2129
  options?: HandlerOptions,
1635
2130
  user: Record<string, unknown> | null = null,
1636
2131
  client: Record<string, unknown> | null = null,
2132
+ requestId?: string,
2133
+ deferred: Deferred[] = [],
1637
2134
  ): Promise<Response> {
2135
+ // Ownership refusal first: before the body, the filter, or any hook is examined.
2136
+ const scope = ownershipScope(table, tableName, options, user);
2137
+ if ('refusal' in scope) return scope.refusal;
2138
+
1638
2139
  let body: Record<string, unknown>;
1639
2140
  try {
1640
2141
  body = await request.json();
1641
2142
  } catch {
1642
2143
  return json({ message: 'Invalid JSON body' }, 400);
1643
2144
  }
2145
+ const clientBody = body;
1644
2146
 
1645
2147
  try {
1646
2148
  // Map body keys to Drizzle property names when naming convention is active
@@ -1656,40 +2158,68 @@ async function handlePatch(
1656
2158
  throw err;
1657
2159
  }
1658
2160
 
2161
+ // Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
2162
+ // this table. Anyone else is matched to live rows, as if the param were absent.
2163
+ const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
2164
+ const restoring = sdCol !== null && mayRestore(tableName, options, user);
2165
+ const deletedMode = params.get('deleted');
2166
+ if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
2167
+ // Checked after the protectedFields strip, so a body stripped to {} is refused here.
2168
+ const key = columnPropertyName(table, options?.softDelete?.column || 'deletedAt');
2169
+ const isRestoreBody = body !== null && typeof body === 'object' && !Array.isArray(body)
2170
+ && Object.keys(body).length === 1 && key !== undefined && Object.hasOwn(body, key) && body[key] === null;
2171
+ if (!isRestoreBody) {
2172
+ const column = options?.softDelete?.column || 'deletedAt';
2173
+ const wire = options?.naming === 'snake_case' ? toSnakeCase(column) : column;
2174
+ return json({ message: `restore accepts only { ${wire}: null }` }, 400);
2175
+ }
2176
+ }
2177
+
2178
+ // With the ownership backstop lifted, an unfiltered PATCH would write every row the
2179
+ // role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
2180
+ const ownership = options?.rowOwnership?.[tableName];
2181
+ if (user && options && ownership && isOwnershipExempt(ownership, user, options)
2182
+ && buildsNoFilter(table, params)) {
2183
+ return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
2184
+ }
2185
+
2186
+ // The owner clause proves the caller owns the row, not that they may give it away.
2187
+ // A caller it scopes may not set the owner column to anyone else. RLS stays primary.
2188
+ if (scope.condition && user && ownership) {
2189
+ const key = ownerReassignmentKey(table, ownership, user, clientBody, body);
2190
+ if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
2191
+ }
2192
+
1659
2193
  const hook = options?.hooks?.[tableName];
1660
2194
 
1661
2195
  if (hook?.beforeUpdate) {
1662
2196
  try {
1663
2197
  body = await hook.beforeUpdate(body, user, client);
1664
2198
  } catch (err) {
1665
- if (err instanceof HandlerError) {
1666
- return json({ message: err.message }, err.status);
1667
- }
1668
- return json({ message: 'Hook error' }, 500);
2199
+ return beforeHookFailure(err, options, { requestId, name: tableName, method: 'PATCH' }, deferred);
1669
2200
  }
1670
2201
  }
1671
2202
 
2203
+ // The final body, after the strip and the hook, must have something to write: an empty
2204
+ // SET is a 500 from PostgreSQL. A hook may fill a body the strip emptied, so check here.
2205
+ if (body === null || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length === 0) {
2206
+ return json({ message: 'No writable fields in request body' }, 400);
2207
+ }
2208
+
1672
2209
  const filterResult = buildAllConditions(table, params);
1673
2210
  if ('error' in filterResult) {
1674
2211
  return sanitizeError(filterResult.error, 400, options);
1675
2212
  }
1676
2213
  const conditions = filterResult.conditions;
1677
2214
 
1678
- // Soft-delete: exclude deleted records from updates
1679
- if (isSoftDeleteTable(tableName, table, options)) {
1680
- const sdCol = getSoftDeleteColumn(table, options);
1681
- if (sdCol) conditions.push(isNull(sdCol));
2215
+ // Soft-delete: update live records, unless a restore role sent ?deleted=include|only
2216
+ if (sdCol) {
2217
+ const condition = restoring ? softDeleteCondition(sdCol, params) : isNull(sdCol);
2218
+ if (condition) conditions.push(condition);
1682
2219
  }
1683
2220
 
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
- }
2221
+ // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
2222
+ if (scope.condition) conditions.push(scope.condition);
1693
2223
 
1694
2224
  let coerced: unknown;
1695
2225
  try {
@@ -1712,12 +2242,115 @@ async function handlePatch(
1712
2242
  const rows = await query.returning();
1713
2243
 
1714
2244
  if (hook?.afterUpdate && rows.length > 0) {
1715
- try { await hook.afterUpdate(rows, user, client); } catch {}
2245
+ const { afterUpdate } = hook;
2246
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterUpdate`, method: 'PATCH' },
2247
+ () => afterUpdate.call(hook, rows, user, client));
1716
2248
  }
1717
2249
 
1718
2250
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
1719
2251
  }
1720
2252
 
2253
+ /**
2254
+ * True when the caller's role is an exact member of the entry's `exceptRoles`, so the
2255
+ * ownership clause is skipped. Fail closed: no list, an empty list, a missing or
2256
+ * non-string role claim, or any other role keeps the clause.
2257
+ */
2258
+ function isOwnershipExempt(
2259
+ ownership: { exceptRoles?: string[] },
2260
+ user: Record<string, unknown>,
2261
+ options: HandlerOptions,
2262
+ ): boolean {
2263
+ if (!ownership.exceptRoles?.length) return false;
2264
+ const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
2265
+ return role !== '' && ownership.exceptRoles.includes(role);
2266
+ }
2267
+
2268
+ /**
2269
+ * The refusal message (sent with a 500) for a PATCH or DELETE whose ownership exemption
2270
+ * fires while the request's pgSettings map has no role, or null. executeWithSettings runs
2271
+ * SET LOCAL ROLE only for a truthy `role`; without it the connection stays on the table
2272
+ * owner, RLS does not apply, and the JWT role claim alone would lift the owner clause. A
2273
+ * caller who is not excepted keeps the clause, so it is never refused here.
2274
+ */
2275
+ function unbackedExemptionRefusal(
2276
+ method: string,
2277
+ tableName: string,
2278
+ options: HandlerOptions | undefined,
2279
+ user: Record<string, unknown> | null,
2280
+ settings: Record<string, string> | undefined,
2281
+ ): string | null {
2282
+ if (method !== 'PATCH' && method !== 'DELETE') return null;
2283
+ const ownership = options?.rowOwnership?.[tableName];
2284
+ if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
2285
+ if (settings?.['role']) return null;
2286
+ 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.`;
2287
+ console.error(`[everystack/api] ${message}`);
2288
+ return message;
2289
+ }
2290
+
2291
+ /**
2292
+ * The key, as the client spelled it, that would set the owner column to a value other
2293
+ * than the caller's own, or null. A key matches when it resolves to the owner column
2294
+ * object, so either spelling counts. The decision reads `body` (transformed and
2295
+ * stripped: what would be written); the reported key comes from `clientBody`, so the
2296
+ * message echoes only what the client sent. Null, an object or any other value is a
2297
+ * change; the caller's own value, compared as a string, is a no-op.
2298
+ */
2299
+ function ownerReassignmentKey(
2300
+ table: DrizzleTable,
2301
+ ownership: { column: string; userField: string },
2302
+ user: Record<string, unknown>,
2303
+ clientBody: unknown,
2304
+ body: unknown,
2305
+ ): string | null {
2306
+ const ownerCol = getColumn(table, ownership.column);
2307
+ const owner = String(user[ownership.userField]);
2308
+ const changesOwner = ([key, value]: [string, unknown]): boolean =>
2309
+ getColumn(table, key) === ownerCol
2310
+ && (value === null || typeof value === 'object' || String(value) !== owner);
2311
+ const written = Object.entries(body ?? {}).find(changesOwner);
2312
+ if (!written) return null;
2313
+ return Object.entries(clientBody ?? {}).find(changesOwner)?.[0] ?? written[0];
2314
+ }
2315
+
2316
+ type OwnershipScope = { refusal: Response } | { condition: SQL | null };
2317
+
2318
+ /**
2319
+ * The owner clause for a PATCH or DELETE, or the refusal when it cannot be built. Fails
2320
+ * closed for a table with a rowOwnership entry and a caller who is not excepted: no
2321
+ * user is 401, a null, undefined or empty owner value is 403, an owner column that does not
2322
+ * resolve is 403 (startup validation makes that unreachable; logged, never echoed).
2323
+ * No entry, or an excepted role, yields no clause.
2324
+ */
2325
+ function ownershipScope(
2326
+ table: DrizzleTable,
2327
+ tableName: string,
2328
+ options: HandlerOptions | undefined,
2329
+ user: Record<string, unknown> | null,
2330
+ ): OwnershipScope {
2331
+ const ownership = options?.rowOwnership?.[tableName];
2332
+ if (!options || !ownership) return { condition: null };
2333
+ if (!user) {
2334
+ return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
2335
+ }
2336
+ if (isOwnershipExempt(ownership, user, options)) return { condition: null };
2337
+ const forbidden = (): OwnershipScope => ({
2338
+ refusal: json({ message: 'Forbidden: row ownership cannot be verified for this request' }, 403),
2339
+ });
2340
+ const userValue = user[ownership.userField];
2341
+ // An empty string is not an owner: it would scope the write to rows owned by ''.
2342
+ // A numeric 0 is a legal owner value, so only null, undefined and '' refuse.
2343
+ if (userValue == null || userValue === '') return forbidden();
2344
+ const ownerCol = getColumn(table, ownership.column);
2345
+ if (!ownerCol) {
2346
+ console.error(
2347
+ `[everystack/api] rowOwnership.${tableName}.column "${ownership.column}" does not resolve to a column of table "${tableName}"; write refused`,
2348
+ );
2349
+ return forbidden();
2350
+ }
2351
+ return { condition: eq(ownerCol, userValue) };
2352
+ }
2353
+
1721
2354
  async function handleDelete(
1722
2355
  db: DrizzleDb,
1723
2356
  table: DrizzleTable,
@@ -1726,12 +2359,15 @@ async function handleDelete(
1726
2359
  options?: HandlerOptions,
1727
2360
  user: Record<string, unknown> | null = null,
1728
2361
  client: Record<string, unknown> | null = null,
2362
+ requestId?: string,
2363
+ deferred: Deferred[] = [],
1729
2364
  ): Promise<Response> {
1730
- const filters = parseFilters(params);
1731
- const hasLogicalGroup = params.has('or') || params.has('and');
2365
+ // Ownership refusal first, so it never reveals whether the filter was valid.
2366
+ const scope = ownershipScope(table, tableName, options, user);
2367
+ if ('refusal' in scope) return scope.refusal;
1732
2368
 
1733
2369
  // Safety: require at least one filter to prevent accidental mass deletion
1734
- if (filters.length === 0 && !hasLogicalGroup) {
2370
+ if (buildsNoFilter(table, params)) {
1735
2371
  return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
1736
2372
  }
1737
2373
 
@@ -1741,10 +2377,7 @@ async function handleDelete(
1741
2377
  try {
1742
2378
  await hook.beforeDelete(user, client);
1743
2379
  } catch (err) {
1744
- if (err instanceof HandlerError) {
1745
- return json({ message: err.message }, err.status);
1746
- }
1747
- return json({ message: 'Hook error' }, 500);
2380
+ return beforeHookFailure(err, options, { requestId, name: tableName, method: 'DELETE' }, deferred);
1748
2381
  }
1749
2382
  }
1750
2383
 
@@ -1754,15 +2387,8 @@ async function handleDelete(
1754
2387
  }
1755
2388
  const conditions = filterResult.conditions;
1756
2389
 
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
- }
2390
+ // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
2391
+ if (scope.condition) conditions.push(scope.condition);
1766
2392
 
1767
2393
  // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
1768
2394
  if (isSoftDeleteTable(tableName, table, options)) {
@@ -1782,7 +2408,9 @@ async function handleDelete(
1782
2408
  const rows = await query.returning();
1783
2409
 
1784
2410
  if (hook?.afterDelete && rows.length > 0) {
1785
- try { await hook.afterDelete(rows, user, client); } catch {}
2411
+ const { afterDelete } = hook;
2412
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
2413
+ () => afterDelete.call(hook, rows, user, client));
1786
2414
  }
1787
2415
 
1788
2416
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
@@ -1801,7 +2429,9 @@ async function handleDelete(
1801
2429
  const rows = await query.returning();
1802
2430
 
1803
2431
  if (hook?.afterDelete && rows.length > 0) {
1804
- try { await hook.afterDelete(rows, user, client); } catch {}
2432
+ const { afterDelete } = hook;
2433
+ deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
2434
+ () => afterDelete.call(hook, rows, user, client));
1805
2435
  }
1806
2436
 
1807
2437
  return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
@@ -2049,12 +2679,13 @@ function buildOrderBy(table: DrizzleTable, orderParam: string): any {
2049
2679
  return direction === 'desc' ? desc(column) : asc(column);
2050
2680
  }
2051
2681
 
2682
+ const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
2683
+
2052
2684
  function parseFilters(params: URLSearchParams): Filter[] {
2053
2685
  const filters: Filter[] = [];
2054
- const reserved = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
2055
2686
 
2056
2687
  for (const [key, value] of params.entries()) {
2057
- if (reserved.has(key)) continue;
2688
+ if (RESERVED_QUERY_PARAMS.has(key)) continue;
2058
2689
 
2059
2690
  // Check for NOT modifier: not.eq.value → negated=true, operator=eq
2060
2691
  let negated = false;
@@ -2172,6 +2803,27 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
2172
2803
  return { conditions };
2173
2804
  }
2174
2805
 
2806
+ /**
2807
+ * True when the request's own filters build no SQL condition, so the write would run
2808
+ * with no WHERE. A param that parses but builds nothing (`or=`, `or=()`, `and=junk`,
2809
+ * `id=is.x`) is not a filter. Only user filters count: the soft-delete and ownership
2810
+ * clauses the handler adds are not part of this test. A filter error is left to the
2811
+ * caller's own buildAllConditions, which reports it.
2812
+ */
2813
+ function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
2814
+ const result = buildAllConditions(table, params);
2815
+ return !('error' in result) && result.conditions.length === 0;
2816
+ }
2817
+
2818
+ /**
2819
+ * The outage answer. no-store keeps a CDN from caching it: CloudFront holds an
2820
+ * origin 5xx for about 10 seconds, so a protected GET could get a stale 503
2821
+ * after the database recovers.
2822
+ */
2823
+ function serviceUnavailableResponse(): Response {
2824
+ return json({ message: SERVICE_UNAVAILABLE_MESSAGE }, 503, { 'Cache-Control': 'no-store' });
2825
+ }
2826
+
2175
2827
  function json(data: unknown, status: number, headers?: Record<string, string>): Response {
2176
2828
  return new Response(JSON.stringify(data), {
2177
2829
  status,