@everystack/api 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ All notable changes to `@everystack/api` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.5] - 2026-10-15
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - a PATCH with no row filter is now refused with 400, like DELETE; it used to update every row. Pass a filter such as ?id=eq.1
12
+ - hiddenColumns hides a column under every spelling (security); an entry that names no column, or a table not in the schema, now makes createHandler throw at startup instead of logging a warning
13
+
14
+ ### Added
15
+
16
+ - clean HTML columns on write and signal the change
17
+ - state the sanitizer contract and bound promise handling
18
+
19
+ ### Fixed
20
+
21
+ - sanitize on write review fixes: hidden signal, no raw HTML in faults
22
+ - keep input lines out of the sanitizer fault stack
23
+ - the sanitizer fault carries no error text or stack
24
+ - contain hostile thrown values and thenables in the sanitizer guard
25
+
26
+ ## [0.4.4] - 2026-10-15
27
+
28
+ ### Added
29
+
30
+ - PATCH honours ?deleted=include|only so a soft-deleted row can be restored
31
+
32
+ ### Fixed
33
+
34
+ - refuse a JOIN embed list that names no real column
35
+ - restore through PATCH is off unless softDelete.restore names the caller's role
36
+ - a PATCH whose final body has nothing to write answers 400, not 500
37
+
7
38
  ## [0.4.3] - 2026-10-14
8
39
 
9
40
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -39,6 +39,9 @@ export interface CountResponse<T> extends ClientResponse<T> {
39
39
  total: number;
40
40
  }
41
41
 
42
+ /** Soft-delete scope: `include` reads or writes live and deleted rows, `only` deleted rows. */
43
+ export type DeletedMode = 'include' | 'only';
44
+
42
45
  export interface QueryBuilder {
43
46
  eq(column: string, value: string): QueryBuilder;
44
47
  neq(column: string, value: string): QueryBuilder;
@@ -68,6 +71,9 @@ export interface QueryBuilder {
68
71
  order(column: string, direction?: 'asc' | 'desc'): QueryBuilder;
69
72
  limit(count: number): QueryBuilder;
70
73
  offset(count: number): QueryBuilder;
74
+ /** Soft-delete tables: ?deleted=include|only on select and update. `.deleted('only').update({ deletedAt: null })` restores,
75
+ * for a role the handler lists in `softDelete.restore`; for anyone else update ignores it. */
76
+ deleted(mode: DeletedMode): QueryBuilder;
71
77
  execute(): Promise<ClientResponse<unknown[]>>;
72
78
  executeWithCount(): Promise<CountResponse<unknown[]>>;
73
79
  single(): Promise<ClientResponse<unknown>>;
@@ -123,6 +129,7 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
123
129
  let orderParam: string | null = null;
124
130
  let limitParam: number | null = null;
125
131
  let offsetParam: number | null = null;
132
+ let deletedParam: DeletedMode | null = null;
126
133
 
127
134
  const builder: QueryBuilder = {
128
135
  eq(column, value) { filters.push([column, `eq.${value}`]); return builder; },
@@ -151,9 +158,10 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
151
158
  order(column, direction = 'asc') { orderParam = `${column}.${direction}`; return builder; },
152
159
  limit(count) { limitParam = count; return builder; },
153
160
  offset(count) { offsetParam = count; return builder; },
161
+ deleted(mode) { deletedParam = mode; return builder; },
154
162
 
155
163
  async execute(): Promise<ClientResponse<unknown[]>> {
156
- const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam);
164
+ const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam, deletedParam);
157
165
  const headers = await buildHeaders(options);
158
166
  const { response, retried } = await fetchWithRetry(url, { method: 'GET', headers }, options);
159
167
 
@@ -168,7 +176,7 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
168
176
  },
169
177
 
170
178
  async executeWithCount(): Promise<CountResponse<unknown[]>> {
171
- const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam);
179
+ const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam, deletedParam);
172
180
  const headers = await buildHeaders(options);
173
181
  headers['Prefer'] = 'count=exact';
174
182
 
@@ -220,7 +228,7 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
220
228
  },
221
229
 
222
230
  async update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>> {
223
- const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, null, null);
231
+ const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, null, null, deletedParam);
224
232
  const headers = await buildHeaders(options);
225
233
  headers['Content-Type'] = 'application/json';
226
234
 
