@everystack/api 0.4.2 → 0.4.4
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 +90 -1
- package/package.json +1 -1
- package/src/client/index.ts +13 -3
- package/src/handler/index.ts +808 -156
- package/src/handler/service-unavailable.ts +48 -0
- package/src/handler/thrown.ts +32 -0
package/src/handler/index.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
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 } from 'drizzle-orm';
|
|
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';
|
|
11
11
|
import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
|
|
12
|
+
import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
|
|
13
|
+
import { isPermissionDenied, safeRead, UNREADABLE } from './thrown';
|
|
12
14
|
export type { AuditConfig, AuditRecord, RequestContext, AuditOutcome, PrincipalKind } from './audit';
|
|
13
15
|
export { getPublicSurface, assertPublicSurface } from './public-surface';
|
|
14
16
|
export type { PublicSurface } from './public-surface';
|
|
@@ -41,12 +43,21 @@ export class HandlerError extends Error {
|
|
|
41
43
|
*/
|
|
42
44
|
export interface HandlerFault {
|
|
43
45
|
requestId?: string;
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* 'rpc' for /rpc/<name>; 'rest' for a table operation; 'hook' for an
|
|
48
|
+
* after-hook that threw after its write (the response stayed 2xx).
|
|
49
|
+
*/
|
|
50
|
+
kind: 'rpc' | 'rest' | 'hook';
|
|
51
|
+
/**
|
|
52
|
+
* RPC function name or table name. For 'hook': '<table>.afterCreate',
|
|
53
|
+
* '<table>.afterUpdate', '<table>.afterDelete' or 'rpc.<fn>.afterRpc'.
|
|
54
|
+
*/
|
|
47
55
|
name: string;
|
|
48
56
|
method: string;
|
|
49
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Always >= 500 here — refusals (4xx) never raise a fault. For 'hook' it is
|
|
59
|
+
* the fault's severity (500), not the response status.
|
|
60
|
+
*/
|
|
50
61
|
status: number;
|
|
51
62
|
message: string;
|
|
52
63
|
stack?: string;
|
|
@@ -68,6 +79,99 @@ async function emitFault(
|
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Report an after-hook that threw. It ran after its write was committed, so
|
|
84
|
+
* the caller still answers 2xx; the hook's failure goes to onError only.
|
|
85
|
+
* Never throws.
|
|
86
|
+
*/
|
|
87
|
+
async function reportHookFault(
|
|
88
|
+
onError: HandlerOptions['onError'],
|
|
89
|
+
at: { requestId?: string; name: string; method: string },
|
|
90
|
+
error: unknown,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const { message, ...rest } = describeError(error);
|
|
93
|
+
await emitFault(onError, {
|
|
94
|
+
...at, kind: 'hook', status: 500, ...rest,
|
|
95
|
+
message: `after-hook failed after the write: ${message}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Work a write handler queues to run once executeWithSettings has returned: with
|
|
101
|
+
* pgSettings, after the transaction has ended, so nothing here holds the connection.
|
|
102
|
+
* `always` runs for every outcome, a failed commit included; it is a before-hook
|
|
103
|
+
* fault, and the hook did fail. Otherwise it is a table after-hook and runs only
|
|
104
|
+
* for a 2xx.
|
|
105
|
+
*/
|
|
106
|
+
interface Deferred {
|
|
107
|
+
always: boolean;
|
|
108
|
+
run: () => Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Queue a table after-hook. Its throw is reported as kind 'hook', never re-thrown. */
|
|
112
|
+
function deferAfterHook(
|
|
113
|
+
deferred: Deferred[],
|
|
114
|
+
onError: HandlerOptions['onError'],
|
|
115
|
+
at: { requestId?: string; name: string; method: string },
|
|
116
|
+
run: () => Promise<void>,
|
|
117
|
+
): void {
|
|
118
|
+
deferred.push({
|
|
119
|
+
always: false,
|
|
120
|
+
run: async () => {
|
|
121
|
+
try {
|
|
122
|
+
await run();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
await reportHookFault(onError, at, err);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Look up a config entry by a name the request supplied. Only an own key
|
|
132
|
+
* counts: a plain object also answers `constructor`, `toString` and
|
|
133
|
+
* `__proto__` from Object.prototype.
|
|
134
|
+
*/
|
|
135
|
+
function ownEntry<T>(obj: Record<string, T> | undefined, key: string): T | undefined {
|
|
136
|
+
return obj && Object.hasOwn(obj, key) ? obj[key] : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Name a fault after a declared RPC only; any other path segment is caller text. */
|
|
140
|
+
function declaredRpcName(rpc: HandlerOptions['rpc'], name: string | undefined): string {
|
|
141
|
+
return name && rpc && Object.hasOwn(rpc, name) ? name : '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Report an auth outage (the token or client verifier could not reach its
|
|
146
|
+
* backing service). It answers a returned 503, so no catch reports it. The
|
|
147
|
+
* client gets the fixed body; the driver's message goes here only.
|
|
148
|
+
* A no-op when `outage` is undefined (the auth failure was a 401/403).
|
|
149
|
+
* Never throws: the 503 must not turn into a rejection.
|
|
150
|
+
*/
|
|
151
|
+
async function emitAuthOutage(
|
|
152
|
+
onError: HandlerOptions['onError'],
|
|
153
|
+
where: Pick<HandlerFault, 'requestId' | 'kind' | 'name' | 'method'>,
|
|
154
|
+
outage: unknown,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
if (!onError || outage === undefined) return;
|
|
157
|
+
await emitFault(onError, { ...where, status: 503, ...describeError(outage) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The message, stack and code of anything thrown, for a fault. Never throws:
|
|
162
|
+
* String() throws on a null-prototype object, and a getter can throw.
|
|
163
|
+
*/
|
|
164
|
+
function describeError(error: unknown): Pick<HandlerFault, 'message' | 'stack' | 'pgCode'> {
|
|
165
|
+
const [message, stack, code] = [safeRead(error, 'message'), safeRead(error, 'stack'), safeRead(error, 'code')];
|
|
166
|
+
let text: string;
|
|
167
|
+
try { text = typeof message === 'string' ? message : String(error); } catch { text = 'unreadable error'; }
|
|
168
|
+
return {
|
|
169
|
+
message: text,
|
|
170
|
+
stack: typeof stack === 'string' ? stack : undefined,
|
|
171
|
+
pgCode: typeof code === 'string' ? code : undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
71
175
|
/** Replace schema-revealing error details with a generic message when hideSchemaErrors is enabled. */
|
|
72
176
|
function sanitizeError(message: string, status: number, options?: HandlerOptions): Response {
|
|
73
177
|
if (options?.hideSchemaErrors) {
|
|
@@ -129,6 +233,13 @@ export interface SoftDeleteConfig {
|
|
|
129
233
|
column?: string;
|
|
130
234
|
/** Tables with soft-delete enabled. If omitted, all tables with the column are treated as soft-delete. */
|
|
131
235
|
tables?: string[];
|
|
236
|
+
/** Who may restore through PATCH, per table: table name -> role names. Keyed like `tables`.
|
|
237
|
+
* PATCH honours `?deleted=include|only` only when the caller's role claim (`auth.roleField`,
|
|
238
|
+
* default `role`) is in the table's list: exact string match, no hierarchy, no case folding.
|
|
239
|
+
* Anyone else gets live rows only, as if the param were absent. A restore body must be
|
|
240
|
+
* exactly `{ <column>: null }`, or 400. Omitted: no one restores through the API.
|
|
241
|
+
* This is a backstop. RLS must also keep other roles off deleted rows (docs/handler.md). */
|
|
242
|
+
restore?: Record<string, string[]>;
|
|
132
243
|
}
|
|
133
244
|
|
|
134
245
|
export interface HookConfig {
|
|
@@ -244,8 +355,8 @@ export interface HandlerOptions {
|
|
|
244
355
|
* If omitted, all tables in the schema are exposed (current behavior). */
|
|
245
356
|
exposedTables?: string[];
|
|
246
357
|
/** Per-table list of column names to strip from all responses.
|
|
247
|
-
*
|
|
248
|
-
* Does
|
|
358
|
+
* Explicit references in query parameters are refused before database access.
|
|
359
|
+
* Does not prevent writes to these columns; use protectedFields for that. */
|
|
249
360
|
hiddenColumns?: Record<string, string[]>;
|
|
250
361
|
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
251
362
|
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
@@ -259,8 +370,14 @@ export interface HandlerOptions {
|
|
|
259
370
|
/** Per-table row ownership for IDOR protection on PATCH/DELETE.
|
|
260
371
|
* Auto-appends WHERE <column> = user[<userField>] to mutations.
|
|
261
372
|
* GET/POST are unaffected — reads use RLS, creates use hooks.
|
|
262
|
-
* Example: { posts: { column: 'authorId', userField: 'sub' } }
|
|
263
|
-
|
|
373
|
+
* Example: { posts: { column: 'authorId', userField: 'sub' } }
|
|
374
|
+
*
|
|
375
|
+
* `exceptRoles` lists roles that skip the clause, e.g. an admin RLS lets write any row.
|
|
376
|
+
* It mirrors an RLS policy and never stands alone: RLS still decides the write.
|
|
377
|
+
* The caller's role is read from `auth.roleField` (default `role`) and must match a
|
|
378
|
+
* listed role exactly. No hierarchy, no case folding. A missing role keeps the clause.
|
|
379
|
+
* The role claim must be trusted: keep `role` out of anything a user can write. */
|
|
380
|
+
rowOwnership?: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
|
|
264
381
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
265
382
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
266
383
|
hideSchemaErrors?: boolean;
|
|
@@ -285,6 +402,17 @@ function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): Dri
|
|
|
285
402
|
return getColumn(table, colName);
|
|
286
403
|
}
|
|
287
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The soft-delete condition for a GET or PATCH: ?deleted=include adds none, ?deleted=only
|
|
407
|
+
* matches deleted rows, anything else (absent or unknown) matches live rows. DELETE does
|
|
408
|
+
* not use it: it always matches live rows only.
|
|
409
|
+
*/
|
|
410
|
+
function softDeleteCondition(sdCol: DrizzleColumn, params: URLSearchParams): SQL | null {
|
|
411
|
+
const mode = params.get('deleted');
|
|
412
|
+
if (mode === 'include') return null;
|
|
413
|
+
return mode === 'only' ? isNotNull(sdCol) : isNull(sdCol);
|
|
414
|
+
}
|
|
415
|
+
|
|
288
416
|
function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
|
|
289
417
|
if (!options?.softDelete) return false;
|
|
290
418
|
if (options.softDelete.tables) {
|
|
@@ -294,19 +422,6 @@ function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: Han
|
|
|
294
422
|
return getSoftDeleteColumn(table, options) != null;
|
|
295
423
|
}
|
|
296
424
|
|
|
297
|
-
/**
|
|
298
|
-
* Detect PostgreSQL permission denied errors (code 42501).
|
|
299
|
-
* Used to convert Postgres GRANT/RLS denials into clean 403 responses.
|
|
300
|
-
*/
|
|
301
|
-
function isPermissionDenied(error: unknown): boolean {
|
|
302
|
-
if (error && typeof error === 'object') {
|
|
303
|
-
const e = error as Record<string, unknown>;
|
|
304
|
-
if (e.code === '42501') return true;
|
|
305
|
-
if (typeof e.message === 'string' && e.message.includes('permission denied')) return true;
|
|
306
|
-
}
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
425
|
/**
|
|
311
426
|
* Execute a function with pgSettings applied in the same transaction.
|
|
312
427
|
*
|
|
@@ -345,6 +460,16 @@ async function executeWithSettings<T>(
|
|
|
345
460
|
}
|
|
346
461
|
|
|
347
462
|
|
|
463
|
+
/**
|
|
464
|
+
* The caller's role claim, or '' when it is missing or not a string. pgSettings compares
|
|
465
|
+
* the claim strictly, so a claim such as ['admin'] must never read as 'admin' here.
|
|
466
|
+
* Every role gate reads the claim through this one function.
|
|
467
|
+
*/
|
|
468
|
+
function readRoleClaim(user: Record<string, unknown>, roleField: string): string {
|
|
469
|
+
const claim = user[roleField];
|
|
470
|
+
return typeof claim === 'string' ? claim : '';
|
|
471
|
+
}
|
|
472
|
+
|
|
348
473
|
/**
|
|
349
474
|
* Check whether userRole meets or exceeds requiredRole in the hierarchy.
|
|
350
475
|
* Both in hierarchy → index comparison. Otherwise → exact string match.
|
|
@@ -354,6 +479,7 @@ function hasRole(
|
|
|
354
479
|
requiredRole: string,
|
|
355
480
|
hierarchy: string[],
|
|
356
481
|
): boolean {
|
|
482
|
+
if (!userRole || !requiredRole) return false;
|
|
357
483
|
const requiredIndex = hierarchy.indexOf(requiredRole);
|
|
358
484
|
const userIndex = hierarchy.indexOf(userRole);
|
|
359
485
|
if (requiredIndex !== -1 && userIndex !== -1) {
|
|
@@ -362,6 +488,17 @@ function hasRole(
|
|
|
362
488
|
return userRole === requiredRole;
|
|
363
489
|
}
|
|
364
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The result of an auth check. `outage` is the caught error when the verifier
|
|
493
|
+
* was unreachable (error is then the 503); a caller that returns `error`
|
|
494
|
+
* passes it to emitAuthOutage.
|
|
495
|
+
*/
|
|
496
|
+
interface AuthResult {
|
|
497
|
+
user: Record<string, unknown> | null;
|
|
498
|
+
error: Response | null;
|
|
499
|
+
outage?: unknown;
|
|
500
|
+
}
|
|
501
|
+
|
|
365
502
|
/**
|
|
366
503
|
* Extract Bearer token from request and verify it.
|
|
367
504
|
* Returns { user, error } — error is a Response (401) if auth fails.
|
|
@@ -369,7 +506,7 @@ function hasRole(
|
|
|
369
506
|
async function verifyUserToken(
|
|
370
507
|
request: Request,
|
|
371
508
|
auth: AuthConfig,
|
|
372
|
-
): Promise<
|
|
509
|
+
): Promise<AuthResult> {
|
|
373
510
|
const authHeader = request.headers.get('Authorization');
|
|
374
511
|
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
375
512
|
|
|
@@ -380,7 +517,11 @@ async function verifyUserToken(
|
|
|
380
517
|
let user: Record<string, unknown> | null;
|
|
381
518
|
try {
|
|
382
519
|
user = await auth.verifyToken(token);
|
|
383
|
-
} catch {
|
|
520
|
+
} catch (err) {
|
|
521
|
+
// An unreachable verifier is not a bad token: 401 would log the user out.
|
|
522
|
+
if (isServiceUnavailable(err)) {
|
|
523
|
+
return { user: null, error: serviceUnavailableResponse(), outage: err };
|
|
524
|
+
}
|
|
384
525
|
return { user: null, error: json({ message: 'Authentication failed: invalid or expired token' }, 401) };
|
|
385
526
|
}
|
|
386
527
|
|
|
@@ -402,7 +543,7 @@ async function checkRoleAuth(
|
|
|
402
543
|
requiredRole: string,
|
|
403
544
|
request: Request,
|
|
404
545
|
auth: AuthConfig,
|
|
405
|
-
): Promise<
|
|
546
|
+
): Promise<AuthResult> {
|
|
406
547
|
if (requiredRole === 'public') {
|
|
407
548
|
return { user: null, error: null };
|
|
408
549
|
}
|
|
@@ -417,8 +558,7 @@ async function checkRoleAuth(
|
|
|
417
558
|
|
|
418
559
|
// Named role: check hierarchy
|
|
419
560
|
const hierarchy = auth.roleHierarchy ?? ['public', 'authenticated', 'admin'];
|
|
420
|
-
const
|
|
421
|
-
const userRole = String(result.user![roleField] ?? '');
|
|
561
|
+
const userRole = readRoleClaim(result.user!, auth.roleField ?? 'role');
|
|
422
562
|
|
|
423
563
|
if (!hasRole(userRole, requiredRole, hierarchy)) {
|
|
424
564
|
return {
|
|
@@ -608,7 +748,7 @@ function expandSchema(schema: Schema, pairs: Array<{ exportKey: string; sqlName:
|
|
|
608
748
|
if (pairs.length === 0) return schema;
|
|
609
749
|
const expanded: Schema = { ...schema };
|
|
610
750
|
for (const { exportKey, sqlName } of pairs) {
|
|
611
|
-
if (!(sqlName
|
|
751
|
+
if (!Object.hasOwn(expanded, sqlName)) expanded[sqlName] = schema[exportKey];
|
|
612
752
|
}
|
|
613
753
|
return expanded;
|
|
614
754
|
}
|
|
@@ -632,7 +772,7 @@ function expandTableKeyedOptions(
|
|
|
632
772
|
const out: Record<string, T> = { ...map };
|
|
633
773
|
for (const key of Object.keys(map)) {
|
|
634
774
|
const t = twin(key);
|
|
635
|
-
if (t && !(t
|
|
775
|
+
if (t && !Object.hasOwn(out, t)) out[t] = map[key];
|
|
636
776
|
}
|
|
637
777
|
return out;
|
|
638
778
|
};
|
|
@@ -644,12 +784,114 @@ function expandTableKeyedOptions(
|
|
|
644
784
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
645
785
|
...(options.relations ? { relations: expandMap(options.relations) } : {}),
|
|
646
786
|
...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
|
|
647
|
-
...(options.softDelete
|
|
648
|
-
? {
|
|
787
|
+
...(options.softDelete
|
|
788
|
+
? {
|
|
789
|
+
softDelete: {
|
|
790
|
+
...options.softDelete,
|
|
791
|
+
...(options.softDelete.tables ? { tables: expandList(options.softDelete.tables) } : {}),
|
|
792
|
+
...(options.softDelete.restore ? { restore: expandMap(options.softDelete.restore) } : {}),
|
|
793
|
+
},
|
|
794
|
+
}
|
|
649
795
|
: {}),
|
|
650
796
|
};
|
|
651
797
|
}
|
|
652
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Every table the options name outside rowOwnership, in snake_case so that `userPosts`
|
|
801
|
+
* and `user_posts` in two maps of the same options match.
|
|
802
|
+
*/
|
|
803
|
+
function declaredTables(options: HandlerOptions): Set<string> {
|
|
804
|
+
const names = [
|
|
805
|
+
...(options.exposedTables ?? []),
|
|
806
|
+
...Object.keys(options.hiddenColumns ?? {}),
|
|
807
|
+
...Object.keys(options.protectedFields ?? {}),
|
|
808
|
+
...Object.keys(options.hooks ?? {}),
|
|
809
|
+
...Object.keys(options.relations ?? {}),
|
|
810
|
+
...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
|
|
811
|
+
...(options.softDelete?.tables ?? []),
|
|
812
|
+
];
|
|
813
|
+
return new Set(names.map(toSnakeCase));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
|
|
818
|
+
* needs a user, so rowOwnership without auth is an error. An entry whose column does not
|
|
819
|
+
* resolve (same resolver as request time) is an error. An entry for a table this handler
|
|
820
|
+
* does not serve is accepted only when the options declare that table elsewhere: derived
|
|
821
|
+
* configs name every table while a handler serves a few. Otherwise it is a typo that would
|
|
822
|
+
* leave the real table unscoped, and an error. An entry with exceptRoles needs pgSettings:
|
|
823
|
+
* without SET LOCAL ROLE no RLS policy backs the exemption, only the JWT role claim.
|
|
824
|
+
*/
|
|
825
|
+
function validateRowOwnership(schema: Schema, options: HandlerOptions | undefined): void {
|
|
826
|
+
const entries = Object.entries(options?.rowOwnership ?? {});
|
|
827
|
+
if (entries.length === 0) return;
|
|
828
|
+
if (!options?.auth) {
|
|
829
|
+
throw new Error('[everystack/api] rowOwnership requires auth: the owner clause needs an authenticated user');
|
|
830
|
+
}
|
|
831
|
+
const declared = declaredTables(options);
|
|
832
|
+
for (const [tableName, entry] of entries) {
|
|
833
|
+
const table = schema[tableName];
|
|
834
|
+
if (!table) {
|
|
835
|
+
if (declared.has(toSnakeCase(tableName))) continue;
|
|
836
|
+
throw new Error(
|
|
837
|
+
`[everystack/api] rowOwnership.${tableName}: table "${tableName}" is not in the schema and is not declared anywhere else in the handler options (exposedTables, relations, hiddenColumns, protectedFields, softDelete, hooks). Fix the name or remove the entry.`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (getColumn(table, entry.column) === null) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`[everystack/api] rowOwnership.${tableName}.column "${entry.column}" does not resolve to a column of table "${tableName}"`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if ((entry.exceptRoles?.length ?? 0) > 0 && !options.pgSettings) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
`[everystack/api] rowOwnership.${tableName}.exceptRoles requires options.pgSettings: without SET LOCAL ROLE the exemption trusts the JWT role claim alone and no RLS policy backs it. Add pgSettings or remove exceptRoles.`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Refuse to start on a softDelete.restore config the handler cannot honour. Every entry
|
|
855
|
+
* must name a soft-delete table. A restorable table must not list the soft-delete column
|
|
856
|
+
* in protectedFields: the strip would leave every restore body empty. Takes the expanded
|
|
857
|
+
* options, so both spellings of a table are checked.
|
|
858
|
+
*/
|
|
859
|
+
function validateSoftDeleteRestore(schema: Schema, options: HandlerOptions | undefined): void {
|
|
860
|
+
const softDelete = options?.softDelete;
|
|
861
|
+
const colName = softDelete?.column || 'deletedAt';
|
|
862
|
+
for (const [tableName, roles] of Object.entries(softDelete?.restore ?? {})) {
|
|
863
|
+
const table = schema[tableName];
|
|
864
|
+
const isSoftDelete = table
|
|
865
|
+
? isSoftDeleteTable(tableName, table, options)
|
|
866
|
+
: (softDelete?.tables?.includes(tableName) ?? false);
|
|
867
|
+
if (!isSoftDelete) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
`[everystack/api] softDelete.restore.${tableName}: table "${tableName}" is not a soft-delete table. Add it to softDelete.tables or remove the entry.`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const sameColumn = (field: string): boolean => (table
|
|
873
|
+
? getColumn(table, field) !== null && getColumn(table, field) === getColumn(table, colName)
|
|
874
|
+
: toSnakeCase(field) === toSnakeCase(colName));
|
|
875
|
+
if (roles.length > 0 && (options?.protectedFields?.[tableName] ?? []).some(sameColumn)) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
`[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.`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* True when the caller may restore on this table: its role claim is an exact member of
|
|
885
|
+
* softDelete.restore[tableName]. Fail closed: no option, no list, no user, or a missing or
|
|
886
|
+
* non-string role claim is not a member.
|
|
887
|
+
*/
|
|
888
|
+
function mayRestore(tableName: string, options: HandlerOptions | undefined, user: Record<string, unknown> | null): boolean {
|
|
889
|
+
const roles = options?.softDelete?.restore?.[tableName];
|
|
890
|
+
if (!roles?.length || !user) return false;
|
|
891
|
+
const role = readRoleClaim(user, options?.auth?.roleField ?? 'role');
|
|
892
|
+
return role !== '' && roles.includes(role);
|
|
893
|
+
}
|
|
894
|
+
|
|
653
895
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
654
896
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
655
897
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -659,6 +901,19 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
659
901
|
const namePairs = tableNamePairs(rawSchema);
|
|
660
902
|
const schema = expandSchema(rawSchema, namePairs);
|
|
661
903
|
const options = expandTableKeyedOptions(rawOptions, namePairs);
|
|
904
|
+
// Validate configured entries, not their generated table-name twins.
|
|
905
|
+
for (const [tableName, columns] of Object.entries(rawOptions?.hiddenColumns ?? {})) {
|
|
906
|
+
for (const column of columns) {
|
|
907
|
+
if (columnPropertyName(schema[tableName], column) === undefined) {
|
|
908
|
+
console.warn(
|
|
909
|
+
`[everystack/api] invalid hiddenColumns entry: table=${tableName} column=${column}; `
|
|
910
|
+
+ 'JOIN-path embeds of this table return 400 until the entry is fixed.',
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
validateRowOwnership(schema, rawOptions);
|
|
916
|
+
validateSoftDeleteRestore(schema, options);
|
|
662
917
|
const tableNames = new Set(Object.keys(schema));
|
|
663
918
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
664
919
|
|
|
@@ -707,7 +962,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
707
962
|
|
|
708
963
|
try {
|
|
709
964
|
client = await options.auth.verifyClient(clientId ?? '', clientSecret ?? '', referer ?? undefined);
|
|
710
|
-
} catch {
|
|
965
|
+
} catch (err) {
|
|
966
|
+
if (isServiceUnavailable(err)) {
|
|
967
|
+
// The route is not resolved yet: name what the path asks for, but
|
|
968
|
+
// only a declared table or RPC. Any other segment is caller text.
|
|
969
|
+
const [first, second] = pathSegments;
|
|
970
|
+
const where = first === 'rpc'
|
|
971
|
+
? { kind: 'rpc' as const, name: declaredRpcName(options.rpc, second) }
|
|
972
|
+
: { kind: 'rest' as const, name: first && tableNames.has(first) ? first : '' };
|
|
973
|
+
await emitAuthOutage(options.onError, { requestId, method, ...where }, err);
|
|
974
|
+
return serviceUnavailableResponse();
|
|
975
|
+
}
|
|
711
976
|
return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
|
|
712
977
|
}
|
|
713
978
|
|
|
@@ -737,14 +1002,23 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
737
1002
|
&& options.auth.publicRpc?.includes(pathSegments[1]);
|
|
738
1003
|
if (!isPublicMethod && !isPublicRpc) {
|
|
739
1004
|
const result = await verifyUserToken(request, options.auth);
|
|
740
|
-
if (result.error)
|
|
1005
|
+
if (result.error) {
|
|
1006
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
|
|
1007
|
+
return result.error;
|
|
1008
|
+
}
|
|
741
1009
|
user = result.user;
|
|
742
1010
|
} else if (request.headers.get('authorization')) {
|
|
743
1011
|
// Public RPC with token present — verify opportunistically, same
|
|
744
1012
|
// policy as public table routes below. If valid, the request runs
|
|
745
1013
|
// as authenticated (RLS self-visibility, PII self-tiers). If
|
|
746
|
-
// invalid, ignore and fall through as anonymous.
|
|
1014
|
+
// invalid, ignore and fall through as anonymous. If the verifier
|
|
1015
|
+
// is down, answer 503: running a signed-in caller as anonymous
|
|
1016
|
+
// would hand them anonymous data as if it were theirs.
|
|
747
1017
|
const result = await verifyUserToken(request, options.auth);
|
|
1018
|
+
if (result.error && result.outage !== undefined) {
|
|
1019
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
|
|
1020
|
+
return result.error;
|
|
1021
|
+
}
|
|
748
1022
|
if (!result.error) user = result.user;
|
|
749
1023
|
}
|
|
750
1024
|
if (user && options.auth.onAuthenticated) {
|
|
@@ -753,14 +1027,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
753
1027
|
}
|
|
754
1028
|
|
|
755
1029
|
const fnName = pathSegments[1];
|
|
756
|
-
|
|
757
|
-
|
|
1030
|
+
const entry = fnName ? ownEntry(options?.rpc, fnName) : undefined;
|
|
1031
|
+
if (fnName && entry) {
|
|
758
1032
|
const isObjectForm = typeof entry !== 'function';
|
|
759
1033
|
|
|
760
1034
|
// Per-RPC role check (object syntax: { fn, role })
|
|
761
1035
|
if (isObjectForm && entry.role && options?.auth) {
|
|
762
1036
|
const roleResult = await checkRoleAuth(entry.role, request, options.auth);
|
|
763
|
-
if (roleResult.error)
|
|
1037
|
+
if (roleResult.error) {
|
|
1038
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
|
|
1039
|
+
return roleResult.error;
|
|
1040
|
+
}
|
|
764
1041
|
user = roleResult.user;
|
|
765
1042
|
} else if (isObjectForm && entry.role && !options?.auth) {
|
|
766
1043
|
// Role required but no auth configured — deny
|
|
@@ -802,7 +1079,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
802
1079
|
// webhooks). Swallow errors — a side-effect failure must not fail the
|
|
803
1080
|
// write, exactly like the after* REST hooks. Success path only.
|
|
804
1081
|
if (options?.afterRpc) {
|
|
805
|
-
try {
|
|
1082
|
+
try {
|
|
1083
|
+
await options.afterRpc(result, fnName, body, user, client, ctx);
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
await reportHookFault(options.onError, { requestId, name: `rpc.${fnName}.afterRpc`, method }, err);
|
|
1086
|
+
}
|
|
806
1087
|
}
|
|
807
1088
|
// Same cache discipline as table GETs: with pgSettings configured the
|
|
808
1089
|
// response can vary by identity (opportunistic auth on public RPCs),
|
|
@@ -813,9 +1094,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
813
1094
|
const rpcCacheControl = getCacheControl(options);
|
|
814
1095
|
return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
|
|
815
1096
|
} catch (error) {
|
|
816
|
-
const
|
|
1097
|
+
const code = safeRead(error, 'code');
|
|
1098
|
+
const text = safeRead(error, 'message');
|
|
1099
|
+
// A value whose getter throws is hostile: classify nothing from it
|
|
1100
|
+
// and echo nothing of it. It answers the generic 500 (or an outage's 503).
|
|
1101
|
+
const hostile = code === UNREADABLE || text === UNREADABLE || safeRead(error, 'cause') === UNREADABLE;
|
|
1102
|
+
const pgCode = hostile ? undefined : code;
|
|
1103
|
+
// An outage: answer 503 with a fixed body. The driver's message
|
|
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);
|
|
817
1107
|
let status = 500;
|
|
818
|
-
if (
|
|
1108
|
+
if (unavailable) status = 503;
|
|
819
1109
|
else if (pgCode === '42501') status = 403;
|
|
820
1110
|
else if (pgCode === '23505') status = 409;
|
|
821
1111
|
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
@@ -833,15 +1123,14 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
833
1123
|
await emitFault(options?.onError, {
|
|
834
1124
|
requestId, kind: 'rpc', name: fnName, method,
|
|
835
1125
|
status: error instanceof HandlerError ? error.status : status,
|
|
836
|
-
|
|
837
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
838
|
-
pgCode,
|
|
1126
|
+
...describeError(error),
|
|
839
1127
|
});
|
|
840
1128
|
}
|
|
841
1129
|
if (error instanceof HandlerError) {
|
|
842
1130
|
return json({ message: error.message }, error.status);
|
|
843
1131
|
}
|
|
844
|
-
|
|
1132
|
+
if (unavailable) return serviceUnavailableResponse();
|
|
1133
|
+
const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
|
|
845
1134
|
if (options?.hideSchemaErrors && status === 500) {
|
|
846
1135
|
return json({ message: 'Internal Server Error' }, 500);
|
|
847
1136
|
}
|
|
@@ -867,13 +1156,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
867
1156
|
const isPublicMethod = options.auth.publicRoutes.includes(method);
|
|
868
1157
|
if (!isPublicMethod) {
|
|
869
1158
|
const result = await verifyUserToken(request, options.auth);
|
|
870
|
-
if (result.error)
|
|
1159
|
+
if (result.error) {
|
|
1160
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
|
|
1161
|
+
return result.error;
|
|
1162
|
+
}
|
|
871
1163
|
user = result.user;
|
|
872
1164
|
} else if (request.headers.get('authorization')) {
|
|
873
1165
|
// Public route with token present — verify opportunistically.
|
|
874
1166
|
// If valid, the request runs as authenticated (better RLS access).
|
|
875
|
-
// If invalid, ignore and fall through as anonymous.
|
|
1167
|
+
// If invalid, ignore and fall through as anonymous. If the verifier
|
|
1168
|
+
// is down, answer 503, as the public RPC branch above does.
|
|
876
1169
|
const result = await verifyUserToken(request, options.auth);
|
|
1170
|
+
if (result.error && result.outage !== undefined) {
|
|
1171
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
|
|
1172
|
+
return result.error;
|
|
1173
|
+
}
|
|
877
1174
|
if (!result.error) user = result.user;
|
|
878
1175
|
}
|
|
879
1176
|
|
|
@@ -882,16 +1179,15 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
882
1179
|
}
|
|
883
1180
|
}
|
|
884
1181
|
|
|
885
|
-
// Compute pgSettings after auth (user now resolved)
|
|
886
|
-
const settings = options?.pgSettings?.(user, client);
|
|
887
1182
|
const table = schema[tableName];
|
|
888
1183
|
|
|
889
1184
|
// Forensic audit of the non-GET table surface (writes). Capture the query, not
|
|
890
1185
|
// the response: the operation, the WHERE predicate, and the body's column NAMES
|
|
891
|
-
// (never values — values can be PII).
|
|
1186
|
+
// (never values — values can be PII). Unresolved embed policies also record
|
|
1187
|
+
// GET denials; other reads are delegated to RLS.
|
|
892
1188
|
const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE';
|
|
893
|
-
const auditOp: 'insert' | 'update' | 'delete' =
|
|
894
|
-
method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
|
|
1189
|
+
const auditOp: 'insert' | 'update' | 'delete' | 'select' =
|
|
1190
|
+
method === 'GET' ? 'select' : method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
|
|
895
1191
|
const auditFilter = url.searchParams.toString() || undefined;
|
|
896
1192
|
let auditColumns: string[] | undefined;
|
|
897
1193
|
if (isWrite && options?.audit?.sink && method !== 'DELETE') {
|
|
@@ -909,27 +1205,89 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
909
1205
|
const routedDb = method === 'GET' && !user && options?.readDb ? options.readDb : db;
|
|
910
1206
|
|
|
911
1207
|
try {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1208
|
+
// Bound every select before the hidden-column guard walks it. The guard
|
|
1209
|
+
// reads duplicates and runs for writes too, so a GET-only check is too late.
|
|
1210
|
+
let queryError: string | undefined;
|
|
1211
|
+
let auditReadDenial = false;
|
|
1212
|
+
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1213
|
+
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
1214
|
+
const depth = getEmbedDepth(selectParam);
|
|
1215
|
+
if (depth > maxEmbedDepth) {
|
|
1216
|
+
queryError = `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}`;
|
|
1217
|
+
break;
|
|
916
1218
|
}
|
|
1219
|
+
}
|
|
917
1220
|
|
|
918
|
-
|
|
919
|
-
|
|
1221
|
+
// Refusals share the response audit below without opening a transaction.
|
|
1222
|
+
// Response stripping cannot prevent value oracles or aggregate alias reads.
|
|
1223
|
+
if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
|
|
1224
|
+
queryError = 'Invalid query';
|
|
1225
|
+
}
|
|
1226
|
+
// Refuse projections we cannot enforce on the common pre-DB audit path.
|
|
1227
|
+
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1228
|
+
if (!queryError && options?.drizzleRelations && tableRelations) {
|
|
1229
|
+
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1230
|
+
const { embeds } = parseSelectParam(selectParam, tableRelations, options.relations);
|
|
1231
|
+
const withClause = buildWithClause(embeds, tableRelations, schema, options);
|
|
1232
|
+
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1233
|
+
queryError = 'Invalid query';
|
|
1234
|
+
auditReadDenial = method === 'GET' && withClause === 'unresolvedHiddenColumns';
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
920
1237
|
}
|
|
1238
|
+
}
|
|
921
1239
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1240
|
+
// pgSettings runs once per request. The settings map is known only here, so the
|
|
1241
|
+
// check that it backs an ownership exemption runs here, before any transaction.
|
|
1242
|
+
const settings = queryError ? undefined : options?.pgSettings?.(user, client);
|
|
1243
|
+
const unbacked = queryError ? null : unbackedExemptionRefusal(method, tableName, options, user, settings);
|
|
1244
|
+
// A returned 500 skips the catch below, so report it to onError here.
|
|
1245
|
+
if (unbacked) {
|
|
1246
|
+
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method, status: 500, message: unbacked });
|
|
1247
|
+
}
|
|
1248
|
+
// Work queued by the write. It runs only once executeWithSettings has
|
|
1249
|
+
// returned or thrown (with pgSettings, after the transaction has ended).
|
|
1250
|
+
// A before-hook fault runs in the finally, for any outcome: the hook failed
|
|
1251
|
+
// even when the commit then fails too. A table after-hook runs only for a
|
|
1252
|
+
// 2xx; a throw (a failed commit) skips it, since the write did not happen.
|
|
1253
|
+
// A retry wrapper around db.transaction must reset `deferred` per attempt,
|
|
1254
|
+
// or the hooks of an aborted attempt would run too.
|
|
1255
|
+
const deferred: Deferred[] = [];
|
|
1256
|
+
let response: Response;
|
|
1257
|
+
try {
|
|
1258
|
+
response = queryError ? json({ message: queryError }, 400)
|
|
1259
|
+
: unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
|
|
1260
|
+
if (method === 'GET') {
|
|
1261
|
+
const cacheControl = getCacheControl(options);
|
|
1262
|
+
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|
|
1263
|
+
}
|
|
925
1264
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1265
|
+
if (method === 'POST') {
|
|
1266
|
+
return handlePost(dbOrTx, table, tableName, request, options, user, client, requestId, deferred);
|
|
1267
|
+
}
|
|
929
1268
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1269
|
+
if (method === 'PATCH') {
|
|
1270
|
+
return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client, requestId, deferred);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (method === 'DELETE') {
|
|
1274
|
+
return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client, requestId, deferred);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return json({ message: `Method ${method} not allowed` }, 405);
|
|
1278
|
+
});
|
|
1279
|
+
} finally {
|
|
1280
|
+
// emitFault never throws, so this cannot replace the error in flight.
|
|
1281
|
+
for (const item of deferred) if (item.always) await item.run();
|
|
1282
|
+
}
|
|
1283
|
+
if (response.status >= 200 && response.status < 300) {
|
|
1284
|
+
// The contract: a side effect fires only for a successful write. The guard is
|
|
1285
|
+
// unreachable today (every refusal returns before the write queues a hook) and
|
|
1286
|
+
// stays for the next write path. Each queued hook reports its own throw, so
|
|
1287
|
+
// none can fail the response.
|
|
1288
|
+
for (const item of deferred) if (!item.always) await item.run();
|
|
1289
|
+
}
|
|
1290
|
+
if (isWrite || auditReadDenial) {
|
|
933
1291
|
await emitAudit(options?.audit, {
|
|
934
1292
|
kind: 'rest', name: tableName, user, client,
|
|
935
1293
|
result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
|
|
@@ -948,56 +1306,106 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
948
1306
|
if (isPermissionDenied(error)) {
|
|
949
1307
|
return sanitizeError('Permission denied', 403, options);
|
|
950
1308
|
}
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
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);
|
|
1313
|
+
// Other table faults re-throw to the Lambda boundary (which logs to
|
|
1314
|
+
// CloudWatch), but that path never reaches the S3 archive. Surface the
|
|
1315
|
+
// cause here too so a write 5xx is in logs:errors. A HandlerError with a
|
|
1316
|
+
// 4xx status is a deliberate refusal from a hook, not a fault — leave it alone.
|
|
955
1317
|
if (!(error instanceof HandlerError) || error.status >= 500) {
|
|
956
1318
|
await emitFault(options?.onError, {
|
|
957
1319
|
requestId, kind: 'rest', name: tableName, method,
|
|
958
|
-
status: error instanceof HandlerError ? error.status : 500,
|
|
959
|
-
|
|
960
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
961
|
-
pgCode: (error as any)?.code,
|
|
1320
|
+
status: error instanceof HandlerError ? error.status : unavailable ? 503 : 500,
|
|
1321
|
+
...describeError(error),
|
|
962
1322
|
});
|
|
963
1323
|
}
|
|
1324
|
+
if (unavailable) return serviceUnavailableResponse();
|
|
964
1325
|
throw error;
|
|
965
1326
|
}
|
|
966
1327
|
};
|
|
967
1328
|
}
|
|
968
1329
|
|
|
969
|
-
/**
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1330
|
+
/** Drizzle selections use schema property names, even when the API uses SQL names. */
|
|
1331
|
+
function columnPropertyName(table: DrizzleTable | undefined, name: string): string | undefined {
|
|
1332
|
+
if (!table) return undefined;
|
|
1333
|
+
const column = getColumn(table, name);
|
|
1334
|
+
const columns = Object.entries(getTableColumns(table));
|
|
1335
|
+
return columns.find(([, value]) => value === column)?.[0]
|
|
1336
|
+
?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** Build each relation's projection before Drizzle reads its values. */
|
|
974
1340
|
function buildWithClause(
|
|
975
1341
|
embeds: EmbedRequest[],
|
|
976
1342
|
tableRelations: Record<string, RelationConfig>,
|
|
977
|
-
|
|
1343
|
+
schema: Schema,
|
|
1344
|
+
options?: HandlerOptions,
|
|
1345
|
+
): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
|
|
978
1346
|
if (embeds.length === 0) return undefined;
|
|
979
1347
|
|
|
980
1348
|
const withClause: Record<string, any> = {};
|
|
981
1349
|
let hasEmbedded = false;
|
|
982
1350
|
|
|
983
1351
|
for (const embed of embeds) {
|
|
984
|
-
|
|
1352
|
+
const relation = tableRelations[embed.name];
|
|
1353
|
+
if (!relation) continue;
|
|
985
1354
|
hasEmbedded = true;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1355
|
+
const table = schema[relation.table];
|
|
1356
|
+
const hidden = new Set<string>();
|
|
1357
|
+
for (const name of options?.hiddenColumns?.[relation.table] ?? []) {
|
|
1358
|
+
const property = columnPropertyName(table, name);
|
|
1359
|
+
if (property === undefined) return 'unresolvedHiddenColumns';
|
|
1360
|
+
hidden.add(property);
|
|
1361
|
+
}
|
|
1362
|
+
const config: Record<string, any> = {};
|
|
1363
|
+
if (embed.columns !== null) {
|
|
1364
|
+
// Drop names that are not columns of a known table: Drizzle ignores them,
|
|
1365
|
+
// and throws when nothing else is left. An emptied list is the 400 below.
|
|
1366
|
+
config.columns = Object.fromEntries(embed.columns
|
|
1367
|
+
.map(name => (table ? columnPropertyName(table, name) : name))
|
|
1368
|
+
.filter((name): name is string => name !== undefined && !hidden.has(name))
|
|
1369
|
+
.map(name => [name, true]));
|
|
1370
|
+
if (Object.keys(config.columns).length === 0) return null;
|
|
1371
|
+
} else if (hidden.size > 0) {
|
|
1372
|
+
config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
|
|
995
1373
|
}
|
|
1374
|
+
withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
|
|
996
1375
|
}
|
|
997
1376
|
|
|
998
1377
|
return hasEmbedded ? withClause : undefined;
|
|
999
1378
|
}
|
|
1000
1379
|
|
|
1380
|
+
/** Follow only parsed relations: a similarly named JSON key is not a table column. */
|
|
1381
|
+
function stripEmbeddedHiddenColumns(
|
|
1382
|
+
data: unknown,
|
|
1383
|
+
embeds: EmbedRequest[],
|
|
1384
|
+
tableName: string,
|
|
1385
|
+
schema: Schema,
|
|
1386
|
+
options?: HandlerOptions,
|
|
1387
|
+
): unknown {
|
|
1388
|
+
if (embeds.length === 0 || data === null || typeof data !== 'object') return data;
|
|
1389
|
+
if (Array.isArray(data)) {
|
|
1390
|
+
return data.map(row => stripEmbeddedHiddenColumns(row, embeds, tableName, schema, options));
|
|
1391
|
+
}
|
|
1392
|
+
const result = { ...(data as Record<string, unknown>) };
|
|
1393
|
+
for (const embed of embeds) {
|
|
1394
|
+
const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
|
|
1395
|
+
const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
|
|
1396
|
+
if (!relation || !(key in result)) continue;
|
|
1397
|
+
const hidden = (options?.hiddenColumns?.[relation.table] ?? []).flatMap(name => {
|
|
1398
|
+
const property = columnPropertyName(schema[relation.table], name);
|
|
1399
|
+
return [name, toSnakeCase(name), ...(property ? [property, toSnakeCase(property)] : [])];
|
|
1400
|
+
});
|
|
1401
|
+
const related = stripHiddenColumns(result[key], relation.table, {
|
|
1402
|
+
hiddenColumns: { [relation.table]: hidden },
|
|
1403
|
+
});
|
|
1404
|
+
result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
|
|
1405
|
+
}
|
|
1406
|
+
return result;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1001
1409
|
/**
|
|
1002
1410
|
* Convert column names array to Drizzle's `columns` selection format.
|
|
1003
1411
|
* ['id', 'title'] → { id: true, title: true }
|
|
@@ -1100,17 +1508,8 @@ async function handleGet(
|
|
|
1100
1508
|
const prefer = request.headers.get('Prefer') || '';
|
|
1101
1509
|
const wantExactCount = prefer.includes('count=exact');
|
|
1102
1510
|
|
|
1103
|
-
// Enforce maximum embed depth to prevent DoS from deeply nested relation queries
|
|
1104
|
-
if (selectParam) {
|
|
1105
|
-
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
1106
|
-
const depth = getEmbedDepth(selectParam);
|
|
1107
|
-
if (depth > maxEmbedDepth) {
|
|
1108
|
-
return json({ message: `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}` }, 400);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
1511
|
// Parse select parameter for relation embedding and aggregates
|
|
1113
|
-
let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations
|
|
1512
|
+
let { columns, embeds, aggregates } = parseSelectParam(selectParam, ownEntry(options?.relations, tableName), options?.relations);
|
|
1114
1513
|
|
|
1115
1514
|
// Resolve snake_case select column names to Drizzle camelCase properties
|
|
1116
1515
|
if (options?.naming === 'snake_case' && columns) {
|
|
@@ -1132,13 +1531,8 @@ async function handleGet(
|
|
|
1132
1531
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
1133
1532
|
const sdCol = getSoftDeleteColumn(table, options);
|
|
1134
1533
|
if (sdCol) {
|
|
1135
|
-
const
|
|
1136
|
-
if (
|
|
1137
|
-
conditions.push(isNotNull(sdCol));
|
|
1138
|
-
} else if (deletedParam !== 'include') {
|
|
1139
|
-
// Default: exclude deleted records
|
|
1140
|
-
conditions.push(isNull(sdCol));
|
|
1141
|
-
}
|
|
1534
|
+
const condition = softDeleteCondition(sdCol, params);
|
|
1535
|
+
if (condition) conditions.push(condition);
|
|
1142
1536
|
}
|
|
1143
1537
|
}
|
|
1144
1538
|
|
|
@@ -1197,7 +1591,7 @@ async function handleGet(
|
|
|
1197
1591
|
}
|
|
1198
1592
|
|
|
1199
1593
|
// Detect if Drizzle relational query (JOINs) should be used
|
|
1200
|
-
const tableRelations = options?.relations
|
|
1594
|
+
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1201
1595
|
const useRelationalQuery = embeds.length > 0
|
|
1202
1596
|
&& tableRelations
|
|
1203
1597
|
&& options?.drizzleRelations
|
|
@@ -1215,7 +1609,10 @@ async function handleGet(
|
|
|
1215
1609
|
const colSelection = buildColumnsSelection(columns);
|
|
1216
1610
|
if (colSelection) queryOptions.columns = colSelection;
|
|
1217
1611
|
|
|
1218
|
-
const withClause = buildWithClause(embeds, tableRelations
|
|
1612
|
+
const withClause = buildWithClause(embeds, tableRelations!, schema, options);
|
|
1613
|
+
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1614
|
+
return json({ message: 'Invalid query' }, 400);
|
|
1615
|
+
}
|
|
1219
1616
|
if (withClause) queryOptions.with = withClause;
|
|
1220
1617
|
|
|
1221
1618
|
if (conditions.length === 1) {
|
|
@@ -1304,7 +1701,8 @@ async function handleGet(
|
|
|
1304
1701
|
};
|
|
1305
1702
|
if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
|
|
1306
1703
|
|
|
1307
|
-
|
|
1704
|
+
const transformed = stripHiddenColumns(transformResponse(responseData, options), tableName, options);
|
|
1705
|
+
return new Response(JSON.stringify(stripEmbeddedHiddenColumns(transformed, embeds, tableName, schema, options)), {
|
|
1308
1706
|
status: 200,
|
|
1309
1707
|
headers: getHeaders,
|
|
1310
1708
|
});
|
|
@@ -1374,12 +1772,12 @@ function parseSelectParam(
|
|
|
1374
1772
|
}
|
|
1375
1773
|
|
|
1376
1774
|
const embedMatch = part.match(/^(\w+)\((.+)\)$/);
|
|
1377
|
-
if (embedMatch && tableRelations && embedMatch[1]
|
|
1775
|
+
if (embedMatch && tableRelations && Object.hasOwn(tableRelations, embedMatch[1])) {
|
|
1378
1776
|
const name = embedMatch[1];
|
|
1379
1777
|
const innerSelect = embedMatch[2].trim();
|
|
1380
1778
|
// Recursively parse sub-embeds using the target table's relations
|
|
1381
1779
|
const targetTableName = tableRelations[name].table;
|
|
1382
|
-
const targetRelations = allRelations
|
|
1780
|
+
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
1383
1781
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
1384
1782
|
embeds.push({
|
|
1385
1783
|
name,
|
|
@@ -1431,6 +1829,71 @@ function splitSelectParam(param: string): string[] {
|
|
|
1431
1829
|
return parts;
|
|
1432
1830
|
}
|
|
1433
1831
|
|
|
1832
|
+
/** Check explicit query references, including each related table's own policy. */
|
|
1833
|
+
function requestUsesHiddenColumn(
|
|
1834
|
+
schema: Schema,
|
|
1835
|
+
tableName: string,
|
|
1836
|
+
params: URLSearchParams,
|
|
1837
|
+
options?: HandlerOptions,
|
|
1838
|
+
): boolean {
|
|
1839
|
+
if (!options?.hiddenColumns) return false;
|
|
1840
|
+
|
|
1841
|
+
const isHidden = (sourceTable: string, reference: string): boolean => {
|
|
1842
|
+
let targetName = sourceTable;
|
|
1843
|
+
let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
|
|
1844
|
+
// Prefer a real source column. The relation fallback only rejects hidden
|
|
1845
|
+
// references; the filter and order builders do not support relation paths.
|
|
1846
|
+
if (!getColumn(schema[targetName], columnName)) {
|
|
1847
|
+
const parts = columnName.split('.');
|
|
1848
|
+
while (parts.length > 1) {
|
|
1849
|
+
const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
|
|
1850
|
+
if (!relation || !schema[relation.table]) return false;
|
|
1851
|
+
targetName = relation.table;
|
|
1852
|
+
}
|
|
1853
|
+
columnName = parts[0];
|
|
1854
|
+
}
|
|
1855
|
+
const table = schema[targetName];
|
|
1856
|
+
const column = getColumn(table, columnName);
|
|
1857
|
+
return (options.hiddenColumns?.[targetName] ?? []).some(hidden => {
|
|
1858
|
+
const hiddenColumn = getColumn(table, hidden);
|
|
1859
|
+
return column && hiddenColumn
|
|
1860
|
+
? column === hiddenColumn
|
|
1861
|
+
: toCamelCase(columnName) === toCamelCase(hidden);
|
|
1862
|
+
});
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
const selectUsesHidden = (sourceTable: string, select: string): boolean => {
|
|
1866
|
+
// Walk the raw selection: parseSelectParam collapses `*,secret` to a
|
|
1867
|
+
// wildcard and discards aggregates inside embeds, losing explicit names.
|
|
1868
|
+
return splitSelectParam(select).some(part => {
|
|
1869
|
+
const aggregate = part.match(AGG_REGEX);
|
|
1870
|
+
if (aggregate) return aggregate[2] ? isHidden(sourceTable, aggregate[2]) : false;
|
|
1871
|
+
const embed = part.match(/^(\w+)\((.+)\)$/);
|
|
1872
|
+
const relation = embed && ownEntry(ownEntry(options.relations, sourceTable), embed[1]);
|
|
1873
|
+
if (embed && relation && schema[relation.table]) {
|
|
1874
|
+
return selectUsesHidden(relation.table, embed[2]);
|
|
1875
|
+
}
|
|
1876
|
+
return part !== '*' && isHidden(sourceTable, part);
|
|
1877
|
+
});
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
for (const [key, value] of params) {
|
|
1881
|
+
if (key === 'select') {
|
|
1882
|
+
if (selectUsesHidden(tableName, value)) return true;
|
|
1883
|
+
} else if (key === 'order') {
|
|
1884
|
+
if (value.split(',').some(order => {
|
|
1885
|
+
const dot = order.lastIndexOf('.');
|
|
1886
|
+
return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
|
|
1887
|
+
})) return true;
|
|
1888
|
+
} else if (key === 'or' || key === 'and') {
|
|
1889
|
+
if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
|
|
1890
|
+
} else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
|
|
1891
|
+
return true;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1434
1897
|
/**
|
|
1435
1898
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
1436
1899
|
*/
|
|
@@ -1442,7 +1905,7 @@ async function resolveEmbeds(
|
|
|
1442
1905
|
embeds: EmbedRequest[],
|
|
1443
1906
|
options: HandlerOptions,
|
|
1444
1907
|
): Promise<Record<string, unknown>[]> {
|
|
1445
|
-
const tableRelations = options.relations
|
|
1908
|
+
const tableRelations = ownEntry(options.relations, tableName);
|
|
1446
1909
|
if (!tableRelations) return rows;
|
|
1447
1910
|
|
|
1448
1911
|
// Clone rows to avoid mutation
|
|
@@ -1563,6 +2026,37 @@ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<st
|
|
|
1563
2026
|
return result;
|
|
1564
2027
|
}
|
|
1565
2028
|
|
|
2029
|
+
/**
|
|
2030
|
+
* The response for a before-hook that threw. A HandlerError keeps its own status and
|
|
2031
|
+
* message. An outage (the hook could not reach the database) answers the fixed 503,
|
|
2032
|
+
* like every other outage path. Anything else is a generic 500 so the hook's text
|
|
2033
|
+
* never reaches the client. A 5xx is a fault and goes to onError; a 4xx HandlerError
|
|
2034
|
+
* is a refusal and does not. The fault is queued, not sent: with pgSettings the hook
|
|
2035
|
+
* ran inside the transaction, and a slow onError must not hold it open.
|
|
2036
|
+
*/
|
|
2037
|
+
function beforeHookFailure(
|
|
2038
|
+
err: unknown,
|
|
2039
|
+
options: HandlerOptions | undefined,
|
|
2040
|
+
at: { requestId?: string; name: string; method: string },
|
|
2041
|
+
deferred: Deferred[],
|
|
2042
|
+
): Response {
|
|
2043
|
+
const report = (fault: HandlerFault): void => {
|
|
2044
|
+
deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
|
|
2045
|
+
};
|
|
2046
|
+
if (err instanceof HandlerError) {
|
|
2047
|
+
if (err.status >= 500) {
|
|
2048
|
+
report({ ...at, kind: 'rest', status: err.status, message: err.message });
|
|
2049
|
+
}
|
|
2050
|
+
return json({ message: err.message }, err.status);
|
|
2051
|
+
}
|
|
2052
|
+
if (isServiceUnavailable(err)) {
|
|
2053
|
+
report({ ...at, kind: 'rest', status: 503, ...describeError(err) });
|
|
2054
|
+
return serviceUnavailableResponse();
|
|
2055
|
+
}
|
|
2056
|
+
report({ ...at, kind: 'rest', status: 500, ...describeError(err) });
|
|
2057
|
+
return json({ message: 'Hook error' }, 500);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1566
2060
|
async function handlePost(
|
|
1567
2061
|
db: DrizzleDb,
|
|
1568
2062
|
table: DrizzleTable,
|
|
@@ -1571,6 +2065,8 @@ async function handlePost(
|
|
|
1571
2065
|
options?: HandlerOptions,
|
|
1572
2066
|
user: Record<string, unknown> | null = null,
|
|
1573
2067
|
client: Record<string, unknown> | null = null,
|
|
2068
|
+
requestId?: string,
|
|
2069
|
+
deferred: Deferred[] = [],
|
|
1574
2070
|
): Promise<Response> {
|
|
1575
2071
|
let body: Record<string, unknown>;
|
|
1576
2072
|
try {
|
|
@@ -1599,10 +2095,7 @@ async function handlePost(
|
|
|
1599
2095
|
try {
|
|
1600
2096
|
body = await hook.beforeCreate(body, user, client);
|
|
1601
2097
|
} catch (err) {
|
|
1602
|
-
|
|
1603
|
-
return json({ message: err.message }, err.status);
|
|
1604
|
-
}
|
|
1605
|
-
return json({ message: 'Hook error' }, 500);
|
|
2098
|
+
return beforeHookFailure(err, options, { requestId, name: tableName, method: 'POST' }, deferred);
|
|
1606
2099
|
}
|
|
1607
2100
|
}
|
|
1608
2101
|
|
|
@@ -1619,7 +2112,9 @@ async function handlePost(
|
|
|
1619
2112
|
const rows = await db.insert(table).values(coerced as Record<string, unknown>).returning();
|
|
1620
2113
|
|
|
1621
2114
|
if (hook?.afterCreate && rows.length > 0) {
|
|
1622
|
-
|
|
2115
|
+
const { afterCreate } = hook;
|
|
2116
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterCreate`, method: 'POST' },
|
|
2117
|
+
() => afterCreate.call(hook, rows[0], user, client));
|
|
1623
2118
|
}
|
|
1624
2119
|
|
|
1625
2120
|
return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201);
|
|
@@ -1634,13 +2129,20 @@ async function handlePatch(
|
|
|
1634
2129
|
options?: HandlerOptions,
|
|
1635
2130
|
user: Record<string, unknown> | null = null,
|
|
1636
2131
|
client: Record<string, unknown> | null = null,
|
|
2132
|
+
requestId?: string,
|
|
2133
|
+
deferred: Deferred[] = [],
|
|
1637
2134
|
): Promise<Response> {
|
|
2135
|
+
// Ownership refusal first: before the body, the filter, or any hook is examined.
|
|
2136
|
+
const scope = ownershipScope(table, tableName, options, user);
|
|
2137
|
+
if ('refusal' in scope) return scope.refusal;
|
|
2138
|
+
|
|
1638
2139
|
let body: Record<string, unknown>;
|
|
1639
2140
|
try {
|
|
1640
2141
|
body = await request.json();
|
|
1641
2142
|
} catch {
|
|
1642
2143
|
return json({ message: 'Invalid JSON body' }, 400);
|
|
1643
2144
|
}
|
|
2145
|
+
const clientBody = body;
|
|
1644
2146
|
|
|
1645
2147
|
try {
|
|
1646
2148
|
// Map body keys to Drizzle property names when naming convention is active
|
|
@@ -1656,40 +2158,68 @@ async function handlePatch(
|
|
|
1656
2158
|
throw err;
|
|
1657
2159
|
}
|
|
1658
2160
|
|
|
2161
|
+
// Restore: ?deleted=include|only is honoured only for a role in softDelete.restore for
|
|
2162
|
+
// this table. Anyone else is matched to live rows, as if the param were absent.
|
|
2163
|
+
const sdCol = isSoftDeleteTable(tableName, table, options) ? getSoftDeleteColumn(table, options) : null;
|
|
2164
|
+
const restoring = sdCol !== null && mayRestore(tableName, options, user);
|
|
2165
|
+
const deletedMode = params.get('deleted');
|
|
2166
|
+
if (restoring && (deletedMode === 'include' || deletedMode === 'only')) {
|
|
2167
|
+
// Checked after the protectedFields strip, so a body stripped to {} is refused here.
|
|
2168
|
+
const key = columnPropertyName(table, options?.softDelete?.column || 'deletedAt');
|
|
2169
|
+
const isRestoreBody = body !== null && typeof body === 'object' && !Array.isArray(body)
|
|
2170
|
+
&& Object.keys(body).length === 1 && key !== undefined && Object.hasOwn(body, key) && body[key] === null;
|
|
2171
|
+
if (!isRestoreBody) {
|
|
2172
|
+
const column = options?.softDelete?.column || 'deletedAt';
|
|
2173
|
+
const wire = options?.naming === 'snake_case' ? toSnakeCase(column) : column;
|
|
2174
|
+
return json({ message: `restore accepts only { ${wire}: null }` }, 400);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
2179
|
+
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
2180
|
+
const ownership = options?.rowOwnership?.[tableName];
|
|
2181
|
+
if (user && options && ownership && isOwnershipExempt(ownership, user, options)
|
|
2182
|
+
&& buildsNoFilter(table, params)) {
|
|
2183
|
+
return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// The owner clause proves the caller owns the row, not that they may give it away.
|
|
2187
|
+
// A caller it scopes may not set the owner column to anyone else. RLS stays primary.
|
|
2188
|
+
if (scope.condition && user && ownership) {
|
|
2189
|
+
const key = ownerReassignmentKey(table, ownership, user, clientBody, body);
|
|
2190
|
+
if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1659
2193
|
const hook = options?.hooks?.[tableName];
|
|
1660
2194
|
|
|
1661
2195
|
if (hook?.beforeUpdate) {
|
|
1662
2196
|
try {
|
|
1663
2197
|
body = await hook.beforeUpdate(body, user, client);
|
|
1664
2198
|
} catch (err) {
|
|
1665
|
-
|
|
1666
|
-
return json({ message: err.message }, err.status);
|
|
1667
|
-
}
|
|
1668
|
-
return json({ message: 'Hook error' }, 500);
|
|
2199
|
+
return beforeHookFailure(err, options, { requestId, name: tableName, method: 'PATCH' }, deferred);
|
|
1669
2200
|
}
|
|
1670
2201
|
}
|
|
1671
2202
|
|
|
2203
|
+
// The final body, after the strip and the hook, must have something to write: an empty
|
|
2204
|
+
// SET is a 500 from PostgreSQL. A hook may fill a body the strip emptied, so check here.
|
|
2205
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
2206
|
+
return json({ message: 'No writable fields in request body' }, 400);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1672
2209
|
const filterResult = buildAllConditions(table, params);
|
|
1673
2210
|
if ('error' in filterResult) {
|
|
1674
2211
|
return sanitizeError(filterResult.error, 400, options);
|
|
1675
2212
|
}
|
|
1676
2213
|
const conditions = filterResult.conditions;
|
|
1677
2214
|
|
|
1678
|
-
// Soft-delete:
|
|
1679
|
-
if (
|
|
1680
|
-
const
|
|
1681
|
-
if (
|
|
2215
|
+
// Soft-delete: update live records, unless a restore role sent ?deleted=include|only
|
|
2216
|
+
if (sdCol) {
|
|
2217
|
+
const condition = restoring ? softDeleteCondition(sdCol, params) : isNull(sdCol);
|
|
2218
|
+
if (condition) conditions.push(condition);
|
|
1682
2219
|
}
|
|
1683
2220
|
|
|
1684
|
-
// Row ownership:
|
|
1685
|
-
if (
|
|
1686
|
-
const ownership = options.rowOwnership[tableName];
|
|
1687
|
-
const ownerCol = getColumn(table, ownership.column);
|
|
1688
|
-
const userValue = user[ownership.userField];
|
|
1689
|
-
if (ownerCol && userValue != null) {
|
|
1690
|
-
conditions.push(eq(ownerCol, userValue));
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
2221
|
+
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2222
|
+
if (scope.condition) conditions.push(scope.condition);
|
|
1693
2223
|
|
|
1694
2224
|
let coerced: unknown;
|
|
1695
2225
|
try {
|
|
@@ -1712,12 +2242,115 @@ async function handlePatch(
|
|
|
1712
2242
|
const rows = await query.returning();
|
|
1713
2243
|
|
|
1714
2244
|
if (hook?.afterUpdate && rows.length > 0) {
|
|
1715
|
-
|
|
2245
|
+
const { afterUpdate } = hook;
|
|
2246
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterUpdate`, method: 'PATCH' },
|
|
2247
|
+
() => afterUpdate.call(hook, rows, user, client));
|
|
1716
2248
|
}
|
|
1717
2249
|
|
|
1718
2250
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
1719
2251
|
}
|
|
1720
2252
|
|
|
2253
|
+
/**
|
|
2254
|
+
* True when the caller's role is an exact member of the entry's `exceptRoles`, so the
|
|
2255
|
+
* ownership clause is skipped. Fail closed: no list, an empty list, a missing or
|
|
2256
|
+
* non-string role claim, or any other role keeps the clause.
|
|
2257
|
+
*/
|
|
2258
|
+
function isOwnershipExempt(
|
|
2259
|
+
ownership: { exceptRoles?: string[] },
|
|
2260
|
+
user: Record<string, unknown>,
|
|
2261
|
+
options: HandlerOptions,
|
|
2262
|
+
): boolean {
|
|
2263
|
+
if (!ownership.exceptRoles?.length) return false;
|
|
2264
|
+
const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
|
|
2265
|
+
return role !== '' && ownership.exceptRoles.includes(role);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* The refusal message (sent with a 500) for a PATCH or DELETE whose ownership exemption
|
|
2270
|
+
* fires while the request's pgSettings map has no role, or null. executeWithSettings runs
|
|
2271
|
+
* SET LOCAL ROLE only for a truthy `role`; without it the connection stays on the table
|
|
2272
|
+
* owner, RLS does not apply, and the JWT role claim alone would lift the owner clause. A
|
|
2273
|
+
* caller who is not excepted keeps the clause, so it is never refused here.
|
|
2274
|
+
*/
|
|
2275
|
+
function unbackedExemptionRefusal(
|
|
2276
|
+
method: string,
|
|
2277
|
+
tableName: string,
|
|
2278
|
+
options: HandlerOptions | undefined,
|
|
2279
|
+
user: Record<string, unknown> | null,
|
|
2280
|
+
settings: Record<string, string> | undefined,
|
|
2281
|
+
): string | null {
|
|
2282
|
+
if (method !== 'PATCH' && method !== 'DELETE') return null;
|
|
2283
|
+
const ownership = options?.rowOwnership?.[tableName];
|
|
2284
|
+
if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
|
|
2285
|
+
if (settings?.['role']) return null;
|
|
2286
|
+
const message = `rowOwnership.${tableName}.exceptRoles: pgSettings returned no role for this request, so SET LOCAL ROLE did not run and no RLS policy backs the exemption. Write refused.`;
|
|
2287
|
+
console.error(`[everystack/api] ${message}`);
|
|
2288
|
+
return message;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* The key, as the client spelled it, that would set the owner column to a value other
|
|
2293
|
+
* than the caller's own, or null. A key matches when it resolves to the owner column
|
|
2294
|
+
* object, so either spelling counts. The decision reads `body` (transformed and
|
|
2295
|
+
* stripped: what would be written); the reported key comes from `clientBody`, so the
|
|
2296
|
+
* message echoes only what the client sent. Null, an object or any other value is a
|
|
2297
|
+
* change; the caller's own value, compared as a string, is a no-op.
|
|
2298
|
+
*/
|
|
2299
|
+
function ownerReassignmentKey(
|
|
2300
|
+
table: DrizzleTable,
|
|
2301
|
+
ownership: { column: string; userField: string },
|
|
2302
|
+
user: Record<string, unknown>,
|
|
2303
|
+
clientBody: unknown,
|
|
2304
|
+
body: unknown,
|
|
2305
|
+
): string | null {
|
|
2306
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
2307
|
+
const owner = String(user[ownership.userField]);
|
|
2308
|
+
const changesOwner = ([key, value]: [string, unknown]): boolean =>
|
|
2309
|
+
getColumn(table, key) === ownerCol
|
|
2310
|
+
&& (value === null || typeof value === 'object' || String(value) !== owner);
|
|
2311
|
+
const written = Object.entries(body ?? {}).find(changesOwner);
|
|
2312
|
+
if (!written) return null;
|
|
2313
|
+
return Object.entries(clientBody ?? {}).find(changesOwner)?.[0] ?? written[0];
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
type OwnershipScope = { refusal: Response } | { condition: SQL | null };
|
|
2317
|
+
|
|
2318
|
+
/**
|
|
2319
|
+
* The owner clause for a PATCH or DELETE, or the refusal when it cannot be built. Fails
|
|
2320
|
+
* closed for a table with a rowOwnership entry and a caller who is not excepted: no
|
|
2321
|
+
* user is 401, a null, undefined or empty owner value is 403, an owner column that does not
|
|
2322
|
+
* resolve is 403 (startup validation makes that unreachable; logged, never echoed).
|
|
2323
|
+
* No entry, or an excepted role, yields no clause.
|
|
2324
|
+
*/
|
|
2325
|
+
function ownershipScope(
|
|
2326
|
+
table: DrizzleTable,
|
|
2327
|
+
tableName: string,
|
|
2328
|
+
options: HandlerOptions | undefined,
|
|
2329
|
+
user: Record<string, unknown> | null,
|
|
2330
|
+
): OwnershipScope {
|
|
2331
|
+
const ownership = options?.rowOwnership?.[tableName];
|
|
2332
|
+
if (!options || !ownership) return { condition: null };
|
|
2333
|
+
if (!user) {
|
|
2334
|
+
return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
|
|
2335
|
+
}
|
|
2336
|
+
if (isOwnershipExempt(ownership, user, options)) return { condition: null };
|
|
2337
|
+
const forbidden = (): OwnershipScope => ({
|
|
2338
|
+
refusal: json({ message: 'Forbidden: row ownership cannot be verified for this request' }, 403),
|
|
2339
|
+
});
|
|
2340
|
+
const userValue = user[ownership.userField];
|
|
2341
|
+
// An empty string is not an owner: it would scope the write to rows owned by ''.
|
|
2342
|
+
// A numeric 0 is a legal owner value, so only null, undefined and '' refuse.
|
|
2343
|
+
if (userValue == null || userValue === '') return forbidden();
|
|
2344
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
2345
|
+
if (!ownerCol) {
|
|
2346
|
+
console.error(
|
|
2347
|
+
`[everystack/api] rowOwnership.${tableName}.column "${ownership.column}" does not resolve to a column of table "${tableName}"; write refused`,
|
|
2348
|
+
);
|
|
2349
|
+
return forbidden();
|
|
2350
|
+
}
|
|
2351
|
+
return { condition: eq(ownerCol, userValue) };
|
|
2352
|
+
}
|
|
2353
|
+
|
|
1721
2354
|
async function handleDelete(
|
|
1722
2355
|
db: DrizzleDb,
|
|
1723
2356
|
table: DrizzleTable,
|
|
@@ -1726,12 +2359,15 @@ async function handleDelete(
|
|
|
1726
2359
|
options?: HandlerOptions,
|
|
1727
2360
|
user: Record<string, unknown> | null = null,
|
|
1728
2361
|
client: Record<string, unknown> | null = null,
|
|
2362
|
+
requestId?: string,
|
|
2363
|
+
deferred: Deferred[] = [],
|
|
1729
2364
|
): Promise<Response> {
|
|
1730
|
-
|
|
1731
|
-
const
|
|
2365
|
+
// Ownership refusal first, so it never reveals whether the filter was valid.
|
|
2366
|
+
const scope = ownershipScope(table, tableName, options, user);
|
|
2367
|
+
if ('refusal' in scope) return scope.refusal;
|
|
1732
2368
|
|
|
1733
2369
|
// Safety: require at least one filter to prevent accidental mass deletion
|
|
1734
|
-
if (
|
|
2370
|
+
if (buildsNoFilter(table, params)) {
|
|
1735
2371
|
return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
|
|
1736
2372
|
}
|
|
1737
2373
|
|
|
@@ -1741,10 +2377,7 @@ async function handleDelete(
|
|
|
1741
2377
|
try {
|
|
1742
2378
|
await hook.beforeDelete(user, client);
|
|
1743
2379
|
} catch (err) {
|
|
1744
|
-
|
|
1745
|
-
return json({ message: err.message }, err.status);
|
|
1746
|
-
}
|
|
1747
|
-
return json({ message: 'Hook error' }, 500);
|
|
2380
|
+
return beforeHookFailure(err, options, { requestId, name: tableName, method: 'DELETE' }, deferred);
|
|
1748
2381
|
}
|
|
1749
2382
|
}
|
|
1750
2383
|
|
|
@@ -1754,15 +2387,8 @@ async function handleDelete(
|
|
|
1754
2387
|
}
|
|
1755
2388
|
const conditions = filterResult.conditions;
|
|
1756
2389
|
|
|
1757
|
-
// Row ownership:
|
|
1758
|
-
if (
|
|
1759
|
-
const ownership = options.rowOwnership[tableName];
|
|
1760
|
-
const ownerCol = getColumn(table, ownership.column);
|
|
1761
|
-
const userValue = user[ownership.userField];
|
|
1762
|
-
if (ownerCol && userValue != null) {
|
|
1763
|
-
conditions.push(eq(ownerCol, userValue));
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
2390
|
+
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2391
|
+
if (scope.condition) conditions.push(scope.condition);
|
|
1766
2392
|
|
|
1767
2393
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
1768
2394
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -1782,7 +2408,9 @@ async function handleDelete(
|
|
|
1782
2408
|
const rows = await query.returning();
|
|
1783
2409
|
|
|
1784
2410
|
if (hook?.afterDelete && rows.length > 0) {
|
|
1785
|
-
|
|
2411
|
+
const { afterDelete } = hook;
|
|
2412
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
|
|
2413
|
+
() => afterDelete.call(hook, rows, user, client));
|
|
1786
2414
|
}
|
|
1787
2415
|
|
|
1788
2416
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
@@ -1801,7 +2429,9 @@ async function handleDelete(
|
|
|
1801
2429
|
const rows = await query.returning();
|
|
1802
2430
|
|
|
1803
2431
|
if (hook?.afterDelete && rows.length > 0) {
|
|
1804
|
-
|
|
2432
|
+
const { afterDelete } = hook;
|
|
2433
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
|
|
2434
|
+
() => afterDelete.call(hook, rows, user, client));
|
|
1805
2435
|
}
|
|
1806
2436
|
|
|
1807
2437
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
@@ -2049,12 +2679,13 @@ function buildOrderBy(table: DrizzleTable, orderParam: string): any {
|
|
|
2049
2679
|
return direction === 'desc' ? desc(column) : asc(column);
|
|
2050
2680
|
}
|
|
2051
2681
|
|
|
2682
|
+
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
2683
|
+
|
|
2052
2684
|
function parseFilters(params: URLSearchParams): Filter[] {
|
|
2053
2685
|
const filters: Filter[] = [];
|
|
2054
|
-
const reserved = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
2055
2686
|
|
|
2056
2687
|
for (const [key, value] of params.entries()) {
|
|
2057
|
-
if (
|
|
2688
|
+
if (RESERVED_QUERY_PARAMS.has(key)) continue;
|
|
2058
2689
|
|
|
2059
2690
|
// Check for NOT modifier: not.eq.value → negated=true, operator=eq
|
|
2060
2691
|
let negated = false;
|
|
@@ -2172,6 +2803,27 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
|
|
|
2172
2803
|
return { conditions };
|
|
2173
2804
|
}
|
|
2174
2805
|
|
|
2806
|
+
/**
|
|
2807
|
+
* True when the request's own filters build no SQL condition, so the write would run
|
|
2808
|
+
* with no WHERE. A param that parses but builds nothing (`or=`, `or=()`, `and=junk`,
|
|
2809
|
+
* `id=is.x`) is not a filter. Only user filters count: the soft-delete and ownership
|
|
2810
|
+
* clauses the handler adds are not part of this test. A filter error is left to the
|
|
2811
|
+
* caller's own buildAllConditions, which reports it.
|
|
2812
|
+
*/
|
|
2813
|
+
function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
|
|
2814
|
+
const result = buildAllConditions(table, params);
|
|
2815
|
+
return !('error' in result) && result.conditions.length === 0;
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
/**
|
|
2819
|
+
* The outage answer. no-store keeps a CDN from caching it: CloudFront holds an
|
|
2820
|
+
* origin 5xx for about 10 seconds, so a protected GET could get a stale 503
|
|
2821
|
+
* after the database recovers.
|
|
2822
|
+
*/
|
|
2823
|
+
function serviceUnavailableResponse(): Response {
|
|
2824
|
+
return json({ message: SERVICE_UNAVAILABLE_MESSAGE }, 503, { 'Cache-Control': 'no-store' });
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2175
2827
|
function json(data: unknown, status: number, headers?: Record<string, string>): Response {
|
|
2176
2828
|
return new Response(JSON.stringify(data), {
|
|
2177
2829
|
status,
|