pgkiln 0.47.0 → 0.49.0

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.
Files changed (69) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/db/migrations/116_dump_jobs.sql +41 -0
  7. package/db/migrations/117_rest_handlers.sql +63 -0
  8. package/db/migrations/118_change_log.sql +54 -0
  9. package/docs/apex-feature-parity.md +11 -11
  10. package/docs/guide/01-installation.md +4 -0
  11. package/docs/guide/03-builder.md +267 -7
  12. package/docs/guide/08-security.md +29 -0
  13. package/docs/guide/12-development.md +70 -6
  14. package/docs/guide/13-rest-api.md +133 -10
  15. package/docs/guide/15-extensions.md +1 -1
  16. package/examples/hr/hr_62_rest_module.sql +39 -0
  17. package/package.json +1 -1
  18. package/public/app.css +1 -1
  19. package/public/builder-icons.svg +2 -0
  20. package/public/builder.css +442 -173
  21. package/public/builder.js +48 -0
  22. package/public/data-modeler.css +56 -0
  23. package/public/data-modeler.js +367 -0
  24. package/public/palette.js +133 -0
  25. package/public/sql-monitor.js +73 -0
  26. package/public/worksheet.css +49 -0
  27. package/public/worksheet.js +4 -0
  28. package/src/app.ts +10 -0
  29. package/src/automations.ts +6 -0
  30. package/src/binds.ts +2 -1
  31. package/src/builder/advisor.ts +24 -2
  32. package/src/builder/autorest.ts +363 -0
  33. package/src/builder/changelog.ts +240 -0
  34. package/src/builder/components.ts +1 -1
  35. package/src/builder/data-modeler.ts +382 -0
  36. package/src/builder/datapump.ts +364 -0
  37. package/src/builder/db-users.ts +472 -0
  38. package/src/builder/ddl-tools.ts +13 -4
  39. package/src/builder/find-workspace.ts +2 -2
  40. package/src/builder/launchpad.ts +140 -0
  41. package/src/builder/object-actions.ts +1 -0
  42. package/src/builder/object-browser.ts +7 -1
  43. package/src/builder/plan-view.ts +25 -0
  44. package/src/builder/rest-modules.ts +629 -0
  45. package/src/builder/rest.ts +1 -0
  46. package/src/builder/routes.ts +4 -2
  47. package/src/builder/scheduling.ts +495 -0
  48. package/src/builder/sql-charts.ts +203 -0
  49. package/src/builder/sql-monitor.ts +397 -0
  50. package/src/builder/sql-tools.ts +53 -0
  51. package/src/builder/sql-utilities.ts +7 -39
  52. package/src/builder/sql.ts +20 -9
  53. package/src/builder/table-editor.ts +4 -1
  54. package/src/builder/top-sql.ts +24 -6
  55. package/src/builder/ui.ts +25 -17
  56. package/src/builder/worksheet.ts +35 -28
  57. package/src/builder/workspaces.ts +4 -2
  58. package/src/changelog.ts +109 -0
  59. package/src/cli/replace.ts +3 -1
  60. package/src/cron-schedule.ts +282 -0
  61. package/src/datamodel.ts +324 -0
  62. package/src/datapump.ts +546 -0
  63. package/src/dbroles.ts +215 -0
  64. package/src/dumpscript.ts +407 -0
  65. package/src/pgcron.ts +225 -0
  66. package/src/runtime/autorest.ts +336 -0
  67. package/src/runtime/rest-handlers.ts +75 -0
  68. package/src/runtime/rest.ts +120 -26
  69. package/src/session.ts +2 -0