@@ -340,6 +348,7 @@ function buildUrl(
340
348
  order: string | null,
341
349
  limit: number | null,
342
350
  offset: number | null,
351
+ deleted: DeletedMode | null = null,
343
352
  ): string {
344
353
  const path = `${baseUrl}/${table}`;
345
354
  const base = typeof globalThis.location !== 'undefined' ? globalThis.location.origin : undefined;
@@ -355,6 +364,7 @@ function buildUrl(
355
364
  if (order) url.searchParams.set('order', order);
356
365
  if (limit !== null) url.searchParams.set('limit', String(limit));
357
366
  if (offset !== null) url.searchParams.set('offset', String(offset));
367
+ if (deleted) url.searchParams.set('deleted', deleted);
358
368
 
359
369
  return url.toString();
360
370
  }
@@ -60,6 +60,9 @@ export interface HandlerFault {
60
60
  */
61
61
  status: number;
62
62
  message: string;
63
+ /** SQL names of the cell when a sanitizeColumns cleaner fails. */
64
+ table?: string;
65
+ column?: string;
63
66
  stack?: string;
64
67
  /** Postgres SQLSTATE code when the error carried one. */
65
68
  pgCode?: string;
@@ -233,6 +236,13 @@ export interface SoftDeleteConfig {
233
236
  column?: string;
234
237
  /** Tables with soft-delete enabled. If omitted, all tables with the column are treated as soft-delete. */
235
238
  tables?: string[];
239
+ /** Who may restore through PATCH, per table: table name -> role names. Keyed like `tables`.
240
+ * PATCH honours `?deleted=include|only` only when the caller's role claim (`auth.roleField`,
241
+ * default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
242
+ * Anyone else gets live rows only, as if the param were absent. A restore body must be
243
+ * exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
244
+ * This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md). */
245
+ restore?: Record<string, string[]>;
236
246
  }
237
247
 
238
248
  export interface HookConfig {
@@ -354,6 +364,14 @@ export interface HandlerOptions {
354
364
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
355
365
  * Prevents mass assignment / privilege escalation on sensitive columns. */
356
366
  protectedFields?: Record<string, string[]>;
367
+ /** HTML columns to clean on POST/PATCH after before-hooks. Table and column
368
+ * names accept SQL or Drizzle spelling. Requires sanitize when non-empty.
369
+ * Changed bytes are stored and signalled by X-Sanitized-Columns. */
370
+ sanitizeColumns?: Record<string, string[]>;
371
+ /** Injected HTML cleaner for sanitizeColumns. Context names are SQL names.
372
+ * Must return a string synchronously, ideally a fixed point. Throws or non-string returns
373
+ * refuse the entire write with 500 and report through onError. */
374
+ sanitize?: (html: string, ctx: { table: string; column: string }) => string;
357
375
  /** Maximum nesting depth for relation embedding via ?select=rel(sub_rel(...)).
358
376
  * Prevents DoS from deeply nested relation queries. Default: 3. */
359
377
  maxEmbedDepth?: number;
@@ -395,6 +413,17 @@ function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): Dri
395
413
  return getColumn(table, colName);
396
414
  }
397
415
 
416
+ /**
417
+ * The soft-delete condition for a GET or PATCH: ?deleted=include adds none, ?deleted=only
418
+ * matches deleted rows, anything else (absent or unknown) matches live rows. DELETE does
419
+ * not use it: it always matches live rows only.
420
+ */
421
+ function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL | null {
422
+ const mode = params.get('deleted');
423
+ if (mode === 'include') return null;
424
+ return mode === 'only' ? isNotNull(sdCol) : isNull(sdCol);
425
+ }
426
+
398
427
  function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
399
428
  if (!options?.softDelete) return false;
400
429
  if (options.softDelete.tables) {
@@ -763,11 +792,18 @@ function expandTableKeyedOptions(
763
792
  ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
764
793
  ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
765
794
  ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
795
+ ...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
766
796
  ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
767
797
  ...(options.relations ? { relations: expandMap(options.relations) } : {}),
768
798
  ...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
769
- ...(options.softDelete?.tables
770
- ? { softDelete: { ...options.softDelete, tables: expandList(options.softDelete.tables) } }
799
+ ...(options.softDelete
800
+ ? {
801
+ softDelete: {
802
+ ...options.softDelete,
803
+ ...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
804
+ ...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
805
+ },
806
+ }
771
807
  : {}),
772
808
  };
773
809
  }
@@ -826,6 +862,108 @@ function validateRowOwnership(schema: Schema, options: HandlerOptions | undefine
826
862
  }
827
863
  }
828
864
 
865
+ /**
866
+ * Refuse to start on a softDelete.restore config the handler cannot honour. Every entry
867
+ * must name a soft-delete table. A restorable table must not list the soft-delete column
868
+ * in protectedFields: the strip would leave every restore body empty. Takes the expanded
869
+ * options, so both spellings of a table are checked.
870
+ */
871
+ function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | undefined): void {
872
+ const softDelete = options?.softDelete;
873
+ const colName = softDelete?.column || 'deletedAt';
874
+ for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
875
+ const table = schema[tableName];
876
+ const isSoftDelete = table
877
+ ? isSoftDeleteTable(tableName, table, options)
878
+ : (softDelete?.tables?.includes(tableName) ?? false);
879
+ if (!isSoftDelete) {
880
+ throw new Error(
881
+ `[everystack/api] softDelete.restore.${tableName}: table "${tableName}" is not a soft-delete table. Add it to softDelete.tables or remove the entry.`,
882
+ );
883
+ }
884
+ const sameColumn = (field: string): boolean => (table
885
+ ? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
886
+ : toSnakeCase(field) === toSnakeCase(colName));
887
+ if (roles.length > 0 && (options?.protectedFields?.[tableName] ?? []).some(sameColumn)) {
888
+ throw new Error(
889
+ `[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.`,
890
+ );
891
+ }
892
+ }
893
+ }
894
+
895
+ /**
896
+ * True when the caller may restore on this table: its role claim is an exact member of
897
+ * softDelete.restore[tableName]. Fail closed: no option, no list, no user, or a missing or
898
+ * non-string role claim is not a member.
899
+ */
900
+ function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
901
+ const roles = options?.softDelete?.restore?.[tableName];
902
+ if (!roles?.length || !user) return false;
903
+ const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
904
+ return role !== '' && roles.includes(role);
905
+ }
906
+
907
+ /** Refuse a cleaning policy that cannot be enforced, including conflicting table aliases. */
908
+ function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undefined): void {
909
+ const configured = new Map<DrizzleTable, Set<string>>();
910
+ for (const [tableName, columns] of Object.entries(options?.sanitizeColumns ?? {})) {
911
+ const prefix = `[everystack/api] sanitizeColumns.${tableName}:`;
912
+ const table = schema[tableName];
913
+ if (!table || sqlNameOf(table) === null) {
914
+ throw new Error(`${prefix} table "${tableName}" is not in the schema (columns ${JSON.stringify(columns)})`);
915
+ }
916
+ if (typeof options?.sanitize !== 'function') {
917
+ throw new Error(`${prefix} columns ${JSON.stringify(columns)} require a sanitize function`);
918
+ }
919
+ const resolved = new Set<string>();
920
+ const tableColumns = getTableColumns(table);
921
+ for (const name of columns) {
922
+ const key = columnPropertyName(table, name);
923
+ if (key === undefined) {
924
+ throw new Error(`${prefix} column "${name}" does not resolve on table "${tableName}"`);
925
+ }
926
+ const wire = options?.naming === 'snake_case' ? toSnakeCase(key) : key;
927
+ if (!/^[A-Za-z0-9_]+$/.test(wire)) {
928
+ throw new Error(`${prefix} column "${name}" has a name that cannot appear safely in X-Sanitized-Columns (use letters, digits and underscore)`);
929
+ }
930
+ if (tableColumns[key].dataType !== 'string') {
931
+ throw new Error(`${prefix} column "${name}" must be a string column`);
932
+ }
933
+ resolved.add(key);
934
+ }
935
+ const prior = configured.get(table);
936
+ if (prior && (prior.size !== resolved.size || [...prior].some(key => !resolved.has(key)))) {
937
+ throw new Error(`${prefix} different column lists conflict for the two table spellings: ${JSON.stringify([...prior])} and ${JSON.stringify([...resolved])}`);
938
+ }
939
+ configured.set(table, resolved);
940
+ }
941
+ }
942
+
943
+ /** Resolve hidden columns once so every read uses the same column spellings. */
944
+ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
945
+ if (!options?.hiddenColumns) return options;
946
+ const byTable = new Map<DrizzleTable, Set<string>>();
947
+ for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
948
+ const table = schema[tableName];
949
+ const hidden = byTable.get(table) ?? new Set<string>();
950
+ for (const name of names) {
951
+ const property = columnPropertyName(table, name);
952
+ if (property === undefined) {
953
+ throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" does not resolve to a column`);
954
+ }
955
+ const column = getTableColumns(table)[property] as DrizzleColumn;
956
+ for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) {
957
+ hidden.add(spelling);
958
+ }
959
+ }
960
+ byTable.set(table, hidden);
961
+ }
962
+ const hiddenColumns = Object.fromEntries(Object.keys(options.hiddenColumns)
963
+ .map(tableName => [tableName, [...byTable.get(schema[tableName])!]]));
964
+ return { ...options, hiddenColumns };
965
+ }
966
+
829
967
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
830
968
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
831
969
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
@@ -834,19 +972,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
834
972
  // apply. The root listing below stays one entry per table (the SQL name).
