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.
- package/db/migrations/144_workflow_migrate_branches.sql +186 -0
- package/db/migrations/145_remote_databases.sql +41 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/03-builder.md +37 -0
- package/docs/guide/04-pages-and-regions.md +8 -2
- package/docs/guide/06-processing.md +13 -2
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/18-cli.md +19 -2
- package/docs/guide/19-rest-data-sources.md +2 -1
- package/docs/guide/20-ai-agents.md +1 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +83 -0
- package/src/ai/chat.ts +32 -5
- package/src/app.ts +2 -0
- package/src/builder/advisor.ts +305 -149
- package/src/builder/home.ts +1 -0
- package/src/builder/remote-databases.ts +215 -0
- package/src/builder/search.ts +15 -4
- package/src/builder/workflow-migrate.ts +19 -13
- package/src/cli/advise.ts +39 -15
- package/src/cli/main.ts +7 -4
- package/src/cli/mcp.ts +10 -2
- package/src/json-sources.ts +14 -8
- package/src/remote-db.ts +145 -0
- package/src/runtime/assistant.ts +54 -17
package/src/builder/advisor.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 = (
|
|
175
|
-
const pages
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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' &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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);
|
package/src/builder/home.ts
CHANGED
|
@@ -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).') : ''}
|