@everystack/api 0.4.4 → 0.4.6
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 +38 -0
- package/package.json +1 -1
- package/src/client/index.ts +10 -1
- package/src/handler/array-literal.ts +58 -0
- package/src/handler/index.ts +446 -64
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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.6] - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- an undeclared embed in select now answers 400 instead of a 200 with the embed dropped
|
|
12
|
+
- a JSON-path filter that cannot be built now answers 400 instead of being dropped; a DELETE or PATCH with such a filter used to run on the remaining filters alone
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- cs, cd and ov array filter operators on text[] and varchar[] columns
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- map integrity errors to safe client refusals
|
|
21
|
+
- preserve legacy errors alongside integrity mapping
|
|
22
|
+
- protect integrity diagnostics across failing tables
|
|
23
|
+
- show integrity errors under matching form fields
|
|
24
|
+
- the integrity mapper follows nested causes and keeps legacy hostile-value answers
|
|
25
|
+
|
|
26
|
+
## [0.4.5] - 2026-10-15
|
|
27
|
+
|
|
28
|
+
### BREAKING CHANGES
|
|
29
|
+
|
|
30
|
+
- 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
|
|
31
|
+
- 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
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- clean HTML columns on write and signal the change
|
|
36
|
+
- state the sanitizer contract and bound promise handling
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- sanitize on write review fixes: hidden signal, no raw HTML in faults
|
|
41
|
+
- keep input lines out of the sanitizer fault stack
|
|
42
|
+
- the sanitizer fault carries no error text or stack
|
|
43
|
+
- contain hostile thrown values and thenables in the sanitizer guard
|
|
44
|
+
|
|
7
45
|
## [0.4.4] - 2026-10-15
|
|
8
46
|
|
|
9
47
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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
|
@@ -27,6 +27,9 @@ export interface ClientOptions {
|
|
|
27
27
|
export interface ClientError {
|
|
28
28
|
status: number;
|
|
29
29
|
message: string;
|
|
30
|
+
code?: string;
|
|
31
|
+
constraint?: string;
|
|
32
|
+
column?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface ClientResponse<T> {
|
|
@@ -337,7 +340,13 @@ function parseContentRange(header: string | null): number {
|
|
|
337
340
|
|
|
338
341
|
async function parseError(response: Response): Promise<ClientError> {
|
|
339
342
|
const body = await response.json().catch(() => ({}));
|
|
340
|
-
return {
|
|
343
|
+
return {
|
|
344
|
+
status: response.status,
|
|
345
|
+
message: body.message || response.statusText,
|
|
346
|
+
...(typeof body.code === 'string' ? { code: body.code } : {}),
|
|
347
|
+
...(typeof body.constraint === 'string' ? { constraint: body.constraint } : {}),
|
|
348
|
+
...(typeof body.column === 'string' ? { column: body.column } : {}),
|
|
349
|
+
};
|
|
341
350
|
}
|
|
342
351
|
|
|
343
352
|
function buildUrl(
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Most elements an array filter value may list. A longer list is refused. */
|
|
2
|
+
export const ARRAY_LITERAL_MAX_ELEMENTS = 100;
|
|
3
|
+
/** Most characters one element may hold, after unescaping. A longer one is refused. */
|
|
4
|
+
export const ARRAY_LITERAL_MAX_ELEMENT_LENGTH = 200;
|
|
5
|
+
|
|
6
|
+
const UNQUOTED_FORBIDDEN = new Set(['{', '}', '"', '\\']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse the PostgREST array value of a cs/cd/ov filter: `{a,b}`, `{"a,b","c\"d"}`, `{}`.
|
|
10
|
+
*
|
|
11
|
+
* An element is either unquoted (non-empty; no comma, brace, double quote, backslash or
|
|
12
|
+
* edge whitespace) or double-quoted, where `\"` and `\\` are the only escapes. Returns
|
|
13
|
+
* null for anything else, or past the element and length caps. The caller binds the
|
|
14
|
+
* result as one parameter; this text never reaches SQL.
|
|
15
|
+
*/
|
|
16
|
+
export function parseArrayLiteral(value: string): string[] | null {
|
|
17
|
+
if (value.length < 2 || value[0] !== '{' || value[value.length - 1] !== '}') return null;
|
|
18
|
+
const body = value.slice(1, -1);
|
|
19
|
+
if (body === '') return [];
|
|
20
|
+
|
|
21
|
+
const elements: string[] = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
for (;;) {
|
|
24
|
+
if (elements.length === ARRAY_LITERAL_MAX_ELEMENTS) return null;
|
|
25
|
+
let element = '';
|
|
26
|
+
if (body[i] === '"') {
|
|
27
|
+
i++;
|
|
28
|
+
for (;;) {
|
|
29
|
+
if (i >= body.length) return null;
|
|
30
|
+
const char = body[i];
|
|
31
|
+
if (char === '"') { i++; break; }
|
|
32
|
+
if (char === '\\') {
|
|
33
|
+
const next = body[i + 1];
|
|
34
|
+
if (next !== '"' && next !== '\\') return null;
|
|
35
|
+
element += next;
|
|
36
|
+
i += 2;
|
|
37
|
+
} else {
|
|
38
|
+
element += char;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
if (element.length > ARRAY_LITERAL_MAX_ELEMENT_LENGTH) return null;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
const start = i;
|
|
45
|
+
while (i < body.length && body[i] !== ',') {
|
|
46
|
+
if (UNQUOTED_FORBIDDEN.has(body[i])) return null;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
element = body.slice(start, i);
|
|
50
|
+
if (element === '' || /^\s|\s$/.test(element)) return null;
|
|
51
|
+
if (element.length > ARRAY_LITERAL_MAX_ELEMENT_LENGTH) return null;
|
|
52
|
+
}
|
|
53
|
+
elements.push(element);
|
|
54
|
+
if (i === body.length) return elements;
|
|
55
|
+
if (body[i] !== ',') return null;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/handler/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* // Mount as Expo Router API route or any Web Standard handler
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql, getTableColumns, type SQL } from 'drizzle-orm';
|
|
10
|
+
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, arrayContains, arrayContained, arrayOverlaps, and, or, not, asc, desc, sql, getTableColumns, Param, type SQL } from 'drizzle-orm';
|
|
11
|
+
import { parseArrayLiteral, ARRAY_LITERAL_MAX_ELEMENTS, ARRAY_LITERAL_MAX_ELEMENT_LENGTH } from './array-literal';
|
|
11
12
|
import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
|
|
12
13
|
import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
|
|
13
14
|
import { isPermissionDenied, safeRead, UNREADABLE } from './thrown';
|
|
@@ -60,6 +61,9 @@ export interface HandlerFault {
|
|
|
60
61
|
*/
|
|
61
62
|
status: number;
|
|
62
63
|
message: string;
|
|
64
|
+
/** SQL names of the cell when a sanitizeColumns cleaner fails. */
|
|
65
|
+
table?: string;
|
|
66
|
+
column?: string;
|
|
63
67
|
stack?: string;
|
|
64
68
|
/** Postgres SQLSTATE code when the error carried one. */
|
|
65
69
|
pgCode?: string;
|
|
@@ -361,6 +365,14 @@ export interface HandlerOptions {
|
|
|
361
365
|
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
362
366
|
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
363
367
|
protectedFields?: Record<string, string[]>;
|
|
368
|
+
/** HTML columns to clean on POST/PATCH after before-hooks. Table and column
|
|
369
|
+
* names accept SQL or Drizzle spelling. Requires sanitize when non-empty.
|
|
370
|
+
* Changed bytes are stored and signalled by X-Sanitized-Columns. */
|
|
371
|
+
sanitizeColumns?: Record<string, string[]>;
|
|
372
|
+
/** Injected HTML cleaner for sanitizeColumns. Context names are SQL names.
|
|
373
|
+
* Must return a string synchronously, ideally a fixed point. Throws or non-string returns
|
|
374
|
+
* refuse the entire write with 500 and report through onError. */
|
|
375
|
+
sanitize?: (html: string, ctx: { table: string; column: string }) => string;
|
|
364
376
|
/** Maximum nesting depth for relation embedding via ?select=rel(sub_rel(...)).
|
|
365
377
|
* Prevents DoS from deeply nested relation queries. Default: 3. */
|
|
366
378
|
maxEmbedDepth?: number;
|
|
@@ -381,6 +393,9 @@ export interface HandlerOptions {
|
|
|
381
393
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
382
394
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
383
395
|
hideSchemaErrors?: boolean;
|
|
396
|
+
/** App-approved messages keyed by PostgreSQL constraint name. These may expose an
|
|
397
|
+
* optional column even with hideSchemaErrors; hiddenColumns always wins. */
|
|
398
|
+
constraintMessages?: Record<string, string | { message: string; column?: string }>;
|
|
384
399
|
/** Forensic audit logging (distinct from observability). When a sink is set,
|
|
385
400
|
* records every non-GET request (writes + RPCs) — the query, not the response —
|
|
386
401
|
* to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
|
|
@@ -781,6 +796,7 @@ function expandTableKeyedOptions(
|
|
|
781
796
|
...(options.exposedTables ? { exposedTables: expandList(options.exposedTables) } : {}),
|
|
782
797
|
...(options.hiddenColumns ? { hiddenColumns: expandMap(options.hiddenColumns) } : {}),
|
|
783
798
|
...(options.protectedFields ? { protectedFields: expandMap(options.protectedFields) } : {}),
|
|
799
|
+
...(options.sanitizeColumns ? { sanitizeColumns: expandMap(options.sanitizeColumns) } : {}),
|
|
784
800
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
785
801
|
...(options.relations ? { relations: expandMap(options.relations) } : {}),
|
|
786
802
|
...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
|
|
@@ -892,6 +908,167 @@ function mayRestore(tableName: string, options: HandlerOptions | undefined, user
|
|
|
892
908
|
return role !== '' && roles.includes(role);
|
|
893
909
|
}
|
|
894
910
|
|
|
911
|
+
/** Refuse a cleaning policy that cannot be enforced, including conflicting table aliases. */
|
|
912
|
+
function validateSanitizeColumns(schema: Schema, options: HandlerOptions | undefined): void {
|
|
913
|
+
const configured = new Map<DrizzleTable, Set<string>>();
|
|
914
|
+
for (const [tableName, columns] of Object.entries(options?.sanitizeColumns ?? {})) {
|
|
915
|
+
const prefix = `[everystack/api] sanitizeColumns.${tableName}:`;
|
|
916
|
+
const table = schema[tableName];
|
|
917
|
+
if (!table || sqlNameOf(table) === null) {
|
|
918
|
+
throw new Error(`${prefix} table "${tableName}" is not in the schema (columns ${JSON.stringify(columns)})`);
|
|
919
|
+
}
|
|
920
|
+
if (typeof options?.sanitize !== 'function') {
|
|
921
|
+
throw new Error(`${prefix} columns ${JSON.stringify(columns)} require a sanitize function`);
|
|
922
|
+
}
|
|
923
|
+
const resolved = new Set<string>();
|
|
924
|
+
const tableColumns = getTableColumns(table);
|
|
925
|
+
for (const name of columns) {
|
|
926
|
+
const key = columnPropertyName(table, name);
|
|
927
|
+
if (key === undefined) {
|
|
928
|
+
throw new Error(`${prefix} column "${name}" does not resolve on table "${tableName}"`);
|
|
929
|
+
}
|
|
930
|
+
const wire = options?.naming === 'snake_case' ? toSnakeCase(key) : key;
|
|
931
|
+
if (!/^[A-Za-z0-9_]+$/.test(wire)) {
|
|
932
|
+
throw new Error(`${prefix} column "${name}" has a name that cannot appear safely in X-Sanitized-Columns (use letters, digits and underscore)`);
|
|
933
|
+
}
|
|
934
|
+
if (tableColumns[key].dataType !== 'string') {
|
|
935
|
+
throw new Error(`${prefix} column "${name}" must be a string column`);
|
|
936
|
+
}
|
|
937
|
+
resolved.add(key);
|
|
938
|
+
}
|
|
939
|
+
const prior = configured.get(table);
|
|
940
|
+
if (prior && (prior.size !== resolved.size || [...prior].some(key => !resolved.has(key)))) {
|
|
941
|
+
throw new Error(`${prefix} different column lists conflict for the two table spellings: ${JSON.stringify([...prior])} and ${JSON.stringify([...resolved])}`);
|
|
942
|
+
}
|
|
943
|
+
configured.set(table, resolved);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Resolve hidden columns once so every read uses the same column spellings. */
|
|
948
|
+
function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): HandlerOptions | undefined {
|
|
949
|
+
if (!options?.hiddenColumns) return options;
|
|
950
|
+
const byTable = new Map<DrizzleTable, Set<string>>();
|
|
951
|
+
for (const [tableName, names] of Object.entries(options.hiddenColumns)) {
|
|
952
|
+
const table = schema[tableName];
|
|
953
|
+
const hidden = byTable.get(table) ?? new Set<string>();
|
|
954
|
+
for (const name of names) {
|
|
955
|
+
const property = columnPropertyName(table, name);
|
|
956
|
+
if (property === undefined) {
|
|
957
|
+
throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" does not resolve to a column`);
|
|
958
|
+
}
|
|
959
|
+
const column = getTableColumns(table)[property] as DrizzleColumn;
|
|
960
|
+
for (const spelling of [property, column.name, toSnakeCase(property), toCamelCase(column.name)]) {
|
|
961
|
+
hidden.add(spelling);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
byTable.set(table, hidden);
|
|
965
|
+
}
|
|
966
|
+
const hiddenColumns = Object.fromEntries(Object.keys(options.hiddenColumns)
|
|
967
|
+
.map(tableName => [tableName, [...byTable.get(schema[tableName])!]]));
|
|
968
|
+
return { ...options, hiddenColumns };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Safe, fixed client messages. Never derive these from PostgreSQL text or detail. */
|
|
972
|
+
const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
|
|
973
|
+
'23514': { status: 422, message: 'Value does not meet a constraint' },
|
|
974
|
+
'23502': { status: 422, message: 'A required value is missing' },
|
|
975
|
+
'23503': { status: 422, message: 'Related record does not exist' },
|
|
976
|
+
'23000': { status: 422, message: 'Value violates an integrity constraint' },
|
|
977
|
+
'23505': { status: 409, message: 'Value already exists' },
|
|
978
|
+
'23P01': { status: 409, message: 'Value conflicts with an existing record' },
|
|
979
|
+
'22P02': { status: 422, message: 'Invalid value format' },
|
|
980
|
+
'22001': { status: 422, message: 'Value is too long' },
|
|
981
|
+
'22003': { status: 422, message: 'Value is out of range' },
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
type IntegrityAnswer = { status: number; body: {
|
|
985
|
+
message: string; code: string; constraint?: string; column?: string;
|
|
986
|
+
} };
|
|
987
|
+
|
|
988
|
+
/** How many `cause` links the integrity mapper follows from the thrown value. */
|
|
989
|
+
const MAX_CAUSE_HOPS = 3;
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Both dispatch catches use this after their transaction has rolled back. A throwing
|
|
993
|
+
* diagnostic getter invalidates the whole classification, including a wrapped cause.
|
|
994
|
+
* undefined means unclassified; UNREADABLE preserves the caller's 500 fault path.
|
|
995
|
+
*/
|
|
996
|
+
function mapIntegrityError(
|
|
997
|
+
error: unknown,
|
|
998
|
+
tableName: string | undefined,
|
|
999
|
+
options?: HandlerOptions,
|
|
1000
|
+
): IntegrityAnswer | typeof UNREADABLE | undefined {
|
|
1001
|
+
// Drizzle drivers may throw directly or wrap the driver's error as cause, and a
|
|
1002
|
+
// query wrapper can wrap again. The first string code on the chain decides.
|
|
1003
|
+
// Classify before touching diagnostics, so unrelated codes keep their old answer.
|
|
1004
|
+
const chain: unknown[] = [error];
|
|
1005
|
+
let code = safeRead(error, 'code');
|
|
1006
|
+
while (typeof code !== 'string' && chain.length <= MAX_CAUSE_HOPS) {
|
|
1007
|
+
if (code === UNREADABLE) return UNREADABLE;
|
|
1008
|
+
const cause = safeRead(chain[chain.length - 1], 'cause');
|
|
1009
|
+
if (cause === UNREADABLE) return UNREADABLE;
|
|
1010
|
+
if (cause === null || typeof cause !== 'object' || chain.includes(cause)) break;
|
|
1011
|
+
chain.push(cause);
|
|
1012
|
+
code = safeRead(cause, 'code');
|
|
1013
|
+
}
|
|
1014
|
+
if (code === UNREADABLE) return UNREADABLE;
|
|
1015
|
+
if (typeof code !== 'string' || !Object.hasOwn(INTEGRITY_ERRORS, code)) return undefined;
|
|
1016
|
+
const read = (value: unknown) => ({
|
|
1017
|
+
message: safeRead(value, 'message'), cause: safeRead(value, 'cause'),
|
|
1018
|
+
constraint: safeRead(value, 'constraint') ?? safeRead(value, 'constraint_name'),
|
|
1019
|
+
column: safeRead(value, 'column') ?? safeRead(value, 'column_name'),
|
|
1020
|
+
table: safeRead(value, 'table') ?? safeRead(value, 'table_name'),
|
|
1021
|
+
});
|
|
1022
|
+
// Diagnostics come only from the object that carried the code (the last link).
|
|
1023
|
+
// Wrapper messages and fields are read only to refuse a hostile value.
|
|
1024
|
+
let fields!: ReturnType<typeof read>;
|
|
1025
|
+
for (const value of chain) {
|
|
1026
|
+
fields = read(value);
|
|
1027
|
+
if (Object.values(fields).includes(UNREADABLE)) return UNREADABLE;
|
|
1028
|
+
}
|
|
1029
|
+
const mapped = INTEGRITY_ERRORS[code];
|
|
1030
|
+
const constraint = typeof fields.constraint === 'string' ? fields.constraint : undefined;
|
|
1031
|
+
const declared = constraint === undefined ? undefined : ownEntry(options?.constraintMessages, constraint);
|
|
1032
|
+
const body: IntegrityAnswer['body'] = { message: mapped.message, code };
|
|
1033
|
+
if (declared !== undefined) {
|
|
1034
|
+
body.message = typeof declared === 'string' ? declared : declared.message;
|
|
1035
|
+
body.constraint = constraint;
|
|
1036
|
+
if (typeof declared !== 'string' && declared.column !== undefined) body.column = declared.column;
|
|
1037
|
+
} else if (!options?.hideSchemaErrors) {
|
|
1038
|
+
if (constraint !== undefined) body.constraint = constraint;
|
|
1039
|
+
if (code === '23502' && typeof fields.column === 'string') body.column = fields.column;
|
|
1040
|
+
}
|
|
1041
|
+
// A failing trigger or FK action can name a different table than the URL.
|
|
1042
|
+
// Missing driver tables (and unknown RPC policies) use every configured list.
|
|
1043
|
+
const driverTable = typeof fields.table === 'string' ? fields.table : undefined;
|
|
1044
|
+
const hiddenColumns = options?.hiddenColumns ?? {};
|
|
1045
|
+
const driverHidden = driverTable === undefined
|
|
1046
|
+
|| (tableName === undefined && !Object.hasOwn(hiddenColumns, driverTable))
|
|
1047
|
+
? Object.values(hiddenColumns).flat() : ownEntry(hiddenColumns, driverTable) ?? [];
|
|
1048
|
+
const hidden = [
|
|
1049
|
+
...(tableName !== undefined ? ownEntry(hiddenColumns, tableName) ?? [] : []),
|
|
1050
|
+
...driverHidden,
|
|
1051
|
+
];
|
|
1052
|
+
if (body.column !== undefined && hidden.includes(body.column)) delete body.column;
|
|
1053
|
+
return { status: mapped.status, body };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function validateConstraintMessages(options?: HandlerOptions): void {
|
|
1057
|
+
if (options?.constraintMessages === undefined) return;
|
|
1058
|
+
if (options.constraintMessages === null || typeof options.constraintMessages !== 'object'
|
|
1059
|
+
|| Array.isArray(options.constraintMessages)) {
|
|
1060
|
+
throw new Error('[everystack/api] constraintMessages must be a record');
|
|
1061
|
+
}
|
|
1062
|
+
for (const [constraint, entry] of Object.entries(options.constraintMessages)) {
|
|
1063
|
+
const object = entry !== null && typeof entry === 'object' && !Array.isArray(entry);
|
|
1064
|
+
const message = typeof entry === 'string' ? entry : object ? entry.message : undefined;
|
|
1065
|
+
if (typeof message !== 'string' || message.trim().length === 0
|
|
1066
|
+
|| (object && entry.column !== undefined && typeof entry.column !== 'string')) {
|
|
1067
|
+
throw new Error(`[everystack/api] constraintMessages.${constraint}: expected a non-empty message and optional string column`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
895
1072
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
896
1073
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
897
1074
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -900,20 +1077,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
900
1077
|
// apply. The root listing below stays one entry per table (the SQL name).
|
|
901
1078
|
const namePairs = tableNamePairs(rawSchema);
|
|
902
1079
|
const schema = expandSchema(rawSchema, namePairs);
|
|
903
|
-
const options = expandTableKeyedOptions(rawOptions, namePairs);
|
|
904
|
-
|
|
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
|
-
}
|
|
1080
|
+
const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1081
|
+
validateConstraintMessages(options);
|
|
915
1082
|
validateRowOwnership(schema, rawOptions);
|
|
916
1083
|
validateSoftDeleteRestore(schema, options);
|
|
1084
|
+
validateSanitizeColumns(schema, options);
|
|
917
1085
|
const tableNames = new Set(Object.keys(schema));
|
|
918
1086
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
919
1087
|
|
|
@@ -1094,20 +1262,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1094
1262
|
const rpcCacheControl = getCacheControl(options);
|
|
1095
1263
|
return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
|
|
1096
1264
|
} catch (error) {
|
|
1265
|
+
// App answers and outages take precedence over integrity classification.
|
|
1266
|
+
const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
|
|
1267
|
+
const integrity = error instanceof HandlerError || unavailable
|
|
1268
|
+
? undefined : mapIntegrityError(error, undefined, options);
|
|
1097
1269
|
const code = safeRead(error, 'code');
|
|
1098
1270
|
const text = safeRead(error, 'message');
|
|
1099
|
-
// A value whose getter throws is hostile: classify nothing from it
|
|
1100
|
-
//
|
|
1101
|
-
const hostile =
|
|
1271
|
+
// A value whose getter throws is hostile: classify nothing from it and echo
|
|
1272
|
+
// nothing of it, whatever its code (the mapper reads cause only for its codes).
|
|
1273
|
+
const hostile = integrity === UNREADABLE || code === UNREADABLE || text === UNREADABLE
|
|
1274
|
+
|| safeRead(error, 'cause') === UNREADABLE;
|
|
1102
1275
|
const pgCode = hostile ? undefined : code;
|
|
1103
|
-
|
|
1104
|
-
// carries host and port; it goes to onError only. A HandlerError's
|
|
1105
|
-
// own answer still wins: its return below runs first.
|
|
1106
|
-
const unavailable = isServiceUnavailable(error);
|
|
1276
|
+
const mapped = !hostile ? integrity : undefined;
|
|
1107
1277
|
let status = 500;
|
|
1108
1278
|
if (unavailable) status = 503;
|
|
1279
|
+
else if (mapped) status = mapped.status;
|
|
1109
1280
|
else if (pgCode === '42501') status = 403;
|
|
1110
|
-
else if (pgCode === '23505') status = 409;
|
|
1111
1281
|
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
1112
1282
|
// Split the outcome: an authz/validation refusal (4xx) is `deny`; a
|
|
1113
1283
|
// system fault (5xx) is `error`. Conflating them blinds an investigation.
|
|
@@ -1130,6 +1300,8 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1130
1300
|
return json({ message: error.message }, error.status);
|
|
1131
1301
|
}
|
|
1132
1302
|
if (unavailable) return serviceUnavailableResponse();
|
|
1303
|
+
if (hostile) return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'RPC execution failed' }, 500);
|
|
1304
|
+
if (mapped) return json(mapped.body, mapped.status);
|
|
1133
1305
|
const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
|
|
1134
1306
|
if (options?.hideSchemaErrors && status === 500) {
|
|
1135
1307
|
return json({ message: 'Internal Server Error' }, 500);
|
|
@@ -1208,6 +1380,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1208
1380
|
// Bound every select before the hidden-column guard walks it. The guard
|
|
1209
1381
|
// reads duplicates and runs for writes too, so a GET-only check is too late.
|
|
1210
1382
|
let queryError: string | undefined;
|
|
1383
|
+
let queryErrorEchoesInput = false;
|
|
1211
1384
|
let auditReadDenial = false;
|
|
1212
1385
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1213
1386
|
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
@@ -1218,20 +1391,33 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1218
1391
|
}
|
|
1219
1392
|
}
|
|
1220
1393
|
|
|
1394
|
+
// A part with parentheses is an aggregate or a declared embed. Anything
|
|
1395
|
+
// else cannot be honored, so refuse it rather than drop it from the rows.
|
|
1396
|
+
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1397
|
+
if (!queryError) {
|
|
1398
|
+
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1399
|
+
const { undeclared } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1400
|
+
if (undeclared !== undefined) {
|
|
1401
|
+
queryError = undeclaredSelectPartMessage(undeclared);
|
|
1402
|
+
queryErrorEchoesInput = true;
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1221
1408
|
// Refusals share the response audit below without opening a transaction.
|
|
1222
1409
|
// Response stripping cannot prevent value oracles or aggregate alias reads.
|
|
1223
1410
|
if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
|
|
1224
1411
|
queryError = 'Invalid query';
|
|
1225
1412
|
}
|
|
1226
|
-
// Refuse projections
|
|
1227
|
-
|
|
1228
|
-
if (!queryError && options?.drizzleRelations && tableRelations) {
|
|
1413
|
+
// Refuse projections neither read path can enforce, on the common pre-DB audit path.
|
|
1414
|
+
if (!queryError && tableRelations) {
|
|
1229
1415
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1230
|
-
const { embeds } = parseSelectParam(selectParam, tableRelations, options
|
|
1231
|
-
const
|
|
1232
|
-
if (
|
|
1416
|
+
const { embeds } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1417
|
+
const refused = refusedEmbedProjection(embeds, tableRelations, schema, options);
|
|
1418
|
+
if (refused !== undefined) {
|
|
1233
1419
|
queryError = 'Invalid query';
|
|
1234
|
-
auditReadDenial = method === 'GET' &&
|
|
1420
|
+
auditReadDenial = method === 'GET' && refused === 'unresolvedHiddenColumns';
|
|
1235
1421
|
break;
|
|
1236
1422
|
}
|
|
1237
1423
|
}
|
|
@@ -1255,7 +1441,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1255
1441
|
const deferred: Deferred[] = [];
|
|
1256
1442
|
let response: Response;
|
|
1257
1443
|
try {
|
|
1258
|
-
response = queryError ? json({ message: queryError }, 400)
|
|
1444
|
+
response = queryError ? (queryErrorEchoesInput ? sanitizeError(queryError, 400, options) : json({ message: queryError }, 400))
|
|
1259
1445
|
: unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
|
|
1260
1446
|
if (method === 'GET') {
|
|
1261
1447
|
const cacheControl = getCacheControl(options);
|
|
@@ -1296,20 +1482,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1296
1482
|
}
|
|
1297
1483
|
return response;
|
|
1298
1484
|
} catch (error) {
|
|
1485
|
+
const appError = error instanceof HandlerError;
|
|
1486
|
+
const unavailable = !appError && isServiceUnavailable(error);
|
|
1487
|
+
const integrity = appError || unavailable ? undefined : mapIntegrityError(error, tableName, options);
|
|
1488
|
+
const hostile = integrity === UNREADABLE;
|
|
1489
|
+
const mapped = integrity !== UNREADABLE ? integrity : undefined;
|
|
1490
|
+
const permissionDenied = !appError && !unavailable && !hostile && isPermissionDenied(error);
|
|
1299
1491
|
if (isWrite) {
|
|
1300
1492
|
await emitAudit(options?.audit, {
|
|
1301
1493
|
kind: 'rest', name: tableName, user, client,
|
|
1302
|
-
result:
|
|
1494
|
+
result: mapped || permissionDenied ? 'deny' : 'error',
|
|
1303
1495
|
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1304
1496
|
});
|
|
1305
1497
|
}
|
|
1306
|
-
if (
|
|
1307
|
-
|
|
1308
|
-
}
|
|
1309
|
-
// An outage answers 503 with a fixed body; the driver's message (host and
|
|
1310
|
-
// port) goes to onError only. A HandlerError is the app's own answer and
|
|
1311
|
-
// never an outage.
|
|
1312
|
-
const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
|
|
1498
|
+
if (mapped) return json(mapped.body, mapped.status);
|
|
1499
|
+
if (permissionDenied) return sanitizeError('Permission denied', 403, options);
|
|
1313
1500
|
// Other table faults re-throw to the Lambda boundary (which logs to
|
|
1314
1501
|
// CloudWatch), but that path never reaches the S3 archive. Surface the
|
|
1315
1502
|
// cause here too so a write 5xx is in logs:errors. A HandlerError with a
|
|
@@ -1353,12 +1540,8 @@ function buildWithClause(
|
|
|
1353
1540
|
if (!relation) continue;
|
|
1354
1541
|
hasEmbedded = true;
|
|
1355
1542
|
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
|
-
}
|
|
1543
|
+
const names = new Set(options?.hiddenColumns?.[relation.table] ?? []);
|
|
1544
|
+
const hidden = new Set(table ? Object.keys(getTableColumns(table)).filter(name => names.has(name)) : []);
|
|
1362
1545
|
const config: Record<string, any> = {};
|
|
1363
1546
|
if (embed.columns !== null) {
|
|
1364
1547
|
// Drop names that are not columns of a known table: Drizzle ignores them,
|
|
@@ -1394,13 +1577,7 @@ function stripEmbeddedHiddenColumns(
|
|
|
1394
1577
|
const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
|
|
1395
1578
|
const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
|
|
1396
1579
|
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
|
-
});
|
|
1580
|
+
const related = stripHiddenColumns(result[key], relation.table, options);
|
|
1404
1581
|
result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
|
|
1405
1582
|
}
|
|
1406
1583
|
return result;
|
|
@@ -1747,7 +1924,7 @@ function parseSelectParam(
|
|
|
1747
1924
|
selectParam: string | null,
|
|
1748
1925
|
tableRelations?: Record<string, RelationConfig>,
|
|
1749
1926
|
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
1750
|
-
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
|
|
1927
|
+
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
|
|
1751
1928
|
if (!selectParam) {
|
|
1752
1929
|
return { columns: null, embeds: [], aggregates: [] };
|
|
1753
1930
|
}
|
|
@@ -1756,6 +1933,9 @@ function parseSelectParam(
|
|
|
1756
1933
|
const columns: string[] = [];
|
|
1757
1934
|
const aggregates: AggregateField[] = [];
|
|
1758
1935
|
let hasWildcard = false;
|
|
1936
|
+
// The first part with parentheses that is neither an aggregate nor a declared
|
|
1937
|
+
// embed, at any depth. The caller refuses the select; it is never a column.
|
|
1938
|
+
let undeclared: string | undefined;
|
|
1759
1939
|
|
|
1760
1940
|
// Parse: split on commas, but respect parentheses
|
|
1761
1941
|
const parts = splitSelectParam(selectParam);
|
|
@@ -1779,11 +1959,14 @@ function parseSelectParam(
|
|
|
1779
1959
|
const targetTableName = tableRelations[name].table;
|
|
1780
1960
|
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
1781
1961
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
1962
|
+
undeclared ??= inner.undeclared;
|
|
1782
1963
|
embeds.push({
|
|
1783
1964
|
name,
|
|
1784
1965
|
columns: inner.columns,
|
|
1785
1966
|
embeds: inner.embeds,
|
|
1786
1967
|
});
|
|
1968
|
+
} else if (part.includes('(')) {
|
|
1969
|
+
undeclared ??= part;
|
|
1787
1970
|
} else if (part === '*') {
|
|
1788
1971
|
hasWildcard = true;
|
|
1789
1972
|
} else {
|
|
@@ -1795,9 +1978,39 @@ function parseSelectParam(
|
|
|
1795
1978
|
columns: hasWildcard ? null : (columns.length > 0 ? columns : null),
|
|
1796
1979
|
embeds,
|
|
1797
1980
|
aggregates,
|
|
1981
|
+
...(undeclared === undefined ? {} : { undeclared }),
|
|
1798
1982
|
};
|
|
1799
1983
|
}
|
|
1800
1984
|
|
|
1985
|
+
/** The 400 for an undeclared select part. It echoes client input only, capped. */
|
|
1986
|
+
function undeclaredSelectPartMessage(part: string): string {
|
|
1987
|
+
const chars = Array.from(part);
|
|
1988
|
+
const shown = chars.length > 200 ? `${chars.slice(0, 200).join('')}…` : part;
|
|
1989
|
+
return `Select part is not a declared embed or an aggregate: ${shown}`;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* An embed list left empty once unknown and hidden names are removed, at any
|
|
1994
|
+
* depth. Both read paths refuse it the same way, before any DB call.
|
|
1995
|
+
*/
|
|
1996
|
+
function refusedEmbedProjection(
|
|
1997
|
+
embeds: EmbedRequest[],
|
|
1998
|
+
tableRelations: Record<string, RelationConfig>,
|
|
1999
|
+
schema: Schema,
|
|
2000
|
+
options?: HandlerOptions,
|
|
2001
|
+
): null | 'unresolvedHiddenColumns' | undefined {
|
|
2002
|
+
const withClause = buildWithClause(embeds, tableRelations, schema, options);
|
|
2003
|
+
if (withClause === null || withClause === 'unresolvedHiddenColumns') return withClause;
|
|
2004
|
+
for (const embed of embeds) {
|
|
2005
|
+
const relation = ownEntry(tableRelations, embed.name);
|
|
2006
|
+
const targetRelations = relation && ownEntry(options?.relations, relation.table);
|
|
2007
|
+
if (embed.embeds.length === 0 || !targetRelations) continue;
|
|
2008
|
+
const nested = refusedEmbedProjection(embed.embeds, targetRelations, schema, options);
|
|
2009
|
+
if (nested !== undefined) return nested;
|
|
2010
|
+
}
|
|
2011
|
+
return undefined;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1801
2014
|
/**
|
|
1802
2015
|
* Split select param respecting parentheses:
|
|
1803
2016
|
* "id,title,author(name,email),comments(*)" → ["id", "title", "author(name,email)", "comments(*)"]
|
|
@@ -1852,14 +2065,7 @@ function requestUsesHiddenColumn(
|
|
|
1852
2065
|
}
|
|
1853
2066
|
columnName = parts[0];
|
|
1854
2067
|
}
|
|
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
|
-
});
|
|
2068
|
+
return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
|
|
1863
2069
|
};
|
|
1864
2070
|
|
|
1865
2071
|
const selectUsesHidden = (sourceTable: string, select: string): boolean => {
|
|
@@ -2057,6 +2263,95 @@ function beforeHookFailure(
|
|
|
2057
2263
|
return json({ message: 'Hook error' }, 500);
|
|
2058
2264
|
}
|
|
2059
2265
|
|
|
2266
|
+
function errorKind(err: unknown): string {
|
|
2267
|
+
try {
|
|
2268
|
+
return err instanceof TypeError ? 'TypeError'
|
|
2269
|
+
: err instanceof RangeError ? 'RangeError'
|
|
2270
|
+
: err instanceof SyntaxError ? 'SyntaxError'
|
|
2271
|
+
: err instanceof ReferenceError ? 'ReferenceError'
|
|
2272
|
+
: err instanceof EvalError ? 'EvalError'
|
|
2273
|
+
: err instanceof URIError ? 'URIError'
|
|
2274
|
+
: err instanceof AggregateError ? 'AggregateError'
|
|
2275
|
+
: err instanceof Error ? 'Error' : 'non-Error';
|
|
2276
|
+
} catch {
|
|
2277
|
+
return 'Error';
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function consumeThenable(value: unknown, depth = 4): void {
|
|
2282
|
+
if (depth === 0) return;
|
|
2283
|
+
try {
|
|
2284
|
+
const then = (value as { then?: unknown } | null | undefined)?.then;
|
|
2285
|
+
if (typeof then !== 'function') return;
|
|
2286
|
+
const next = then.call(value, () => {}, () => {});
|
|
2287
|
+
consumeThenable(next, depth - 1);
|
|
2288
|
+
} catch {
|
|
2289
|
+
// A throwing getter or then must not prevent the refusal.
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/** Clean every submitted cell before the caller can start a write. */
|
|
2294
|
+
function sanitizeWriteBody(
|
|
2295
|
+
body: unknown,
|
|
2296
|
+
table: DrizzleTable,
|
|
2297
|
+
tableName: string,
|
|
2298
|
+
options: HandlerOptions | undefined,
|
|
2299
|
+
at: { requestId?: string; name: string; method: string },
|
|
2300
|
+
deferred: Deferred[],
|
|
2301
|
+
): { body: unknown; changed: string[] } | { refusal: Response } {
|
|
2302
|
+
const columns = options?.sanitizeColumns?.[tableName];
|
|
2303
|
+
if (!columns?.length) return { body, changed: [] };
|
|
2304
|
+
const keys = [...new Set(columns.map(name => columnPropertyName(table, name)!))];
|
|
2305
|
+
const hidden = new Set((options?.hiddenColumns?.[tableName] ?? []).map(name => columnPropertyName(table, name)));
|
|
2306
|
+
const refuse = (message: string, column: string): { refusal: Response } => {
|
|
2307
|
+
const fault: HandlerFault = {
|
|
2308
|
+
...at, kind: 'rest', status: 500, table: sqlNameOf(table)!, column, message,
|
|
2309
|
+
};
|
|
2310
|
+
deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
|
|
2311
|
+
return { refusal: json({ message: 'Sanitizer error' }, 500) };
|
|
2312
|
+
};
|
|
2313
|
+
const bulk = Array.isArray(body);
|
|
2314
|
+
const rows = bulk ? body : [body];
|
|
2315
|
+
const cleaned: unknown[] = [];
|
|
2316
|
+
const changed: string[] = [];
|
|
2317
|
+
for (const [index, row] of rows.entries()) {
|
|
2318
|
+
// Leave body-shape checks to coerceDateColumnValues.
|
|
2319
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
|
|
2320
|
+
cleaned.push(row);
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
const result: Record<string, unknown> = { ...row };
|
|
2324
|
+
for (const key of keys) {
|
|
2325
|
+
if (!Object.hasOwn(result, key) || result[key] === null) continue;
|
|
2326
|
+
const value = result[key];
|
|
2327
|
+
const wire = options?.naming === 'snake_case' ? toSnakeCase(key) : key;
|
|
2328
|
+
if (typeof value !== 'string') {
|
|
2329
|
+
return { refusal: sanitizeError(`Column "${wire}" must be a string`, 400, options) };
|
|
2330
|
+
}
|
|
2331
|
+
let sanitized: unknown;
|
|
2332
|
+
try {
|
|
2333
|
+
sanitized = options!.sanitize!(value, { table: sqlNameOf(table)!, column: table[key].name });
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
consumeThenable(err);
|
|
2336
|
+
return refuse(`Sanitizer threw (${errorKind(err)})`, table[key].name);
|
|
2337
|
+
}
|
|
2338
|
+
if (typeof sanitized !== 'string') {
|
|
2339
|
+
// An async cleaner is invalid; rejection handling is bounded and best effort.
|
|
2340
|
+
consumeThenable(sanitized);
|
|
2341
|
+
return refuse('Sanitizer returned a non-string', table[key].name);
|
|
2342
|
+
}
|
|
2343
|
+
result[key] = sanitized;
|
|
2344
|
+
if (sanitized !== value && !hidden.has(key)) changed.push(bulk ? `${wire}[${index}]` : wire);
|
|
2345
|
+
}
|
|
2346
|
+
cleaned.push(result);
|
|
2347
|
+
}
|
|
2348
|
+
return { body: bulk ? cleaned : cleaned[0], changed };
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function sanitizedHeaders(changed: string[]): Record<string, string> | undefined {
|
|
2352
|
+
return changed.length > 0 ? { 'X-Sanitized-Columns': changed.join(',') } : undefined;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2060
2355
|
async function handlePost(
|
|
2061
2356
|
db: DrizzleDb,
|
|
2062
2357
|
table: DrizzleTable,
|
|
@@ -2099,6 +2394,11 @@ async function handlePost(
|
|
|
2099
2394
|
}
|
|
2100
2395
|
}
|
|
2101
2396
|
|
|
2397
|
+
const sanitized = sanitizeWriteBody(body, table, tableName, options,
|
|
2398
|
+
{ requestId, name: tableName, method: 'POST' }, deferred);
|
|
2399
|
+
if ('refusal' in sanitized) return sanitized.refusal;
|
|
2400
|
+
body = sanitized.body as Record<string, unknown>;
|
|
2401
|
+
|
|
2102
2402
|
let coerced: unknown;
|
|
2103
2403
|
try {
|
|
2104
2404
|
coerced = coerceDateColumnValues(body, table);
|
|
@@ -2117,7 +2417,8 @@ async function handlePost(
|
|
|
2117
2417
|
() => afterCreate.call(hook, rows[0], user, client));
|
|
2118
2418
|
}
|
|
2119
2419
|
|
|
2120
|
-
return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201
|
|
2420
|
+
return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201,
|
|
2421
|
+
rows.length > 0 ? sanitizedHeaders(sanitized.changed) : undefined);
|
|
2121
2422
|
}
|
|
2122
2423
|
|
|
2123
2424
|
async function handlePatch(
|
|
@@ -2183,6 +2484,11 @@ async function handlePatch(
|
|
|
2183
2484
|
return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
|
|
2184
2485
|
}
|
|
2185
2486
|
|
|
2487
|
+
// Safety: require at least one filter to prevent accidental mass update
|
|
2488
|
+
if (buildsNoFilter(table, params)) {
|
|
2489
|
+
return json({ message: 'PATCH requires at least one filter to prevent accidental mass update' }, 400);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2186
2492
|
// The owner clause proves the caller owns the row, not that they may give it away.
|
|
2187
2493
|
// A caller it scopes may not set the owner column to anyone else. RLS stays primary.
|
|
2188
2494
|
if (scope.condition && user && ownership) {
|
|
@@ -2206,6 +2512,11 @@ async function handlePatch(
|
|
|
2206
2512
|
return json({ message: 'No writable fields in request body' }, 400);
|
|
2207
2513
|
}
|
|
2208
2514
|
|
|
2515
|
+
const sanitized = sanitizeWriteBody(body, table, tableName, options,
|
|
2516
|
+
{ requestId, name: tableName, method: 'PATCH' }, deferred);
|
|
2517
|
+
if ('refusal' in sanitized) return sanitized.refusal;
|
|
2518
|
+
body = sanitized.body as Record<string, unknown>;
|
|
2519
|
+
|
|
2209
2520
|
const filterResult = buildAllConditions(table, params);
|
|
2210
2521
|
if ('error' in filterResult) {
|
|
2211
2522
|
return sanitizeError(filterResult.error, 400, options);
|
|
@@ -2247,7 +2558,8 @@ async function handlePatch(
|
|
|
2247
2558
|
() => afterUpdate.call(hook, rows, user, client));
|
|
2248
2559
|
}
|
|
2249
2560
|
|
|
2250
|
-
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200
|
|
2561
|
+
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200,
|
|
2562
|
+
rows.length > 0 ? sanitizedHeaders(sanitized.changed) : undefined);
|
|
2251
2563
|
}
|
|
2252
2564
|
|
|
2253
2565
|
/**
|
|
@@ -2530,6 +2842,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2530
2842
|
const jp = filter.jsonPath!;
|
|
2531
2843
|
const baseCol = getColumn(table, jp.baseColumn);
|
|
2532
2844
|
if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
|
|
2845
|
+
if (ARRAY_OPERATORS.has(filter.operator)) return arrayOperatorRefusal(filter.operator, jp.baseColumn);
|
|
2533
2846
|
|
|
2534
2847
|
// Validate all JSON path keys before interpolation
|
|
2535
2848
|
for (const step of jp.steps) {
|
|
@@ -2576,6 +2889,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2576
2889
|
case 'is':
|
|
2577
2890
|
if (filter.value === 'null') condition = sql`${expr} IS NULL`;
|
|
2578
2891
|
else if (filter.value === 'not.null') condition = sql`${expr} IS NOT NULL`;
|
|
2892
|
+
else return { __filterError: `Invalid value for 'is': expected null or not.null` };
|
|
2579
2893
|
break;
|
|
2580
2894
|
case 'in': {
|
|
2581
2895
|
const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
@@ -2584,7 +2898,8 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2584
2898
|
break;
|
|
2585
2899
|
}
|
|
2586
2900
|
default:
|
|
2587
|
-
|
|
2901
|
+
// Refused, never dropped: a dropped filter widens the answer.
|
|
2902
|
+
return { __filterError: `Unknown filter operator: '${filter.operator.slice(0, 100)}'` };
|
|
2588
2903
|
}
|
|
2589
2904
|
|
|
2590
2905
|
if (condition && filter.negated) {
|
|
@@ -2597,7 +2912,35 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2597
2912
|
return condition;
|
|
2598
2913
|
}
|
|
2599
2914
|
|
|
2600
|
-
const
|
|
2915
|
+
const ARRAY_OPERATORS = new Set(['cs', 'cd', 'ov']);
|
|
2916
|
+
const VALID_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'is', 'in', ...ARRAY_OPERATORS]);
|
|
2917
|
+
const TEXT_ARRAY_ELEMENT_TYPES = new Set(['PgText', 'PgVarchar']);
|
|
2918
|
+
|
|
2919
|
+
function arrayOperatorRefusal(operator: string, column: string): { __filterError: string } {
|
|
2920
|
+
return { __filterError: `Unknown filter operator: '${operator}' for column '${column}' (cs, cd and ov need a text[] or varchar[] column)` };
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* cs (@>), cd (<@) and ov (&&) on a text[] or varchar[] column. The parsed list is
|
|
2925
|
+
* bound as ONE parameter through the column's own encoder. Passing a Param, not the
|
|
2926
|
+
* bare array, keeps the empty set: drizzle's helpers throw on an empty JS array, and
|
|
2927
|
+
* `{}` must keep PostgreSQL's own meaning.
|
|
2928
|
+
*/
|
|
2929
|
+
function buildArrayCondition(column: DrizzleColumn, filter: Filter): any {
|
|
2930
|
+
const element = (column as { baseColumn?: { columnType?: string } }).baseColumn?.columnType;
|
|
2931
|
+
if (column.dataType !== 'array' || !element || !TEXT_ARRAY_ELEMENT_TYPES.has(element)) {
|
|
2932
|
+
return arrayOperatorRefusal(filter.operator, filter.column);
|
|
2933
|
+
}
|
|
2934
|
+
const values = parseArrayLiteral(filter.value);
|
|
2935
|
+
if (!values) {
|
|
2936
|
+
return { __filterError: `Invalid array value for '${filter.operator}' on column '${filter.column}': expected {a,b} with at most ${ARRAY_LITERAL_MAX_ELEMENTS} elements of at most ${ARRAY_LITERAL_MAX_ELEMENT_LENGTH} characters` };
|
|
2937
|
+
}
|
|
2938
|
+
const list = new Param(values, column);
|
|
2939
|
+
const condition = filter.operator === 'cs' ? arrayContains(column, list)
|
|
2940
|
+
: filter.operator === 'cd' ? arrayContained(column, list)
|
|
2941
|
+
: arrayOverlaps(column, list);
|
|
2942
|
+
return filter.negated ? not(condition) : condition;
|
|
2943
|
+
}
|
|
2601
2944
|
|
|
2602
2945
|
function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
2603
2946
|
if (filter.jsonPath) {
|
|
@@ -2610,6 +2953,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2610
2953
|
if (!VALID_OPERATORS.has(filter.operator)) {
|
|
2611
2954
|
return { __filterError: `Unknown filter operator: '${filter.operator}'` };
|
|
2612
2955
|
}
|
|
2956
|
+
if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
|
|
2613
2957
|
|
|
2614
2958
|
let condition: any = null;
|
|
2615
2959
|
|
|
@@ -2715,6 +3059,44 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
2715
3059
|
return filters;
|
|
2716
3060
|
}
|
|
2717
3061
|
|
|
3062
|
+
/** A group part reading `column.[not.]cs.` so far: a `{` here opens an array value. */
|
|
3063
|
+
const ARRAY_VALUE_OPENING = /^[^.]+\.(?:not\.)?(?:cs|cd|ov)\.$/;
|
|
3064
|
+
|
|
3065
|
+
/**
|
|
3066
|
+
* Split a group's content on commas, except inside the `{...}` value of a cs/cd/ov
|
|
3067
|
+
* filter, where a comma, or a brace inside a double-quoted element, is data. Every
|
|
3068
|
+
* other operator splits on every comma, as it always has (`in.(a,b)` included).
|
|
3069
|
+
*/
|
|
3070
|
+
function splitLogicalGroup(content: string): string[] {
|
|
3071
|
+
const parts: string[] = [];
|
|
3072
|
+
let current = '';
|
|
3073
|
+
let inArray = false;
|
|
3074
|
+
let inQuote = false;
|
|
3075
|
+
for (let i = 0; i < content.length; i++) {
|
|
3076
|
+
const char = content[i];
|
|
3077
|
+
if (inArray) {
|
|
3078
|
+
if (inQuote && char === '\\') {
|
|
3079
|
+
current += char + (content[i + 1] ?? '');
|
|
3080
|
+
i++;
|
|
3081
|
+
continue;
|
|
3082
|
+
}
|
|
3083
|
+
if (char === '"') inQuote = !inQuote;
|
|
3084
|
+
else if (char === '}' && !inQuote) inArray = false;
|
|
3085
|
+
current += char;
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
if (char === ',') {
|
|
3089
|
+
parts.push(current);
|
|
3090
|
+
current = '';
|
|
3091
|
+
continue;
|
|
3092
|
+
}
|
|
3093
|
+
if (char === '{' && ARRAY_VALUE_OPENING.test(current)) inArray = true;
|
|
3094
|
+
current += char;
|
|
3095
|
+
}
|
|
3096
|
+
parts.push(current);
|
|
3097
|
+
return parts;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
2718
3100
|
/**
|
|
2719
3101
|
* Parse a logical group value into individual filters.
|
|
2720
3102
|
* Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
|
|
@@ -2726,7 +3108,7 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
2726
3108
|
}
|
|
2727
3109
|
|
|
2728
3110
|
const filters: Filter[] = [];
|
|
2729
|
-
for (const part of content
|
|
3111
|
+
for (const part of splitLogicalGroup(content)) {
|
|
2730
3112
|
const dotIndex = part.indexOf('.');
|
|
2731
3113
|
if (dotIndex === -1) continue;
|
|
2732
3114
|
|