pgkiln 0.78.0 → 0.80.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.
@@ -14,8 +14,9 @@ import { processProblems } from '../runtime/processes.ts';
14
14
  import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
15
15
  import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } from '../runtime/search.ts';
16
16
  import { COMPONENTS } from './components.ts';
17
+ import { arrayTriggerArg, CATALOG_TYPE, jsonSourceProblems, POSITION, ROW_KEY, viewTriggerArg, type JsonSource } from '../json-sources.ts';
17
18
  import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
18
- import { appEntries, type Entry } from './search.ts';
19
+ import { entriesOf, type Entry } from './search.ts';
19
20
  import { LOV_CHECKED } from '../runtime/items.ts';
20
21
  import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
21
22
 
@@ -157,6 +158,9 @@ export async function checkSql(c: pg.PoolClient, sql: string, shape: SqlShape):
157
158
 
158
159
  /** process types that write data (sprint 144: the security checks below; a sql process when its code has DML) */
159
160
  const WRITES = ['form_dml', 'grid_dml', 'sql'];
161
+ type Row = { kind: string; row: any };
162
+ /** (sprint 147) an application as rows: from the database (advise) or from an export's files (adviseDoc) */
163
+ export interface AppRows { app: any; pages: any[]; all: Row[]; entries: Entry[]; byKey: Map<string, Entry> }
160
164
  /** (sprint 144) session limits above which the Advisor warns (APEX CodeScan's session limits) */
161
165
  export const SESSION_LIMITS = { idleMinutes: 120, maxHours: 24 };
162
166
 
@@ -169,25 +173,31 @@ export const SESSION_LIMITS = { idleMinutes: 120, maxHours: 24 };
169
173
  * protection; hidden items never take posted values in pgkiln, so URL values are the way in);
170
174
  * - the installation's session limits above SESSION_LIMITS.
171
175
  */
