@everystack/api 0.4.2 → 0.4.3
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 +78 -1
- package/package.json +1 -1
- package/src/handler/index.ts +708 -143
- 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) {
|
|
@@ -244,8 +348,8 @@ export interface HandlerOptions {
|
|
|
244
348
|
* If omitted, all tables in the schema are exposed (current behavior). */
|
|
245
349
|
exposedTables?: string[];
|
|
246
350
|
/** Per-table list of column names to strip from all responses.
|
|
247
|
-
*
|
|
248
|
-
* Does
|
|
351
|
+
* Explicit references in query parameters are refused before database access.
|
|
352
|
+
* Does not prevent writes to these columns; use protectedFields for that. */
|
|
249
353
|
hiddenColumns?: Record<string, string[]>;
|
|
250
354
|
/** Per-table list of fields stripped from POST/PATCH request bodies before mutation.
|
|
251
355
|
* Prevents mass assignment / privilege escalation on sensitive columns. */
|
|
@@ -259,8 +363,14 @@ export interface HandlerOptions {
|
|
|
259
363
|
/** Per-table row ownership for IDOR protection on PATCH/DELETE.
|
|
260
364
|
* Auto-appends WHERE <column> = user[<userField>] to mutations.
|
|
261
365
|
* GET/POST are unaffected — reads use RLS, creates use hooks.
|
|
262
|
-
* Example: { posts: { column: 'authorId', userField: 'sub' } }
|
|
263
|
-
|
|
366
|
+
* Example: { posts: { column: 'authorId', userField: 'sub' } }
|
|
367
|
+
*
|
|
368
|
+
* `exceptRoles` lists roles that skip the clause, e.g. an admin RLS lets write any row.
|
|
369
|
+
* It mirrors an RLS policy and never stands alone: RLS still decides the write.
|
|
370
|
+
* The caller's role is read from `auth.roleField` (default `role`) and must match a
|
|
371
|
+
* listed role exactly. No hierarchy, no case folding. A missing role keeps the clause.
|
|
372
|
+
* The role claim must be trusted: keep `role` out of anything a user can write. */
|
|
373
|
+
rowOwnership?: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
|
|
264
374
|
/** When true, error messages hide internal details (table names, column names, operators).
|
|
265
375
|
* Enable in production to prevent schema enumeration. Default: false. */
|
|
266
376
|
hideSchemaErrors?: boolean;
|
|
@@ -294,19 +404,6 @@ function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: Han
|
|
|
294
404
|
return getSoftDeleteColumn(table, options) != null;
|
|
295
405
|
}
|
|
296
406
|
|
|
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
407
|
/**
|
|
311
408
|
* Execute a function with pgSettings applied in the same transaction.
|
|
312
409
|
*
|
|
@@ -345,6 +442,16 @@ async function executeWithSettings<T>(
|
|
|
345
442
|
}
|
|
346
443
|
|
|
347
444
|
|
|
445
|
+
/**
|
|
446
|
+
* The caller's role claim, or '' when it is missing or not a string. pgSettings compares
|
|
447
|
+
* the claim strictly, so a claim such as ['admin'] must never read as 'admin' here.
|
|
448
|
+
* Every role gate reads the claim through this one function.
|
|
449
|
+
*/
|
|
450
|
+
function readRoleClaim(user: Record<string, unknown>, roleField: string): string {
|
|
451
|
+
const claim = user[roleField];
|
|
452
|
+
return typeof claim === 'string' ? claim : '';
|
|
453
|
+
}
|
|
454
|
+
|
|
348
455
|
/**
|
|
349
456
|
* Check whether userRole meets or exceeds requiredRole in the hierarchy.
|
|
350
457
|
* Both in hierarchy → index comparison. Otherwise → exact string match.
|
|
@@ -354,6 +461,7 @@ function hasRole(
|
|
|
354
461
|
requiredRole: string,
|
|
355
462
|
hierarchy: string[],
|
|
356
463
|
): boolean {
|
|
464
|
+
if (!userRole || !requiredRole) return false;
|
|
357
465
|
const requiredIndex = hierarchy.indexOf(requiredRole);
|
|
358
466
|
const userIndex = hierarchy.indexOf(userRole);
|
|
359
467
|
if (requiredIndex !== -1 && userIndex !== -1) {
|
|
@@ -362,6 +470,17 @@ function hasRole(
|
|
|
362
470
|
return userRole === requiredRole;
|
|
363
471
|
}
|
|
364
472
|
|
|
473
|
+
/**
|
|
474
|
+
* The result of an auth check. `outage` is the caught error when the verifier
|
|
475
|
+
* was unreachable (error is then the 503); a caller that returns `error`
|
|
476
|
+
* passes it to emitAuthOutage.
|
|
477
|
+
*/
|
|
478
|
+
interface AuthResult {
|
|
479
|
+
user: Record<string, unknown> | null;
|
|
480
|
+
error: Response | null;
|
|
481
|
+
outage?: unknown;
|
|
482
|
+
}
|
|
483
|
+
|
|
365
484
|
/**
|
|
366
485
|
* Extract Bearer token from request and verify it.
|
|
367
486
|
* Returns { user, error } — error is a Response (401) if auth fails.
|
|
@@ -369,7 +488,7 @@ function hasRole(
|
|
|
369
488
|
async function verifyUserToken(
|
|
370
489
|
request: Request,
|
|
371
490
|
auth: AuthConfig,
|
|
372
|
-
): Promise<
|
|
491
|
+
): Promise<AuthResult> {
|
|
373
492
|
const authHeader = request.headers.get('Authorization');
|
|
374
493
|
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
375
494
|
|
|
@@ -380,7 +499,11 @@ async function verifyUserToken(
|
|
|
380
499
|
let user: Record<string, unknown> | null;
|
|
381
500
|
try {
|
|
382
501
|
user = await auth.verifyToken(token);
|
|
383
|
-
} catch {
|
|
502
|
+
} catch (err) {
|
|
503
|
+
// An unreachable verifier is not a bad token: 401 would log the user out.
|
|
504
|
+
if (isServiceUnavailable(err)) {
|
|
505
|
+
return { user: null, error: serviceUnavailableResponse(), outage: err };
|
|
506
|
+
}
|
|
384
507
|
return { user: null, error: json({ message: 'Authentication failed: invalid or expired token' }, 401) };
|
|
385
508
|
}
|
|
386
509
|
|
|
@@ -402,7 +525,7 @@ async function checkRoleAuth(
|
|
|
402
525
|
requiredRole: string,
|
|
403
526
|
request: Request,
|
|
404
527
|
auth: AuthConfig,
|
|
405
|
-
): Promise<
|
|
528
|
+
): Promise<AuthResult> {
|
|
406
529
|
if (requiredRole === 'public') {
|
|
407
530
|
return { user: null, error: null };
|
|
408
531
|
}
|
|
@@ -417,8 +540,7 @@ async function checkRoleAuth(
|
|
|
417
540
|
|
|
418
541
|
// Named role: check hierarchy
|
|
419
542
|
const hierarchy = auth.roleHierarchy ?? ['public', 'authenticated', 'admin'];
|
|
420
|
-
const
|
|
421
|
-
const userRole = String(result.user![roleField] ?? '');
|
|
543
|
+
const userRole = readRoleClaim(result.user!, auth.roleField ?? 'role');
|
|
422
544
|
|
|
423
545
|
if (!hasRole(userRole, requiredRole, hierarchy)) {
|
|
424
546
|
return {
|
|
@@ -608,7 +730,7 @@ function expandSchema(schema: Schema, pairs: Array<{ exportKey: string; sqlName:
|
|
|
608
730
|
if (pairs.length === 0) return schema;
|
|
609
731
|
const expanded: Schema = { ...schema };
|
|
610
732
|
for (const { exportKey, sqlName } of pairs) {
|
|
611
|
-
if (!(sqlName
|
|
733
|
+
if (!Object.hasOwn(expanded, sqlName)) expanded[sqlName] = schema[exportKey];
|
|
612
734
|
}
|
|
613
735
|
return expanded;
|
|
614
736
|
}
|
|
@@ -632,7 +754,7 @@ function expandTableKeyedOptions(
|
|
|
632
754
|
const out: Record<string, T> = { ...map };
|
|
633
755
|
for (const key of Object.keys(map)) {
|
|
634
756
|
const t = twin(key);
|
|
635
|
-
if (t && !(t
|
|
757
|
+
if (t && !Object.hasOwn(out, t)) out[t] = map[key];
|
|
636
758
|
}
|
|
637
759
|
return out;
|
|
638
760
|
};
|
|
@@ -650,6 +772,60 @@ function expandTableKeyedOptions(
|
|
|
650
772
|
};
|
|
651
773
|
}
|
|
652
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Every table the options name outside rowOwnership, in snake_case so that `userPosts`
|
|
777
|
+
* and `user_posts` in two maps of the same options match.
|
|
778
|
+
*/
|
|
779
|
+
function declaredTables(options: HandlerOptions): Set<string> {
|
|
780
|
+
const names = [
|
|
781
|
+
...(options.exposedTables ?? []),
|
|
782
|
+
...Object.keys(options.hiddenColumns ?? {}),
|
|
783
|
+
...Object.keys(options.protectedFields ?? {}),
|
|
784
|
+
...Object.keys(options.hooks ?? {}),
|
|
785
|
+
...Object.keys(options.relations ?? {}),
|
|
786
|
+
...Object.values(options.relations ?? {}).flatMap(rels => Object.values(rels).map(rel => rel.table)),
|
|
787
|
+
...(options.softDelete?.tables ?? []),
|
|
788
|
+
];
|
|
789
|
+
return new Set(names.map(toSnakeCase));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Refuse to start on a rowOwnership config the handler cannot enforce. The owner clause
|
|
794
|
+
* needs a user, so rowOwnership without auth is an error. An entry whose column does not
|
|
795
|
+
* resolve (same resolver as request time) is an error. An entry for a table this handler
|
|
796
|
+
* does not serve is accepted only when the options declare that table elsewhere: derived
|
|
797
|
+
* configs name every table while a handler serves a few. Otherwise it is a typo that would
|
|
798
|
+
* leave the real table unscoped, and an error. An entry with exceptRoles needs pgSettings:
|
|
799
|
+
* without SET LOCAL ROLE no RLS policy backs the exemption, only the JWT role claim.
|
|
800
|
+
*/
|
|
801
|
+
function validateRowOwnership(schema: Schema, options: HandlerOptions | undefined): void {
|
|
802
|
+
const entries = Object.entries(options?.rowOwnership ?? {});
|
|
803
|
+
if (entries.length === 0) return;
|
|
804
|
+
if (!options?.auth) {
|
|
805
|
+
throw new Error('[everystack/api] rowOwnership requires auth: the owner clause needs an authenticated user');
|
|
806
|
+
}
|
|
807
|
+
const declared = declaredTables(options);
|
|
808
|
+
for (const [tableName, entry] of entries) {
|
|
809
|
+
const table = schema[tableName];
|
|
810
|
+
if (!table) {
|
|
811
|
+
if (declared.has(toSnakeCase(tableName))) continue;
|
|
812
|
+
throw new Error(
|
|
813
|
+
`[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.`,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
if (getColumn(table, entry.column) === null) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`[everystack/api] rowOwnership.${tableName}.column "${entry.column}" does not resolve to a column of table "${tableName}"`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
if ((entry.exceptRoles?.length ?? 0) > 0 && !options.pgSettings) {
|
|
822
|
+
throw new Error(
|
|
823
|
+
`[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.`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
653
829
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
654
830
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
655
831
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -659,6 +835,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
659
835
|
const namePairs = tableNamePairs(rawSchema);
|
|
660
836
|
const schema = expandSchema(rawSchema, namePairs);
|
|
661
837
|
const options = expandTableKeyedOptions(rawOptions, namePairs);
|
|
838
|
+
// Validate configured entries, not their generated table-name twins.
|
|
839
|
+
for (const [tableName, columns] of Object.entries(rawOptions?.hiddenColumns ?? {})) {
|
|
840
|
+
for (const column of columns) {
|
|
841
|
+
if (columnPropertyName(schema[tableName], column) === undefined) {
|
|
842
|
+
console.warn(
|
|
843
|
+
`[everystack/api] invalid hiddenColumns entry: table=${tableName} column=${column}; `
|
|
844
|
+
+ 'JOIN-path embeds of this table return 400 until the entry is fixed.',
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
validateRowOwnership(schema, rawOptions);
|
|
662
850
|
const tableNames = new Set(Object.keys(schema));
|
|
663
851
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
664
852
|
|
|
@@ -707,7 +895,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
707
895
|
|
|
708
896
|
try {
|
|
709
897
|
client = await options.auth.verifyClient(clientId ?? '', clientSecret ?? '', referer ?? undefined);
|
|
710
|
-
} catch {
|
|
898
|
+
} catch (err) {
|
|
899
|
+
if (isServiceUnavailable(err)) {
|
|
900
|
+
// The route is not resolved yet: name what the path asks for, but
|
|
901
|
+
// only a declared table or RPC. Any other segment is caller text.
|
|
902
|
+
const [first, second] = pathSegments;
|
|
903
|
+
const where = first === 'rpc'
|
|
904
|
+
? { kind: 'rpc' as const, name: declaredRpcName(options.rpc, second) }
|
|
905
|
+
: { kind: 'rest' as const, name: first && tableNames.has(first) ? first : '' };
|
|
906
|
+
await emitAuthOutage(options.onError, { requestId, method, ...where }, err);
|
|
907
|
+
return serviceUnavailableResponse();
|
|
908
|
+
}
|
|
711
909
|
return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
|
|
712
910
|
}
|
|
713
911
|
|
|
@@ -737,14 +935,23 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
737
935
|
&& options.auth.publicRpc?.includes(pathSegments[1]);
|
|
738
936
|
if (!isPublicMethod && !isPublicRpc) {
|
|
739
937
|
const result = await verifyUserToken(request, options.auth);
|
|
740
|
-
if (result.error)
|
|
938
|
+
if (result.error) {
|
|
939
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
|
|
940
|
+
return result.error;
|
|
941
|
+
}
|
|
741
942
|
user = result.user;
|
|
742
943
|
} else if (request.headers.get('authorization')) {
|
|
743
944
|
// Public RPC with token present — verify opportunistically, same
|
|
744
945
|
// policy as public table routes below. If valid, the request runs
|
|
745
946
|
// as authenticated (RLS self-visibility, PII self-tiers). If
|
|
746
|
-
// invalid, ignore and fall through as anonymous.
|
|
947
|
+
// invalid, ignore and fall through as anonymous. If the verifier
|
|
948
|
+
// is down, answer 503: running a signed-in caller as anonymous
|
|
949
|
+
// would hand them anonymous data as if it were theirs.
|
|
747
950
|
const result = await verifyUserToken(request, options.auth);
|
|
951
|
+
if (result.error && result.outage !== undefined) {
|
|
952
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: declaredRpcName(options.rpc, pathSegments[1]), method }, result.outage);
|
|
953
|
+
return result.error;
|
|
954
|
+
}
|
|
748
955
|
if (!result.error) user = result.user;
|
|
749
956
|
}
|
|
750
957
|
if (user && options.auth.onAuthenticated) {
|
|
@@ -753,14 +960,17 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
753
960
|
}
|
|
754
961
|
|
|
755
962
|
const fnName = pathSegments[1];
|
|
756
|
-
|
|
757
|
-
|
|
963
|
+
const entry = fnName ? ownEntry(options?.rpc, fnName) : undefined;
|
|
964
|
+
if (fnName && entry) {
|
|
758
965
|
const isObjectForm = typeof entry !== 'function';
|
|
759
966
|
|
|
760
967
|
// Per-RPC role check (object syntax: { fn, role })
|
|
761
968
|
if (isObjectForm && entry.role && options?.auth) {
|
|
762
969
|
const roleResult = await checkRoleAuth(entry.role, request, options.auth);
|
|
763
|
-
if (roleResult.error)
|
|
970
|
+
if (roleResult.error) {
|
|
971
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rpc', name: fnName, method }, roleResult.outage);
|
|
972
|
+
return roleResult.error;
|
|
973
|
+
}
|
|
764
974
|
user = roleResult.user;
|
|
765
975
|
} else if (isObjectForm && entry.role && !options?.auth) {
|
|
766
976
|
// Role required but no auth configured — deny
|
|
@@ -802,7 +1012,11 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
802
1012
|
// webhooks). Swallow errors — a side-effect failure must not fail the
|
|
803
1013
|
// write, exactly like the after* REST hooks. Success path only.
|
|
804
1014
|
if (options?.afterRpc) {
|
|
805
|
-
try {
|
|
1015
|
+
try {
|
|
1016
|
+
await options.afterRpc(result, fnName, body, user, client, ctx);
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
await reportHookFault(options.onError, { requestId, name: `rpc.${fnName}.afterRpc`, method }, err);
|
|
1019
|
+
}
|
|
806
1020
|
}
|
|
807
1021
|
// Same cache discipline as table GETs: with pgSettings configured the
|
|
808
1022
|
// response can vary by identity (opportunistic auth on public RPCs),
|
|
@@ -813,9 +1027,18 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
813
1027
|
const rpcCacheControl = getCacheControl(options);
|
|
814
1028
|
return json(result, 200, rpcCacheControl ? { 'Cache-Control': rpcCacheControl } : undefined);
|
|
815
1029
|
} catch (error) {
|
|
816
|
-
const
|
|
1030
|
+
const code = safeRead(error, 'code');
|
|
1031
|
+
const text = safeRead(error, 'message');
|
|
1032
|
+
// A value whose getter throws is hostile: classify nothing from it
|
|
1033
|
+
// and echo nothing of it. It answers the generic 500 (or an outage's 503).
|
|
1034
|
+
const hostile = code === UNREADABLE || text === UNREADABLE || safeRead(error, 'cause') === UNREADABLE;
|
|
1035
|
+
const pgCode = hostile ? undefined : code;
|
|
1036
|
+
// An outage: answer 503 with a fixed body. The driver's message
|
|
1037
|
+
// carries host and port; it goes to onError only. A HandlerError's
|
|
1038
|
+
// own answer still wins: its return below runs first.
|
|
1039
|
+
const unavailable = isServiceUnavailable(error);
|
|
817
1040
|
let status = 500;
|
|
818
|
-
if (
|
|
1041
|
+
if (unavailable) status = 503;
|
|
819
1042
|
else if (pgCode === '42501') status = 403;
|
|
820
1043
|
else if (pgCode === '23505') status = 409;
|
|
821
1044
|
else if (pgCode === '22023' || pgCode === 'P0001') status = 422;
|
|
@@ -833,15 +1056,14 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
833
1056
|
await emitFault(options?.onError, {
|
|
834
1057
|
requestId, kind: 'rpc', name: fnName, method,
|
|
835
1058
|
status: error instanceof HandlerError ? error.status : status,
|
|
836
|
-
|
|
837
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
838
|
-
pgCode,
|
|
1059
|
+
...describeError(error),
|
|
839
1060
|
});
|
|
840
1061
|
}
|
|
841
1062
|
if (error instanceof HandlerError) {
|
|
842
1063
|
return json({ message: error.message }, error.status);
|
|
843
1064
|
}
|
|
844
|
-
|
|
1065
|
+
if (unavailable) return serviceUnavailableResponse();
|
|
1066
|
+
const message = !hostile && error instanceof Error ? text : 'RPC execution failed';
|
|
845
1067
|
if (options?.hideSchemaErrors && status === 500) {
|
|
846
1068
|
return json({ message: 'Internal Server Error' }, 500);
|
|
847
1069
|
}
|
|
@@ -867,13 +1089,21 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
867
1089
|
const isPublicMethod = options.auth.publicRoutes.includes(method);
|
|
868
1090
|
if (!isPublicMethod) {
|
|
869
1091
|
const result = await verifyUserToken(request, options.auth);
|
|
870
|
-
if (result.error)
|
|
1092
|
+
if (result.error) {
|
|
1093
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
|
|
1094
|
+
return result.error;
|
|
1095
|
+
}
|
|
871
1096
|
user = result.user;
|
|
872
1097
|
} else if (request.headers.get('authorization')) {
|
|
873
1098
|
// Public route with token present — verify opportunistically.
|
|
874
1099
|
// If valid, the request runs as authenticated (better RLS access).
|
|
875
|
-
// If invalid, ignore and fall through as anonymous.
|
|
1100
|
+
// If invalid, ignore and fall through as anonymous. If the verifier
|
|
1101
|
+
// is down, answer 503, as the public RPC branch above does.
|
|
876
1102
|
const result = await verifyUserToken(request, options.auth);
|
|
1103
|
+
if (result.error && result.outage !== undefined) {
|
|
1104
|
+
await emitAuthOutage(options.onError, { requestId, kind: 'rest', name: tableName, method }, result.outage);
|
|
1105
|
+
return result.error;
|
|
1106
|
+
}
|
|
877
1107
|
if (!result.error) user = result.user;
|
|
878
1108
|
}
|
|
879
1109
|
|
|
@@ -882,16 +1112,15 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
882
1112
|
}
|
|
883
1113
|
}
|
|
884
1114
|
|
|
885
|
-
// Compute pgSettings after auth (user now resolved)
|
|
886
|
-
const settings = options?.pgSettings?.(user, client);
|
|
887
1115
|
const table = schema[tableName];
|
|
888
1116
|
|
|
889
1117
|
// Forensic audit of the non-GET table surface (writes). Capture the query, not
|
|
890
1118
|
// the response: the operation, the WHERE predicate, and the body's column NAMES
|
|
891
|
-
// (never values — values can be PII).
|
|
1119
|
+
// (never values — values can be PII). Unresolved embed policies also record
|
|
1120
|
+
// GET denials; other reads are delegated to RLS.
|
|
892
1121
|
const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE';
|
|
893
|
-
const auditOp: 'insert' | 'update' | 'delete' =
|
|
894
|
-
method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
|
|
1122
|
+
const auditOp: 'insert' | 'update' | 'delete' | 'select' =
|
|
1123
|
+
method === 'GET' ? 'select' : method === 'POST' ? 'insert' : method === 'PATCH' ? 'update' : 'delete';
|
|
895
1124
|
const auditFilter = url.searchParams.toString() || undefined;
|
|
896
1125
|
let auditColumns: string[] | undefined;
|
|
897
1126
|
if (isWrite && options?.audit?.sink && method !== 'DELETE') {
|
|
@@ -909,27 +1138,89 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
909
1138
|
const routedDb = method === 'GET' && !user && options?.readDb ? options.readDb : db;
|
|
910
1139
|
|
|
911
1140
|
try {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1141
|
+
// Bound every select before the hidden-column guard walks it. The guard
|
|
1142
|
+
// reads duplicates and runs for writes too, so a GET-only check is too late.
|
|
1143
|
+
let queryError: string | undefined;
|
|
1144
|
+
let auditReadDenial = false;
|
|
1145
|
+
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1146
|
+
const maxEmbedDepth = options?.maxEmbedDepth ?? 3;
|
|
1147
|
+
const depth = getEmbedDepth(selectParam);
|
|
1148
|
+
if (depth > maxEmbedDepth) {
|
|
1149
|
+
queryError = `Embed depth ${depth} exceeds maximum of ${maxEmbedDepth}`;
|
|
1150
|
+
break;
|
|
916
1151
|
}
|
|
1152
|
+
}
|
|
917
1153
|
|
|
918
|
-
|
|
919
|
-
|
|
1154
|
+
// Refusals share the response audit below without opening a transaction.
|
|
1155
|
+
// Response stripping cannot prevent value oracles or aggregate alias reads.
|
|
1156
|
+
if (!queryError && requestUsesHiddenColumn(schema, tableName, url.searchParams, options)) {
|
|
1157
|
+
queryError = 'Invalid query';
|
|
1158
|
+
}
|
|
1159
|
+
// Refuse projections we cannot enforce on the common pre-DB audit path.
|
|
1160
|
+
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1161
|
+
if (!queryError && options?.drizzleRelations && tableRelations) {
|
|
1162
|
+
for (const selectParam of url.searchParams.getAll('select')) {
|
|
1163
|
+
const { embeds } = parseSelectParam(selectParam, tableRelations, options.relations);
|
|
1164
|
+
const withClause = buildWithClause(embeds, tableRelations, schema, options);
|
|
1165
|
+
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1166
|
+
queryError = 'Invalid query';
|
|
1167
|
+
auditReadDenial = method === 'GET' && withClause === 'unresolvedHiddenColumns';
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
920
1170
|
}
|
|
1171
|
+
}
|
|
921
1172
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1173
|
+
// pgSettings runs once per request. The settings map is known only here, so the
|
|
1174
|
+
// check that it backs an ownership exemption runs here, before any transaction.
|
|
1175
|
+
const settings = queryError ? undefined : options?.pgSettings?.(user, client);
|
|
1176
|
+
const unbacked = queryError ? null : unbackedExemptionRefusal(method, tableName, options, user, settings);
|
|
1177
|
+
// A returned 500 skips the catch below, so report it to onError here.
|
|
1178
|
+
if (unbacked) {
|
|
1179
|
+
await emitFault(options?.onError, { requestId, kind: 'rest', name: tableName, method, status: 500, message: unbacked });
|
|
1180
|
+
}
|
|
1181
|
+
// Work queued by the write. It runs only once executeWithSettings has
|
|
1182
|
+
// returned or thrown (with pgSettings, after the transaction has ended).
|
|
1183
|
+
// A before-hook fault runs in the finally, for any outcome: the hook failed
|
|
1184
|
+
// even when the commit then fails too. A table after-hook runs only for a
|
|
1185
|
+
// 2xx; a throw (a failed commit) skips it, since the write did not happen.
|
|
1186
|
+
// A retry wrapper around db.transaction must reset `deferred` per attempt,
|
|
1187
|
+
// or the hooks of an aborted attempt would run too.
|
|
1188
|
+
const deferred: Deferred[] = [];
|
|
1189
|
+
let response: Response;
|
|
1190
|
+
try {
|
|
1191
|
+
response = queryError ? json({ message: queryError }, 400)
|
|
1192
|
+
: unbacked ? json({ message: unbacked }, 500) : await executeWithSettings(routedDb, settings, async (dbOrTx) => {
|
|
1193
|
+
if (method === 'GET') {
|
|
1194
|
+
const cacheControl = getCacheControl(options);
|
|
1195
|
+
return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
|
|
1196
|
+
}
|
|
925
1197
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1198
|
+
if (method === 'POST') {
|
|
1199
|
+
return handlePost(dbOrTx, table, tableName, request, options, user, client, requestId, deferred);
|
|
1200
|
+
}
|
|
929
1201
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1202
|
+
if (method === 'PATCH') {
|
|
1203
|
+
return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client, requestId, deferred);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (method === 'DELETE') {
|
|
1207
|
+
return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client, requestId, deferred);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
return json({ message: `Method ${method} not allowed` }, 405);
|
|
1211
|
+
});
|
|
1212
|
+
} finally {
|
|
1213
|
+
// emitFault never throws, so this cannot replace the error in flight.
|
|
1214
|
+
for (const item of deferred) if (item.always) await item.run();
|
|
1215
|
+
}
|
|
1216
|
+
if (response.status >= 200 && response.status < 300) {
|
|
1217
|
+
// The contract: a side effect fires only for a successful write. The guard is
|
|
1218
|
+
// unreachable today (every refusal returns before the write queues a hook) and
|
|
1219
|
+
// stays for the next write path. Each queued hook reports its own throw, so
|
|
1220
|
+
// none can fail the response.
|
|
1221
|
+
for (const item of deferred) if (!item.always) await item.run();
|
|
1222
|
+
}
|
|
1223
|
+
if (isWrite || auditReadDenial) {
|
|
933
1224
|
await emitAudit(options?.audit, {
|
|
934
1225
|
kind: 'rest', name: tableName, user, client,
|
|
935
1226
|
result: response.status < 400 ? 'allow' : response.status < 500 ? 'deny' : 'error',
|
|
@@ -948,56 +1239,104 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
948
1239
|
if (isPermissionDenied(error)) {
|
|
949
1240
|
return sanitizeError('Permission denied', 403, options);
|
|
950
1241
|
}
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
1242
|
+
// An outage answers 503 with a fixed body; the driver's message (host and
|
|
1243
|
+
// port) goes to onError only. A HandlerError is the app's own answer and
|
|
1244
|
+
// never an outage.
|
|
1245
|
+
const unavailable = !(error instanceof HandlerError) && isServiceUnavailable(error);
|
|
1246
|
+
// Other table faults re-throw to the Lambda boundary (which logs to
|
|
1247
|
+
// CloudWatch), but that path never reaches the S3 archive. Surface the
|
|
1248
|
+
// cause here too so a write 5xx is in logs:errors. A HandlerError with a
|
|
1249
|
+
// 4xx status is a deliberate refusal from a hook, not a fault — leave it alone.
|
|
955
1250
|
if (!(error instanceof HandlerError) || error.status >= 500) {
|
|
956
1251
|
await emitFault(options?.onError, {
|
|
957
1252
|
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,
|
|
1253
|
+
status: error instanceof HandlerError ? error.status : unavailable ? 503 : 500,
|
|
1254
|
+
...describeError(error),
|
|
962
1255
|
});
|
|
963
1256
|
}
|
|
1257
|
+
if (unavailable) return serviceUnavailableResponse();
|
|
964
1258
|
throw error;
|
|
965
1259
|
}
|
|
966
1260
|
};
|
|
967
1261
|
}
|
|
968
1262
|
|
|
969
|
-
/**
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1263
|
+
/** Drizzle selections use schema property names, even when the API uses SQL names. */
|
|
1264
|
+
function columnPropertyName(table: DrizzleTable | undefined, name: string): string | undefined {
|
|
1265
|
+
if (!table) return undefined;
|
|
1266
|
+
const column = getColumn(table, name);
|
|
1267
|
+
const columns = Object.entries(getTableColumns(table));
|
|
1268
|
+
return columns.find(([, value]) => value === column)?.[0]
|
|
1269
|
+
?? columns.find(([, value]) => (value as DrizzleColumn).name === name)?.[0];
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Build each relation's projection before Drizzle reads its values. */
|
|
974
1273
|
function buildWithClause(
|
|
975
1274
|
embeds: EmbedRequest[],
|
|
976
1275
|
tableRelations: Record<string, RelationConfig>,
|
|
977
|
-
|
|
1276
|
+
schema: Schema,
|
|
1277
|
+
options?: HandlerOptions,
|
|
1278
|
+
): Record<string, any> | undefined | null | 'unresolvedHiddenColumns' {
|
|
978
1279
|
if (embeds.length === 0) return undefined;
|
|
979
1280
|
|
|
980
1281
|
const withClause: Record<string, any> = {};
|
|
981
1282
|
let hasEmbedded = false;
|
|
982
1283
|
|
|
983
1284
|
for (const embed of embeds) {
|
|
984
|
-
|
|
1285
|
+
const relation = tableRelations[embed.name];
|
|
1286
|
+
if (!relation) continue;
|
|
985
1287
|
hasEmbedded = true;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1288
|
+
const table = schema[relation.table];
|
|
1289
|
+
const hidden = new Set<string>();
|
|
1290
|
+
for (const name of options?.hiddenColumns?.[relation.table] ?? []) {
|
|
1291
|
+
const property = columnPropertyName(table, name);
|
|
1292
|
+
if (property === undefined) return 'unresolvedHiddenColumns';
|
|
1293
|
+
hidden.add(property);
|
|
1294
|
+
}
|
|
1295
|
+
const config: Record<string, any> = {};
|
|
1296
|
+
if (embed.columns !== null) {
|
|
1297
|
+
config.columns = Object.fromEntries(embed.columns
|
|
1298
|
+
.map(name => columnPropertyName(table, name) ?? name)
|
|
1299
|
+
.filter(name => !hidden.has(name))
|
|
1300
|
+
.map(name => [name, true]));
|
|
1301
|
+
if (Object.keys(config.columns).length === 0) return null;
|
|
1302
|
+
} else if (hidden.size > 0) {
|
|
1303
|
+
config.columns = Object.fromEntries([...hidden].map(name => [name, false]));
|
|
995
1304
|
}
|
|
1305
|
+
withClause[embed.name] = Object.keys(config).length > 0 ? config : true;
|
|
996
1306
|
}
|
|
997
1307
|
|
|
998
1308
|
return hasEmbedded ? withClause : undefined;
|
|
999
1309
|
}
|
|
1000
1310
|
|
|
1311
|
+
/** Follow only parsed relations: a similarly named JSON key is not a table column. */
|
|
1312
|
+
function stripEmbeddedHiddenColumns(
|
|
1313
|
+
data: unknown,
|
|
1314
|
+
embeds: EmbedRequest[],
|
|
1315
|
+
tableName: string,
|
|
1316
|
+
schema: Schema,
|
|
1317
|
+
options?: HandlerOptions,
|
|
1318
|
+
): unknown {
|
|
1319
|
+
if (embeds.length === 0 || data === null || typeof data !== 'object') return data;
|
|
1320
|
+
if (Array.isArray(data)) {
|
|
1321
|
+
return data.map(row => stripEmbeddedHiddenColumns(row, embeds, tableName, schema, options));
|
|
1322
|
+
}
|
|
1323
|
+
const result = { ...(data as Record<string, unknown>) };
|
|
1324
|
+
for (const embed of embeds) {
|
|
1325
|
+
const relation = ownEntry(ownEntry(options?.relations, tableName), embed.name);
|
|
1326
|
+
const key = options?.naming === 'snake_case' ? toSnakeCase(embed.name) : embed.name;
|
|
1327
|
+
if (!relation || !(key in result)) continue;
|
|
1328
|
+
const hidden = (options?.hiddenColumns?.[relation.table] ?? []).flatMap(name => {
|
|
1329
|
+
const property = columnPropertyName(schema[relation.table], name);
|
|
1330
|
+
return [name, toSnakeCase(name), ...(property ? [property, toSnakeCase(property)] : [])];
|
|
1331
|
+
});
|
|
1332
|
+
const related = stripHiddenColumns(result[key], relation.table, {
|
|
1333
|
+
hiddenColumns: { [relation.table]: hidden },
|
|
1334
|
+
});
|
|
1335
|
+
result[key] = stripEmbeddedHiddenColumns(related, embed.embeds, relation.table, schema, options);
|
|
1336
|
+
}
|
|
1337
|
+
return result;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1001
1340
|
/**
|
|
1002
1341
|
* Convert column names array to Drizzle's `columns` selection format.
|
|
1003
1342
|
* ['id', 'title'] → { id: true, title: true }
|
|
@@ -1100,17 +1439,8 @@ async function handleGet(
|
|
|
1100
1439
|
const prefer = request.headers.get('Prefer') || '';
|
|
1101
1440
|
const wantExactCount = prefer.includes('count=exact');
|
|
1102
1441
|
|
|
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
1442
|
// Parse select parameter for relation embedding and aggregates
|
|
1113
|
-
let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations
|
|
1443
|
+
let { columns, embeds, aggregates } = parseSelectParam(selectParam, ownEntry(options?.relations, tableName), options?.relations);
|
|
1114
1444
|
|
|
1115
1445
|
// Resolve snake_case select column names to Drizzle camelCase properties
|
|
1116
1446
|
if (options?.naming === 'snake_case' && columns) {
|
|
@@ -1197,7 +1527,7 @@ async function handleGet(
|
|
|
1197
1527
|
}
|
|
1198
1528
|
|
|
1199
1529
|
// Detect if Drizzle relational query (JOINs) should be used
|
|
1200
|
-
const tableRelations = options?.relations
|
|
1530
|
+
const tableRelations = ownEntry(options?.relations, tableName);
|
|
1201
1531
|
const useRelationalQuery = embeds.length > 0
|
|
1202
1532
|
&& tableRelations
|
|
1203
1533
|
&& options?.drizzleRelations
|
|
@@ -1215,7 +1545,10 @@ async function handleGet(
|
|
|
1215
1545
|
const colSelection = buildColumnsSelection(columns);
|
|
1216
1546
|
if (colSelection) queryOptions.columns = colSelection;
|
|
1217
1547
|
|
|
1218
|
-
const withClause = buildWithClause(embeds, tableRelations
|
|
1548
|
+
const withClause = buildWithClause(embeds, tableRelations!, schema, options);
|
|
1549
|
+
if (withClause === null || withClause === 'unresolvedHiddenColumns') {
|
|
1550
|
+
return json({ message: 'Invalid query' }, 400);
|
|
1551
|
+
}
|
|
1219
1552
|
if (withClause) queryOptions.with = withClause;
|
|
1220
1553
|
|
|
1221
1554
|
if (conditions.length === 1) {
|
|
@@ -1304,7 +1637,8 @@ async function handleGet(
|
|
|
1304
1637
|
};
|
|
1305
1638
|
if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
|
|
1306
1639
|
|
|
1307
|
-
|
|
1640
|
+
const transformed = stripHiddenColumns(transformResponse(responseData, options), tableName, options);
|
|
1641
|
+
return new Response(JSON.stringify(stripEmbeddedHiddenColumns(transformed, embeds, tableName, schema, options)), {
|
|
1308
1642
|
status: 200,
|
|
1309
1643
|
headers: getHeaders,
|
|
1310
1644
|
});
|
|
@@ -1374,12 +1708,12 @@ function parseSelectParam(
|
|
|
1374
1708
|
}
|
|
1375
1709
|
|
|
1376
1710
|
const embedMatch = part.match(/^(\w+)\((.+)\)$/);
|
|
1377
|
-
if (embedMatch && tableRelations && embedMatch[1]
|
|
1711
|
+
if (embedMatch && tableRelations && Object.hasOwn(tableRelations, embedMatch[1])) {
|
|
1378
1712
|
const name = embedMatch[1];
|
|
1379
1713
|
const innerSelect = embedMatch[2].trim();
|
|
1380
1714
|
// Recursively parse sub-embeds using the target table's relations
|
|
1381
1715
|
const targetTableName = tableRelations[name].table;
|
|
1382
|
-
const targetRelations = allRelations
|
|
1716
|
+
const targetRelations = ownEntry(allRelations, targetTableName);
|
|
1383
1717
|
const inner = parseSelectParam(innerSelect, targetRelations, allRelations);
|
|
1384
1718
|
embeds.push({
|
|
1385
1719
|
name,
|
|
@@ -1431,6 +1765,71 @@ function splitSelectParam(param: string): string[] {
|
|
|
1431
1765
|
return parts;
|
|
1432
1766
|
}
|
|
1433
1767
|
|
|
1768
|
+
/** Check explicit query references, including each related table's own policy. */
|
|
1769
|
+
function requestUsesHiddenColumn(
|
|
1770
|
+
schema: Schema,
|
|
1771
|
+
tableName: string,
|
|
1772
|
+
params: URLSearchParams,
|
|
1773
|
+
options?: HandlerOptions,
|
|
1774
|
+
): boolean {
|
|
1775
|
+
if (!options?.hiddenColumns) return false;
|
|
1776
|
+
|
|
1777
|
+
const isHidden = (sourceTable: string, reference: string): boolean => {
|
|
1778
|
+
let targetName = sourceTable;
|
|
1779
|
+
let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
|
|
1780
|
+
// Prefer a real source column. The relation fallback only rejects hidden
|
|
1781
|
+
// references; the filter and order builders do not support relation paths.
|
|
1782
|
+
if (!getColumn(schema[targetName], columnName)) {
|
|
1783
|
+
const parts = columnName.split('.');
|
|
1784
|
+
while (parts.length > 1) {
|
|
1785
|
+
const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
|
|
1786
|
+
if (!relation || !schema[relation.table]) return false;
|
|
1787
|
+
targetName = relation.table;
|
|
1788
|
+
}
|
|
1789
|
+
columnName = parts[0];
|
|
1790
|
+
}
|
|
1791
|
+
const table = schema[targetName];
|
|
1792
|
+
const column = getColumn(table, columnName);
|
|
1793
|
+
return (options.hiddenColumns?.[targetName] ?? []).some(hidden => {
|
|
1794
|
+
const hiddenColumn = getColumn(table, hidden);
|
|
1795
|
+
return column && hiddenColumn
|
|
1796
|
+
? column === hiddenColumn
|
|
1797
|
+
: toCamelCase(columnName) === toCamelCase(hidden);
|
|
1798
|
+
});
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
const selectUsesHidden = (sourceTable: string, select: string): boolean => {
|
|
1802
|
+
// Walk the raw selection: parseSelectParam collapses `*,secret` to a
|
|
1803
|
+
// wildcard and discards aggregates inside embeds, losing explicit names.
|
|
1804
|
+
return splitSelectParam(select).some(part => {
|
|
1805
|
+
const aggregate = part.match(AGG_REGEX);
|
|
1806
|
+
if (aggregate) return aggregate[2] ? isHidden(sourceTable, aggregate[2]) : false;
|
|
1807
|
+
const embed = part.match(/^(\w+)\((.+)\)$/);
|
|
1808
|
+
const relation = embed && ownEntry(ownEntry(options.relations, sourceTable), embed[1]);
|
|
1809
|
+
if (embed && relation && schema[relation.table]) {
|
|
1810
|
+
return selectUsesHidden(relation.table, embed[2]);
|
|
1811
|
+
}
|
|
1812
|
+
return part !== '*' && isHidden(sourceTable, part);
|
|
1813
|
+
});
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
for (const [key, value] of params) {
|
|
1817
|
+
if (key === 'select') {
|
|
1818
|
+
if (selectUsesHidden(tableName, value)) return true;
|
|
1819
|
+
} else if (key === 'order') {
|
|
1820
|
+
if (value.split(',').some(order => {
|
|
1821
|
+
const dot = order.lastIndexOf('.');
|
|
1822
|
+
return isHidden(tableName, (dot < 0 ? order : order.slice(0, dot)).trim());
|
|
1823
|
+
})) return true;
|
|
1824
|
+
} else if (key === 'or' || key === 'and') {
|
|
1825
|
+
if (parseLogicalGroup(value).some(filter => isHidden(tableName, filter.column))) return true;
|
|
1826
|
+
} else if (!RESERVED_QUERY_PARAMS.has(key) && isHidden(tableName, key)) {
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1434
1833
|
/**
|
|
1435
1834
|
* Resolve relation embeds by fetching related rows and nesting them.
|
|
1436
1835
|
*/
|
|
@@ -1442,7 +1841,7 @@ async function resolveEmbeds(
|
|
|
1442
1841
|
embeds: EmbedRequest[],
|
|
1443
1842
|
options: HandlerOptions,
|
|
1444
1843
|
): Promise<Record<string, unknown>[]> {
|
|
1445
|
-
const tableRelations = options.relations
|
|
1844
|
+
const tableRelations = ownEntry(options.relations, tableName);
|
|
1446
1845
|
if (!tableRelations) return rows;
|
|
1447
1846
|
|
|
1448
1847
|
// Clone rows to avoid mutation
|
|
@@ -1563,6 +1962,37 @@ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<st
|
|
|
1563
1962
|
return result;
|
|
1564
1963
|
}
|
|
1565
1964
|
|
|
1965
|
+
/**
|
|
1966
|
+
* The response for a before-hook that threw. A HandlerError keeps its own status and
|
|
1967
|
+
* message. An outage (the hook could not reach the database) answers the fixed 503,
|
|
1968
|
+
* like every other outage path. Anything else is a generic 500 so the hook's text
|
|
1969
|
+
* never reaches the client. A 5xx is a fault and goes to onError; a 4xx HandlerError
|
|
1970
|
+
* is a refusal and does not. The fault is queued, not sent: with pgSettings the hook
|
|
1971
|
+
* ran inside the transaction, and a slow onError must not hold it open.
|
|
1972
|
+
*/
|
|
1973
|
+
function beforeHookFailure(
|
|
1974
|
+
err: unknown,
|
|
1975
|
+
options: HandlerOptions | undefined,
|
|
1976
|
+
at: { requestId?: string; name: string; method: string },
|
|
1977
|
+
deferred: Deferred[],
|
|
1978
|
+
): Response {
|
|
1979
|
+
const report = (fault: HandlerFault): void => {
|
|
1980
|
+
deferred.push({ always: true, run: () => emitFault(options?.onError, fault) });
|
|
1981
|
+
};
|
|
1982
|
+
if (err instanceof HandlerError) {
|
|
1983
|
+
if (err.status >= 500) {
|
|
1984
|
+
report({ ...at, kind: 'rest', status: err.status, message: err.message });
|
|
1985
|
+
}
|
|
1986
|
+
return json({ message: err.message }, err.status);
|
|
1987
|
+
}
|
|
1988
|
+
if (isServiceUnavailable(err)) {
|
|
1989
|
+
report({ ...at, kind: 'rest', status: 503, ...describeError(err) });
|
|
1990
|
+
return serviceUnavailableResponse();
|
|
1991
|
+
}
|
|
1992
|
+
report({ ...at, kind: 'rest', status: 500, ...describeError(err) });
|
|
1993
|
+
return json({ message: 'Hook error' }, 500);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1566
1996
|
async function handlePost(
|
|
1567
1997
|
db: DrizzleDb,
|
|
1568
1998
|
table: DrizzleTable,
|
|
@@ -1571,6 +2001,8 @@ async function handlePost(
|
|
|
1571
2001
|
options?: HandlerOptions,
|
|
1572
2002
|
user: Record<string, unknown> | null = null,
|
|
1573
2003
|
client: Record<string, unknown> | null = null,
|
|
2004
|
+
requestId?: string,
|
|
2005
|
+
deferred: Deferred[] = [],
|
|
1574
2006
|
): Promise<Response> {
|
|
1575
2007
|
let body: Record<string, unknown>;
|
|
1576
2008
|
try {
|
|
@@ -1599,10 +2031,7 @@ async function handlePost(
|
|
|
1599
2031
|
try {
|
|
1600
2032
|
body = await hook.beforeCreate(body, user, client);
|
|
1601
2033
|
} catch (err) {
|
|
1602
|
-
|
|
1603
|
-
return json({ message: err.message }, err.status);
|
|
1604
|
-
}
|
|
1605
|
-
return json({ message: 'Hook error' }, 500);
|
|
2034
|
+
return beforeHookFailure(err, options, { requestId, name: tableName, method: 'POST' }, deferred);
|
|
1606
2035
|
}
|
|
1607
2036
|
}
|
|
1608
2037
|
|
|
@@ -1619,7 +2048,9 @@ async function handlePost(
|
|
|
1619
2048
|
const rows = await db.insert(table).values(coerced as Record<string, unknown>).returning();
|
|
1620
2049
|
|
|
1621
2050
|
if (hook?.afterCreate && rows.length > 0) {
|
|
1622
|
-
|
|
2051
|
+
const { afterCreate } = hook;
|
|
2052
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterCreate`, method: 'POST' },
|
|
2053
|
+
() => afterCreate.call(hook, rows[0], user, client));
|
|
1623
2054
|
}
|
|
1624
2055
|
|
|
1625
2056
|
return json(stripHiddenColumns(transformResponse(rows[0], options), tableName, options), 201);
|
|
@@ -1634,13 +2065,20 @@ async function handlePatch(
|
|
|
1634
2065
|
options?: HandlerOptions,
|
|
1635
2066
|
user: Record<string, unknown> | null = null,
|
|
1636
2067
|
client: Record<string, unknown> | null = null,
|
|
2068
|
+
requestId?: string,
|
|
2069
|
+
deferred: Deferred[] = [],
|
|
1637
2070
|
): Promise<Response> {
|
|
2071
|
+
// Ownership refusal first: before the body, the filter, or any hook is examined.
|
|
2072
|
+
const scope = ownershipScope(table, tableName, options, user);
|
|
2073
|
+
if ('refusal' in scope) return scope.refusal;
|
|
2074
|
+
|
|
1638
2075
|
let body: Record<string, unknown>;
|
|
1639
2076
|
try {
|
|
1640
2077
|
body = await request.json();
|
|
1641
2078
|
} catch {
|
|
1642
2079
|
return json({ message: 'Invalid JSON body' }, 400);
|
|
1643
2080
|
}
|
|
2081
|
+
const clientBody = body;
|
|
1644
2082
|
|
|
1645
2083
|
try {
|
|
1646
2084
|
// Map body keys to Drizzle property names when naming convention is active
|
|
@@ -1656,16 +2094,28 @@ async function handlePatch(
|
|
|
1656
2094
|
throw err;
|
|
1657
2095
|
}
|
|
1658
2096
|
|
|
2097
|
+
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
2098
|
+
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
2099
|
+
const ownership = options?.rowOwnership?.[tableName];
|
|
2100
|
+
if (user && options && ownership && isOwnershipExempt(ownership, user, options)
|
|
2101
|
+
&& buildsNoFilter(table, params)) {
|
|
2102
|
+
return json({ message: 'PATCH requires at least one filter when the ownership backstop is lifted for your role' }, 400);
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// The owner clause proves the caller owns the row, not that they may give it away.
|
|
2106
|
+
// A caller it scopes may not set the owner column to anyone else. RLS stays primary.
|
|
2107
|
+
if (scope.condition && user && ownership) {
|
|
2108
|
+
const key = ownerReassignmentKey(table, ownership, user, clientBody, body);
|
|
2109
|
+
if (key !== null) return json({ message: `Cannot change ownership column "${key}"` }, 403);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
1659
2112
|
const hook = options?.hooks?.[tableName];
|
|
1660
2113
|
|
|
1661
2114
|
if (hook?.beforeUpdate) {
|
|
1662
2115
|
try {
|
|
1663
2116
|
body = await hook.beforeUpdate(body, user, client);
|
|
1664
2117
|
} catch (err) {
|
|
1665
|
-
|
|
1666
|
-
return json({ message: err.message }, err.status);
|
|
1667
|
-
}
|
|
1668
|
-
return json({ message: 'Hook error' }, 500);
|
|
2118
|
+
return beforeHookFailure(err, options, { requestId, name: tableName, method: 'PATCH' }, deferred);
|
|
1669
2119
|
}
|
|
1670
2120
|
}
|
|
1671
2121
|
|
|
@@ -1681,15 +2131,8 @@ async function handlePatch(
|
|
|
1681
2131
|
if (sdCol) conditions.push(isNull(sdCol));
|
|
1682
2132
|
}
|
|
1683
2133
|
|
|
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
|
-
}
|
|
2134
|
+
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2135
|
+
if (scope.condition) conditions.push(scope.condition);
|
|
1693
2136
|
|
|
1694
2137
|
let coerced: unknown;
|
|
1695
2138
|
try {
|
|
@@ -1712,12 +2155,115 @@ async function handlePatch(
|
|
|
1712
2155
|
const rows = await query.returning();
|
|
1713
2156
|
|
|
1714
2157
|
if (hook?.afterUpdate && rows.length > 0) {
|
|
1715
|
-
|
|
2158
|
+
const { afterUpdate } = hook;
|
|
2159
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterUpdate`, method: 'PATCH' },
|
|
2160
|
+
() => afterUpdate.call(hook, rows, user, client));
|
|
1716
2161
|
}
|
|
1717
2162
|
|
|
1718
2163
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
1719
2164
|
}
|
|
1720
2165
|
|
|
2166
|
+
/**
|
|
2167
|
+
* True when the caller's role is an exact member of the entry's `exceptRoles`, so the
|
|
2168
|
+
* ownership clause is skipped. Fail closed: no list, an empty list, a missing or
|
|
2169
|
+
* non-string role claim, or any other role keeps the clause.
|
|
2170
|
+
*/
|
|
2171
|
+
function isOwnershipExempt(
|
|
2172
|
+
ownership: { exceptRoles?: string[] },
|
|
2173
|
+
user: Record<string, unknown>,
|
|
2174
|
+
options: HandlerOptions,
|
|
2175
|
+
): boolean {
|
|
2176
|
+
if (!ownership.exceptRoles?.length) return false;
|
|
2177
|
+
const role = readRoleClaim(user, options.auth?.roleField ?? 'role');
|
|
2178
|
+
return role !== '' && ownership.exceptRoles.includes(role);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* The refusal message (sent with a 500) for a PATCH or DELETE whose ownership exemption
|
|
2183
|
+
* fires while the request's pgSettings map has no role, or null. executeWithSettings runs
|
|
2184
|
+
* SET LOCAL ROLE only for a truthy `role`; without it the connection stays on the table
|
|
2185
|
+
* owner, RLS does not apply, and the JWT role claim alone would lift the owner clause. A
|
|
2186
|
+
* caller who is not excepted keeps the clause, so it is never refused here.
|
|
2187
|
+
*/
|
|
2188
|
+
function unbackedExemptionRefusal(
|
|
2189
|
+
method: string,
|
|
2190
|
+
tableName: string,
|
|
2191
|
+
options: HandlerOptions | undefined,
|
|
2192
|
+
user: Record<string, unknown> | null,
|
|
2193
|
+
settings: Record<string, string> | undefined,
|
|
2194
|
+
): string | null {
|
|
2195
|
+
if (method !== 'PATCH' && method !== 'DELETE') return null;
|
|
2196
|
+
const ownership = options?.rowOwnership?.[tableName];
|
|
2197
|
+
if (!options || !ownership || !user || !isOwnershipExempt(ownership, user, options)) return null;
|
|
2198
|
+
if (settings?.['role']) return null;
|
|
2199
|
+
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.`;
|
|
2200
|
+
console.error(`[everystack/api] ${message}`);
|
|
2201
|
+
return message;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* The key, as the client spelled it, that would set the owner column to a value other
|
|
2206
|
+
* than the caller's own, or null. A key matches when it resolves to the owner column
|
|
2207
|
+
* object, so either spelling counts. The decision reads `body` (transformed and
|
|
2208
|
+
* stripped: what would be written); the reported key comes from `clientBody`, so the
|
|
2209
|
+
* message echoes only what the client sent. Null, an object or any other value is a
|
|
2210
|
+
* change; the caller's own value, compared as a string, is a no-op.
|
|
2211
|
+
*/
|
|
2212
|
+
function ownerReassignmentKey(
|
|
2213
|
+
table: DrizzleTable,
|
|
2214
|
+
ownership: { column: string; userField: string },
|
|
2215
|
+
user: Record<string, unknown>,
|
|
2216
|
+
clientBody: unknown,
|
|
2217
|
+
body: unknown,
|
|
2218
|
+
): string | null {
|
|
2219
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
2220
|
+
const owner = String(user[ownership.userField]);
|
|
2221
|
+
const changesOwner = ([key, value]: [string, unknown]): boolean =>
|
|
2222
|
+
getColumn(table, key) === ownerCol
|
|
2223
|
+
&& (value === null || typeof value === 'object' || String(value) !== owner);
|
|
2224
|
+
const written = Object.entries(body ?? {}).find(changesOwner);
|
|
2225
|
+
if (!written) return null;
|
|
2226
|
+
return Object.entries(clientBody ?? {}).find(changesOwner)?.[0] ?? written[0];
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
type OwnershipScope = { refusal: Response } | { condition: SQL | null };
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* The owner clause for a PATCH or DELETE, or the refusal when it cannot be built. Fails
|
|
2233
|
+
* closed for a table with a rowOwnership entry and a caller who is not excepted: no
|
|
2234
|
+
* user is 401, a null, undefined or empty owner value is 403, an owner column that does not
|
|
2235
|
+
* resolve is 403 (startup validation makes that unreachable; logged, never echoed).
|
|
2236
|
+
* No entry, or an excepted role, yields no clause.
|
|
2237
|
+
*/
|
|
2238
|
+
function ownershipScope(
|
|
2239
|
+
table: DrizzleTable,
|
|
2240
|
+
tableName: string,
|
|
2241
|
+
options: HandlerOptions | undefined,
|
|
2242
|
+
user: Record<string, unknown> | null,
|
|
2243
|
+
): OwnershipScope {
|
|
2244
|
+
const ownership = options?.rowOwnership?.[tableName];
|
|
2245
|
+
if (!options || !ownership) return { condition: null };
|
|
2246
|
+
if (!user) {
|
|
2247
|
+
return { refusal: json({ message: 'Unauthorized: this table requires an authenticated owner' }, 401) };
|
|
2248
|
+
}
|
|
2249
|
+
if (isOwnershipExempt(ownership, user, options)) return { condition: null };
|
|
2250
|
+
const forbidden = (): OwnershipScope => ({
|
|
2251
|
+
refusal: json({ message: 'Forbidden: row ownership cannot be verified for this request' }, 403),
|
|
2252
|
+
});
|
|
2253
|
+
const userValue = user[ownership.userField];
|
|
2254
|
+
// An empty string is not an owner: it would scope the write to rows owned by ''.
|
|
2255
|
+
// A numeric 0 is a legal owner value, so only null, undefined and '' refuse.
|
|
2256
|
+
if (userValue == null || userValue === '') return forbidden();
|
|
2257
|
+
const ownerCol = getColumn(table, ownership.column);
|
|
2258
|
+
if (!ownerCol) {
|
|
2259
|
+
console.error(
|
|
2260
|
+
`[everystack/api] rowOwnership.${tableName}.column "${ownership.column}" does not resolve to a column of table "${tableName}"; write refused`,
|
|
2261
|
+
);
|
|
2262
|
+
return forbidden();
|
|
2263
|
+
}
|
|
2264
|
+
return { condition: eq(ownerCol, userValue) };
|
|
2265
|
+
}
|
|
2266
|
+
|
|
1721
2267
|
async function handleDelete(
|
|
1722
2268
|
db: DrizzleDb,
|
|
1723
2269
|
table: DrizzleTable,
|
|
@@ -1726,12 +2272,15 @@ async function handleDelete(
|
|
|
1726
2272
|
options?: HandlerOptions,
|
|
1727
2273
|
user: Record<string, unknown> | null = null,
|
|
1728
2274
|
client: Record<string, unknown> | null = null,
|
|
2275
|
+
requestId?: string,
|
|
2276
|
+
deferred: Deferred[] = [],
|
|
1729
2277
|
): Promise<Response> {
|
|
1730
|
-
|
|
1731
|
-
const
|
|
2278
|
+
// Ownership refusal first, so it never reveals whether the filter was valid.
|
|
2279
|
+
const scope = ownershipScope(table, tableName, options, user);
|
|
2280
|
+
if ('refusal' in scope) return scope.refusal;
|
|
1732
2281
|
|
|
1733
2282
|
// Safety: require at least one filter to prevent accidental mass deletion
|
|
1734
|
-
if (
|
|
2283
|
+
if (buildsNoFilter(table, params)) {
|
|
1735
2284
|
return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
|
|
1736
2285
|
}
|
|
1737
2286
|
|
|
@@ -1741,10 +2290,7 @@ async function handleDelete(
|
|
|
1741
2290
|
try {
|
|
1742
2291
|
await hook.beforeDelete(user, client);
|
|
1743
2292
|
} catch (err) {
|
|
1744
|
-
|
|
1745
|
-
return json({ message: err.message }, err.status);
|
|
1746
|
-
}
|
|
1747
|
-
return json({ message: 'Hook error' }, 500);
|
|
2293
|
+
return beforeHookFailure(err, options, { requestId, name: tableName, method: 'DELETE' }, deferred);
|
|
1748
2294
|
}
|
|
1749
2295
|
}
|
|
1750
2296
|
|
|
@@ -1754,15 +2300,8 @@ async function handleDelete(
|
|
|
1754
2300
|
}
|
|
1755
2301
|
const conditions = filterResult.conditions;
|
|
1756
2302
|
|
|
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
|
-
}
|
|
2303
|
+
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|
|
2304
|
+
if (scope.condition) conditions.push(scope.condition);
|
|
1766
2305
|
|
|
1767
2306
|
// Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
|
|
1768
2307
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
@@ -1782,7 +2321,9 @@ async function handleDelete(
|
|
|
1782
2321
|
const rows = await query.returning();
|
|
1783
2322
|
|
|
1784
2323
|
if (hook?.afterDelete && rows.length > 0) {
|
|
1785
|
-
|
|
2324
|
+
const { afterDelete } = hook;
|
|
2325
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
|
|
2326
|
+
() => afterDelete.call(hook, rows, user, client));
|
|
1786
2327
|
}
|
|
1787
2328
|
|
|
1788
2329
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
@@ -1801,7 +2342,9 @@ async function handleDelete(
|
|
|
1801
2342
|
const rows = await query.returning();
|
|
1802
2343
|
|
|
1803
2344
|
if (hook?.afterDelete && rows.length > 0) {
|
|
1804
|
-
|
|
2345
|
+
const { afterDelete } = hook;
|
|
2346
|
+
deferAfterHook(deferred, options?.onError, { requestId, name: `${tableName}.afterDelete`, method: 'DELETE' },
|
|
2347
|
+
() => afterDelete.call(hook, rows, user, client));
|
|
1805
2348
|
}
|
|
1806
2349
|
|
|
1807
2350
|
return json(stripHiddenColumns(transformResponse(rows, options), tableName, options), 200);
|
|
@@ -2049,12 +2592,13 @@ function buildOrderBy(table: DrizzleTable, orderParam: string): any {
|
|
|
2049
2592
|
return direction === 'desc' ? desc(column) : asc(column);
|
|
2050
2593
|
}
|
|
2051
2594
|
|
|
2595
|
+
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
2596
|
+
|
|
2052
2597
|
function parseFilters(params: URLSearchParams): Filter[] {
|
|
2053
2598
|
const filters: Filter[] = [];
|
|
2054
|
-
const reserved = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|
|
2055
2599
|
|
|
2056
2600
|
for (const [key, value] of params.entries()) {
|
|
2057
|
-
if (
|
|
2601
|
+
if (RESERVED_QUERY_PARAMS.has(key)) continue;
|
|
2058
2602
|
|
|
2059
2603
|
// Check for NOT modifier: not.eq.value → negated=true, operator=eq
|
|
2060
2604
|
let negated = false;
|
|
@@ -2172,6 +2716,27 @@ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): { con
|
|
|
2172
2716
|
return { conditions };
|
|
2173
2717
|
}
|
|
2174
2718
|
|
|
2719
|
+
/**
|
|
2720
|
+
* True when the request's own filters build no SQL condition, so the write would run
|
|
2721
|
+
* with no WHERE. A param that parses but builds nothing (`or=`, `or=()`, `and=junk`,
|
|
2722
|
+
* `id=is.x`) is not a filter. Only user filters count: the soft-delete and ownership
|
|
2723
|
+
* clauses the handler adds are not part of this test. A filter error is left to the
|
|
2724
|
+
* caller's own buildAllConditions, which reports it.
|
|
2725
|
+
*/
|
|
2726
|
+
function buildsNoFilter(table: DrizzleTable, params: URLSearchParams): boolean {
|
|
2727
|
+
const result = buildAllConditions(table, params);
|
|
2728
|
+
return !('error' in result) && result.conditions.length === 0;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/**
|
|
2732
|
+
* The outage answer. no-store keeps a CDN from caching it: CloudFront holds an
|
|
2733
|
+
* origin 5xx for about 10 seconds, so a protected GET could get a stale 503
|
|
2734
|
+
* after the database recovers.
|
|
2735
|
+
*/
|
|
2736
|
+
function serviceUnavailableResponse(): Response {
|
|
2737
|
+
return json({ message: SERVICE_UNAVAILABLE_MESSAGE }, 503, { 'Cache-Control': 'no-store' });
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2175
2740
|
function json(data: unknown, status: number, headers?: Record<string, string>): Response {
|
|
2176
2741
|
return new Response(JSON.stringify(data), {
|
|
2177
2742
|
status,
|