835
973
  const namePairs = tableNamePairs(rawSchema);
836
974
  const schema = expandSchema(rawSchema, namePairs);
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
- }
975
+ const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
849
976
  validateRowOwnership(schema, rawOptions);
977
+ validateSoftDeleteRestore(schema, options);
978
+ validateSanitizeColumns(schema, options);
850
979
  const tableNames = new Set(Object.keys(schema));
851
980
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
852
981
 
@@ -1286,17 +1415,15 @@ function buildWithClause(
1286
1415
  if (!relation) continue;
1287
1416
  hasEmbedded = true;
1288
1417
  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
- }
1418
+ const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
1419
+ const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
1295
1420
  const config: Record<string, any> = {};
1296
1421
  if (embed.columns !== null) {
1422
+ // Drop names that are not columns of a known table: Drizzle ignores them,
1423
+ // and throws when nothing else is left. An emptied list is the 400 below.
1297
1424
  config.columns = Object.fromEntries(embed.columns
1298
- .map(name => columnPropertyName(table, name) ?? name)
1299
- .filter(name => !hidden.has(name))
1425
+ .map(name => (table ? columnPropertyName(table, name) : name))
1426
+ .filter((name): name is string => name !== undefined && !hidden.has(name))
1300
1427
  .map(name => [name, true]));
1301
1428
  if (Object.keys(config.columns).length === 0) return null;
1302
1429
  } else if (hidden.size > 0) {
@@ -1325,13 +1452,7 @@ function stripEmbeddedHiddenColumns(
1325
1452
  const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
1326
1453
  const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
1327
1454
  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
- });
1455
+ const related = stripHiddenColumns(result[key], relation.table, options);
1335
1456
  result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
