pgkiln 0.48.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 (48) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/116_dump_jobs.sql +41 -0
  3. package/db/migrations/117_rest_handlers.sql +63 -0
  4. package/db/migrations/118_change_log.sql +54 -0
  5. package/docs/apex-feature-parity.md +7 -7
  6. package/docs/guide/01-installation.md +4 -0
  7. package/docs/guide/03-builder.md +179 -5
  8. package/docs/guide/08-security.md +11 -0
  9. package/docs/guide/12-development.md +44 -6
  10. package/docs/guide/13-rest-api.md +59 -1
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/examples/hr/hr_62_rest_module.sql +39 -0
  13. package/package.json +1 -1
  14. package/public/app.css +1 -1
  15. package/public/builder-icons.svg +2 -0
  16. package/public/builder.css +420 -173
  17. package/public/builder.js +48 -0
  18. package/public/sql-monitor.js +73 -0
  19. package/src/app.ts +6 -0
  20. package/src/automations.ts +6 -0
  21. package/src/binds.ts +2 -1
  22. package/src/builder/advisor.ts +24 -2
  23. package/src/builder/autorest.ts +3 -2
  24. package/src/builder/changelog.ts +240 -0
  25. package/src/builder/components.ts +1 -1
  26. package/src/builder/datapump.ts +364 -0
  27. package/src/builder/ddl-tools.ts +13 -4
  28. package/src/builder/find-workspace.ts +2 -2
  29. package/src/builder/plan-view.ts +25 -0
  30. package/src/builder/rest-modules.ts +629 -0
  31. package/src/builder/rest.ts +1 -0
  32. package/src/builder/routes.ts +4 -2
  33. package/src/builder/scheduling.ts +495 -0
  34. package/src/builder/sql-monitor.ts +397 -0
  35. package/src/builder/sql-tools.ts +6 -2
  36. package/src/builder/sql-utilities.ts +7 -39
  37. package/src/builder/sql.ts +6 -0
  38. package/src/builder/table-editor.ts +4 -1
  39. package/src/builder/top-sql.ts +24 -6
  40. package/src/builder/ui.ts +6 -4
  41. package/src/builder/worksheet.ts +5 -21
  42. package/src/changelog.ts +109 -0
  43. package/src/cron-schedule.ts +282 -0
  44. package/src/datapump.ts +546 -0
  45. package/src/dumpscript.ts +407 -0
  46. package/src/pgcron.ts +225 -0
  47. package/src/runtime/rest-handlers.ts +75 -0
  48. package/src/runtime/rest.ts +116 -22
@@ -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
+ }
@@ -10,8 +10,10 @@ import { loadApp, type App } from '../metadata.ts';
10
10
  import { clientIp } from '../session.ts';
11
11
  import { PassThrough } from 'node:stream';
12
12
  import { publicError, writeOut } from './context.ts';
13
+ import { DO_TAG, HandlerInputError, mediaResponse, runPlpgsql, setBinds } from './rest-handlers.ts';
13
14
 
14
- // REST modules (APEX: RESTful Services): handlers defined in the builder,
15
+ // REST modules (APEX: RESTful Services): handlers defined in the builder (Shared Components, or (sprint 104)
16
+ // SQL Workshop › RESTful Services › Modules as templates and handlers; plpgsql and media types: rest-handlers.ts),
15
17
  // served under /a/<alias>/rest/<module>/<path>.
16
18
  //
17
19
  // {"method": "GET", "path": "employees", "type": "collection", "source": "select … order by empno"}
@@ -33,17 +35,36 @@ import { publicError, writeOut } from './context.ts';
33
35
  export interface Handler {
34
36
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
35
37
  path: string;
36
- type: 'collection' | 'item' | 'sql';
38
+ /** (117) plpgsql: a PL/pgSQL block (rest-handlers.ts); media: a row's content_type and content */
39
+ type: 'collection' | 'item' | 'sql' | 'plpgsql' | 'media';
37
40
  source: string;
38
41
  roles?: string[];
39
42
  auth?: 'token' | 'public';
40
43
  description?: string;
41
44
  page_size?: number;
42
45
  status?: number;
46
+ /** (117) documented parameters: in the OpenAPI document and the Try it form; a required one missing is a 400 */
47
+ params?: HandlerParam[];
43
48
  }
44
49
 
