@everystack/api 0.4.3 → 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 +12 -0
- package/package.json +1 -1
- package/src/client/index.ts +13 -3
- package/src/handler/index.ts +102 -15
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to `@everystack/api` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.4] - 2026-10-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- PATCH honours ?deleted=include|only so a soft-deleted row can be restored
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- refuse a JOIN embed list that names no real column
|
|
16
|
+
- restore through PATCH is off unless softDelete.restore names the caller's role
|
|
17
|
+
- a PATCH whose final body has nothing to write answers 400, not 500
|
|
18
|
+
|
|
7
19
|
## [0.4.3] - 2026-10-14
|
|
8
20
|
|
|
9
21
|
### BREAKING CHANGES
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
package/src/client/index.ts
CHANGED
|
@@ -39,6 +39,9 @@ export interface CountResponse<T> extends ClientResponse<T> {
|
|
|
39
39
|
total: number;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Soft-delete scope: `include` reads or writes live and deleted rows, `only` deleted rows. */
|
|
43
|
+
export type DeletedMode = 'include' | 'only';
|
|
44
|
+
|
|
42
45
|
export interface QueryBuilder {
|
|
43
46
|
eq(column: string, value: string): QueryBuilder;
|
|
44
47
|
neq(column: string, value: string): QueryBuilder;
|
|
@@ -68,6 +71,9 @@ export interface QueryBuilder {
|
|
|
68
71
|
order(column: string, direction?: 'asc' | 'desc'): QueryBuilder;
|
|
69
72
|
limit(count: number): QueryBuilder;
|
|
70
73
|
offset(count: number): QueryBuilder;
|
|
74
|
+
/** Soft-delete tables: ?deleted=include|only on select and update. `.deleted('only').update({ deletedAt: null })` restores,
|
|
75
|
+
* for a role the handler lists in `softDelete.restore`; for anyone else update ignores it. */
|
|
76
|
+
deleted(mode: DeletedMode): QueryBuilder;
|
|
71
77
|
execute(): Promise<ClientResponse<unknown[]>>;
|
|
72
78
|
executeWithCount(): Promise<CountResponse<unknown[]>>;
|
|
73
79
|
single(): Promise<ClientResponse<unknown>>;
|
|
@@ -123,6 +129,7 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
|
|
|
123
129
|
let orderParam: string | null = null;
|
|
124
130
|
let limitParam: number | null = null;
|
|
125
131
|
let offsetParam: number | null = null;
|
|
132
|
+
let deletedParam: DeletedMode | null = null;
|
|
126
133
|
|
|
127
134
|
const builder: QueryBuilder = {
|
|
128
135
|
eq(column, value) { filters.push([column, `eq.${value}`]); return builder; },
|
|
@@ -151,9 +158,10 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
|
|
|
151
158
|
order(column, direction = 'asc') { orderParam = `${column}.${direction}`; return builder; },
|
|
152
159
|
limit(count) { limitParam = count; return builder; },
|
|
153
160
|
offset(count) { offsetParam = count; return builder; },
|
|
161
|
+
deleted(mode) { deletedParam = mode; return builder; },
|
|
154
162
|
|
|
155
163
|
async execute(): Promise<ClientResponse<unknown[]>> {
|
|
156
|
-
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam);
|
|
164
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam, deletedParam);
|
|
157
165
|
const headers = await buildHeaders(options);
|
|
158
166
|
const { response, retried } = await fetchWithRetry(url, { method: 'GET', headers }, options);
|
|
159
167
|
|
|
@@ -168,7 +176,7 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
|
|
|
168
176
|
},
|
|
169
177
|
|
|
170
178
|
async executeWithCount(): Promise<CountResponse<unknown[]>> {
|
|
171
|
-
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam);
|
|
179
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, limitParam, offsetParam, deletedParam);
|
|
172
180
|
const headers = await buildHeaders(options);
|
|
173
181
|
headers['Prefer'] = 'count=exact';
|
|
174
182
|
|
|
@@ -220,7 +228,7 @@ function createQueryBuilder(options: ClientOptions, table: string): QueryBuilder
|
|
|
220
228
|
},
|
|
221
229
|
|
|
222
230
|
async update(data: Record<string, unknown>): Promise<ClientResponse<unknown[]>> {
|
|
223
|
-
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, null, null);
|
|
231
|
+
const url = buildUrl(options.baseUrl, table, filters, selectParam, orderParam, null, null, deletedParam);
|
|
224
232
|
const headers = await buildHeaders(options);
|
|
225
233
|
headers['Content-Type'] = 'application/json';
|
|
226
234
|
|
|
@@ -340,6 +348,7 @@ function buildUrl(
|
|
|
340
348
|
order: string | null,
|
|
341
349
|
limit: number | null,
|
|
342
350
|
offset: number | null,
|
|
351
|
+
deleted: DeletedMode | null = null,
|
|
343
352
|
): string {
|
|
344
353
|
const path = `${baseUrl}/${table}`;
|
|
345
354
|
const base = typeof globalThis.location !== 'undefined' ? globalThis.location.origin : undefined;
|
|
@@ -355,6 +364,7 @@ function buildUrl(
|
|
|
355
364
|
if (order) url.searchParams.set('order', order);
|
|
356
365
|
if (limit !== null) url.searchParams.set('limit', String(limit));
|
|
357
366
|
if (offset !== null) url.searchParams.set('offset', String(offset));
|
|
367
|
+
if (deleted) url.searchParams.set('deleted', deleted);
|
|
358
368
|
|
|
359
369
|
return url.toString();
|
|
360
370
|
}
|
package/src/handler/index.ts
CHANGED
|
@@ -233,6 +233,13 @@ export interface SoftDeleteConfig {
|
|
|
233
233
|
column?: string;
|
|
234
234
|
/** Tables with soft-delete enabled. If omitted, all tables with the column are treated as soft-delete. */
|
|
235
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[]>;
|
|
236
243
|
}
|
|
237
244
|
|
|
238
245
|
export interface HookConfig {
|
|
@@ -395,6 +402,17 @@ function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): Dri
|
|
|
395
402
|
return getColumn(table, colName);
|
|
396
403
|
}
|
|
397
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
|
+
|
|
398
416
|
function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
|
|
399
417
|
if (!options?.softDelete) return false;
|
|
400
418
|
if (options.softDelete.tables) {
|
|
@@ -766,8 +784,14 @@ function expandTableKeyedOptions(
|
|
|
766
784
|
...(options.rowOwnership ? { rowOwnership: expandMap(options.rowOwnership) } : {}),
|
|
767
785
|
...(options.relations ? { relations: expandMap(options.relations) } : {}),
|
|
768
786
|
...(options.hooks ? { hooks: expandMap(options.hooks) } : {}),
|
|
769
|
-
...(options.softDelete
|
|
770
|
-
? {
|
|
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
|
+
}
|
|
771
795
|
: {}),
|
|
772
796
|
};
|
|
773
797
|
}
|
|
@@ -826,6 +850,48 @@ function validateRowOwnership(schema: Schema, options: HandlerOptions | undefine
|
|
|
826
850
|
}
|
|
827
851
|
}
|
|
828
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
|
+
|
|
829
895
|
export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: HandlerOptions): Handler {
|
|
830
896
|
// The naming seam: URLs, exposedTables, and relation targets historically mixed
|
|
831
897
|
// drizzle export keys (`texturePacks`) with SQL names (`texture_packs`), and any
|
|
@@ -847,6 +913,7 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
847
913
|
}
|
|
848
914
|
}
|
|
849
915
|
validateRowOwnership(schema, rawOptions);
|
|
916
|
+
validateSoftDeleteRestore(schema, options);
|
|
850
917
|
const tableNames = new Set(Object.keys(schema));
|
|
851
918
|
const canonicalTables = [...new Set(Object.entries(rawSchema).map(([key, value]) => sqlNameOf(value) ?? key))];
|
|
852
919
|
|
|
@@ -1294,9 +1361,11 @@ function buildWithClause(
|
|
|
1294
1361
|
}
|
|
1295
1362
|
const config: Record<string, any> = {};
|
|
1296
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.
|
|
1297
1366
|
config.columns = Object.fromEntries(embed.columns
|
|
1298
|
-
.map(name => columnPropertyName(table, name)
|
|
1299
|
-
.filter(name => !hidden.has(name))
|
|
1367
|
+
.map(name => (table ? columnPropertyName(table, name) : name))
|
|
1368
|
+
.filter((name): name is string => name !== undefined && !hidden.has(name))
|
|
1300
1369
|
.map(name => [name, true]));
|
|
1301
1370
|
if (Object.keys(config.columns).length === 0) return null;
|
|
1302
1371
|
} else if (hidden.size > 0) {
|
|
@@ -1462,13 +1531,8 @@ async function handleGet(
|
|
|
1462
1531
|
if (isSoftDeleteTable(tableName, table, options)) {
|
|
1463
1532
|
const sdCol = getSoftDeleteColumn(table, options);
|
|
1464
1533
|
if (sdCol) {
|
|
1465
|
-
const
|
|
1466
|
-
if (
|
|
1467
|
-
conditions.push(isNotNull(sdCol));
|
|
1468
|
-
} else if (deletedParam !== 'include') {
|
|
1469
|
-
// Default: exclude deleted records
|
|
1470
|
-
conditions.push(isNull(sdCol));
|
|
1471
|
-
}
|
|
1534
|
+
const condition = softDeleteCondition(sdCol, params);
|
|
1535
|
+
if (condition) conditions.push(condition);
|
|
1472
1536
|
}
|
|
1473
1537
|
}
|
|
1474
1538
|
|
|
@@ -2094,6 +2158,23 @@ async function handlePatch(
|
|
|
2094
2158
|
throw err;
|
|
2095
2159
|
}
|
|
2096
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
|
+
|
|
2097
2178
|
// With the ownership backstop lifted, an unfiltered PATCH would write every row the
|
|
2098
2179
|
// role's RLS allows. Refuse it, the same filter test DELETE applies to every caller.
|
|
2099
2180
|
const ownership = options?.rowOwnership?.[tableName];
|
|
@@ -2119,16 +2200,22 @@ async function handlePatch(
|
|
|
2119
2200
|
}
|
|
2120
2201
|
}
|
|
2121
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
|
+
|
|
2122
2209
|
const filterResult = buildAllConditions(table, params);
|
|
2123
2210
|
if ('error' in filterResult) {
|
|
2124
2211
|
return sanitizeError(filterResult.error, 400, options);
|
|
2125
2212
|
}
|
|
2126
2213
|
const conditions = filterResult.conditions;
|
|
2127
2214
|
|
|
2128
|
-
// Soft-delete:
|
|
2129
|
-
if (
|
|
2130
|
-
const
|
|
2131
|
-
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);
|
|
2132
2219
|
}
|
|
2133
2220
|
|
|
2134
2221
|
// Row ownership: AND WHERE <column> = user[<userField>] for IDOR protection
|