1336
1457
  }
1337
1458
  return result;
@@ -1462,13 +1583,8 @@ async function handleGet(
1462
1583
  if (isSoftDeleteTable(tableName, table, options)) {
1463
1584
  const sdCol = getSoftDeleteColumn(table, options);
1464
1585
  if (sdCol) {
1465
- const deletedParam = params.get('deleted');
1466
- if (deletedParam === 'only') {
1467
- conditions.push(isNotNull(sdCol));
1468
- } else if (deletedParam !== 'include') {
1469
- // Default: exclude deleted records
1470
- conditions.push(isNull(sdCol));
1471
- }
1586
+ const condition = softDeleteCondition(sdCol, params);
1587
+ if (condition) conditions.push(condition);
1472
1588
  }
1473
1589
  }
1474
1590
 
@@ -1788,14 +1904,7 @@ function requestUsesHiddenColumn(
1788
1904
  }
1789
1905
  columnName = parts[0];
1790
1906
  }
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
- });
1907
+ return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
1799
1908
  };
1800
1909
 
1801
1910
  const selectUsesHidden = (sourceTable: string, select: string): boolean => {
@@ -1993,6 +2102,95 @@ function beforeHookFailure(
1993
2102
  return json({ message: 'Hook error' }, 500);
1994
2103
  }
1995
2104
 
2105
+ function errorKind(err: unknown): string {
2106
+ try {
2107
+ return err instanceof TypeError ? 'TypeError'
2108
+ : err instanceof RangeError ? 'RangeError'
2109
+ : err instanceof SyntaxError ? 'SyntaxError'
2110
+ : err instanceof ReferenceError ? 'ReferenceError'
2111
+ : err instanceof EvalError ? 'EvalError'
2112
+ : err instanceof URIError ? 'URIError'
2113
+ : err instanceof AggregateError ? 'AggregateError'
2114
+ : err instanceof Error ? 'Error' : 'non-Error';
2115
+ } catch {
2116
+ return 'Error';
2117
+ }
2118
+ }
2119
+
2120
+ function consumeThenable(value: unknown, depth = 4): void {
2121
+ if (depth === 0) return;
2122
+ try {
2123
+ const then = (value as { then?: unknown } | null | undefined)?.then;
2124
+ if (typeof then !== 'function') return;
2125
+ const next = then.call(value, () => {}, () => {});
2126
+ consumeThenable(next, depth - 1);
2127
+ } catch {
2128
+ // A throwing getter or then must not prevent the refusal.
2129
+ }
2130
+ }
2131
+
2132
+ /** Clean every submitted cell before the caller can start a write. */
2133
+ function sanitizeWriteBody(
2134
+ body: unknown,
2135
+ table: DrizzleTable,
2136
+ tableName: string,
2137
+ options: HandlerOptions | undefined,
2138
+ at: { requestId?: string; name: string; method: string },
2139
+ deferred: Deferred[],
2140
+ ): { body: unknown; changed: string[] } | { refusal: Response } {
2141
+ const columns = options?.sanitizeColumns?.[tableName];
2142
+ if (!columns?.length) return { body, changed: [] };
2143
+ const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
2144
+ const hidden = new Set((options?.hiddenColumns?.[tableName] ?? []).map(name => columnPropertyName(table, name)));
2145
+ const refuse = (message: string, column: string): { refusal: Response } => {
2146
+ const fault: HandlerFault = {
2147
+ ...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
2148
+ };
2149
+ deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
2150
+ return { refusal: json({ message: 'Sanitizer error' }, 500) };
2151
+ };
2152
+ const bulk = Array.isArray(body);
2153
+ const rows = bulk ? body : [body];
2154
+ const cleaned: unknown[] = [];
2155
+ const changed: string[] = [];
2156
+ for (const [index, row] of rows.entries()) {
2157
+ // Leave body-shape checks to coerceDateColumnValues.
2158
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
2159
+ cleaned.push(row);
2160
+ continue;
2161
+ }
2162
+ const result: Record<string, unknown> = { ...row };
2163
+ for (const key of keys) {
2164
+ if (!Object.hasOwn(result, key) || result[key] === null) continue;
2165
+ const value = result[key];
2166
+ const wire = options?.naming === 'snake_case' ? toSnakeCase(key) : key;
2167
+ if (typeof value !== 'string') {
2168
+ return { refusal: sanitizeError(`Column "${wire}" must be a string`, 400, options) };
2169
+ }
2170
+ let sanitized: unknown;
2171
+ try {
2172
+ sanitized = options!.sanitize!(value, { table: sqlNameOf(table)!, column: table[key].name });
2173
+ } catch (err) {
2174
+ consumeThenable(err);
2175
+ return refuse(`Sanitizer threw (${errorKind(err)})`, table[key].name);
2176
+ }
2177
+ if (typeof sanitized !== 'string') {
2178
+ // An async cleaner is invalid; rejection handling is bounded and best effort.
2179
+ consumeThenable(sanitized);
2180
+ return refuse('Sanitizer returned a non-string', table[key].name);
2181
+ }
2182
+ result[key] = sanitized;
2183
+ if (sanitized !== value && !hidden.has(key)) changed.push(bulk ? `${wire}[${index}]` : wire);
2184
+ }
2185
+ cleaned.push(result);
2186
+ }
2187
+ return { body: bulk ? cleaned : cleaned[0], changed };
2188
+ }
2189
+
2190
+ function sanitizedHeaders(changed: string[]): Record<string, string> | undefined {
2191
+ return changed.length > 0 ? { 'X-Sanitized-Columns': changed.join(',') } : undefined;
2192
+ }
2193
+
1996
2194
  async function handlePost(
1997
2195
  db: DrizzleDb,
1998
2196
  table: DrizzleTable,
@@ -2035,6 +2233,11 @@ async function handlePost(
2035
2233
  }
2036
2234
  }