45
- const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
46
- const TYPES = ['collection', 'item', 'sql'];
50
+ export interface HandlerParam {
51
+ name: string;
52
+ in: 'query' | 'body';
53
+ type?: 'string' | 'integer' | 'number' | 'boolean';
54
+ required?: boolean;
55
+ description?: string;
56
+ }
57
+
58
+ /** (117) A template of a module: its URI and description (handlers name it in "path"). */
59
+ export interface Template {
60
+ uri: string;
61
+ description?: string;
62
+ }
63
+
64
+ export const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
65
+ export const TYPES = ['collection', 'item', 'sql', 'plpgsql', 'media'];
66
+ export const PARAM_TYPES = ['string', 'integer', 'number', 'boolean'];
67
+ const PARAM_NAME = /^[a-z_][a-z0-9_]{0,62}$/i;
47
68
  const SEGMENT = /^(:[a-z][a-z0-9_]*|[a-z0-9][a-z0-9._-]*)$/i;
48
69
  const MAX_PAGE = 500;
49
70
  /** Rows fetched from a collection's cursor at a time. */
@@ -65,7 +86,18 @@ export function handlerProblems(handlers: unknown): string[] {
65
86
  problems.push(`${at}: ${[...RESERVED_BINDS].join(', ')} are set by the server and can't be path parameters.`);
66
87
  if (!TYPES.includes(h.type)) problems.push(`${at}: "type" is one of ${TYPES.join(', ')}.`);
67
88
  if (typeof h.source !== 'string' || !h.source.trim()) problems.push(`${at}: "source" is the SQL.`);
68
- if (h.type !== 'sql' && h.method !== 'GET') problems.push(`${at}: collection and item handlers answer GET; use "sql" for ${h.method}.`);
89
+ if ((h.type === 'collection' || h.type === 'item' || h.type === 'media') && h.method !== 'GET') problems.push(`${at}: collection, item and media handlers answer GET; use "sql" or "plpgsql" for ${h.method}.`);
90
+ if (h.type === 'plpgsql' && typeof h.source === 'string' && h.source.includes(DO_TAG)) problems.push(`${at}: a PL/pgSQL block may not contain ${DO_TAG}.`);
91
+ if (h.params !== undefined) {
92
+ if (!Array.isArray(h.params)) problems.push(`${at}: "params" is a list of {"name", "in": "query" or "body", "type", "required"}.`);
93
+ else h.params.forEach((p: any) => {
94
+ if (!p || typeof p !== 'object' || typeof p.name !== 'string' || !PARAM_NAME.test(p.name)) problems.push(`${at}: a parameter's name is letters, digits and _.`);
95
+ else if (RESERVED_BINDS.has(p.name.toUpperCase())) problems.push(`${at}: ${p.name} is set by the server and can't be a parameter.`);
96
+ else if (p.name.toUpperCase() === 'BODY') problems.push(`${at}: :BODY is the whole JSON body; name the parameter otherwise.`);
97
+ if (p && p.in !== 'query' && p.in !== 'body') problems.push(`${at}: a parameter is "in" query or body.`);
98
+ if (p?.type !== undefined && !PARAM_TYPES.includes(p.type)) problems.push(`${at}: a parameter's type is one of ${PARAM_TYPES.join(', ')}.`);
99
+ });
100
+ }
69
101
  if (h.roles !== undefined && !(Array.isArray(h.roles) && h.roles.every((r: unknown) => typeof r === 'string'))) problems.push(`${at}: "roles" is a list of role names.`);
70
102
  if (h.auth !== undefined && h.auth !== 'token' && h.auth !== 'public') problems.push(`${at}: "auth" is token (default) or public.`);
71
103
  if (h.status !== undefined && !(Number.isInteger(h.status) && h.status >= 200 && h.status < 300)) problems.push(`${at}: "status" is a 2xx status.`);
@@ -76,6 +108,28 @@ export function handlerProblems(handlers: unknown): string[] {
76
108
  return problems;
77
109
  }
78
110
 
111
+ /** (117) Problems in a module's templates, or []. */
112
+ export function templateProblems(templates: unknown): string[] {
113
+ if (!Array.isArray(templates)) return ['The templates must be a JSON array.'];
114
+ const problems: string[] = [];
115
+ const seen = new Set<string>();
116
+ templates.forEach((t: any, i) => {
117
+ const uri = t?.uri;
118
+ if (typeof uri !== 'string' || !uri || !uri.split('/').every((s: string) => SEGMENT.test(s)))
119
+ problems.push(`Template ${i + 1}: the URI is like emp or emp/:id (letters, digits, - _ . and :parameters).`);
120
+ else if (uri === 'openapi.json') problems.push(`Template ${i + 1}: openapi.json is reserved for the module's description.`);
121
+ else if (uri.split('/').some((s: string) => s.startsWith(':') && RESERVED_BINDS.has(s.slice(1).toUpperCase())))
122
+ problems.push(`Template ${i + 1}: ${[...RESERVED_BINDS].join(', ')} can't be URI parameters.`);
123
+ else {
124
+ const key = uri.replace(/:[^/]+/g, ':');
125
+ if (seen.has(key)) problems.push(`Template ${i + 1}: ${uri} is there twice.`);
126
+ seen.add(key);
127
+ }
128
+ if (t?.description !== undefined && typeof t.description !== 'string') problems.push(`Template ${i + 1}: the description is text.`);
129
+ });
130
+ return problems;
131
+ }
132
+
79
133
  /** The handler for a method and path, with its path parameters. */
80
134
  export function matchHandler(handlers: Handler[], method: string, path: string) {
81
135
  const parts = path.split('/').filter(Boolean);
@@ -145,7 +199,7 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
145
199
  const { alias, module: name } = req.params as { alias: string; module: string };
146
200
  const sub = ((req.params as Record<string, string>)['*'] ?? '').replace(/\/+$/, '');
147
201
  const a = await loadApp(alias);
148
- const mod = a ? await runtime.one<{ handlers: Handler[]; enabled: boolean }>('select handlers, enabled from meta.rest_module where app_id = $1 and name = $2', [a.id, name]) : undefined;
202
+ const mod = a ? await runtime.one<{ handlers: Handler[]; enabled: boolean; roles: string[] | null }>('select handlers, enabled, roles from meta.rest_module where app_id = $1 and name = $2', [a.id, name]) : undefined;
149
203
  if (!a || !mod?.enabled || !(await restServicesOn(a.id))) return reply.code(404).send({ error: 'Not found' });
150
204
  const m = matchHandler(mod.handlers ?? [], req.method, sub);
151
205
  if (m === 'method') return reply.code(405).send({ error: `${req.method} is not allowed here.` });
@@ -165,6 +219,9 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
165
219
  binds.BODY = JSON.stringify(body);
166
220
  }
167
221
  Object.assign(binds, params);
222
+ // (117) documented parameters marked required
223
+ const missing = (h.params ?? []).filter((p) => p.required && (binds[p.name.toUpperCase()] ?? '') === '').map((p) => p.name);
224
+ if (missing.length) throw new RestError(400, `Missing parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}.`);
168
225
  // the built-in names are the server's, never the caller's (?app_user=… must not set :APP_USER)
169
226
  for (const k of Object.keys(binds)) if (RESERVED_BINDS.has(k)) delete binds[k];
170
227
  Object.assign(binds, { APP_USER: who.user, APP_ID: String(a.id), APP_ALIAS: a.alias, APP_SESSION: null, APP_PAGE_ID: null, REQUEST: null });
@@ -174,11 +231,24 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
174
231
  set_config('request.jwt.claims', $3, true), set_config('statement_timeout', '30s', true)`,
175
232
  [String(a.id), who.user, who.claims ? JSON.stringify(who.claims) : ''],
176
233
  );
177
- if (h.roles?.length) {
178
- const ok = (await c.query('select bool_or(meta.has_role(r)) as ok from unnest($1::text[]) r', [h.roles])).rows[0].ok;
234
+ // (117) the module's privilege, then the handler's own roles
235
+ for (const roles of [mod.roles, h.roles]) if (roles?.length) {
236
+ const ok = (await c.query('select bool_or(meta.has_role(r)) as ok from unnest($1::text[]) r', [roles])).rows[0].ok;
179
237
  if (!ok) throw new RestError(403, 'You do not have a role this endpoint needs.');
180
238
  }
239
+ if (h.type === 'plpgsql') await setBinds(c, binds);
181
240
  if (a.db_role) await c.query(`set local role ${pg.escapeIdentifier(a.db_role)}`);
241
+ // (117) handlers run as a constrained role: never a superuser, a role that bypasses row level security, or
242
+ // pgkiln's owner (a runtime connection without RUNTIME_DATABASE_URL is the owner's)
243
+ const role = (await c.query(
244
+ `select r.rolsuper or r.rolbypassrls or pg_has_role(current_user, n.nspowner, 'MEMBER') as bad
245
+ from pg_roles r, pg_namespace n where r.rolname = current_user and n.nspname = 'meta'`)).rows[0];
246
+ if (!role || role.bad) throw new RestError(403, 'This endpoint would run as a role that bypasses row level security or owns pgkiln: refused. Give the application its own database role.');
247
+ if (h.type === 'plpgsql') {
248
+ const out = await runPlpgsql(c, h.source);
249
+ const status = out.status ?? (out.body === undefined ? 204 : h.status ?? (req.method === 'POST' ? 201 : 200));
250
+ return { status, body: out.body };
251
+ }
182
252
  const sql = applyBinds(h.source.trim().replace(/;+\s*$/, ''), binds);
183
253
  if (h.type === 'collection') {
184
254
  const size = Math.max(1, Math.min(MAX_PAGE, Number(binds.LIMIT) || h.page_size || 25));
@@ -211,9 +281,10 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
211
281
  }
212
282
  return { status: 200, body: undefined, streamed: true };
213
283
  }
214
- if (h.type === 'item') {
284
+ if (h.type === 'item' || h.type === 'media') {
215
285
  const rows = (await c.query(`select * from (\n${sql}\n) "__r" limit 2`)).rows;
216
286
  if (!rows.length) throw new RestError(404, 'Not found');
287
+ if (h.type === 'media') return { status: 200, body: undefined, media: mediaResponse(rows[0]) };
217
288
  return { status: 200, body: rows[0] };
218
289
  }
219
290
  let last: pg.QueryResult | undefined;
@@ -222,9 +293,11 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
222
293
  return row ? { status: h.status ?? (req.method === 'POST' ? 201 : 200), body: row } : { status: 204, body: undefined };
223
294
  });
224
295
  if ('streamed' in result) return reply;
225
- return result.body === undefined ? reply.code(204).send() : reply.code(result.status).send(result.body);
296
+ if ('media' in result && result.media) return reply.code(200).headers(result.media.headers).send(result.media.body);
297
+ return result.body === undefined ? reply.code(result.status === 204 || result.status < 200 ? 204 : result.status).send() : reply.code(result.status).send(result.body);
226
298
  } catch (e) {
227
299
  if (e instanceof RestError) return reply.code(e.status).send({ error: e.message });
300
+ if (e instanceof HandlerInputError) return reply.code(400).send({ error: e.message });
228
301
  const err = e as pg.DatabaseError;
229
302
  const message = await publicError({ app: a, page: { page_no: 0 } as never, user: 'api', ip: clientIp(req) }, e, `REST ${req.method} ${name}/${sub}`);
230
303
  const status = err.code === '42501' ? 403 : err.code && /^(P0001|22|23)/.test(err.code) ? 400 : 500;
@@ -233,26 +306,47 @@ async function handle(req: FastifyRequest, reply: FastifyReply) {
233
306
  }
234
307
 
235
308
  /** The OpenAPI 3 description of a module. */
236
- export function openApi(a: Pick<App, 'alias' | 'name'>, mod: { name: string; title: string; description: string | null; handlers: Handler[] }) {
309
+ export function openApi(
310
+ a: Pick<App, 'alias' | 'name'>,
311
+ mod: { name: string; title: string; description: string | null; handlers: Handler[]; roles?: string[] | null; templates?: Template[] | null },
312
+ ) {
237
313
  const paths: Record<string, Record<string, unknown>> = {};
314
+ const toPath = (uri: string) => `/${uri.replace(/:([a-z][a-z0-9_]*)/gi, '{$1}')}`;
315
+ const modRoles = mod.roles ?? [];
316
+ for (const t of Array.isArray(mod.templates) ? mod.templates : []) if (t?.uri && t.description) (paths[toPath(t.uri)] ??= {}).description = t.description;
238
317
  for (const h of mod.handlers) {
239
- const p = `/${h.path.replace(/:([a-z][a-z0-9_]*)/gi, '{$1}')}`;
240
- const params = [...h.path.matchAll(/:([a-z][a-z0-9_]*)/gi)].map((x) => ({ name: x[1], in: 'path', required: true, schema: { type: 'string' } }));
318
+ const p = toPath(h.path);
319
+ const params: Record<string, unknown>[] = [...h.path.matchAll(/:([a-z][a-z0-9_]*)/gi)].map((x) => ({ name: x[1], in: 'path', required: true, schema: { type: 'string' } }));
241
320
  if (h.type === 'collection')
242
- params.push({ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } } as never, { name: 'offset', in: 'query', required: false, schema: { type: 'integer' } } as never);
321
+ params.push({ name: 'limit', in: 'query', required: false, schema: { type: 'integer' } }, { name: 'offset', in: 'query', required: false, schema: { type: 'integer' } });
322
+ const documented = Array.isArray(h.params) ? h.params : [];
323
+ for (const q of documented.filter((x) => x.in === 'query'))
324
+ params.push({ name: q.name, in: 'query', required: !!q.required, schema: { type: q.type ?? 'string' }, ...(q.description ? { description: q.description } : {}) });
325
+ const bodyParams = documented.filter((x) => x.in === 'body');
326
+ const bodySchema = bodyParams.length
327
+ ? { type: 'object', properties: Object.fromEntries(bodyParams.map((q) => [q.name, { type: q.type ?? 'string', ...(q.description ? { description: q.description } : {}) }])),
328
+ ...(bodyParams.some((q) => q.required) ? { required: bodyParams.filter((q) => q.required).map((q) => q.name) } : {}) }
329
+ : { type: 'object' };
330
+ const roles = [modRoles.length ? `one of the roles ${modRoles.join(', ')}` : '', h.roles?.length ? `one of the roles ${h.roles.join(', ')}` : ''].filter(Boolean);
331
+ const ok = h.type === 'sql' || h.type === 'plpgsql' ? String(h.status ?? (h.method === 'POST' ? 201 : 200)) : '200';
243
332
  paths[p] ??= {};
244
333
  paths[p][h.method.toLowerCase()] = {
245
334
  summary: h.description ?? `${h.method} ${h.path || '/'}`,
335
+ ...(roles.length ? { description: `The caller needs ${roles.join(' and ')}.`, 'x-pgkiln-roles': [modRoles, h.roles ?? []].filter((r) => r.length) } : {}),
246
336
  ...(params.length ? { parameters: params } : {}),
247
- ...(h.method !== 'GET' ? { requestBody: { required: false, content: { 'application/json': { schema: { type: 'object' } } } } } : {}),
337
+ ...(h.method !== 'GET' ? { requestBody: { required: bodyParams.some((q) => q.required), content: { 'application/json': { schema: bodySchema } } } } : {}),
248
338
  ...(h.auth === 'public' ? { security: [] } : {}),
249
339
  responses: {
250
- [h.type === 'sql' ? String(h.status ?? (h.method === 'POST' ? 201 : 200)) : '200']: {
251
- description: h.type === 'collection' ? '{items, offset, limit, has_more}' : 'The row as an object',
252
- content: { 'application/json': { schema: { type: 'object' } } },
253
- },
254
- ...(h.type === 'item' ? { 404: { description: 'Not found' } } : {}),
255
- ...(h.auth === 'public' ? {} : { 401: { description: 'No valid token' }, 403: { description: 'Not allowed' } }),
340
+ [ok]: h.type === 'media'
341
+ ? { description: 'The content (content type from the row)', content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }
342
+ : {
343
+ description: h.type === 'collection' ? '{items, offset, limit, has_more}' : h.type === 'plpgsql' ? 'The body of meta.rest_reply' : 'The row as an object',
344
+ content: { 'application/json': { schema: { type: 'object' } } },
345
+ },
346
+ ...(h.type === 'sql' || h.type === 'plpgsql' ? { 204: { description: 'No content' } } : {}),
347
+ ...(documented.some((q) => q.required) || h.type === 'plpgsql' || h.type === 'sql' ? { 400: { description: 'Missing or invalid input' } } : {}),
348
+ ...(h.type === 'item' || h.type === 'media' ? { 404: { description: 'Not found' } } : {}),
349
+ ...(h.auth === 'public' && !roles.length ? {} : { 401: { description: 'No valid token' }, 403: { description: 'Not allowed' } }),
256
350
  },
257
351
  };
258
352
  }
@@ -269,7 +363,7 @@ export function openApi(a: Pick<App, 'alias' | 'name'>, mod: { name: string; tit
269
363
  export async function restRoutes(app: FastifyInstance) {
270
364
  app.get<{ Params: { alias: string; module: string } }>('/a/:alias/rest/:module/openapi.json', async (req, reply) => {
271
365
  const a = await loadApp(req.params.alias);
272
- const mod = a ? await runtime.one<{ name: string; title: string; description: string | null; handlers: Handler[]; enabled: boolean }>('select * from meta.rest_module where app_id = $1 and name = $2', [a.id, req.params.module]) : undefined;
366
+ const mod = a ? await runtime.one<{ name: string; title: string; description: string | null; handlers: Handler[]; enabled: boolean; roles: string[]; templates: Template[] }>('select * from meta.rest_module where app_id = $1 and name = $2', [a.id, req.params.module]) : undefined;
273
367
  if (!a || !mod?.enabled || !(await restServicesOn(a.id))) return reply.code(404).send({ error: english('error.not_found') });
274
368
  return reply.send(openApi(a, mod));
275
369
  });