package/src/pgcron.ts ADDED
@@ -0,0 +1,225 @@
1
+ import pg from 'pg';
2
+ import { createHash } from 'node:crypto';
3
+ import { owner, ownerUrl } from './db.ts';
4
+ import { parseSchedule, ScheduleError } from './cron-schedule.ts';
5
+
6
+ // (sprint 101, Database Actions DA-12) pg_cron for SQL Workshop › Scheduling (src/builder/scheduling.ts): whether it
7
+ // can be used here, its jobs and runs, the SQL that changes them (shown to the administrator before it runs) and
8
+ // Run now. Schedules are parsed and explained by src/cron-schedule.ts; pg_cron itself decides what it accepts.
9
+
10
+ export type CronState =
11
+ | { ok: true; version: string; timeZone: string; user: string; database: string; seesAll: boolean }
12
+ | { ok: false; reason: 'not_available' | 'not_loaded' | 'other_database' | 'not_created' | 'no_access' | 'old'; database: string; cronDatabase: string | null; message?: string };
13
+
14
+ const FUNCTIONS = ['cron.schedule_in_database(text,text,text,text,text,boolean)', 'cron.alter_job(bigint,text,text,text,text,boolean)', 'cron.unschedule(bigint)'];
15
+
16
+ /** Can pg_cron be used from pgkiln's owner connection, and if not, why. */
17
+ export async function cronState(): Promise<CronState> {
18
+ const info = (await owner.one<{ version: string | null; available: boolean; preload: string; cron_db: string | null; database: string; user: string; tz: string | null; job: boolean; funcs: boolean; sees_all: boolean }>(
19
+ `select (select extversion from pg_extension where extname = 'pg_cron') as version,
20
+ exists (select 1 from pg_available_extensions where name = 'pg_cron') as available,
21
+ current_setting('shared_preload_libraries') as preload,
22
+ current_setting('cron.database_name', true) as cron_db,
23
+ current_database() as database, current_user as "user",
24
+ current_setting('cron.timezone', true) as tz,
25
+ to_regclass('cron.job') is not null and to_regclass('cron.job_run_details') is not null as job,
26
+ (select bool_and(to_regprocedure(f) is not null) from unnest($1::text[]) f) as funcs,
27
+ (select rolsuper or rolbypassrls from pg_roles where rolname = current_user) as sees_all`, [FUNCTIONS]))!;
28
+ const base = { database: info.database, cronDatabase: info.cron_db };
29
+ // a cron schema with pg_cron's tables and functions counts as installed (tests stand in for pg_cron with one)
30
+ if (!info.version && !info.job) {
31
+ if (!info.available) return { ok: false, reason: 'not_available', ...base };
32
+ if (!/(^|[\s,])"?pg_cron"?($|[\s,])/.test(info.preload)) return { ok: false, reason: 'not_loaded', ...base };
33
+ if ((info.cron_db ?? 'postgres') !== info.database) return { ok: false, reason: 'other_database', ...base, cronDatabase: info.cron_db ?? 'postgres' };
34
+ return { ok: false, reason: 'not_created', ...base };
35
+ }
36
+ if (!info.funcs) return { ok: false, reason: 'old', ...base, message: info.version ?? undefined };
37
+ try {
38
+ await owner.query('select 1 from cron.job limit 1');
39
+ await owner.query('select 1 from cron.job_run_details limit 1');
40
+ } catch (e) {
41
+ return { ok: false, reason: 'no_access', ...base, message: (e as Error).message };
42
+ }
43
+ return { ok: true, version: info.version ?? '(no extension)', timeZone: info.tz || 'GMT', user: info.user, database: info.database, seesAll: info.sees_all };
44
+ }
45
+
46
+ export interface Job {
47
+ jobid: string;
48
+ jobname: string | null;
49
+ schedule: string;
50
+ command: string;
51
+ database: string;
52
+ username: string;
53
+ active: boolean;
54
+ last_status: string | null;
55
+ last_start: string | null;
56
+ last_secs: number | null;
57
+ last_message: string | null;
58
+ failures: number;
59
+ }
60
+
61
+ const JOBS_SQL = `
62
+ select j.jobid::text as jobid, j.jobname, j.schedule, j.command, j.database, j.username, j.active,
63
+ r.status as last_status, to_char(r.start_time at time zone $1, 'YYYY-MM-DD HH24:MI:SS') as last_start,
64
+ round(extract(epoch from r.end_time - r.start_time)::numeric, 3)::float8 as last_secs, r.return_message as last_message,
65
+ (select count(*)::int from (select status from cron.job_run_details f where f.jobid = j.jobid order by f.runid desc limit 10) x where x.status = 'failed') as failures
66
+ from cron.job j
67
+ left join lateral (select d.status, d.start_time, d.end_time, d.return_message from cron.job_run_details d
68
+ where d.jobid = j.jobid order by d.runid desc limit 1) r on true`;
69
+
70
+ export const listJobs = async (timeZone: string) => (await owner.query<Job>(`${JOBS_SQL} order by lower(coalesce(j.jobname, '')), j.jobid`, [timeZone])).rows;
71
+ export const oneJob = async (timeZone: string, id: string) =>
72
+ /^\d{1,18}$/.test(id) ? ((await owner.one<Job>(`${JOBS_SQL} where j.jobid = $2::bigint`, [timeZone, id])) ?? null) : null;
73
+
74
+ export interface Run {
75
+ runid: string;
76
+ jobid: string | null;
77
+ jobname: string | null;
78
+ database: string | null;
79
+ username: string | null;
80
+ command: string | null;
81
+ status: string | null;
82
+ return_message: string | null;
83
+ start: string | null;
84
+ secs: number | null;
85
+ }
86
+
87
+ export const RUN_STATUSES = ['starting', 'running', 'sending', 'connecting', 'succeeded', 'failed'];
88
+
89
+ /** Runs, newest first: of one job, with a status, or whose message or command contains a text. */
90
+ export async function listRuns(timeZone: string, f: { job?: string; status?: string; q?: string; limit?: number }) {
91
+ const where: string[] = [];
92
+ const params: unknown[] = [timeZone];
93
+ if (f.job && /^\d{1,18}$/.test(f.job)) where.push(`d.jobid = $${params.push(f.job)}::bigint`);
94
+ if (f.status && RUN_STATUSES.includes(f.status)) where.push(`d.status = $${params.push(f.status)}`);
95
+ if (f.q) where.push(`(strpos(lower(d.return_message), lower($${params.push(f.q)})) > 0 or strpos(lower(d.command), lower($${params.length})) > 0)`);
96
+ return (await owner.query<Run>(
97
+ `select d.runid::text as runid, d.jobid::text as jobid, j.jobname, d.database, d.username, d.command, d.status, d.return_message,
98
+ to_char(d.start_time at time zone $1, 'YYYY-MM-DD HH24:MI:SS') as start,
99
+ round(extract(epoch from d.end_time - d.start_time)::numeric, 3)::float8 as secs
100
+ from cron.job_run_details d left join cron.job j on j.jobid = d.jobid
101
+ ${where.length ? `where ${where.join(' and ')}` : ''} order by d.runid desc limit ${Math.min(Math.max(f.limit ?? 200, 1), 1000)}`, params)).rows;
102
+ }
103
+
104
+ /** Runs older than a number of days (or all of one job's), as the purge would delete them. */
105
+ export async function countPurge(days: number | null, job: string | null) {
106
+ const p = purgeWhere(days, job);
107
+ return (await owner.one<{ n: number }>(`select count(*)::int as n from cron.job_run_details ${p}`))!.n;
108
+ }
109
+
110
+ // ------------------------------------------------------------------ SQL
111
+
112
+ export interface JobSpec {
113
+ name: string;
114
+ schedule: string;
115
+ command: string;
116
+ database: string;
117
+ username: string;
118
+ active: boolean;
119
+ }
120
+
121
+ export const lit = (v: string) => pg.escapeLiteral(v);
122
+
123
+ /** The command as a dollar-quoted string (readable when it has quotes and lines), with a tag it doesn't contain. */
124
+ export function dollar(v: string) {
125
+ let tag = '$cmd$';
126
+ for (let i = 1; v.includes(tag) || v.endsWith(tag.slice(0, -1)); i++) tag = `$cmd${i}$`;
127
+ return `${tag}${v}${tag}`;
128
+ }
129
+
130
+ /** What is wrong with a job (empty: nothing). */
131
+ export function checkJob(spec: JobSpec, o: { databases: string[]; users: string[] }) {
132
+ const errors: string[] = [];
133
+ if (!spec.name) errors.push('Give the job a name.');
134
+ else if (spec.name.length > 100) errors.push('A name is at most 100 characters.');
135
+ else if (/[\x00-\x1f\x7f]/.test(spec.name)) errors.push('A name has no control characters.');
136
+ try {
137
+ parseSchedule(spec.schedule);
138
+ } catch (e) {
139
+ if (!(e instanceof ScheduleError)) throw e;
140
+ errors.push(`Schedule: ${e.message}`);
141
+ }
142
+ if (!spec.command.trim()) errors.push('Enter the SQL the job runs.');
143
+ else if (spec.command.length > 100_000) errors.push('The command is at most 100,000 characters.');
144
+ else if (spec.command.includes('\0')) errors.push('The command has a NUL character.');
145
+ if (!o.databases.includes(spec.database)) errors.push('Choose a database that accepts connections.');
146
+ if (!o.users.includes(spec.username)) errors.push('Choose a role that can sign in to run the job as.');
147
+ return errors;
148
+ }
149
+
150
+ /** The statements that create (before null) or change a job. */
151
+ export function jobSql(before: (JobSpec & { jobid: string }) | null, spec: JobSpec): string[] {
152
+ if (!before) {
153
+ return [`select cron.schedule_in_database(\n job_name := ${lit(spec.name)},\n schedule := ${lit(spec.schedule)},\n command := ${dollar(spec.command)},\n database := ${lit(spec.database)},\n username := ${lit(spec.username)},\n active := ${spec.active}\n);`];
154
+ }
155
+ const set: string[] = [];
156
+ if (spec.schedule !== before.schedule) set.push(`schedule := ${lit(spec.schedule)}`);
157
+ if (spec.command !== before.command) set.push(`command := ${dollar(spec.command)}`);
158
+ if (spec.database !== before.database) set.push(`database := ${lit(spec.database)}`);
159
+ if (spec.username !== before.username) set.push(`username := ${lit(spec.username)}`);
160
+ if (spec.active !== before.active) set.push(`active := ${spec.active}`);
161
+ const out = set.length ? [`select cron.alter_job(\n job_id := ${BigInt(before.jobid)},\n ${set.join(',\n ')}\n);`] : [];
162
+ // pg_cron's alter_job has no name: the name is cron.job's own column (unique per user)
163
+ if (spec.name !== before.name) out.push(`update cron.job set jobname = ${lit(spec.name)} where jobid = ${BigInt(before.jobid)};`);
164
+ return out;
165
+ }
166
+
167
+ export const activeSql = (jobid: string, active: boolean) => [`select cron.alter_job(job_id := ${BigInt(jobid)}, active := ${active});`];
168
+ export const unscheduleSql = (jobid: string) => [`select cron.unschedule(job_id := ${BigInt(jobid)});`];
169
+
170
+ function purgeWhere(days: number | null, job: string | null) {
171
+ const w: string[] = [];
172
+ if (job !== null) w.push(`jobid = ${BigInt(job)}`);
173
+ if (days !== null) w.push(`end_time < now() - interval '${Math.trunc(days)} days'`);
174
+ return w.length ? `where ${w.join(' and ')}` : '';
175
+ }
176
+ export const purgeSql = (days: number | null, job: string | null) => [`delete from cron.job_run_details ${purgeWhere(days, job)}`.trim() + ';'];
177
+
178
+ /** A digest of reviewed statements: Apply runs only what the review showed. */
179
+ export const reviewDigest = (stmts: string[]) => createHash('sha256').update(stmts.join('\n')).digest('base64url');
180
+
181
+ /** Run reviewed statements in one transaction; the error message when they fail (nothing changed). */
182
+ export async function applySql(stmts: string[]): Promise<string> {
183
+ try {
184
+ await owner.tx(async (c) => {
185
+ await c.query(`set local lock_timeout = '10s'`);
186
+ for (const x of stmts) await c.query(x);
187
+ });
188
+ return '';
189
+ } catch (e) {
190
+ return (e as Error).message;
191
+ }
192
+ }
193
+
194
+ // ------------------------------------------------------------------ Run now
195
+
196
+ export interface RunNow { ok: boolean; ms: number; message: string; notices: string[] }
197
+
198
+ /**
199
+ * Run a job's command once, now, as pg_cron would: in the job's database (a new connection with the owner's login
200
+ * to it), as the job's user (SET ROLE when it isn't the owner), outside a transaction block so VACUUM works, each
201
+ * statement in its own transaction unless the command says otherwise. pg_cron doesn't record the run (it has no
202
+ * run-now): the caller logs it.
203
+ */
204
+ export async function runNow(job: Pick<Job, 'command' | 'database' | 'username'>, timeoutMs = 600_000): Promise<RunNow> {
205
+ const url = new URL(ownerUrl);
206
+ url.pathname = `/${encodeURIComponent(job.database)}`;
207
+ const client = new pg.Client({ connectionString: url.toString(), application_name: 'pgkiln-cron-run-now', statement_timeout: timeoutMs, connectionTimeoutMillis: 10_000 });
208
+ const notices: string[] = [];
209
+ client.on('notice', (n) => notices.length < 100 && notices.push(`${n.severity ?? 'NOTICE'}: ${n.message}`));
210
+ const t0 = performance.now();
211
+ try {
212
+ await client.connect();
213
+ const me = (await client.query<{ u: string }>('select current_user as u')).rows[0].u;
214
+ if (job.username !== me) await client.query(`set role ${pg.escapeIdentifier(job.username)}`);
215
+ // the simple query protocol: several statements, and no transaction block around them (as pg_cron runs them)
216
+ const res = await client.query(job.command);
217
+ const last = Array.isArray(res) ? res[res.length - 1] : res;
218
+ const tag = last ? `${last.command ?? ''}${last.rowCount !== null && last.rowCount !== undefined ? ` ${last.rowCount}` : ''}`.trim() : '';
219
+ return { ok: true, ms: performance.now() - t0, message: tag || 'done', notices };
220
+ } catch (e) {
221
+ return { ok: false, ms: performance.now() - t0, message: (e as Error).message, notices };
222
+ } finally {
223
+ await client.end().catch(() => undefined);
224
+ }
225
+ }
@@ -0,0 +1,336 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
+ import pg from 'pg';
3
+ import { owner, runtime, type Client } from '../db.ts';
4
+ import { english } from '../i18n.ts';
5
+ import { loadApp, type App } from '../metadata.ts';
6
+ import { clientIp } from '../session.ts';
7
+ import { availabilityRefusal } from './availability.ts';
8
+ import { publicError } from './context.ts';
9
+ import { caller, RestError, restServicesOn } from './rest.ts';
10
+
11
+ // (sprint 100, Database Actions DA-11 part 1) AutoREST: a table or view that SQL Workshop › RESTful Services
12
+ // enabled (meta.rest_object, migration 113), served through an application:
13
+ //
14
+ // GET /a/<alias>/autorest/<object>/ rows, paged: ?limit= (1–500) &offset= ; filters: ?<column>=<value>
15
+ // (equality), ?q= (text columns contain it), ?order=col,col.desc
16
+ // POST /a/<alias>/autorest/<object>/ a JSON object: the new row (201)
17
+ // GET /a/<alias>/autorest/<object>/<key> one row by primary key (composite keys: 1,2), 404 when there is none
18
+ // PUT /a/<alias>/autorest/<object>/<key> the JSON object's columns changed; the row (200) or 404
19
+ // DELETE /a/<alias>/autorest/<object>/<key> 204 or 404
20
+ // GET /a/<alias>/autorest/openapi.json OpenAPI 3 of the application's objects (public ones without a token)
21
+ //
22
+ // Authentication is the application's (as for its REST modules, rest.ts caller()): a bearer token of an account
23
+ // or an OAuth client of the application; "public" objects need none (meta.app_user() = nobody). The statement
24
+ // runs as the application's database role with meta.app_user() and meta.has_role() set, so grants (also column
25
+ // grants) and row level security apply. The role may not be the object's owner (or a member of it), a superuser
26
+ // or a role that bypasses row level security: checked at every request, after SET ROLE. Names come from the
27
+ // catalog and are quoted; every value is a query parameter.
28
+
29
+ export interface RestObject {
30
+ id: number;
31
+ app_id: number;
32
+ schema_name: string;
33
+ object_name: string;
34
+ alias: string;
35
+ methods: string[];
36
+ auth: 'token' | 'public';
37
+ roles: string[];
38
+ page_size: number;
39
+ enabled: boolean;
40
+ }
41
+
42
+ export interface Column {
43
+ name: string;
44
+ type: string;
45
+ /** pg_type.typcategory: S string, N numeric, B boolean, D date/time, … */
46
+ cat: string;
47
+ readable: boolean;
48
+ /** position in the primary key (1…), or null */
49
+ pk: number | null;
50
+ }
51
+
52
+ export const AUTOREST_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const;
53
+ export const MAX_PAGE = 500;
54
+ /** Query parameters that are not column filters. */
55
+ const RESERVED = new Set(['limit', 'offset', 'q', 'order']);
56
+ export const RELKINDS: Record<string, string> = { r: 'table', p: 'table', v: 'view', m: 'materialized view', f: 'foreign table' };
57
+
58
+ const ident = pg.escapeIdentifier;
59
+ export const qualified = (o: Pick<RestObject, 'schema_name' | 'object_name'>) => `${ident(o.schema_name)}.${ident(o.object_name)}`;
60
+
61
+ /** The columns of a relation as `role` may use them (the catalog only; no privilege needed to read it). */
62
+ export async function describeColumns(db: { query: (sql: string, params: unknown[]) => Promise<pg.QueryResult> }, schema: string, name: string, role: string): Promise<Column[]> {
63
+ return (await db.query(
64
+ `select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, t.typcategory as cat,
65
+ has_column_privilege($3, c.oid, a.attnum, 'SELECT') as readable,
66
+ (select array_position(i.indkey::int2[], a.attnum) from pg_index i where i.indrelid = c.oid and i.indisprimary) as pk
67
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
68
+ join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
69
+ join pg_type t on t.oid = a.atttypid
70
+ where n.nspname = $1 and c.relname = $2
71
+ order by a.attnum`,
72
+ [schema, name, role],
73
+ )).rows as Column[];
74
+ }
75
+
76
+ /** The primary key's columns in key order. */
77
+ export const keyColumns = (cols: Column[]) => cols.filter((c) => c.pk !== null).sort((a, b) => a.pk! - b.pk!);
78
+
79
+ /** The object a request names, if the application published it and it is on. */
80
+ async function objectOf(a: App, alias: string) {
81
+ const o = await runtime.one<RestObject>('select * from meta.rest_object where app_id = $1 and alias = $2', [a.id, alias]);
82
+ return o?.enabled ? o : undefined;
83
+ }
84
+
85
+ /** Split a key segment "1" or "10,20" into its values (each part URL-decoded on its own). */
86
+ export function splitKey(raw: string, n: number): string[] | null {
87
+ if (!raw || raw.includes('/')) return null;
88
+ const parts = raw.split(',');
89
+ if (parts.length !== n) return null;
90
+ try {
91
+ return parts.map((p) => decodeURIComponent(p));
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** The ORDER BY of ?order=a,b.desc: only readable columns; null when it names something else. */
98
+ export function orderBy(spec: string | undefined, cols: Column[]): string | null {
99
+ const readable = new Map(cols.filter((c) => c.readable).map((c) => [c.name, c]));
100
+ if (spec === undefined || spec === '') {
101
+ const key = keyColumns(cols).filter((c) => c.readable);
102
+ return key.length ? key.map((c) => `t.${ident(c.name)}`).join(', ') : '1';
103
+ }
104
+ const out: string[] = [];
105
+ for (const part of spec.split(',')) {
106
+ const m = /^(.+?)(?:\.(asc|desc))?$/.exec(part);
107
+ if (!m || !readable.has(m[1])) return null;
108
+ out.push(`t.${ident(m[1])}${m[2] === 'desc' ? ' desc' : ''}`);
109
+ }
110
+ return out.join(', ');
111
+ }
112
+
113
+ const likeEscape = (s: string) => s.replace(/[\\%_]/g, (x) => `\\${x}`);
114
+ const one = (v: unknown) => (Array.isArray(v) ? v[0] : v);
115
+
116
+ /** Checks run after SET ROLE: the role isn't the owner and doesn't bypass row level security. */
117
+ async function checkRole(c: Client, o: RestObject) {
118
+ const r = (await c.query(
119
+ `select c.relkind, pg_has_role(current_user, c.relowner, 'MEMBER') as owns, r.rolsuper or r.rolbypassrls as bypass
120
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace, pg_roles r
121
+ where n.nspname = $1 and c.relname = $2 and r.rolname = current_user`,
122
+ [o.schema_name, o.object_name],
123
+ )).rows[0];
124
+ if (!r || !RELKINDS[r.relkind]) throw new RestError(404, 'Not found');
125
+ if (r.owns || r.bypass) throw new RestError(403, 'The application\'s database role owns this object or bypasses row level security: AutoREST refuses it.');
126
+ return r.relkind as string;
127
+ }
128
+
129
+ /** A JSON body as an object of known columns; RestError otherwise. */
130
+ function bodyColumns(body: unknown, cols: Column[]) {
131
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Buffer.isBuffer(body)) throw new RestError(400, 'The body is a JSON object of column values.');
132
+ const names = new Set(cols.map((c) => c.name));
133
+ const keys = Object.keys(body as object);
134
+ const unknown = keys.filter((k) => !names.has(k));
135
+ if (unknown.length) throw new RestError(400, `Unknown column(s): ${unknown.slice(0, 5).join(', ')}.`);
136
+ return keys;
137
+ }
138
+
139
+ async function handle(req: FastifyRequest, reply: FastifyReply) {
140
+ const p = req.params as { alias: string; object: string; '*'?: string };
141
+ const keyRaw = (p['*'] ?? '').replace(/\/+$/, '');
142
+ const a = await loadApp(p.alias);
143
+ const o = a ? await objectOf(a, p.object) : undefined;
144
+ if (!a || !o || !(await restServicesOn(a.id))) return reply.code(404).send({ error: 'Not found' });
145
+ const method = req.method;
146
+ const allowed = o.methods.filter((m) => (keyRaw ? m !== 'POST' : m === 'GET' || m === 'POST'));
147
+ if (!o.methods.includes(method) || !allowed.includes(method)) return reply.code(405).header('allow', allowed.join(', ')).send({ error: `${method} is not allowed here.` });
148
+ try {
149
+ const who = await caller(req, a, o.auth === 'public');
150
+ const refusal = availabilityRefusal(a, { username: who.user === 'nobody' ? null : who.user, developer: false });
151
+ if (refusal) throw new RestError(refusal.status, refusal.message ?? english(refusal.key));
152
+ if (!a.db_role) throw new RestError(403, 'The application has no database role: AutoREST needs one.');
153
+ const result = await runtime.tx(async (c) => {
154
+ await c.query(
155
+ `select set_config('pgkiln.app_id', $1, true), set_config('pgkiln.app_user', $2, true), set_config('pgkiln.session_id', '', true),
156
+ set_config('request.jwt.claims', $3, true), set_config('statement_timeout', '30s', true)`,
157
+ [String(a.id), who.user, who.claims ? JSON.stringify(who.claims) : ''],
158
+ );
159
+ if (o.roles.length) {
160
+ const ok = (await c.query('select bool_or(meta.has_role(r)) as ok from unnest($1::text[]) r', [o.roles])).rows[0].ok;
161
+ if (!ok) throw new RestError(403, 'You do not have a role this endpoint needs.');
162
+ }
163
+ await c.query(`set local role ${ident(a.db_role!)}`);
164
+ await checkRole(c, o);
165
+ const cols = await describeColumns(c, o.schema_name, o.object_name, a.db_role!);
166
+ const readable = cols.filter((x) => x.readable);
167
+ const list = readable.map((x) => `t.${ident(x.name)}`).join(', ');
168
+ const returning = list ? ` returning ${list}` : '';
169
+ const rel = qualified(o);
170
+ const key = keyColumns(cols);
171
+ const params: unknown[] = [];
172
+ const param = (v: unknown) => `$${params.push(v)}`;
173
+ let where = '';
174
+ if (keyRaw) {
175
+ const values = key.length ? splitKey(keyRaw, key.length) : null;
176
+ if (!values) throw new RestError(404, 'Not found');
177
+ where = key.map((k, i) => `t.${ident(k.name)} = ${param(values[i])}`).join(' and ');
178
+ }
179
+
180
+ if (method === 'GET' && !keyRaw) {
181
+ if (!readable.length) throw new RestError(403, 'Not allowed.');
182
+ const q = req.query as Record<string, string | string[]>;
183
+ const conds: string[] = [];
184
+ for (const [k, raw] of Object.entries(q)) {
185
+ if (RESERVED.has(k)) continue;
186
+ const col = readable.find((x) => x.name === k);
187
+ if (!col) throw new RestError(400, `Unknown parameter ${k.slice(0, 63)}: filter on a column, or use limit, offset, q, order.`);
188
+ conds.push(`t.${ident(col.name)} = ${param(String(one(raw)))}`);
189
+ }
190
+ const search = one(q.q);
191
+ if (search) {
192
+ const text = readable.filter((x) => x.cat === 'S');
193
+ if (!text.length) throw new RestError(400, 'q= searches text columns, and this object has none you may read.');
194
+ const v = param(`%${likeEscape(String(search))}%`);
195
+ conds.push(`(${text.map((x) => `t.${ident(x.name)} ilike ${v}`).join(' or ')})`);
196
+ }
197
+ const order = orderBy(one(q.order) as string | undefined, cols);
198
+ if (order === null) throw new RestError(400, 'order= is a list of readable columns, each optionally followed by .asc or .desc.');
199
+ const limitRaw = one(q.limit);
200
+ const offsetRaw = one(q.offset);
201
+ if ((limitRaw !== undefined && !/^\d{1,9}$/.test(String(limitRaw))) || (offsetRaw !== undefined && !/^\d{1,12}$/.test(String(offsetRaw))))
202
+ throw new RestError(400, 'limit and offset are whole numbers.');
203
+ const limit = Math.max(1, Math.min(MAX_PAGE, limitRaw !== undefined ? Number(limitRaw) : o.page_size));
204
+ const offset = offsetRaw !== undefined ? Number(offsetRaw) : 0;
205
+ const rows = (await c.query(
206
+ `select ${list} from ${rel} t${conds.length ? ` where ${conds.join(' and ')}` : ''} order by ${order} limit ${limit + 1} offset ${offset}`,
207
+ params,
208
+ )).rows;
209
+ return { status: 200, body: { items: rows.slice(0, limit), offset, limit, has_more: rows.length > limit } };
210
+ }
211
+ if (method === 'GET') {
212
+ if (!readable.length) throw new RestError(403, 'Not allowed.');
213
+ const row = (await c.query(`select ${list} from ${rel} t where ${where} limit 1`, params)).rows[0];
214
+ if (!row) throw new RestError(404, 'Not found');
215
+ return { status: 200, body: row };
216
+ }
217
+ if (method === 'POST') {
218
+ const names = bodyColumns(req.body, cols);
219
+ const sql = names.length
220
+ ? `insert into ${rel} as t (${names.map(ident).join(', ')}) select ${names.map((n) => `b.${ident(n)}`).join(', ')} from json_populate_record(null::${rel}, ${param(JSON.stringify(req.body))}::json) b${returning}`
221
+ : `insert into ${rel} as t default values${returning}`;
222
+ const row = (await c.query(sql, params)).rows[0];
223
+ const keyOk = key.length && key.every((k) => k.readable) && row;
224
+ return { status: 201, body: row ?? {}, location: keyOk ? key.map((k) => encodeURIComponent(String(row[k.name] instanceof Date ? row[k.name].toISOString() : row[k.name]))).join(',') : undefined };
225
+ }
226
+ if (method === 'PUT') {
227
+ const names = bodyColumns(req.body, cols);
228
+ if (!names.length) throw new RestError(400, 'The body names no columns to change.');
229
+ const b = param(JSON.stringify(req.body));
230
+ const res = await c.query(
231
+ `update ${rel} as t set ${names.map((n) => `${ident(n)} = b.${ident(n)}`).join(', ')} from json_populate_record(null::${rel}, ${b}::json) b where ${where}${returning}`,
232
+ params,
233
+ );
234
+ if (!res.rowCount) throw new RestError(404, 'Not found');
235
+ return { status: 200, body: res.rows[0] ?? {} };
236
+ }
237
+ // DELETE
238
+ const res = await c.query(`delete from ${rel} t where ${where}`, params);
239
+ if (!res.rowCount) throw new RestError(404, 'Not found');
240
+ return { status: 204, body: undefined };
241
+ });
242
+ reply.header('cache-control', 'no-store');
243
+ if ('location' in result && result.location) reply.header('location', `/a/${encodeURIComponent(a.alias)}/autorest/${o.alias}/${result.location}`);
244
+ return result.body === undefined ? reply.code(204).send() : reply.code(result.status).send(result.body);
245
+ } catch (e) {
246
+ if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
247
+ const err = e as pg.DatabaseError;
248
+ const message = await publicError({ app: a, page: { page_no: 0 } as never, user: 'api', ip: clientIp(req) }, e, `AutoREST ${method} ${o.alias}`);
249
+ const status = err.code === '42501' ? 403 : err.code && /^(P0001|22|23|42804|428C9)/.test(err.code) ? 400 : 500;
250
+ return reply.code(status).send({ error: status === 403 ? 'Not allowed.' : message });
251
+ }
252
+ }
253
+
254
+ /** JSON Schema of a column type (for the OpenAPI description). */
255
+ function jsonType(c: Column): Record<string, unknown> {
256
+ if (/^(smallint|integer|bigint)$/.test(c.type)) return { type: 'integer' };
257
+ if (c.cat === 'N') return { type: 'number' };
258
+ if (c.cat === 'B') return { type: 'boolean' };
259
+ if (c.type === 'date') return { type: 'string', format: 'date' };
260
+ if (c.cat === 'D') return { type: 'string', format: 'date-time' };
261
+ if (c.cat === 'S') return { type: 'string' };
262
+ if (c.cat === 'A') return { type: 'array', items: {} };
263
+ return {};
264
+ }
265
+
266
+ /** The OpenAPI 3 description of an application's AutoREST objects. */
267
+ export function autoRestOpenApi(a: Pick<App, 'alias' | 'name'>, objects: { o: RestObject; cols: Column[] }[]) {
268
+ const paths: Record<string, Record<string, unknown>> = {};
269
+ const schemas: Record<string, unknown> = {};
270
+ const errors = (o: RestObject) => (o.auth === 'public' ? {} : { 401: { description: 'No valid token' }, 403: { description: 'Not allowed' } });
271
+ for (const { o, cols } of objects) {
272
+ const readable = cols.filter((c) => c.readable);
273
+ const name = o.alias.replace(/[^A-Za-z0-9_.-]/g, '_');
274
+ schemas[name] = { type: 'object', properties: Object.fromEntries(readable.map((c) => [c.name, { ...jsonType(c), description: c.type }])) };
275
+ const ref = { $ref: `#/components/schemas/${name}` };
276
+ const sec = o.auth === 'public' ? { security: [] } : {};
277
+ const body = { required: true, content: { 'application/json': { schema: ref } } };
278
+ const coll: Record<string, unknown> = {};
279
+ if (o.methods.includes('GET'))
280
+ coll.get = {
281
+ summary: `Rows of ${o.schema_name}.${o.object_name}`, ...sec,
282
+ parameters: [
283
+ { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: MAX_PAGE, default: o.page_size } },
284
+ { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0 } },
285
+ { name: 'q', in: 'query', description: 'Rows whose text columns contain this', schema: { type: 'string' } },
286
+ { name: 'order', in: 'query', description: 'Columns, each optionally .asc or .desc, comma separated', schema: { type: 'string' } },
287
+ ...readable.filter((c) => !RESERVED.has(c.name)).map((c) => ({ name: c.name, in: 'query', description: `${c.name} equals`, schema: { type: 'string' } })),
288
+ ],
289
+ responses: { 200: { description: '{items, offset, limit, has_more}', content: { 'application/json': { schema: { type: 'object', properties: { items: { type: 'array', items: ref }, offset: { type: 'integer' }, limit: { type: 'integer' }, has_more: { type: 'boolean' } } } } } }, 400: { description: 'A filter is not valid' }, ...errors(o) },
290
+ };
291
+ if (o.methods.includes('POST'))
292
+ coll.post = { summary: `New row in ${o.schema_name}.${o.object_name}`, ...sec, requestBody: body, responses: { 201: { description: 'The new row', content: { 'application/json': { schema: ref } } }, 400: { description: 'Invalid values' }, ...errors(o) } };
293
+ if (Object.keys(coll).length) paths[`/${o.alias}/`] = coll;
294
+ const key = keyColumns(cols);
295
+ if (!key.length) continue;
296
+ const item: Record<string, unknown> = { parameters: [{ name: 'key', in: 'path', required: true, description: `${key.map((k) => k.name).join(', ')}${key.length > 1 ? ' (comma separated)' : ''}`, schema: { type: 'string' } }] };
297
+ const notFound = { 404: { description: 'Not found' } };
298
+ if (o.methods.includes('GET')) item.get = { summary: 'One row by its key', ...sec, responses: { 200: { description: 'The row', content: { 'application/json': { schema: ref } } }, ...notFound, ...errors(o) } };
299
+ if (o.methods.includes('PUT')) item.put = { summary: 'Change the columns the body names', ...sec, requestBody: body, responses: { 200: { description: 'The changed row', content: { 'application/json': { schema: ref } } }, ...notFound, ...errors(o) } };
300
+ if (o.methods.includes('DELETE')) item.delete = { summary: 'Delete the row', ...sec, responses: { 204: { description: 'Deleted' }, ...notFound, ...errors(o) } };
301
+ if (Object.keys(item).length > 1) paths[`/${o.alias}/{key}`] = item;
302
+ }
303
+ return {
304
+ openapi: '3.0.3',
305
+ info: { title: `${a.name} AutoREST`, description: 'Tables and views published with SQL Workshop › RESTful Services', version: '1' },
306
+ servers: [{ url: `/a/${a.alias}/autorest` }],
307
+ components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }, schemas },
308
+ security: [{ bearer: [] }],
309
+ paths,
310
+ };
311
+ }
312
+
313
+ export async function autoRestRoutes(app: FastifyInstance) {
314
+ // without a token: the public objects only; with a valid token of the application: all of them
315
+ app.get<{ Params: { alias: string } }>('/a/:alias/autorest/openapi.json', async (req, reply) => {
316
+ const a = await loadApp(req.params.alias);
317
+ if (!a || !(await restServicesOn(a.id))) return reply.code(404).send({ error: english('error.not_found') });
318
+ let all = false;
319
+ if (req.headers.authorization) {
320
+ try {
321
+ all = (await caller(req, a, false)).user !== 'nobody';
322
+ } catch (e) {
323
+ if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
324
+ throw e;
325
+ }
326
+ }
327
+ const rows = (await runtime.query<RestObject>('select * from meta.rest_object where app_id = $1 and enabled order by alias', [a.id])).rows.filter((o) => all || o.auth === 'public');
328
+ const objects = a.db_role
329
+ ? await Promise.all(rows.map(async (o) => ({ o, cols: await describeColumns(owner, o.schema_name, o.object_name, a.db_role!) })))
330
+ : [];
331
+ return reply.header('cache-control', 'no-store').send(autoRestOpenApi(a, objects.filter((x) => x.cols.length)));
332
+ });
333
+ const methods = [...AUTOREST_METHODS] as never;
334
+ app.route({ method: methods, url: '/a/:alias/autorest/:object', handler: handle });
335
+ app.route({ method: methods, url: '/a/:alias/autorest/:object/*', handler: handle });
336
+ }
@@ -0,0 +1,75 @@
1
+ import type pg from 'pg';
2
+ import { substitute, type BindValues } from '../binds.ts';
3
+
4
+ // (sprint 104, Database Actions DA-11 part 2; migration 117) The handler source types REST modules gained with
5
+ // SQL Workshop › RESTful Services, next to collection, item and sql (rest.ts):
6
+ //
7
+ // plpgsql a PL/pgSQL block run as an anonymous DO block as the application's role. A DO block takes no
8
+ // parameters, so the binds go in as transaction-local settings, set with query parameters (never part
9
+ // of the SQL text), and :NAME in the block becomes meta.rest_param('NAME'). The block answers with
10
+ // meta.rest_reply(body jsonb, status int); without a reply: 204.
11
+ // media a query whose first row has content_type and content (bytea or text), optional filename: the
12
+ // response is that content. It is served sandboxed (CSP sandbox, nosniff), so an HTML or SVG
13
+ // "image" from the table can't run script in the application's origin.
14
+
15
+ /** Bind names a PL/pgSQL handler can read (a setting name each): letters, digits and _. */
16
+ const BIND_NAME = /^[A-Z_][A-Z0-9_]{0,62}$/;
17
+ /** The dollar-quote tag around a PL/pgSQL handler's block. */
18
+ export const DO_TAG = '$pgkiln_rest$';
19
+ /** At most this many binds go into settings (a body with thousands of fields is not a handler's input). */
20
+ const MAX_BINDS = 200;
21
+
22
+ export class HandlerInputError extends Error {}
23
+
24
+ /** The DO statement of a PL/pgSQL handler: its binds as meta.rest_param('NAME'), wrapped in begin … end if needed. */
25
+ export function plpgsqlBlock(source: string): string {
26
+ if (source.includes(DO_TAG)) throw new Error(`A PL/pgSQL handler may not contain ${DO_TAG}.`);
27
+ let body = substitute(source.trim().replace(/;+\s*$/, ''), (name) => `meta.rest_param('${name}')`);
28
+ if (!/^(declare|begin|<<)/i.test(body.replace(/^(\s*(--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/))*\s*/, ''))) body = `begin\n${body};\nend`;
29
+ return `do ${DO_TAG}\n${body}\n${DO_TAG}`;
30
+ }
31
+
32
+ /** Set the binds as transaction-local settings pgkiln_bind.<name>, as query parameters. */
33
+ export async function setBinds(c: pg.PoolClient, binds: BindValues) {
34
+ const entries = Object.entries(binds).filter(([k]) => BIND_NAME.test(k)).slice(0, MAX_BINDS);
35
+ for (const [, v] of entries) if (typeof v === 'string' && v.includes('\0')) throw new HandlerInputError('A parameter contains a NUL byte.');
36
+ if (!entries.length) return;
37
+ await c.query(`select set_config('pgkiln_bind.' || lower(k), coalesce(v, ''), true) from unnest($1::text[], $2::text[]) as b(k, v)`,
38
+ [entries.map(([k]) => k), entries.map(([, v]) => v ?? null)]);
39
+ }
40
+
41
+ /** Run a PL/pgSQL handler; its reply (meta.rest_reply) or none. */
42
+ export async function runPlpgsql(c: pg.PoolClient, source: string): Promise<{ body: unknown; status: number | null }> {
43
+ // a reply from an earlier statement of this transaction must not leak into this one
44
+ await c.query(`select set_config('pgkiln_rest.body', '', true), set_config('pgkiln_rest.status', '', true)`);
45
+ await c.query(plpgsqlBlock(source));
46
+ const r = (await c.query(`select current_setting('pgkiln_rest.body', true) as body, current_setting('pgkiln_rest.status', true) as status`)).rows[0];
47
+ return { body: r.body ? JSON.parse(r.body) : undefined, status: r.status ? Number(r.status) : null };
48
+ }
49
+
50
+ /** A content type safe to send as a header: type/subtype with optional parameters, no line breaks. */
51
+ export function safeContentType(v: unknown): string {
52
+ const s = typeof v === 'string' ? v.trim() : '';
53
+ return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(\s*;\s*[a-z0-9_-]+=("[^"\r\n]*"|[^;\s\r\n]+))*$/i.test(s) && s.length <= 200 ? s : 'application/octet-stream';
54
+ }
55
+
56
+ /** A file name for Content-Disposition: printable ASCII without quotes, slashes or backslashes. */
57
+ export const safeFilename = (v: unknown) => (typeof v === 'string' ? v.replace(/[^\x20-\x7e]|["\\/;]/g, '_').slice(0, 200) : '');
58
+
59
+ /** The response of a media handler's row. */
60
+ export function mediaResponse(row: Record<string, unknown>) {
61
+ if (!('content' in row) || !('content_type' in row)) throw new Error('A media handler\'s query returns the columns content_type and content (and optionally filename).');
62
+ const c = row.content;
63
+ const body = c === null || c === undefined ? Buffer.alloc(0) : Buffer.isBuffer(c) ? c : Buffer.from(typeof c === 'string' ? c : JSON.stringify(c));
64
+ const name = safeFilename(row.filename);
65
+ return {
66
+ body,
67
+ headers: {
68
+ 'content-type': safeContentType(row.content_type),
69
+ 'x-content-type-options': 'nosniff',
70
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox",
71
+ 'cache-control': 'no-store',
72
+ ...(name ? { 'content-disposition': `inline; filename="${name}"` } : {}),
73
+ } as Record<string, string>,
74
+ };
75
+ }