2037
2235
 
2236
+ const sanitized = sanitizeWriteBody(body, table, tableName, options,
2237
+ { requestId, name: tableName, method: 'POST' }, deferred);
2238
+ if ('refusal' in sanitized) return sanitized.refusal;
2239
+ body = sanitized.body as Record<string, unknown>;
2240
+
2038
2241
  let coerced: unknown;
2039
2242
  try {
2040
2243
  coerced = coerceDateColumnValues(body, table);
@@ -2053,7 +2256,8 @@ async function handlePost(
2053
2256
  () => afterCreate.call(hook, rows[0], user, client));
2054
2257
  }
2055
2258
 
2056
- return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201);
2259
+ return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201,
2260
+ rows.length > 0 ? sanitizedHeaders(sanitized.changed) : undefined);
2057
2261
  }
2058
2262
 
2059
2263
  async function handlePatch(
@@ -2094,6 +2298,23 @@ async function handlePatch(
2094
2298
  throw err;
2095
2299
  }
2096
2300
 
2301
+ // Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
2302
+ // this table. Anyone else is matched to live rows, as if the param were absent.
2303
+ const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
2304
+ const restoring = sdCol !== null && mayRestore(tableName, options, user);
2305
+ const deletedMode = params.get('deleted');
2306
+ if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
2307
+ // Checked after the protectedFields strip, so a body stripped to {} is refused here.
2308
+ const key = columnPropertyName(table, options?.softDelete?.column || 'deletedAt');
2309
+ const isRestoreBody = body !== null && typeof body === 'object' && !Array.isArray(body)
2310
+ && Object.keys(body).length === 1 && key !== undefined && Object.hasOwn(body, key) && body[key] === null;
2311
+ if (!isRestoreBody) {
2312
+ const column = options?.softDelete?.column || 'deletedAt';
2313
+ const wire = options?.naming === 'snake_case' ? toSnakeCase(column) : column;
2314
+ return json({ message: `restore accepts only { ${wire}: null }` }, 400);
2315
+ }
2316
+ }
2317
+
2097
2318
  // With the ownership backstop lifted, an unfiltered PATCH would write every row the
