diff --git a/CHANGELOG.md b/CHANGELOG.md index 722f704..314ab35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [0.4.0] - 2026-11-03 + +### Added +- A top-level `write` option turns writes on for every exposed table at once: `write: true`, or `{ create, update, delete, bulk }`. A resource's own `write` replaces it, and views stay read-only unless their resource allows writes. +- Every write example in the docs states its status, and the security page describes sensitive columns and how to serve one. + ## [0.3.1] - 2026-11-03 ### Changed diff --git a/docs/getting-started.md b/docs/getting-started.md index e9a0962..71297d6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,6 +37,8 @@ Every table and view of `app` is now a resource, read-only: curl -g 'http://localhost:3000/api/todos?select=id,name,owner(name)&status=eq.open' ``` +Add `write: true` beside `expose` to create, update and delete rows in every table too. Do it once your `authorize` hook decides who may write: [Writes](./writes.md#writes-on-every-table). + ## Next - [Discovery and overrides](./discovery.md): what becomes a resource, which columns are hidden, how relations are named. diff --git a/docs/writes.md b/docs/writes.md index 2e481cc..22550f9 100644 --- a/docs/writes.md +++ b/docs/writes.md @@ -1,6 +1,6 @@ # Writes and procedures -Writes are off until a resource enables them. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql): +Writes are off until you turn them on, for every table at once or one resource at a time. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql): ```ts resources: { @@ -16,6 +16,26 @@ procedures: { `write: true` allows create, update and delete of every visible column. A list allows only the columns named. Generated and identity columns are never writable, and neither is a key on update. +## Writes on every table + +For an API that writes everywhere, set `write` once, beside `expose`: + +```ts +createREST({ + db, + expose: { schemas: ['app'] }, + write: true, + resources: { + audit_log: { write: false }, + users: { write: { update: ['name'] } }, + }, +}); +``` + +It applies to every exposed table; views stay read-only until their resource allows writes, since many views cannot be written. A resource that sets its own `write` uses it instead: `write: false` keeps one table read-only, a column list narrows another. `write: { create: true, update: true, delete: true, bulk: true }` adds updates and deletes by filter everywhere. + +What is never writable stays so: hidden and sensitive columns, generated columns, and keys on update; a bulk request still needs a filter. Who may write is decided by your `authorize` hook, which receives every write with its action: see [Security](./security.md#hooks). + | Method | Path | Does | Answers | |---|---|---|---| | POST | `/tags` | creates one row, or an array of rows | 201 | diff --git a/package-lock.json b/package-lock.json index 6b25369..a8a35f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3314,7 +3314,7 @@ }, "packages/core": { "name": "@crudy/restloom", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^24.0.0", @@ -3326,10 +3326,10 @@ }, "packages/express": { "name": "@crudy/restloom-express", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "devDependencies": { "@types/express": "^5.0.0", @@ -3346,10 +3346,10 @@ }, "packages/fastify": { "name": "@crudy/restloom-fastify", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3365,10 +3365,10 @@ }, "packages/mssql": { "name": "@crudy/restloom-mssql", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "devDependencies": { "@types/mssql": "^12.0.0", @@ -3390,10 +3390,10 @@ }, "packages/mysql": { "name": "@crudy/restloom-mysql", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3414,10 +3414,10 @@ }, "packages/postgres": { "name": "@crudy/restloom-postgres", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3439,10 +3439,10 @@ }, "packages/sqlite": { "name": "@crudy/restloom-sqlite", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", diff --git a/packages/core/package.json b/packages/core/package.json index 135c0fa..2a394c5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults", "keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ec3443e..4d668c8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,7 @@ export { createREST, type Rest } from './rest.js'; export { nodeListener, type NodeListenerOptions } from './node.js'; export { RestError, badRequest, notFound } from './errors.js'; export { DEFAULT_LIMITS, DEFAULT_SENSITIVE } from './options.js'; -export type { Hooks, Limits, ProcedureOptions, RelationOptions, ResourceOptions, RestOptions, RestRequest, RestResponse, WriteOptions } from './options.js'; +export type { DefaultWriteOptions, Hooks, Limits, ProcedureOptions, RelationOptions, ResourceOptions, RestOptions, RestRequest, RestResponse, WriteOptions } from './options.js'; export type { Capabilities, Dialect, Tx } from './dialect.js'; export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, CatalogTable, LogicalType } from './catalog.js'; export type { Column, Junction, Relation, Resource } from './resources.js'; diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index 00e82f5..a998c70 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -34,6 +34,14 @@ export interface WriteOptions { bulk?: boolean; } +/** What a global `write` allows on each table: whole methods, since a column list belongs to one resource. */ +export interface DefaultWriteOptions { + create?: boolean; + update?: boolean; + delete?: boolean; + bulk?: boolean; +} + export interface ResourceOptions { /** false removes a discovered table or view from the API. */ expose?: boolean; @@ -92,6 +100,11 @@ export interface RestOptions { db: Dialect; /** Nothing is served until a schema or a table is named here. */ expose: { schemas?: string[]; tables?: string[] }; + /** + * Writes on every exposed table, unless its resource sets its own `write`. + * Views stay read-only until their resource allows writes. + */ + write?: boolean | DefaultWriteOptions; resources?: Record; procedures?: Record; /** Replaces the default list of sensitive name patterns. */ diff --git a/packages/core/src/resources.ts b/packages/core/src/resources.ts index 43aff63..504cdcc 100644 --- a/packages/core/src/resources.ts +++ b/packages/core/src/resources.ts @@ -92,8 +92,9 @@ export function buildResources(catalog: Catalog, options: RestOptions): const resources = new Map(); for (const t of tables) { const name = named.get(t)!; - const o = config[name] ?? {}; - if (o.expose === false) continue; + const own = config[name] ?? {}; + if (own.expose === false) continue; + const o = own.write === undefined && t.kind === 'table' ? { ...own, write: options.write } : own; const key = o.key ?? (t.primaryKey.length ? t.primaryKey : t.columns.some((c) => c.name === 'id') ? ['id'] : []); const w = o.write === true ? { delete: true, bulk: false } : o.write || {}; const columns = columnsOf(t, o, sensitive); diff --git a/packages/core/test/resources.test.mjs b/packages/core/test/resources.test.mjs index e86924d..be06076 100644 --- a/packages/core/test/resources.test.mjs +++ b/packages/core/test/resources.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { resources } from './fixture.mjs'; +import { buildResources } from '../dist/resources.js'; +import { catalog, resources } from './fixture.mjs'; test('only exposed schemas become resources', () => { const r = resources(); @@ -40,6 +41,23 @@ test('writes are off by default and never reach generated or key columns', () => assert.equal(a.write.delete, true); }); +test('a global write applies to every table that does not set its own', () => { + const r = resources({ write: { create: true, update: true, delete: true }, resources: { tags: { write: false }, users: { write: { update: ['name'] } } } }); + assert.deepEqual(r.get('todos').write, { create: true, update: true, delete: true, bulk: false }); + assert.equal(r.get('todos').columns.get('id').update, false); + assert.deepEqual(r.get('tags').write, { create: false, update: false, delete: false, bulk: false }); + assert.deepEqual(r.get('users').write, { create: false, update: true, delete: false, bulk: false }); + assert.equal(r.get('users').columns.has('password_hash'), false); +}); + +test('a global write leaves views read-only', () => { + const c = catalog(); + c.tables.push({ ...c.tables.find((t) => t.name === 'tags'), name: 'tag_view', kind: 'view', primaryKey: [] }); + const r = buildResources(c, { db: null, expose: { schemas: ['demo'] }, write: true }); + assert.equal(r.get('tag_view').write.create, false); + assert.equal(r.get('tags').write.create, true); +}); + test('options naming unknown resources or columns fail at load', () => { assert.throws(() => resources({ resources: { nope: {} } }), /no exposed table/); assert.throws(() => resources({ resources: { users: { hide: ['nope'] } } }), /no column nope/); diff --git a/packages/express/package.json b/packages/express/package.json index 35fc721..43e103c 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-express", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "Express adapter for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "peerDependencies": { "express": "^5.0.0" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 19a7094..894901d 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-fastify", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "Fastify adapter for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "peerDependencies": { "fastify": "^5.0.0" diff --git a/packages/mssql/package.json b/packages/mssql/package.json index 7b91b74..9479660 100644 --- a/packages/mssql/package.json +++ b/packages/mssql/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-mssql", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "SQL Server dialect for restloom", "keywords": [ @@ -37,7 +37,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "peerDependencies": { "mssql": "^12.0.0" diff --git a/packages/mysql/package.json b/packages/mysql/package.json index 3d95641..8afb10f 100644 --- a/packages/mysql/package.json +++ b/packages/mysql/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-mysql", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "MySQL dialect for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "peerDependencies": { "mysql2": "^3.11.0" diff --git a/packages/postgres/package.json b/packages/postgres/package.json index c8266ef..2818c72 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-postgres", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "Postgres dialect for restloom", "keywords": ["restloom", "postgres", "postgresql", "rest", "api"], @@ -18,7 +18,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "peerDependencies": { "pg": "^8.11.0" diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index afd9a5a..c3c0cd7 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-sqlite", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "description": "SQLite dialect for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.3.0" + "@crudy/restloom": "^0.4.0" }, "peerDependencies": { "better-sqlite3": ">=12"