@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 +19 -0
- package/package.json +1 -1
- package/src/handler/index.ts +186 -35
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.
|
|
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>",
|
package/src/handler/index.ts
CHANGED
|
@@ -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
|
|
1357
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|