@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 +31 -0
- package/package.json +1 -1
- package/src/client/index.ts +13 -3
- package/src/handler/index.ts +288 -50
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
|
+
"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/client/index.ts
CHANGED
|
@@ -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
|
}
|
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;
|
|
@@ -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
|
|
770
|
-
? {
|
|
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
|
|
1290
|
-
|
|
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)
|
|
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
|
|
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
|
|
1466
|
-
if (
|
|
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
|
-
|
|
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:
|
|
2129
|
-
if (
|
|
2130
|
-
const
|
|
2131
|
-
if (
|
|
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
|
/**
|