@everystack/api 0.4.5 → 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 +19 -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 +260 -29
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.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
|
+
|
|
7
26
|
## [0.4.5] - 2026-10-15
|
|
8
27
|
|
|
9
28
|
### 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.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';
|
|
@@ -392,6 +393,9 @@ export interface HandlerOptions {
|
|
|
392
393
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
393
394
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
394
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 }>;
|
|
395
399
|
/** Forensic audit logging (distinct from observability). When a sink is set,
|
|
396
400
|
* records every non-GET request (writes + RPCs) — the query, not the response —
|
|
397
401
|
* to an app-provided append-only sink, stamped with the trace-id (`x-amz-cf-id`).
|
|
@@ -964,6 +968,107 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
964
968
|
return { ...options, hiddenColumns };
|
|
965
969
|
}
|
|
966
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
|
+
|
|
967
1072
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
968
1073
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
969
1074
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -973,6 +1078,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
973
1078
|
const namePairs = tableNamePairs(rawSchema);
|
|
974
1079
|
const schema = expandSchema(rawSchema, namePairs);
|
|
975
1080
|
const options = resolveHiddenColumns(schema, expandTableKeyedOptions(rawOptions, namePairs));
|
|
1081
|
+
validateConstraintMessages(options);
|
|
976
1082
|
validateRowOwnership(schema, rawOptions);
|
|
977
1083
|
validateSoftDeleteRestore(schema, options);
|
|
978
1084
|
validateSanitizeColumns(schema, options);
|
|
@@ -1156,20 +1262,22 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1156
1262
|
const rpcCacheControl = getCacheControl(options);
|
|
1157
1263
|
return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
|
|
1158
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);
|
|
1159
1269
|
const code = safeRead(error, 'code');
|
|
1160
1270
|
const text = safeRead(error, 'message');
|
|
1161
|
-
// A value whose getter throws is hostile: classify nothing from it
|
|
1162
|
-
//
|
|
1163
|
-
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;
|
|
1164
1275
|
const pgCode = hostile ? undefined : code;
|
|
1165
|
-
|
|
1166
|
-
// carries host and port; it goes to onError only. A HandlerError's
|
|
1167
|
-
// own answer still wins: its return below runs first.
|
|
1168
|
-
const unavailable = isServiceUnavailable(error);
|
|
1276
|
+
const mapped = !hostile ? integrity : undefined;
|
|
1169
1277
|
let status = 500;
|
|
1170
1278
|
if (unavailable) status = 503;
|
|
1279
|
+
else if (mapped) status = mapped.status;
|
|
1171
1280
|
else if (pgCode === '42501') status = 403;
|
|
1172
|
-
else if (pgCode === '23505') status = 409;
|
|
1173
1281
|
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
1174
1282
|
// Split the outcome: an authz/validation refusal (4xx) is `deny`; a
|
|
1175
1283
|
// system fault (5xx) is `error`. Conflating them blinds an investigation.
|
|
@@ -1192,6 +1300,8 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1192
1300
|
return json({ message: error.message }, error.status);
|
|
1193
1301
|
}
|
|
1194
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);
|
|
1195
1305
|
const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
|
|
1196
1306
|
if (options?.hideSchemaErrors && status === 500) {
|
|
1197
1307
|
return json({ message: 'Internal Server Error' }, 500);
|
|
@@ -1270,6 +1380,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1270
1380
|
// Bound every select before the hidden-column guard walks it. The guard
|
|
1271
1381
|
// reads duplicates and runs for writes too, so a GET-only check is too late.
|
|
1272
1382
|
let queryError: string | undefined;
|
|
1383
|
+
let queryErrorEchoesInput = false;
|
|
1273
1384
|
let auditReadDenial = false;
|
|
1274
1385
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1275
1386
|
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
@@ -1280,20 +1391,33 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1280
1391
|
}
|
|
1281
1392
|
}
|
|
1282
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
|
+
|
|
1283
1408
|
// Refusals share the response audit below without opening a transaction.
|
|
1284
1409
|
// Response stripping cannot prevent value oracles or aggregate alias reads.
|
|
1285
1410
|
if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
|
|
1286
1411
|
queryError = 'Invalid query';
|
|
1287
1412
|
}
|
|
1288
|
-
// Refuse projections
|
|
1289
|
-
|
|
1290
|
-
if (!queryError && options?.drizzleRelations && tableRelations) {
|
|
1413
|
+
// Refuse projections neither read path can enforce, on the common pre-DB audit path.
|
|
1414
|
+
if (!queryError && tableRelations) {
|
|
1291
1415
|
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1292
|
-
const { embeds } = parseSelectParam(selectParam, tableRelations, options
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1416
|
+
const { embeds } = parseSelectParam(selectParam, tableRelations, options?.relations);
|
|
1417
|
+
const refused = refusedEmbedProjection(embeds, tableRelations, schema, options);
|
|
1418
|
+
if (refused !== undefined) {
|
|
1295
1419
|
queryError = 'Invalid query';
|
|
1296
|
-
auditReadDenial = method === 'GET' &&
|
|
1420
|
+
auditReadDenial = method === 'GET' && refused === 'unresolvedHiddenColumns';
|
|
1297
1421
|
break;
|
|
1298
1422
|
}
|
|
1299
1423
|
}
|
|
@@ -1317,7 +1441,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1317
1441
|
const deferred: Deferred[] = [];
|
|
1318
1442
|
let response: Response;
|
|
1319
1443
|
try {
|
|
1320
|
-
response = queryError ? json({ message: queryError }, 400)
|
|
1444
|
+
response = queryError ? (queryErrorEchoesInput ? sanitizeError(queryError, 400, options) : json({ message: queryError }, 400))
|
|
1321
1445
|
: unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
|
|
1322
1446
|
if (method === 'GET') {
|
|
1323
1447
|
const cacheControl = getCacheControl(options);
|
|
@@ -1358,20 +1482,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1358
1482
|
}
|
|
1359
1483
|
return response;
|
|
1360
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);
|
|
1361
1491
|
if (isWrite) {
|
|
1362
1492
|
await emitAudit(options?.audit, {
|
|
1363
1493
|
kind: 'rest', name: tableName, user, client,
|
|
1364
|
-
result:
|
|
1494
|
+
result: mapped || permissionDenied ? 'deny' : 'error',
|
|
1365
1495
|
requestId, operation: auditOp, filter: auditFilter, columns: auditColumns,
|
|
1366
1496
|
});
|
|
1367
1497
|
}
|
|
1368
|
-
if (
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
// An outage answers 503 with a fixed body; the driver's message (host and
|
|
1372
|
-
// port) goes to onError only. A HandlerError is the app's own answer and
|
|
1373
|
-
// never an outage.
|
|
1374
|
-
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);
|
|
1375
1500
|
// Other table faults re-throw to the Lambda boundary (which logs to
|
|
1376
1501
|
// CloudWatch), but that path never reaches the S3 archive. Surface the
|
|
1377
1502
|
// cause here too so a write 5xx is in logs:errors. A HandlerError with a
|
|
@@ -1799,7 +1924,7 @@ function parseSelectParam(
|
|
|
1799
1924
|
selectParam: string | null,
|
|
1800
1925
|
tableRelations?: Record<string, RelationConfig>,
|
|
1801
1926
|
allRelations?: Record<string, Record<string, RelationConfig>>,
|
|
1802
|
-
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
|
|
1927
|
+
): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[]; undeclared?: string } {
|
|
1803
1928
|
if (!selectParam) {
|
|
1804
1929
|
return { columns: null, embeds: [], aggregates: [] };
|
|
1805
1930
|
}
|
|
@@ -1808,6 +1933,9 @@ function parseSelectParam(
|
|
|
1808
1933
|
const columns: string[] = [];
|
|
1809
1934
|
const aggregates: AggregateField[] = [];
|
|
1810
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;
|
|
1811
1939
|
|
|
1812
1940
|
// Parse: split on commas, but respect parentheses
|
|
1813
1941
|
const parts = splitSelectParam(selectParam);
|
|
@@ -1831,11 +1959,14 @@ function parseSelectParam(
|
|
|
1831
1959
|
const targetTableName = tableRelations[name].table;
|
|
1832
1960
|
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
1833
1961
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
1962
|
+
undeclared ??= inner.undeclared;
|
|
1834
1963
|
embeds.push({
|
|
1835
1964
|
name,
|
|
1836
1965
|
columns: inner.columns,
|
|
1837
1966
|
embeds: inner.embeds,
|
|
1838
1967
|
});
|
|
1968
|
+
} else if (part.includes('(')) {
|
|
1969
|
+
undeclared ??= part;
|
|
1839
1970
|
} else if (part === '*') {
|
|
1840
1971
|
hasWildcard = true;
|
|
1841
1972
|
} else {
|
|
@@ -1847,9 +1978,39 @@ function parseSelectParam(
|
|
|
1847
1978
|
columns: hasWildcard ? null : (columns.length > 0 ? columns : null),
|
|
1848
1979
|
embeds,
|
|
1849
1980
|
aggregates,
|
|
1981
|
+
...(undeclared === undefined ? {} : { undeclared }),
|
|
1850
1982
|
};
|
|
1851
1983
|
}
|
|
1852
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
|
+
|
|
1853
2014
|
/**
|
|
1854
2015
|
* Split select param respecting parentheses:
|
|
1855
2016
|
* "id,title,author(name,email),comments(*)" → ["id", "title", "author(name,email)", "comments(*)"]
|
|
@@ -2681,6 +2842,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2681
2842
|
const jp = filter.jsonPath!;
|
|
2682
2843
|
const baseCol = getColumn(table, jp.baseColumn);
|
|
2683
2844
|
if (!baseCol) return { __filterError: `Unknown column: '${jp.baseColumn}'` };
|
|
2845
|
+
if (ARRAY_OPERATORS.has(filter.operator)) return arrayOperatorRefusal(filter.operator, jp.baseColumn);
|
|
2684
2846
|
|
|
2685
2847
|
// Validate all JSON path keys before interpolation
|
|
2686
2848
|
for (const step of jp.steps) {
|
|
@@ -2727,6 +2889,7 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2727
2889
|
case 'is':
|
|
2728
2890
|
if (filter.value === 'null') condition = sql`${expr} IS NULL`;
|
|
2729
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` };
|
|
2730
2893
|
break;
|
|
2731
2894
|
case 'in': {
|
|
2732
2895
|
const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
@@ -2735,7 +2898,8 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2735
2898
|
break;
|
|
2736
2899
|
}
|
|
2737
2900
|
default:
|
|
2738
|
-
|
|
2901
|
+
// Refused, never dropped: a dropped filter widens the answer.
|
|
2902
|
+
return { __filterError: `Unknown filter operator: '${filter.operator.slice(0, 100)}'` };
|
|
2739
2903
|
}
|
|
2740
2904
|
|
|
2741
2905
|
if (condition && filter.negated) {
|
|
@@ -2748,7 +2912,35 @@ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2748
2912
|
return condition;
|
|
2749
2913
|
}
|
|
2750
2914
|
|
|
2751
|
-
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
|
+
}
|
|
2752
2944
|
|
|
2753
2945
|
function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
2754
2946
|
if (filter.jsonPath) {
|
|
@@ -2761,6 +2953,7 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2761
2953
|
if (!VALID_OPERATORS.has(filter.operator)) {
|
|
2762
2954
|
return { __filterError: `Unknown filter operator: '${filter.operator}'` };
|
|
2763
2955
|
}
|
|
2956
|
+
if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
|
|
2764
2957
|
|
|
2765
2958
|
let condition: any = null;
|
|
2766
2959
|
|
|
@@ -2866,6 +3059,44 @@ function parseFilters(params: URLSearchParams): Filter[] {
|
|
|
2866
3059
|
return filters;
|
|
2867
3060
|
}
|
|
2868
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
|
+
|
|
2869
3100
|
/**
|
|
2870
3101
|
* Parse a logical group value into individual filters.
|
|
2871
3102
|
* Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
|
|
@@ -2877,7 +3108,7 @@ function parseLogicalGroup(value: string): Filter[] {
|
|
|
2877
3108
|
}
|
|
2878
3109
|
|
|
2879
3110
|
const filters: Filter[] = [];
|
|
2880
|
-
for (const part of content
|
|
3111
|
+
for (const part of splitLogicalGroup(content)) {
|
|
2881
3112
|
const dotIndex = part.indexOf('.');
|
|
2882
3113
|
if (dotIndex === -1) continue;
|
|
2883
3114
|
|