2098
2319
  // role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
2099
2320
  const ownership = options?.rowOwnership?.[tableName];
@@ -2102,6 +2323,11 @@ async function handlePatch(
2102
2323
  return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
2103
2324
  }
2104
2325
 
2326
+ // Safety: require at least one filter to prevent accidental mass update
2327
+ if (buildsNoFilter(table, params)) {
2328
+ return json({ message: 'PATCH requires at least one filter to prevent accidental mass update' }, 400);
2329
+ }
2330
+
2105
2331
  // The owner clause proves the caller owns the row, not that they may give it away.
2106
2332
  // A caller it scopes may not set the owner column to anyone else. RLS stays primary.
2107
2333
  if (scope.condition && user && ownership) {
@@ -2119,16 +2345,27 @@ async function handlePatch(
2119
2345
  }
2120
2346
  }
2121
2347
 
2348
+ // The final body, after the strip and the hook, must have something to write: an empty
2349
+ // SET is a 500 from PostgreSQL. A hook may fill a body the strip emptied, so check here.
2350
+ if (body === null || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length === 0) {
2351
+ return json({ message: 'No writable fields in request body' }, 400);
2352
+ }
2353
+
2354
+ const sanitized = sanitizeWriteBody(body, table, tableName, options,
2355
+ { requestId, name: tableName, method: 'PATCH' }, deferred);
2356
+ if ('refusal' in sanitized) return sanitized.refusal;
2357
+ body = sanitized.body as Record<string, unknown>;
2358
+
2122
2359
  const filterResult = buildAllConditions(table, params);
2123
2360
  if ('error' in filterResult) {
2124
2361
  return sanitizeError(filterResult.error, 400, options);
2125
2362
  }
2126
2363
  const conditions = filterResult.conditions;
2127
2364
 
2128
- // Soft-delete: exclude deleted records from updates
2129
- if (isSoftDeleteTable(tableName, table, options)) {
2130
- const sdCol = getSoftDeleteColumn(table, options);
2131
- if (sdCol) conditions.push(isNull(sdCol));
2365
+ // Soft-delete: update live records, unless a restore role sent ?deleted=include|only
2366
+ if (sdCol) {
2367
+ const condition = restoring ? softDeleteCondition(sdCol, params) : isNull(sdCol);
2368
+ if (condition) conditions.push(condition);
2132
2369
  }
2133
2370
 
2134
2371
  // Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
@@ -2160,7 +2397,8 @@ async function handlePatch(
2160
2397
  () => afterUpdate.call(hook, rows, user, client));
2161
2398
  }
2162
2399
 
2163
- return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
2400
+ return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200,
2401
+ rows.length > 0 ? sanitizedHeaders(sanitized.changed) : undefined);
2164
2402
  }
2165
2403
 
2166
2404
  /**