172
- async function securityFindings(appId: number, query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult>, byKey: Map<string, Entry>): Promise<Finding[]> {
176
+ /** (sprint 147) the processes of a page that write data: saves, and SQL with a writing statement, not behind a scheme */
177
+ const writesOf = (all: Row[], page: any) => all
178
+ .filter((x) => x.kind === 'process' && x.row.page_id === page.id && WRITES.includes(x.row.type) && !x.row.authz
179
+ && (x.row.build_option ?? '') !== 'COMMENTED_OUT' && (x.row.type !== 'sql' || /\b(insert|update|delete|merge|truncate)\b/i.test(String(x.row.code ?? ''))))
180
+ .sort((a, b) => (a.row.seq ?? 0) - (b.row.seq ?? 0)).map((x) => String(x.row.name));
181
+
182
+ function pageSecurityFindings(x: AppRows): Finding[] {
173
183
  const out: Finding[] = [];
174
- const schemes = (await query('select count(*)::int as n from meta.authz_scheme where app_id = $1', [appId])).rows[0].n as number;
175
- const pages = (await query(
176
- `select p.id, p.page_no, p.requires_auth, p.authz, p.protection,
177
- (select array_agg(pr.name order by pr.seq) from meta.process pr where pr.page_id = p.id and pr.type = any($2) and pr.authz is null and coalesce(pr.build_option, '') <> 'COMMENTED_OUT'
178
- and (pr.type <> 'sql' or pr.code ~* '\\m(insert|update|delete|merge|truncate)\\M')) as writes,
179
- (select count(*)::int from meta.item i where i.page_id = p.id and i.type <> 'display') as items
180
- from meta.page p where p.app_id = $1 and coalesce(p.build_option, '') <> 'COMMENTED_OUT' order by p.page_no`, [appId, WRITES])).rows;
181
- for (const p of pages) {
182
- const entry = byKey.get(`page-${p.id}`) ?? null;
183
- const writes: string[] = p.writes ?? [];
184
+ const schemes = x.all.filter((r) => r.kind === 'authz_scheme').length;
185
+ for (const p of [...x.pages].filter((p) => (p.build_option ?? '') !== 'COMMENTED_OUT').sort((a, b) => a.page_no - b.page_no)) {
186
+ const entry = x.byKey.get(`page-${p.id}`) ?? null;
187
+ const writes = writesOf(x.all, p);
188
+ const items = x.all.filter((r) => r.kind === 'item' && r.row.page_id === p.id && r.row.type !== 'display').length;
184
189
  if (writes.length && !p.requires_auth)
185
190
  out.push({ severity: 'warning', entry, field: 'Security → Authentication', message: `Page ${p.page_no} needs no sign-in, and its process ${writes.join(', ')} writes data: anyone can run it. Require authentication, or put the process behind an authorization scheme.` });
186
191
  else if (writes.length && !p.authz && schemes)
187
192
  out.push({ severity: 'info', entry, field: 'Security → Authorization scheme', message: `Page ${p.page_no} writes data (${writes.join(', ')}) with no authorization scheme on the page or the process: every signed-in user may use it.` });
188
- if (p.protection === 'unrestricted' && p.items)
193
+ if (p.protection === 'unrestricted' && items)
189
194
  out.push({ severity: writes.length ? 'warning' : 'info', entry, field: 'Security → Page protection', message: `Page ${p.page_no} is unrestricted: a link can set its items without a checksum${writes.length ? ', and the page writes data' : ''}. Use checksum protection unless the URL is meant to be typed.` });
190
195
  }
196
+ return out;
197
+ }
198
+
199
+ async function securityFindings(x: AppRows, query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult>): Promise<Finding[]> {
200
+ const out = pageSecurityFindings(x);
191
201
  const settings = (await query(`select name, value from meta.setting where name in ('session_idle_minutes', 'session_max_hours')`).catch(() => ({ rows: [] }))).rows;
192
202
  const idle = settingFrom('session_idle_minutes', settings);
193
203
  const max = settingFrom('session_max_hours', settings);
@@ -198,6 +208,215 @@ async function securityFindings(appId: number, query: (sql: string, params?: unk
198
208
  return out;
199
209
  }
200
210
 
211
+ /**
212
+ * (sprint 146, AP-8) JSON sources whose views no longer match their data profile. Create views makes each view from
213
+ * the profile; editing the profile afterwards (or changing the view by hand) leaves the database behind. Per view:
214
+ * missing (error: pages on it fail), not made by this source (no json_source_write trigger: warning), and the
215
+ * profile against the catalog: its columns and their types, and the trigger's argument, which says what the view
216
+ * writes back (a column added, removed, moved to another path or made read only). Read as the owner.
217
+ */
218
+ export async function jsonSourceFindings(sources: unknown, query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult>): Promise<Finding[]> {
219
+ const out: Finding[] = [];
220
+ const list = (Array.isArray(sources) ? sources : []) as JsonSource[];
221
+ for (const x of list) {
222
+ if (!x || typeof x.schema !== 'string' || typeof x.view !== 'string' || !Array.isArray(x.columns)) continue;
223
+ const field = `JSON source ${x.name}`;
224
+ const views = [
225
+ { view: x.view, arg: viewTriggerArg(x), cols: x.columns, extra: [] as string[] },
226
+ ...(x.arrays ?? []).map((a) => ({ view: a.view, arg: arrayTriggerArg(x, a), cols: a.columns, extra: [ROW_KEY, POSITION] })),
227
+ ];
228
+ const rows = (await query(
229
+ `select c.relname as view,
230
+ (select array_agg(array[a.attname::text, format_type(a.atttypid, a.atttypmod)] order by a.attnum)
231
+ from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped) as cols,
232
+ (select convert_from(substring(t.tgargs from 1 for greatest(length(t.tgargs) - 1, 0)), 'UTF8')
233
+ from pg_trigger t where t.tgrelid = c.oid and t.tgname = 'json_source_write') as arg
234
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
235
+ where n.nspname = $1 and c.relname = any ($2) and c.relkind = 'v'`, [x.schema, views.map((v) => v.view)])).rows;
236
+ const found = new Map(rows.map((r) => [r.view as string, r]));
237
+ for (const v of views) {
238
+ const name = `${x.schema}.${v.view}`;
239
+ const again = 'Shared Components → JSON sources → Create views makes it again.';
240
+ const r = found.get(v.view);
241
+ if (!r) { out.push({ severity: 'error', entry: null, field, message: `View ${name} doesn't exist: pages and reports on it fail. ${again}` }); continue; }
242
+ if (r.arg == null) { out.push({ severity: 'warning', entry: null, field, message: `View ${name} wasn't made by this JSON source (it has no json_source_write trigger): it doesn't write back to ${x.table}.${x.column}.` }); continue; }
243
+ const actual = new Map((r.cols ?? []).map(([n, t]: string[]) => [n, t]));
244
+ const problems: string[] = [];
245
+ for (const n of [...x.key, ...v.extra]) if (!actual.has(n)) problems.push(`no column ${n}`);
246
+ for (const c of v.cols) {
247
+ const t = actual.get(c.name);
248
+ if (t === undefined) problems.push(`no column ${c.name}`);
249
+ else if (CATALOG_TYPE[c.type] && t !== CATALOG_TYPE[c.type]) problems.push(`${c.name} is ${t}, the profile says ${c.type}`);
250
+ }
251
+ const known = new Set([...x.key, ...v.extra, ...v.cols.map((c) => c.name)]);
252
+ for (const n of actual.keys()) if (!known.has(n as string)) problems.push(`column ${n} isn't in the profile`);
253
+ let arg: unknown = null;
254
+ try { arg = JSON.parse(r.arg); } catch { /* compared as text below */ }
255
+ if (!problems.length && JSON.stringify(arg) !== JSON.stringify(v.arg)) problems.push('it writes back other paths than the profile names (a path, a read-only column or the key changed)');
256
+ if (problems.length) out.push({ severity: 'warning', entry: null, field, message: `View ${name} is out of date with the profile: ${problems.join('; ')}. ${again}` });
257
+ }
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * (sprint 147) Checks on the application's own rows, with no database: the login page (087) and page groups (089).
264
+ */
265
+ function structureFindings(x: AppRows): Finding[] {
266
+ const out: Finding[] = [];
267
+ const { app, pages, all } = x;
268
+ if (app.login_page) {
269
+ const page = pages.find((p) => Number(p.page_no) === Number(app.login_page));
270
+ const field = 'Application settings → Login page';
271
+ if (!page) out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} doesn't exist: the built-in sign-in page is shown instead.` });
272
+ else if (page.requires_auth) out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} requires authentication: the built-in sign-in page is shown instead.` });
273
+ else if (!all.some((r) => r.kind === 'region' && r.row.type === 'login' && r.row.page_id === page.id))
274
+ out.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
275
+ }
276
+ const groups = new Set(all.filter((r) => r.kind === 'page_group').map((r) => r.row.name));
277
+ for (const p of [...pages].sort((a, b) => a.page_no - b.page_no))
278
+ if (p.page_group != null && !groups.has(p.page_group))
279
+ out.push({ severity: 'warning', entry: null, field: `Page ${p.page_no} → Page group`, message: `Page group ${p.page_group} doesn't exist (Shared Components → Page groups).` });
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * (sprint 147) References between the application's components: pages, items, lists of values, schemes, build options,
285
+ * layouts, regions, processes, workflow steps. `ext.workspace` (the remote servers and web credentials of the
286
+ * application's workspace) and `ext.otherAppPage` (a branch to another application) need a database: without them
287
+ * (an export checked offline) those checks are skipped.
288
+ */
289
+ async function referenceFindings(x: AppRows, ext: {
290
+ workspace: { servers: Map<string, string | null>; credentials: Set<string> } | null;
291
+ otherAppPage: ((alias: string, pageNo: number) => Promise<boolean>) | null;
292
+ }): Promise<Finding[]> {
293
+ const findings: Finding[] = [];
294
+ const { entries, all, byKey } = x;
295
+ const pageNos = new Set(entries.filter((e) => e.kind === 'page').map((e) => e.pageNo));
296
+ const items = new Set(all.filter((x) => x.kind === 'item' || x.kind === 'app_item').map((x) => x.row.name.toUpperCase()));
297
+ const lovs = new Set(all.filter((x) => x.kind === 'lov').map((x) => x.row.name.toUpperCase()));
298
+ const schemes = new Set(all.filter((x) => x.kind === 'authz_scheme').map((x) => x.row.name));
299
+ const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
300
+ const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
301
+ const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
302
+ // (104) a document button names a document template or a report query
303
+ const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
304
+ const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
305
+ // (141) remote servers and web credentials of the application's workspace (sources and invoke steps may name them)
306
+ const wsServers = ext.workspace?.servers ?? new Map<string, string | null>();
307
+ const wsCredentials = ext.workspace?.credentials ?? new Set<string>();
308
+ const appCredentials = new Set(all.filter((x) => x.kind === 'web_credential').map((x) => String(x.row.name).toUpperCase()));
309
+
310
+ for (const e of entries) {
311
+ for (const f of e.fields) {
312
+ if (!f.value) continue;
313
+ if (f.kind === 'page' && f.value && !pageNos.has(Number(f.value))) missing(e, f.label, `Page ${f.value} doesn't exist.`);
314
+ if (f.kind === 'build_option' && !buildOptions.has(f.value.replace(/^!/, '')))
315
+ missing(e, f.label, `Build option ${f.value.replace(/^!/, '')} doesn't exist, so the component is always left out.`);
316
+ if (f.kind === 'authz') {
317
+ const name = f.value.replace(/^!/, '');
318
+ if (!schemes.has(name) && !BUILT_IN.has(name)) missing(e, f.label, `Authorization scheme ${name} doesn't exist (nobody passes it).`);
319
+ }
320
+ for (const m of f.value.matchAll(/LOV:([A-Za-z0-9_]+)/g)) if (!lovs.has(m[1].toUpperCase())) missing(e, f.label, `List of values ${m[1].toUpperCase()} doesn't exist.`);
321
+ if (f.kind === 'code' || f.kind === 'text' || f.kind === 'textarea' || f.kind === 'json')
322
+ for (const m of f.value.matchAll(ITEM_REF))
323
+ if (!items.has(m[1].toUpperCase())) missing(e, f.label, `Item ${m[1].toUpperCase()} doesn't exist (its value is always empty).`, 'warning');
324
+ if (f.kind === 'json') {
325
+ let cfg: any;
326
+ try {
327
+ cfg = JSON.parse(f.value);
328
+ } catch {
329
+ continue;
330
+ }
331
+ if (cfg?.link?.page !== undefined && !pageNos.has(Number(cfg.link.page))) missing(e, f.label, `The link goes to page ${cfg.link.page}, which doesn't exist.`);
332
+ if (cfg?.link?.items) for (const k of Object.keys(cfg.link.items)) if (!items.has(k.toUpperCase())) missing(e, f.label, `The link sets item ${k}, which doesn't exist.`, 'warning');
333
+ if (cfg?.pdf?.layout && !layouts.has(String(cfg.pdf.layout).toUpperCase())) missing(e, f.label, `Report layout ${cfg.pdf.layout} doesn't exist (the built-in layout is used).`, 'warning');
334
+ if (typeof cfg?.public_reports === 'string' && !schemes.has(cfg.public_reports)) missing(e, f.label, `Authorization scheme ${cfg.public_reports} doesn't exist.`);
335
+ if (e.kind === 'region' && cfg?.report !== undefined && !regionIds.has(Number(cfg.report))) missing(e, f.label, `Region ${cfg.report} doesn't exist.`);
336
+ }
337
+ }
338
+ }
339
+ // forms and grids
340
+ for (const { kind, row } of all) {
341
+ const e = byKey.get(`${kind}-${row.id}`) ?? null;
342
+ // a grid on a REST data source takes its key from the source's key columns when it has no primary key column
343
+ const restKeys = row.rest_source
344
+ ? all.find((x) => x.kind === 'rest_source' && String(x.row.name).toUpperCase() === String(row.rest_source).toUpperCase())?.row.key_columns
345
+ : null;
346
+ if (kind === 'region' && row.type === 'grid' && (!(row.table_name || row.rest_source) || !(row.pk_column || restKeys?.length)))
347
+ missing(e, 'Source', 'A grid needs a table (or a REST data source with key columns) and a primary key column.');
348
+ if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
349
+ missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
350
+ if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
351
+ // (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
352
+ if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
353
+ missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
354
+ // (078) a URL region: a literal origin, valid attributes
355
+ if (kind === 'region' && row.type === 'url') {
356
+ const target = urlTarget(row.source);
357
+ if ('error' in target) missing(e, 'Source', target.error);
358
+ const problem = urlConfigProblem(row.config);
359
+ if (problem) missing(e, 'Attributes', problem);
360
+ }
361
+ if (kind === 'computation' && !items.has(String(row.item_name).toUpperCase())) missing(e, 'Item', `Item ${row.item_name} doesn't exist (the computation sets nothing).`);
362
+ if (kind === 'computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
363
+ if (kind === 'process' && (row.type === 'form_dml' || row.type === 'grid_dml') && !row.region_id) missing(e, 'Region', `A ${row.type} process needs its region.`);
364
+ if (kind === 'process') {
365
+ for (const problem of processProblems(row.type, row.config)) missing(e, 'Configuration', problem);
366
+ if (row.parent_process && !all.some((x) => x.kind === 'process' && x.row.page_id === row.page_id && x.row.type === 'chain' && x.row.name === row.parent_process))
367
+ missing(e, 'Chain (parent process)', `There is no chain process "${row.parent_process}" on this page, so this process never runs.`);
368
+ if (row.type === 'workflow' && (row.config?.action ?? 'start') === 'start' && row.config?.definition
369
+ && !all.some((x) => x.kind === 'workflow_definition' && x.row.name === String(row.config.definition).toUpperCase()))
370
+ missing(e, 'Configuration', `Workflow definition ${row.config.definition} doesn't exist.`);
371
+ }
372
+ if (kind === 'branch' && row.target_type === 'page' && row.target_page && !pageNos.has(Number(row.target_page))) missing(e, 'Page', `Page ${row.target_page} doesn't exist.`);
373
+ if (kind === 'branch' && row.target_type === 'app' && ext.otherAppPage && !(await ext.otherAppPage(row.target_app, row.target_page)))
374
+ missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
375
+ if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
376
+ missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
377
+ if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
378
+ if (kind === 'rest_source') {
379
+ // (141) an export may name a server or credential this workspace doesn't have (yet): it imports, and says so here
380
+ if (ext.workspace && row.server && !wsServers.has(row.server))
381
+ missing(e, 'Remote server', `Remote server ${row.server} doesn't exist in this application's workspace: add it under Workspace utilities → Remote servers (calls fail until then).`, 'warning');
382
+ const credential = row.credential || (row.server ? wsServers.get(row.server) : null);
383
+ if (ext.workspace && credential && !appCredentials.has(credential) && !wsCredentials.has(credential))
384
+ missing(e, 'Web credential', `Web credential ${credential} is neither the application's nor its workspace's (calls fail until it exists).`, 'warning');
385
+ }
386
+ if (kind === 'workflow_definition') {
387
+ const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
388
+ const outcomes = outcomeMap(all.filter((x) => x.kind === 'task_definition').map((x) => x.row));
389
+ for (const problem of stepProblems(row.steps, tasks, { outcomes, drafts: false })) missing(e, 'Steps (JSON)', problem);
390
+ for (const warning of stepWarnings(row.steps)) missing(e, 'Steps (JSON)', warning, 'warning');
391
+ // invoke_api steps: REST data sources, parameters, web credentials, variables (sprint 32)
392
+ const refs = {
393
+ sources: new Map(all.filter((x) => x.kind === 'rest_source').map((x) => [String(x.row.name).toUpperCase(), x.row])),
394
+ credentials: new Set([...appCredentials, ...wsCredentials]),
395
+ startVars: [...String(row.title ?? '').matchAll(/&([A-Za-z][A-Za-z0-9_]*)\./g)].map((m) => m[1]),
396
+ };
397
+ const invokeRefs = invokeStepReferences(row.steps, refs);
398
+ for (const problem of invokeRefs.errors) missing(e, 'Steps (JSON)', problem);
399
+ for (const warning of invokeRefs.warnings) missing(e, 'Steps (JSON)', warning, 'warning');
400
+ if (row.dev_version) {
401
+ const field = `Steps of version ${row.dev_version} (development)`;
402
+ for (const problem of stepProblems(row.dev_steps, tasks, { outcomes })) missing(e, field, problem, 'warning');
403
+ for (const warning of stepWarnings(row.dev_steps)) missing(e, field, warning, 'warning');
404
+ const devRefs = invokeStepReferences(row.dev_steps, refs);
405
+ for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
406
+ }
407
+ }
408
+ if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
409
+ if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
410
+ if (kind === 'document_template') {
411
+ const problem = templateProblem(row.template ?? '');
412
+ if (problem) missing(e, 'Template (HTML)', problem);
413
+ if (row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
414
+ }
415
+ }
416
+
417
+ return findings;
418
+ }
419
+
201
420
  const ITEM_REF = /(?<![A-Za-z0-9_$&:])(?::|&)(P\d+_[A-Z0-9_]+)\b/gi;
202
421
  const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
203
422
 
@@ -210,8 +429,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
210
429
  const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
211
430
  const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
212
431
  const app = await one('select * from meta.app where id = $1', [appId]);
213
- const entries = await appEntries(appId, query);
214
- const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
432
+ const pages = (await query('select * from meta.page where app_id = $1 order by page_no', [appId])).rows;
215
433
  const findings: Finding[] = [];
216
434
  let checked = 0;
217
435
 
@@ -226,17 +444,16 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
226
444
  if (tx) for (const [kind, spec] of Object.entries(COMPONENTS)) lists.push(await rowsOf(kind, spec.table, spec.scope));
227
445
  else lists.push(...(await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))));
228
446
  const all = lists.flat();
447
+ const byKind = new Map<string, any[]>();
448
+ for (const x of all) byKind.set(x.kind, [...(byKind.get(x.kind) ?? []), x.row]);
449
+ for (const [kind, spec] of Object.entries(COMPONENTS))
450
+ byKind.get(kind)?.sort((a, b) => spec.scope === 'page' ? (a.page_no - b.page_no || (a.seq ?? 0) - (b.seq ?? 0) || a.id - b.id) : a.id - b.id);
451
+ const entries = entriesOf(appId, pages, byKind);
452
+ const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
453
+ const rows: AppRows = { app, pages, all, entries, byKey };
229
454
 
230
455
  // metadata checks, as the owner (before the role switch below: the application's role can't read meta)
231
- // (087) the application's login page: a page that needs no sign-in, with a login region
232
- if (app.login_page) {
233
- const page = await one('select id, requires_auth from meta.page where app_id = $1 and page_no = $2', [appId, app.login_page]);
234
- const field = 'Application settings → Login page';
235
- if (!page) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} doesn't exist: the built-in sign-in page is shown instead.` });
236
- else if (page.requires_auth) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} requires authentication: the built-in sign-in page is shown instead.` });
237
- else if (!all.some((x) => x.kind === 'region' && x.row.type === 'login' && Number(x.row.page_no) === Number(app.login_page)))
238
- findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
239
- }
456
+ findings.push(...structureFindings(rows));
240
457
  // (sprint 130, SEC-3) administrators of the application who sign in with only a password
241
458
  if (app.authentication === 'app_users' && (app.local_login || (app.ldap_directories ?? []).length)) {
242
459
  const covered = app.mfa === 'required' || (app.mfa === 'roles' && (app.mfa_roles ?? []).includes('admin'));
@@ -245,11 +462,8 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
245
462
  findings.push({ severity: 'warning', entry: null, field: 'Application settings → Two-factor sign-in',
246
463
  message: `${admins === 1 ? 'A user' : `${admins} users`} with the admin role sign${admins === 1 ? 's' : ''} in with only a password: require two-factor sign-in (Required, or Required for roles: admin).` });
247
464
  }
248
- // (089) a page's group exists
249
- for (const p of (await query(`select p.page_no, p.page_group from meta.page p where p.app_id = $1 and p.page_group is not null
250
- and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)
251
- findings.push({ severity: 'warning', entry: null, field: `Page ${p.page_no} → Page group`, message: `Page group ${p.page_group} doesn't exist (Shared Components → Page groups).` });
252
- findings.push(...(await securityFindings(appId, query, byKey)));
465
+ findings.push(...(await securityFindings(rows, query)));
466
+ findings.push(...(await jsonSourceFindings(app.json_sources, query)));
253
467
  // ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
254
468
  // (090) an isolated workspace's application: planned on the workspace's developer login, in a
255
469
  // transaction of its own (planning can run the application's functions; RESET ROLE there only
@@ -426,128 +640,12 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
426
640
  }
427
641
 
428
642
  // ---- references
429
- const pageNos = new Set(entries.filter((e) => e.kind === 'page').map((e) => e.pageNo));
430
- const items = new Set(all.filter((x) => x.kind === 'item' || x.kind === 'app_item').map((x) => x.row.name.toUpperCase()));
431
- const lovs = new Set(all.filter((x) => x.kind === 'lov').map((x) => x.row.name.toUpperCase()));
432
- const schemes = new Set(all.filter((x) => x.kind === 'authz_scheme').map((x) => x.row.name));
433
- const buildOptions = new Set(all.filter((x) => x.kind === 'build_option').map((x) => x.row.name));
434
- const layouts = new Set(all.filter((x) => x.kind === 'report_layout').map((x) => x.row.name.toUpperCase()));
435
- const regionIds = new Set(all.filter((x) => x.kind === 'region').map((x) => x.row.id));
436
- // (104) a document button names a document template or a report query
437
- const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
438
- const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
439
- // (141) remote servers and web credentials of the application's workspace (sources and invoke steps may name them)
440
643
  const wsServers = new Map((await query('select static_id, credential from meta.remote_server where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => [r.static_id as string, r.credential as string | null]));
441
644
  const wsCredentials = new Set((await query('select name from meta.workspace_credential where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => r.name as string));
442
- const appCredentials = new Set(all.filter((x) => x.kind === 'web_credential').map((x) => String(x.row.name).toUpperCase()));
443
-
444
- for (const e of entries) {
445
- for (const f of e.fields) {
446
- if (!f.value) continue;
447
- if (f.kind === 'page' && f.value && !pageNos.has(Number(f.value))) missing(e, f.label, `Page ${f.value} doesn't exist.`);
448
- if (f.kind === 'build_option' && !buildOptions.has(f.value.replace(/^!/, '')))
449
- missing(e, f.label, `Build option ${f.value.replace(/^!/, '')} doesn't exist, so the component is always left out.`);
450
- if (f.kind === 'authz') {
451
- const name = f.value.replace(/^!/, '');
452
- if (!schemes.has(name) && !BUILT_IN.has(name)) missing(e, f.label, `Authorization scheme ${name} doesn't exist (nobody passes it).`);
453
- }
454
- for (const m of f.value.matchAll(/LOV:([A-Za-z0-9_]+)/g)) if (!lovs.has(m[1].toUpperCase())) missing(e, f.label, `List of values ${m[1].toUpperCase()} doesn't exist.`);
455
- if (f.kind === 'code' || f.kind === 'text' || f.kind === 'textarea' || f.kind === 'json')
456
- for (const m of f.value.matchAll(ITEM_REF))
457
- if (!items.has(m[1].toUpperCase())) missing(e, f.label, `Item ${m[1].toUpperCase()} doesn't exist (its value is always empty).`, 'warning');
458
- if (f.kind === 'json') {
459
- let cfg: any;
460
- try {
461
- cfg = JSON.parse(f.value);
462
- } catch {
463
- continue;
464
- }
465
- if (cfg?.link?.page !== undefined && !pageNos.has(Number(cfg.link.page))) missing(e, f.label, `The link goes to page ${cfg.link.page}, which doesn't exist.`);
466
- if (cfg?.link?.items) for (const k of Object.keys(cfg.link.items)) if (!items.has(k.toUpperCase())) missing(e, f.label, `The link sets item ${k}, which doesn't exist.`, 'warning');
467
- if (cfg?.pdf?.layout && !layouts.has(String(cfg.pdf.layout).toUpperCase())) missing(e, f.label, `Report layout ${cfg.pdf.layout} doesn't exist (the built-in layout is used).`, 'warning');
468
- if (typeof cfg?.public_reports === 'string' && !schemes.has(cfg.public_reports)) missing(e, f.label, `Authorization scheme ${cfg.public_reports} doesn't exist.`);
469
- if (e.kind === 'region' && cfg?.report !== undefined && !regionIds.has(Number(cfg.report))) missing(e, f.label, `Region ${cfg.report} doesn't exist.`);
470
- }
471
- }
472
- }
473
- // forms and grids
474
- for (const { kind, row } of all) {
475
- const e = byKey.get(`${kind}-${row.id}`) ?? null;
476
- // a grid on a REST data source takes its key from the source's key columns when it has no primary key column
477
- const restKeys = row.rest_source
478
- ? all.find((x) => x.kind === 'rest_source' && String(x.row.name).toUpperCase() === String(row.rest_source).toUpperCase())?.row.key_columns
479
- : null;
480
- if (kind === 'region' && row.type === 'grid' && (!(row.table_name || row.rest_source) || !(row.pk_column || restKeys?.length)))
481
- missing(e, 'Source', 'A grid needs a table (or a REST data source with key columns) and a primary key column.');
482
- if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
483
- missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
484
- if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
485
- // (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
486
- if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
487
- missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
488
- // (078) a URL region: a literal origin, valid attributes
489
- if (kind === 'region' && row.type === 'url') {
490
- const target = urlTarget(row.source);
491
- if ('error' in target) missing(e, 'Source', target.error);
492
- const problem = urlConfigProblem(row.config);
493
- if (problem) missing(e, 'Attributes', problem);
494
- }
495
- if (kind === 'computation' && !items.has(String(row.item_name).toUpperCase())) missing(e, 'Item', `Item ${row.item_name} doesn't exist (the computation sets nothing).`);
496
- if (kind === 'computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
497
- if (kind === 'process' && (row.type === 'form_dml' || row.type === 'grid_dml') && !row.region_id) missing(e, 'Region', `A ${row.type} process needs its region.`);
498
- if (kind === 'process') {
499
- for (const problem of processProblems(row.type, row.config)) missing(e, 'Configuration', problem);
500
- if (row.parent_process && !all.some((x) => x.kind === 'process' && x.row.page_id === row.page_id && x.row.type === 'chain' && x.row.name === row.parent_process))
501
- missing(e, 'Chain (parent process)', `There is no chain process "${row.parent_process}" on this page, so this process never runs.`);
502
- if (row.type === 'workflow' && (row.config?.action ?? 'start') === 'start' && row.config?.definition
503
- && !all.some((x) => x.kind === 'workflow_definition' && x.row.name === String(row.config.definition).toUpperCase()))
504
- missing(e, 'Configuration', `Workflow definition ${row.config.definition} doesn't exist.`);
505
- }
506
- if (kind === 'branch' && row.target_type === 'page' && row.target_page && !pageNos.has(Number(row.target_page))) missing(e, 'Page', `Page ${row.target_page} doesn't exist.`);
507
- if (kind === 'branch' && row.target_type === 'app'
508
- && !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
509
- missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
510
- if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
511
- missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
512
- if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
513
- if (kind === 'rest_source') {
514
- // (141) an export may name a server or credential this workspace doesn't have (yet): it imports, and says so here
515
- if (row.server && !wsServers.has(row.server))
516
- missing(e, 'Remote server', `Remote server ${row.server} doesn't exist in this application's workspace: add it under Workspace utilities → Remote servers (calls fail until then).`, 'warning');
517
- const credential = row.credential || (row.server ? wsServers.get(row.server) : null);
518
- if (credential && !appCredentials.has(credential) && !wsCredentials.has(credential))
519
- missing(e, 'Web credential', `Web credential ${credential} is neither the application's nor its workspace's (calls fail until it exists).`, 'warning');
520
- }
521
- if (kind === 'workflow_definition') {
522
- const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
523
- const outcomes = outcomeMap(all.filter((x) => x.kind === 'task_definition').map((x) => x.row));
524
- for (const problem of stepProblems(row.steps, tasks, { outcomes, drafts: false })) missing(e, 'Steps (JSON)', problem);
525
- for (const warning of stepWarnings(row.steps)) missing(e, 'Steps (JSON)', warning, 'warning');
526
- // invoke_api steps: REST data sources, parameters, web credentials, variables (sprint 32)
527
- const refs = {
528
- sources: new Map(all.filter((x) => x.kind === 'rest_source').map((x) => [String(x.row.name).toUpperCase(), x.row])),
529
- credentials: new Set([...appCredentials, ...wsCredentials]),
530
- startVars: [...String(row.title ?? '').matchAll(/&([A-Za-z][A-Za-z0-9_]*)\./g)].map((m) => m[1]),
531
- };
532
- const invokeRefs = invokeStepReferences(row.steps, refs);
533
- for (const problem of invokeRefs.errors) missing(e, 'Steps (JSON)', problem);
534
- for (const warning of invokeRefs.warnings) missing(e, 'Steps (JSON)', warning, 'warning');
535
- if (row.dev_version) {
536
- const field = `Steps of version ${row.dev_version} (development)`;
537
- for (const problem of stepProblems(row.dev_steps, tasks, { outcomes })) missing(e, field, problem, 'warning');
538
- for (const warning of stepWarnings(row.dev_steps)) missing(e, field, warning, 'warning');
539
- const devRefs = invokeStepReferences(row.dev_steps, refs);
540
- for (const problem of [...devRefs.errors, ...devRefs.warnings]) missing(e, field, problem, 'warning');
541
- }
542
- }
543
- if (kind === 'report_query' && row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
544
- if (kind === 'app_computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
545
- if (kind === 'document_template') {
546
- const problem = templateProblem(row.template ?? '');
547
- if (problem) missing(e, 'Template (HTML)', problem);
548
- if (row.layout && !layouts.has(String(row.layout).toUpperCase())) missing(e, 'Report layout', `Report layout ${row.layout} doesn't exist (the default layout is used).`, 'warning');
549
- }
550
- }
645
+ findings.push(...(await referenceFindings(rows, {
646
+ workspace: { servers: wsServers, credentials: wsCredentials },
647
+ otherAppPage: async (alias, pageNo) => !!(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [alias, pageNo])),
648
+ })));
551
649
 
552
650
  // ---- (117) REST modules refuse (403) to run as a superuser, a role that bypasses RLS or a member of pgkiln's
553
651
  // owner: say so here before the endpoints fail. Without a db_role the runtime connection's own user is the role.
@@ -586,6 +684,64 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
586
684
 
587
685
  const SEVERITY_LABEL: Record<Severity, string> = { error: 'Error', warning: 'Warning', info: 'Note' };
588
686
 
687
+ // ------------------------------------------------------------------ (sprint 147) offline: an export without a database
688
+
689
+ /** The export's list of a kind of component (app.json's keys: lovs, nav, list_entries, data_load_definitions …). */
690
+ const docKey = (kind: string) =>
691
+ kind === 'nav_entry' ? 'nav' : kind === 'data_load_def' ? 'data_load_definitions'
692
+ : kind.endsWith('y') ? `${kind.slice(0, -1)}ies` : /(s|ch)$/.test(kind) ? `${kind}es` : `${kind}s`;
693
+
694
+ /**
695
+ * An export (meta.export_app's JSON, or filesToDoc of its files) as the rows the checks read. Rows keep the ids the
696
+ * export carries (regions: processes and buttons name them by region_id); the others, and pages, get ids of their
697
+ * own (negative, so they never meet an exported id).
698
+ */
699
+ export function rowsOfDoc(doc: any): AppRows {
700
+ if (!doc || typeof doc !== 'object' || !doc.app || typeof doc.app !== 'object') throw new Error('not an application export (no "app")');
701
+ let next = 0;
702
+ const idOf = (row: any) => (typeof row?.id === 'number' ? row.id : --next);
703
+ // the checks read names as text (a database row always has one)
704
+ const named = (row: any) => ({ ...row, name: typeof row.name === 'string' ? row.name : String(row.name ?? '') });
705
+ const pages = (Array.isArray(doc.pages) ? doc.pages : []).filter((p: any) => p && typeof p === 'object').map((p: any) => ({ ...named(p), id: --next }));
706
+ const all: Row[] = [];
707
+ for (const [kind, spec] of Object.entries(COMPONENTS)) {
708
+ if (spec.scope === 'page') {
709
+ for (const p of pages) {
710
+ const list = (doc.pages.find((d: any) => d && d.page_no === p.page_no)?.[docKey(kind)] ?? []) as any[];
711
+ for (const row of Array.isArray(list) ? list : []) if (row && typeof row === 'object') all.push({ kind, row: { ...named(row), id: idOf(row), page_id: p.id, page_no: p.page_no } });
712
+ }
713
+ } else {
714
+ const list = doc[docKey(kind)];
715
+ for (const row of Array.isArray(list) ? list : []) if (row && typeof row === 'object') all.push({ kind, row: { ...named(row), id: idOf(row) } });
716
+ }
717
+ }
718
+ const byKind = new Map<string, any[]>();
719
+ for (const x of all) byKind.set(x.kind, [...(byKind.get(x.kind) ?? []), x.row]);
720
+ const entries = entriesOf(0, pages, byKind);
721
+ return { app: doc.app, pages, all, entries, byKey: new Map(entries.map((e) => [`${e.kind}-${e.id}`, e])) };
722
+ }
723
+
724
+ /**
725
+ * The checks that need no database, on an export: references between its components, the login page and page
726
+ * groups, the pages' security defaults, workflow steps, process settings and JSON source profiles. Not done
727
+ * offline: planning SQL (it needs the application's tables and role), the catalog (vector and full-text columns, JSON
728
+ * source views, plpgsql_check), the workspace's remote servers and credentials, branches to other applications, and
729
+ * the installation's session limits and users.
730
+ */
731
+ export async function adviseDoc(doc: any): Promise<{ findings: Finding[]; checked: number }> {
732
+ const x = rowsOfDoc(doc);
733
+ const findings: Finding[] = [...structureFindings(x), ...pageSecurityFindings(x)];
734
+ for (const src of Array.isArray(x.app.json_sources) ? x.app.json_sources : []) {
735
+ if (!src || typeof src !== 'object') continue;
736
+ const problems = jsonSourceProblems(src);
737
+ if (problems.length) findings.push({ severity: 'error', entry: null, field: `JSON source ${src?.name ?? ''}`, message: problems.join(' ') });
738
+ }
739
+ findings.push(...(await referenceFindings(x, { workspace: null, otherAppPage: null })));
740
+ const order = { error: 0, warning: 1, info: 2 };
741
+ findings.sort((a, b) => order[a.severity] - order[b.severity] || (a.entry?.pageNo ?? 0) - (b.entry?.pageNo ?? 0));
742
+ return { findings, checked: 0 };
743
+ }
744
+
589
745
  export async function advisorRoutes(app: FastifyInstance) {
590
746
  app.get(`${BASE}/apps/:id/advisor`, async (req: Req, reply) => {
591
747
  const s = await developer(req, reply);
@@ -288,6 +288,7 @@ export async function homeRoutes(app: FastifyInstance) {
288
288
  ${card(`${BASE}/backups`, icon('archive'), 'Manage backups', 'The backups of every application of this workspace; back them all up at once.')}
289
289
  ${card(`${BASE}/workspace-files`, icon('file'), 'Static workspace files', 'CSS, JavaScript, images and fonts every application of this workspace can load (#WORKSPACE_FILES#).')}
290
290
  ${card(`${BASE}/remote-servers`, icon('server'), 'Remote servers', 'Named base URLs for REST data sources, catalogs and AI services: change one value to move them all.')}
291
+ ${(await isAdmin(s.username)) ? card(`${BASE}/remote-databases`, icon('database'), 'Remote databases', 'Another PostgreSQL database\'s tables as foreign tables (postgres_fdw) the applications query like their own (administrators).') : ''}
291
292
  ${card(`${BASE}/workspace-credentials`, icon('key'), 'Web credentials', 'Keys and OAuth2 clients the applications of this workspace use by name (secrets encrypted, write-only).')}
292
293
  ${card(`${BASE}/rest-catalogs`, icon('globe'), 'REST Source Catalogs', 'Web services described once for the workspace (e.g. from an OpenAPI document); applications make REST data sources from them.')}
293
294
  ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), 'Export and import', 'This workspace\'s components as one file: settings, developers, groups, links, catalogs, scripts, blueprints (workspace administrators).') : ''}