@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 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",
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>",
@@ -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
  }
@@ -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?.tables
770
- ? { softDelete: { ...options.softDelete, tables: expandList(options.softDelete.tables) } }
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) ?? 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 deletedParam = params.get('deleted');
1466
- if (deletedParam === 'only') {
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: exclude deleted records from updates
2129
- if (isSoftDeleteTable(tableName, table, options)) {
2130
- const sdCol = getSoftDeleteColumn(table, options);
2131
- if (sdCol) conditions.push(isNull(sdCol));
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