@everystack/api 0.4.4 → 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,25 @@ 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
+
7
26
  ## [0.4.4] - 2026-10-15
8
27
 
9
28
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/api",
3
- "version": "0.4.4",
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>",
@@ -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;
@@ -361,6 +364,14 @@ export interface HandlerOptions {
361
364
  /** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
362
365
  * Prevents mass assignment / privilege escalation on sensitive columns. */
363
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;
364
375
  /** Maximum nesting depth for relation embedding via ?select=rel(sub_rel(...)).
365
376
  * Prevents DoS from deeply nested relation queries. Default: 3. */
366
377
  maxEmbedDepth?: number;
@@ -781,6 +792,7 @@ function expandTableKeyedOptions(
781
792
  ...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
782
793
  ...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
783
794
  ...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
795
+ ...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
784
796
  ...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
785
797
  ...(options.relations ? { relations: expandMap(options.relations) } : {}),
786
798
  ...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
@@ -892,6 +904,66 @@ function mayRestore(tableName: string, options: HandlerOptions | undefined, user
892
904
  return role !== '' && roles.includes(role);
893
905
  }
894
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
+
895
967
  export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
896
968
  // The naming seam: URLs, exposedTables, and relation targets historically mixed
897
969
  // drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
@@ -900,20 +972,10 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
900
972
  // apply. The root listing below stays one entry per table (the SQL name).
901
973
  const namePairs = tableNamePairs(rawSchema);
902
974
  const schema = expandSchema(rawSchema, namePairs);
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
- }
975
+ const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
915
976
  validateRowOwnership(schema, rawOptions);
916
977
  validateSoftDeleteRestore(schema, options);
978
+ validateSanitizeColumns(schema, options);
917
979
  const tableNames = new Set(Object.keys(schema));
918
980
  const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
919
981
 
@@ -1353,12 +1415,8 @@ function buildWithClause(
1353
1415
  if (!relation) continue;
1354
1416
  hasEmbedded = true;
1355
1417
  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
- }
1418
+ const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
1419
+ const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
1362
1420
  const config: Record<string, any> = {};
1363
1421
  if (embed.columns !== null) {
1364
1422
  // Drop names that are not columns of a known table: Drizzle ignores them,
@@ -1394,13 +1452,7 @@ function stripEmbeddedHiddenColumns(
1394
1452
  const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
1395
1453
  const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
1396
1454
  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
- });
1455
+ const related = stripHiddenColumns(result[key], relation.table, options);
1404
1456
  result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
1405
1457
  }
1406
1458
  return result;
@@ -1852,14 +1904,7 @@ function requestUsesHiddenColumn(
1852
1904
  }
1853
1905
  columnName = parts[0];
1854
1906
  }
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
- });
1907
+ return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
1863
1908
  };
1864
1909
 
1865
1910
  const selectUsesHidden = (sourceTable: string, select: string): boolean => {
@@ -2057,6 +2102,95 @@ function beforeHookFailure(
2057
2102
  return json({ message: 'Hook error' }, 500);
2058
2103
  }
2059
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
+
2060
2194
  async function handlePost(
2061
2195
  db: DrizzleDb,
2062
2196
  table: DrizzleTable,
@@ -2099,6 +2233,11 @@ async function handlePost(
2099
2233
  }
2100
2234
  }
2101
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
+
2102
2241
  let coerced: unknown;
2103
2242
  try {
2104
2243
  coerced = coerceDateColumnValues(body, table);
@@ -2117,7 +2256,8 @@ async function handlePost(
2117
2256
  () => afterCreate.call(hook, rows[0], user, client));
2118
2257
  }
2119
2258
 
2120
- 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);
2121
2261
  }
2122
2262
 
2123
2263
  async function handlePatch(
@@ -2183,6 +2323,11 @@ async function handlePatch(
2183
2323
  return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
2184
2324
  }
2185
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
+
2186
2331
  // The owner clause proves the caller owns the row, not that they may give it away.
2187
2332
  // A caller it scopes may not set the owner column to anyone else. RLS stays primary.
2188
2333
  if (scope.condition && user && ownership) {
@@ -2206,6 +2351,11 @@ async function handlePatch(
2206
2351
  return json({ message: 'No writable fields in request body' }, 400);
2207
2352
  }
2208
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
+
2209
2359
  const filterResult = buildAllConditions(table, params);
2210
2360
  if ('error' in filterResult) {
2211
2361
  return sanitizeError(filterResult.error, 400, options);
@@ -2247,7 +2397,8 @@ async function handlePatch(
2247
2397
  () => afterUpdate.call(hook, rows, user, client));
2248
2398
  }
2249
2399
 
2250
- 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);
2251
2402
  }
2252
2403
 
2253
2404
  /**