pgkiln 0.35.0 → 0.37.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/SECURITY.md +11 -9
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +13 -3
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/src/runtime/regions.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { templateClasses } from './template-options.ts';
|
|
|
33
33
|
import { renderPluginRegion } from './plugins.ts';
|
|
34
34
|
import { renderUrlRegion } from './url-region.ts';
|
|
35
35
|
import { renderSearch } from './search.ts';
|
|
36
|
+
import { renderLoginRegion } from './signin.ts';
|
|
36
37
|
|
|
37
38
|
// ---------------------------------------------------------------- buttons
|
|
38
39
|
|
|
@@ -359,6 +360,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
359
360
|
case 'search':
|
|
360
361
|
body = await renderSearch(ctx, r);
|
|
361
362
|
break;
|
|
363
|
+
case 'login':
|
|
364
|
+
body = await renderLoginRegion(ctx);
|
|
365
|
+
break;
|
|
362
366
|
case 'dynamic':
|
|
363
367
|
// A SELECT returning HTML (like APEX "PL/SQL Dynamic Content"). The
|
|
364
368
|
// developer's SQL produces trusted markup; escape data with meta.html_escape().
|
|
@@ -395,6 +399,35 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
|
|
|
395
399
|
${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
|
|
396
400
|
</section>`;
|
|
397
401
|
|
|
402
|
+
// (088) a large banner: an icon, the title, the text and the buttons
|
|
403
|
+
if (r.template === 'hero') {
|
|
404
|
+
const heroIcon = typeof r.config?.icon === 'string' ? icon(r.config.icon) : '';
|
|
405
|
+
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
|
406
|
+
${heroIcon ? html`<span class="region-hero-icon">${heroIcon}</span>` : ''}
|
|
407
|
+
<div class="region-hero-text"><h2 id="${titleId}">${r.title}</h2>${body}</div>
|
|
408
|
+
${buttons ? html`<div class="region-hero-buttons">${buttons}</div>` : ''}
|
|
409
|
+
</section>`;
|
|
410
|
+
}
|
|
411
|
+
// (088) the title and buttons on one line, without a card
|
|
412
|
+
if (r.template === 'title_bar')
|
|
413
|
+
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
|
414
|
+
<div class="region-title-bar"><h2 id="${titleId}">${r.title}</h2>${buttons ? html`<div class="region-title-buttons">${buttons}</div>` : ''}</div>
|
|
415
|
+
${body}
|
|
416
|
+
</section>`;
|
|
417
|
+
// (088) the buttons (and any text) only
|
|
418
|
+
if (r.template === 'buttons_container')
|
|
419
|
+
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
|
|
420
|
+
${body}${buttons ? html`<div class="region-buttons">${buttons}</div>` : ''}
|
|
421
|
+
</section>`;
|
|
422
|
+
// (088) hidden until opened: a dynamic action "Open region", or a link to #R<id> (CSS :target without JavaScript)
|
|
423
|
+
if (r.template === 'inline_dialog' || r.template === 'inline_drawer')
|
|
424
|
+
return html`<dialog class="${cls}" id="R${r.id}"${staticAttr} aria-labelledby="${titleId}" data-inline-region>
|
|
425
|
+
<header class="region-header"><h2 id="${titleId}">${r.title}</h2>
|
|
426
|
+
<a class="region-dialog-close icon-button" href="#" role="button" data-region-close aria-label="${ctx.locale.t('common.dismiss')}">${icon('close')}</a></header>
|
|
427
|
+
<div class="region-body">${body}</div>
|
|
428
|
+
${buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
|
|
429
|
+
</dialog>`;
|
|
430
|
+
|
|
398
431
|
const header = html`<h2 id="${titleId}">${r.title}</h2>${buttonsOnTop ? buttons : ''}`;
|
|
399
432
|
if (r.template === 'collapsible')
|
|
400
433
|
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
package/src/runtime/render.ts
CHANGED
|
@@ -242,6 +242,21 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
242
242
|
'data-dialog': '1',
|
|
243
243
|
}, html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`, root);
|
|
244
244
|
|
|
245
|
+
// (087) a minimal page (a login page, for one): no header and navigation; the application's
|
|
246
|
+
// login page gets the sign-in page's backdrop
|
|
247
|
+
if (ctx.page.page_template === 'minimal') {
|
|
248
|
+
const login = ctx.app.login_page === ctx.page.page_no;
|
|
249
|
+
return documentShell(
|
|
250
|
+
`${title} · ${ctx.app.name}`,
|
|
251
|
+
html`<main class="t-minimal-main${login ? ' login' : ''}" id="main">${main}</main>
|
|
252
|
+
${devbarHtml(ctx)}`,
|
|
253
|
+
`t-minimal${login ? ' login-body' : ''}`,
|
|
254
|
+
{ 'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user) },
|
|
255
|
+
html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
|
|
256
|
+
root,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
245
260
|
const signedIn = ctx.user !== 'nobody';
|
|
246
261
|
const topNav = ctx.app.theme?.nav === 'top';
|
|
247
262
|
const nav = await navTree(ctx, topNav);
|
|
@@ -11,10 +11,12 @@ import { aggregateFormat, AGGREGATES, cell, columnFormats, filtered, headingOf,
|
|
|
11
11
|
// chosen with ?r<id>_v=group|pivot|chart. Each runs one aggregate query
|
|
12
12
|
// over all filtered rows (search, filters, facets and computed columns
|
|
13
13
|
// included). Column names come from the result of the report's own query;
|
|
14
|
-
// functions from the AGGREGATES whitelist.
|
|
14
|
+
// functions from the AGGREGATES whitelist. An interactive grid has the
|
|
15
|
+
// group by and chart views too, and its own single row view (grid.ts).
|
|
15
16
|
|
|
16
|
-
export type View = 'report' | 'group' | 'pivot' | 'chart';
|
|
17
|
+
export type View = 'report' | 'group' | 'pivot' | 'chart' | 'row';
|
|
17
18
|
export const VIEWS: View[] = ['report', 'group', 'pivot', 'chart'];
|
|
19
|
+
export const GRID_VIEWS: View[] = ['report', 'row', 'group', 'chart'];
|
|
18
20
|
|
|
19
21
|
const MAX_GROUPS = 1000;
|
|
20
22
|
const MAX_PIVOT_VALUES = 30;
|
package/src/runtime/report.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { Formatter } from './format.ts';
|
|
|
14
14
|
import { XlsxWriter, xlsxWidths, type XlsxCell } from '../xlsx.ts';
|
|
15
15
|
import { REPORT_CHART_KINDS } from './charts.ts';
|
|
16
16
|
import { ComputeError, computeNameOk, computeSql, type Computation } from './compute.ts';
|
|
17
|
-
import { renderView, VIEWS, type View } from './report-views.ts';
|
|
17
|
+
import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
|
|
18
18
|
import { columnTemplates } from './template-components.ts';
|
|
19
19
|
import { facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
|
|
20
20
|
import { resolveRestRegion } from './rest-sources.ts';
|
|
@@ -259,7 +259,7 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
|
259
259
|
const [name, ...rest] = raw.split('|');
|
|
260
260
|
return name && computeNameOk(name) && rest.length ? [{ name, expr: rest.join('|'), raw }] : [];
|
|
261
261
|
}),
|
|
262
|
-
view: (VIEWS as string[]).includes(p.get(key(r, 'v')) ?? '') ? (p.get(key(r, 'v')) as View) : 'report',
|
|
262
|
+
view: ((r.type === 'grid' ? GRID_VIEWS : VIEWS) as string[]).includes(p.get(key(r, 'v')) ?? '') ? (p.get(key(r, 'v')) as View) : 'report',
|
|
263
263
|
groupBy: {
|
|
264
264
|
columns: p.getAll(key(r, 'g')).filter(Boolean).slice(0, MAX_GROUP_COLUMNS),
|
|
265
265
|
functions: p.getAll(key(r, 'ga')).slice(0, MAX_GROUP_FUNCTIONS).flatMap((raw) => {
|
|
@@ -501,6 +501,38 @@ export function pageInfo(r: Region, st: { page: number; size: number }, rows: un
|
|
|
501
501
|
}
|
|
502
502
|
export type PageInfo = ReturnType<typeof pageInfo>;
|
|
503
503
|
|
|
504
|
+
/** Actions → Group by of a report or grid: up to three columns and a function to add (r<id>_gb1… → r<id>_g, r<id>_ga). */
|
|
505
|
+
export function groupBySection(ctx: PageContext, r: Region, st: ReportState, names: string[], form: string) {
|
|
506
|
+
const t = ctx.locale.t;
|
|
507
|
+
const options = (selected: string | undefined) => names.map((n) => html`<option value="${n}"${n === selected ? raw(' selected') : ''}>${headingOf(r, n, ctx.locale.tr)}</option>`);
|
|
508
|
+
return html`<div class="menu-section">
|
|
509
|
+
<strong>${t('report.group_by')}</strong>
|
|
510
|
+
<div class="filter-row">
|
|
511
|
+
${[1, 2, 3].map((n) => html`<select name="${key(r, `gb${n}`)}" form="${form}" aria-label="${t('report.group_by')} ${n}">
|
|
512
|
+
<option value="">${t('report.none')}</option>${options(st.groupBy.columns[n - 1])}</select>`)}
|
|
513
|
+
<select name="${key(r, 'gbf')}" form="${form}" aria-label="${t('report.function')}"><option value="">${t('report.none')}</option>${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
|
|
514
|
+
<select name="${key(r, 'gbc')}" form="${form}" aria-label="${t('report.column')}">${options(undefined)}</select>
|
|
515
|
+
<button class="btn" form="${form}">${t('report.apply')}</button>
|
|
516
|
+
</div>
|
|
517
|
+
</div>`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Actions → Chart of a report or grid: kind, label column, function, value column (r<id>_ck… → r<id>_ch). */
|
|
521
|
+
export function chartSection(ctx: PageContext, r: Region, st: ReportState, names: string[], form: string) {
|
|
522
|
+
const t = ctx.locale.t;
|
|
523
|
+
const options = (selected: string | undefined) => names.map((n) => html`<option value="${n}"${n === selected ? raw(' selected') : ''}>${headingOf(r, n, ctx.locale.tr)}</option>`);
|
|
524
|
+
return html`<div class="menu-section">
|
|
525
|
+
<strong>${t('report.chart')}</strong>
|
|
526
|
+
<div class="filter-row">
|
|
527
|
+
<select name="${key(r, 'ck')}" form="${form}" aria-label="${t('report.chart_type')}">${REPORT_CHART_KINDS.map((k) => html`<option value="${k}"${k === st.chart?.kind ? raw(' selected') : ''}>${t(`chart.${k}`)}</option>`)}</select>
|
|
528
|
+
<select name="${key(r, 'cl')}" form="${form}" aria-label="${t('report.label_column')}">${options(st.chart?.label)}</select>
|
|
529
|
+
<select name="${key(r, 'cf')}" form="${form}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}"${fn === (st.chart?.fn ?? 'count') ? raw(' selected') : ''}>${t(`agg.${fn}`)}</option>`)}</select>
|
|
530
|
+
<select name="${key(r, 'cv')}" form="${form}" aria-label="${t('report.value_column')}">${options(st.chart?.value)}</select>
|
|
531
|
+
<button class="btn" form="${form}">${t('report.apply')}</button>
|
|
532
|
+
</div>
|
|
533
|
+
</div>`;
|
|
534
|
+
}
|
|
535
|
+
|
|
504
536
|
/** The pager under a report or grid: "X–Y of N" (or "Rows X–Y"), Previous and Next. */
|
|
505
537
|
export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr: Raw | '' = '') {
|
|
506
538
|
const t = ctx.locale.t;
|
|
@@ -1003,16 +1035,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1003
1035
|
</div>
|
|
1004
1036
|
<small class="help">${t('report.compute_help')}</small>
|
|
1005
1037
|
</div>
|
|
1006
|
-
|
|
1007
|
-
<strong>${t('report.group_by')}</strong>
|
|
1008
|
-
<div class="filter-row">
|
|
1009
|
-
${[1, 2, 3].map((n) => html`<select name="${key(r, `gb${n}`)}" form="${groupForm}" aria-label="${t('report.group_by')} ${n}">
|
|
1010
|
-
<option value="">${t('report.none')}</option>${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.groupBy.columns[n - 1] ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`)}
|
|
1011
|
-
<select name="${key(r, 'gbf')}" form="${groupForm}" aria-label="${t('report.function')}"><option value="">${t('report.none')}</option>${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
|
|
1012
|
-
<select name="${key(r, 'gbc')}" form="${groupForm}" aria-label="${t('report.column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === '' ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1013
|
-
<button class="btn" form="${groupForm}">${t('report.apply')}</button>
|
|
1014
|
-
</div>
|
|
1015
|
-
</div>
|
|
1038
|
+
${groupBySection(ctx, r, st, allCols.map(({ f }) => f.name), groupForm)}
|
|
1016
1039
|
<div class="menu-section">
|
|
1017
1040
|
<strong>${t('report.pivot')}</strong>
|
|
1018
1041
|
<div class="filter-row">
|
|
@@ -1023,16 +1046,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1023
1046
|
<button class="btn" form="${pivotForm}">${t('report.apply')}</button>
|
|
1024
1047
|
</div>
|
|
1025
1048
|
</div>
|
|
1026
|
-
|
|
1027
|
-
<strong>${t('report.chart')}</strong>
|
|
1028
|
-
<div class="filter-row">
|
|
1029
|
-
<select name="${key(r, 'ck')}" form="${chartForm}" aria-label="${t('report.chart_type')}">${REPORT_CHART_KINDS.map((k) => html`<option value="${k}"${k === st.chart?.kind ? raw(' selected') : ''}>${t(`chart.${k}`)}</option>`)}</select>
|
|
1030
|
-
<select name="${key(r, 'cl')}" form="${chartForm}" aria-label="${t('report.label_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.chart?.label ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1031
|
-
<select name="${key(r, 'cf')}" form="${chartForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}"${fn === (st.chart?.fn ?? 'count') ? raw(' selected') : ''}>${t(`agg.${fn}`)}</option>`)}</select>
|
|
1032
|
-
<select name="${key(r, 'cv')}" form="${chartForm}" aria-label="${t('report.value_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.chart?.value ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1033
|
-
<button class="btn" form="${chartForm}">${t('report.apply')}</button>
|
|
1034
|
-
</div>
|
|
1035
|
-
</div>
|
|
1049
|
+
${chartSection(ctx, r, st, allCols.map(({ f }) => f.name), chartForm)}
|
|
1036
1050
|
${saved ?? ''}
|
|
1037
1051
|
<div class="menu-section"><strong>${t('report.rows_per_page')}</strong>
|
|
1038
1052
|
<div class="seg">${PAGE_SIZES.map((n) =>
|
package/src/runtime/routes.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { headerValue, HeaderAuthError, peerAddress, resolveHeaderAccount, truste
|
|
|
9
9
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
10
10
|
import { applyBinds } from '../binds.ts';
|
|
11
11
|
import { appTx, runtime, savepoint } from '../db.ts';
|
|
12
|
+
import { withAppDb } from '../workspace-db.ts';
|
|
12
13
|
import { html } from '../html.ts';
|
|
13
14
|
import { documentShell } from '../layout.ts';
|
|
14
15
|
import { accountRoles, loadApp, loadPage, type App, type Page, type Region } from '../metadata.ts';
|
|
@@ -36,13 +37,15 @@ import { applyRowSelection, mastersOf } from './master-detail.ts';
|
|
|
36
37
|
import { layerInArea, layerTile, parseTile, servedLayer } from './maps.ts';
|
|
37
38
|
import { parseArea } from './spatial.ts';
|
|
38
39
|
import { currentLayout, layoutJson, layoutParam, parseLayout, storeLayout } from './grid-layout.ts';
|
|
39
|
-
import { layoutFromForm, statePart } from './grid.ts';
|
|
40
|
+
import { gridReturn, layoutFromForm, statePart } from './grid.ts';
|
|
40
41
|
import { PassThrough } from 'node:stream';
|
|
41
42
|
import { reportPdf } from './pdf.ts';
|
|
42
43
|
import { renderDocument } from './documents.ts';
|
|
43
44
|
import { pwaHead } from './pwa.ts';
|
|
44
45
|
import { resolveLocale, THEME_COOKIE, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
|
|
45
46
|
import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
|
|
47
|
+
import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
|
|
48
|
+
import { passwordResetRoutes } from './password-reset.ts';
|
|
46
49
|
import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
|
|
47
50
|
import { availabilityRefusal } from './availability.ts';
|
|
48
51
|
import { feedbackRoutes } from './feedback.ts';
|
|
@@ -85,8 +88,7 @@ export async function appWithLocale(req: FastifyRequest, alias: string, session?
|
|
|
85
88
|
return { app, locale };
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
export
|
|
89
|
-
next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') ? next : `/a/${app.alias}/${app.home_page}`;
|
|
91
|
+
export { safeNext };
|
|
90
92
|
|
|
91
93
|
/**
|
|
92
94
|
* Finish a successful sign-in (password or SSO): replace the session (no
|
|
@@ -598,11 +600,15 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
598
600
|
if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
|
|
599
601
|
await saveState(ctx.session);
|
|
600
602
|
if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx));
|
|
601
|
-
|
|
603
|
+
// (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
|
|
604
|
+
const back = branchTo || button.target_page ? '' : gridReturn(ctx, button.name);
|
|
605
|
+
return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : back ? `${self}${self.includes('?') ? '&' : '?'}${back}` : self), 303);
|
|
602
606
|
});
|
|
603
607
|
|
|
604
608
|
// ---------------------------------------------------------------- file downloads
|
|
605
609
|
fileRoutes(app, loadContext, txContext, forbidden);
|
|
610
|
+
// (087) "Forgot password?"
|
|
611
|
+
passwordResetRoutes(app);
|
|
606
612
|
// (079) the developer toolbar's session state viewer and actions
|
|
607
613
|
devbarRoutes(app, loadContext);
|
|
608
614
|
// (084) users' feedback
|
|
@@ -1035,34 +1041,13 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1035
1041
|
// ---------------------------------------------------------------- login / logout
|
|
1036
1042
|
const loginPage = async (app: App, locale: Locale, session: Session, next: string, error?: string) => {
|
|
1037
1043
|
const t = locale.t;
|
|
1038
|
-
// database accounts and custom authentication: the user name and password form only
|
|
1039
|
-
const dbAuth = app.authentication === 'database' || app.authentication === 'custom';
|
|
1040
|
-
if (dbAuth) app = { ...app, local_login: true, remember_me_days: null };
|
|
1041
|
-
const providers = dbAuth ? [] : await enabledProviders(app.sso_providers ?? []);
|
|
1042
1044
|
const nextQs = next ? `?next=${encodeURIComponent(next)}` : '';
|
|
1043
1045
|
return documentShell(
|
|
1044
1046
|
`${t('login.title')} · ${app.name}`,
|
|
1045
1047
|
html`<main class="login">
|
|
1046
1048
|
<div class="card login-card">
|
|
1047
1049
|
<h1>${app.name}</h1>
|
|
1048
|
-
${
|
|
1049
|
-
${((f) => (f ? html`<div class="alert alert-success" role="status">${f}</div>` : ''))(takeFlash(session))}
|
|
1050
|
-
${providers.length
|
|
1051
|
-
? html`<div class="sso-buttons">${providers.map(
|
|
1052
|
-
(pr) => html`<a class="btn${app.local_login ? '' : ' btn-hot'}" href="/a/${app.alias}/sso/${pr.name}${nextQs}">${t('login.with', { provider: pr.display_name })}</a>`,
|
|
1053
|
-
)}</div>${app.local_login ? html`<div class="or" role="separator"><span>${t('login.or')}</span></div>` : ''}`
|
|
1054
|
-
: ''}
|
|
1055
|
-
${app.local_login
|
|
1056
|
-
? html`<form method="post" class="login-form">
|
|
1057
|
-
<input type="hidden" name="__csrf" value="${session.csrf_token}">
|
|
1058
|
-
<input type="hidden" name="next" value="${next}">
|
|
1059
|
-
${app.time_zone_auto ? html`<input type="hidden" name="__tz" value="">` : ''}
|
|
1060
|
-
<div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" autocomplete="username" autofocus required maxlength="100"></div>
|
|
1061
|
-
<div class="field"><label class="label" for="password">${t('login.password')}</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="200"></div>
|
|
1062
|
-
${app.remember_me_days ? html`<label class="check"><input type="checkbox" name="remember" value="true"> ${t('login.remember', { days: app.remember_me_days })}</label>` : ''}
|
|
1063
|
-
<button class="btn btn-hot">${t('login.submit')}</button>
|
|
1064
|
-
</form>`
|
|
1065
|
-
: providers.length ? '' : html`<p>${t('login.none')}</p>`}
|
|
1050
|
+
${await signInForm(app, locale, session, next, { error })}
|
|
1066
1051
|
${languagePicker(app, locale, `/a/${app.alias}/login${nextQs}`)}
|
|
1067
1052
|
</div>
|
|
1068
1053
|
</main>`,
|
|
@@ -1100,6 +1085,12 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1100
1085
|
);
|
|
1101
1086
|
};
|
|
1102
1087
|
|
|
1088
|
+
/** (087) The application's own login page: a page that exists and needs no sign-in, else null (the built-in page). */
|
|
1089
|
+
const ownLoginPage = (a: App) => {
|
|
1090
|
+
const p = a.login_page ? a.pages.find((x) => x.page_no === a.login_page) : undefined;
|
|
1091
|
+
return p && !p.requires_auth && a.authentication !== 'header' ? p.page_no : null;
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1103
1094
|
/** Common start of the sign-in POST handlers: app, session, CSRF, throttling. */
|
|
1104
1095
|
const loginRequest = async (req: Req, reply: FastifyReply) => {
|
|
1105
1096
|
const loaded = await appWithLocale(req, req.params.alias);
|
|
@@ -1108,20 +1099,30 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1108
1099
|
const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
|
|
1109
1100
|
const { username = '', next } = req.body ?? {};
|
|
1110
1101
|
const ip = clientIp(req);
|
|
1111
|
-
const fail = async (msg: string, code = 401) =>
|
|
1112
|
-
|
|
1102
|
+
const fail = async (msg: string, code = 401) => {
|
|
1103
|
+
// (087) the application's own login page: back there with the message and user name, shown once
|
|
1104
|
+
const own = ownLoginPage(a);
|
|
1105
|
+
if (own) {
|
|
1106
|
+
session.state[LOGIN_ERROR] = msg;
|
|
1107
|
+
session.state[LOGIN_USER] = username.slice(0, 100);
|
|
1108
|
+
await saveState(session);
|
|
1109
|
+
return reply.redirect(`/a/${a.alias}/${own}${next ? `?next=${encodeURIComponent(safeNext(a, next))}` : ''}`, 303);
|
|
1110
|
+
}
|
|
1111
|
+
return reply.code(code).type('text/html').send(await loginPage(a, locale, session, safeNext(a, next), msg));
|
|
1112
|
+
};
|
|
1113
|
+
if (req.body?.__csrf !== session.csrf_token) return (await fail(locale.t('login.expired_session'), 403), null);
|
|
1113
1114
|
// automatic time zone: app.js filled in the browser's (signIn() keeps it for the new session)
|
|
1114
1115
|
if (a.time_zone_auto) {
|
|
1115
1116
|
const zone = await validTimeZone(req.body?.__tz);
|
|
1116
1117
|
if (zone) session.state.__TZ = zone;
|
|
1117
1118
|
}
|
|
1118
|
-
if (a.authentication === 'header') return fail(locale.t('login.method_unavailable'), 403), null;
|
|
1119
|
-
if (!a.local_login && a.authentication !== 'database' && a.authentication !== 'custom') return fail(locale.t('login.password_disabled'), 403), null;
|
|
1119
|
+
if (a.authentication === 'header') return (await fail(locale.t('login.method_unavailable'), 403), null);
|
|
1120
|
+
if (!a.local_login && a.authentication !== 'database' && a.authentication !== 'custom') return (await fail(locale.t('login.password_disabled'), 403), null);
|
|
1120
1121
|
// a NUL byte can't be a user name (and PostgreSQL text refuses it)
|
|
1121
|
-
if (username.includes('\0')) return fail(locale.t('login.invalid'), 400), null;
|
|
1122
|
+
if (username.includes('\0')) return (await fail(locale.t('login.invalid'), 400), null);
|
|
1122
1123
|
if (await loginThrottled(a.id, username, ip)) {
|
|
1123
1124
|
logActivity({ appId: a.id, username, event: 'login_locked', ip });
|
|
1124
|
-
return fail(locale.t('login.throttled', { minutes: loginWindowMinutes() }), 429), null;
|
|
1125
|
+
return (await fail(locale.t('login.throttled', { minutes: loginWindowMinutes() }), 429), null);
|
|
1125
1126
|
}
|
|
1126
1127
|
return { a, locale, session, username: username.slice(0, 100), next, ip, fail };
|
|
1127
1128
|
};
|
|
@@ -1135,6 +1136,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1135
1136
|
if (closed && closed.status === 503) return simplePage(reply, 503, english('app.unavailable_title'), closed.message ?? english(closed.key));
|
|
1136
1137
|
// header authentication has no sign-in form: the proxy signs the user in on any page
|
|
1137
1138
|
if (a0.authentication === 'header') return reply.redirect(safeNext(a0, req.query.next));
|
|
1139
|
+
// (087) the application's own login page
|
|
1140
|
+
const own = ownLoginPage(a0);
|
|
1141
|
+
if (own) return reply.redirect(`/a/${a0.alias}/${own}${req.query.next ? `?next=${encodeURIComponent(safeNext(a0, req.query.next))}` : ''}`);
|
|
1138
1142
|
const session = await getSession(req, reply, a0.id, `/a/${a0.alias}`);
|
|
1139
1143
|
const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
|
|
1140
1144
|
const body = await loginPage(a, locale, session, safeNext(a, req.query.next));
|
|
@@ -1271,7 +1275,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1271
1275
|
});
|
|
1272
1276
|
|
|
1273
1277
|
/** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
|
|
1274
|
-
const finishSso =
|
|
1278
|
+
const finishSso = (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) =>
|
|
1279
|
+
withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
|
|
1280
|
+
const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
|
|
1275
1281
|
const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
|
|
1276
1282
|
const loaded = alias ? await appWithLocale(req, alias) : undefined;
|
|
1277
1283
|
if (!loaded || loaded.app.authentication !== 'app_users' || !loaded.app.sso_providers.includes(p.name)) return simplePage(reply, 403, english('login.sso_failed'), english('login.method_unavailable'));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { runtime } from '../db.ts';
|
|
2
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
3
|
+
import type { App } from '../metadata.ts';
|
|
4
|
+
import { takeFlash, type Session } from '../session.ts';
|
|
5
|
+
import { enabledProviders } from '../sso.ts';
|
|
6
|
+
import type { PageContext } from './context.ts';
|
|
7
|
+
import type { Locale } from './locale.ts';
|
|
8
|
+
import { languagePicker } from './render.ts';
|
|
9
|
+
|
|
10
|
+
// The sign-in form: the identity providers' buttons, user name and password,
|
|
11
|
+
// "Keep me signed in" and "Forgot password?". The built-in sign-in page
|
|
12
|
+
// (routes.ts) and the "login" region of an application's own login page
|
|
13
|
+
// (migration 087, meta.app.login_page) both show it; it always posts to
|
|
14
|
+
// /a/<alias>/login, whose checks (CSRF, throttling, every sign-in method)
|
|
15
|
+
// are the same either way.
|
|
16
|
+
//
|
|
17
|
+
// On a login page the form's fields sit inside the page's own form, so they
|
|
18
|
+
// name a separate, detached <form> with the form attribute (formId).
|
|
19
|
+
|
|
20
|
+
/** Where to go after signing in: a path of this application, else its home page. */
|
|
21
|
+
export const safeNext = (app: App, next: string | undefined) =>
|
|
22
|
+
next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') ? next : `/a/${app.alias}/${app.home_page}`;
|
|
23
|
+
|
|
24
|
+
/** "Forgot password?" is offered: turned on, app users with passwords, an enabled mail service. */
|
|
25
|
+
export async function passwordResetAvailable(app: App) {
|
|
26
|
+
if (!app.password_reset) return false;
|
|
27
|
+
return !!(await runtime.one<{ ok: boolean }>('select meta.password_reset_available($1) as ok', [app.id]))?.ok;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A failed sign-in on an application's own login page: the message and user name, shown once. */
|
|
31
|
+
export const LOGIN_ERROR = '__LOGIN_ERROR';
|
|
32
|
+
export const LOGIN_USER = '__LOGIN_USER';
|
|
33
|
+
|
|
34
|
+
export async function signInForm(
|
|
35
|
+
app: App, locale: Locale, session: Session, next: string,
|
|
36
|
+
o: { error?: string; username?: string; formId?: string } = {},
|
|
37
|
+
): Promise<Raw> {
|
|
38
|
+
const t = locale.t;
|
|
39
|
+
// database accounts and custom authentication: the user name and password form only
|
|
40
|
+
const dbAuth = app.authentication === 'database' || app.authentication === 'custom';
|
|
41
|
+
const local = dbAuth || app.local_login;
|
|
42
|
+
const remember = dbAuth ? null : app.remember_me_days;
|
|
43
|
+
const providers = dbAuth ? [] : await enabledProviders(app.sso_providers ?? []);
|
|
44
|
+
const nextQs = next ? `?next=${encodeURIComponent(next)}` : '';
|
|
45
|
+
const forgot = !dbAuth && local && (await passwordResetAvailable(app));
|
|
46
|
+
const fa = o.formId ? raw(` form="${o.formId}"`) : '';
|
|
47
|
+
const fields = html`<input type="hidden" name="__csrf" value="${session.csrf_token}"${fa}>
|
|
48
|
+
<input type="hidden" name="next" value="${next}"${fa}>
|
|
49
|
+
${app.time_zone_auto ? html`<input type="hidden" name="__tz" value=""${fa}>` : ''}
|
|
50
|
+
<div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${o.username ?? ''}" autocomplete="username" autofocus required maxlength="100"${fa}></div>
|
|
51
|
+
<div class="field"><label class="label" for="password">${t('login.password')}</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="200"${fa}></div>
|
|
52
|
+
${remember ? html`<label class="check"><input type="checkbox" name="remember" value="true"${fa}> ${t('login.remember', { days: remember })}</label>` : ''}
|
|
53
|
+
<button class="btn btn-hot"${fa}>${t('login.submit')}</button>
|
|
54
|
+
${forgot ? html`<a class="login-forgot" href="/a/${app.alias}/password/forgot">${t('reset.link')}</a>` : ''}`;
|
|
55
|
+
return html`${o.error ? html`<div class="alert alert-error" role="alert">${o.error}</div>` : ''}
|
|
56
|
+
${((f) => (f ? html`<div class="alert alert-success" role="status">${f}</div>` : ''))(takeFlash(session))}
|
|
57
|
+
${providers.length
|
|
58
|
+
? html`<div class="sso-buttons">${providers.map(
|
|
59
|
+
(pr) => html`<a class="btn${local ? '' : ' btn-hot'}" href="/a/${app.alias}/sso/${pr.name}${nextQs}">${t('login.with', { provider: pr.display_name })}</a>`,
|
|
60
|
+
)}</div>${local ? html`<div class="or" role="separator"><span>${t('login.or')}</span></div>` : ''}`
|
|
61
|
+
: ''}
|
|
62
|
+
${local
|
|
63
|
+
? o.formId ? html`<div class="login-form">${fields}</div>` : html`<form method="post" action="/a/${app.alias}/login" class="login-form">${fields}</form>`
|
|
64
|
+
: providers.length ? '' : html`<p>${t('login.none')}</p>`}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The "login" region of an application's own login page. */
|
|
68
|
+
export async function renderLoginRegion(ctx: PageContext): Promise<Raw> {
|
|
69
|
+
const s = ctx.session.state;
|
|
70
|
+
const error = s[LOGIN_ERROR] ?? undefined;
|
|
71
|
+
const username = s[LOGIN_USER] ?? undefined;
|
|
72
|
+
delete s[LOGIN_ERROR];
|
|
73
|
+
delete s[LOGIN_USER];
|
|
74
|
+
const formId = 'pgkiln-login';
|
|
75
|
+
ctx.detached.push(html`<form id="${formId}" method="post" action="/a/${ctx.app.alias}/login"></form>`);
|
|
76
|
+
const next = safeNext(ctx.app, ctx.params.get('next') ?? undefined);
|
|
77
|
+
const here = `${ctx.base}/${ctx.page.page_no}${ctx.params.get('next') ? `?next=${encodeURIComponent(next)}` : ''}`;
|
|
78
|
+
return html`<div class="login-region">${await signInForm(ctx.app, ctx.locale, ctx.session, next, { error, username, formId })}
|
|
79
|
+
${languagePicker(ctx.app, ctx.locale, here)}</div>`;
|
|
80
|
+
}
|
package/src/sampledata.ts
CHANGED
|
@@ -169,10 +169,10 @@ type Db = pg.ClientBase | pg.Pool;
|
|
|
169
169
|
/** Schemas with at least one ordinary table, the allowed ones only. */
|
|
170
170
|
export async function schemas(db: Db) {
|
|
171
171
|
return (
|
|
172
|
-
await db.query<{ name: string; tables: number }>(
|
|
173
|
-
`select n.nspname as name, count(*)::int as tables from pg_namespace n join pg_class c on c.relnamespace = n.oid
|
|
172
|
+
await db.query<{ name: string; tables: number; usable: boolean }>(
|
|
173
|
+
`select n.nspname as name, count(*)::int as tables, has_schema_privilege(n.oid, 'USAGE') as usable from pg_namespace n join pg_class c on c.relnamespace = n.oid
|
|
174
174
|
where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname not in ('meta', 'information_schema')
|
|
175
|
-
group by n.nspname order by n.nspname`,
|
|
175
|
+
group by n.oid, n.nspname order by n.nspname`,
|
|
176
176
|
)
|
|
177
177
|
).rows;
|
|
178
178
|
}
|
package/src/session.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
-
import { runtime } from './db.ts';
|
|
2
|
+
import { runtime, sharedRuntime } from './db.ts';
|
|
3
|
+
import { runtimeForApp } from './workspace-db.ts';
|
|
3
4
|
import { hashToken, loginMaxFailuresPerIp, loginMaxFailuresPerUser, loginWindowMinutes, newToken } from './security.ts';
|
|
4
5
|
import { instanceSetting } from './instance.ts';
|
|
5
6
|
|
|
@@ -96,7 +97,9 @@ export async function logActivity(entry: {
|
|
|
96
97
|
detail?: string;
|
|
97
98
|
}) {
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
+
// (090) on the connection of the entry's application (an isolated workspace's own); without one, the shared one
|
|
101
|
+
const db = entry.appId != null ? await runtimeForApp(entry.appId) : sharedRuntime;
|
|
102
|
+
const r = await db.one<{ id: string }>(
|
|
100
103
|
`insert into meta.activity_log (app_id, page_no, username, event, ip, elapsed_ms, detail)
|
|
101
104
|
values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
102
105
|
[entry.appId ?? null, entry.pageNo ?? null, entry.username ?? null, entry.event, entry.ip ?? null, entry.elapsedMs ?? null, entry.detail?.slice(0, 2000) ?? null],
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { root } from './env.ts';
|
|
4
|
+
|
|
5
|
+
// The version of pgkiln's own static files (app.css, app.js, icons.svg, sw.js): a hash of
|
|
6
|
+
// their contents, in their URLs (?v=…) and in each application's service worker. The files'
|
|
7
|
+
// sizes and modification times are looked at again at most once a second, so a changed file
|
|
8
|
+
// gets a new URL and a new service worker even when the server wasn't restarted; a browser
|
|
9
|
+
// (or a service worker cache) never keeps an old copy under the new URL.
|
|
10
|
+
|
|
11
|
+
const FILES = ['app.css', 'app.js', 'icons.svg', 'sw.js'].map((f) => join(root, 'public', f));
|
|
12
|
+
let checked = 0;
|
|
13
|
+
let stamp = '';
|
|
14
|
+
let version = '';
|
|
15
|
+
|
|
16
|
+
export function staticVersion() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
if (version && now - checked < 1000) return version;
|
|
19
|
+
checked = now;
|
|
20
|
+
const s = FILES.map((f) => {
|
|
21
|
+
const st = statSync(f);
|
|
22
|
+
return `${st.size}:${st.mtimeMs}`;
|
|
23
|
+
}).join('|');
|
|
24
|
+
if (s !== stamp) {
|
|
25
|
+
let h = 0;
|
|
26
|
+
for (const f of FILES) for (const ch of readFileSync(f, 'utf8')) h = (Math.imul(h, 31) + ch.charCodeAt(0)) | 0;
|
|
27
|
+
stamp = s;
|
|
28
|
+
version = (h >>> 0).toString(36);
|
|
29
|
+
}
|
|
30
|
+
return version;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** /static/<file>?v=<version> for app.css, app.js and icons.svg. */
|
|
34
|
+
export const staticUrl = (file: 'app.css' | 'app.js' | 'icons.svg') => `/static/${file}?v=${staticVersion()}`;
|
package/src/websources.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { owner, runtime } from './db.ts';
|
|
4
|
+
import { runtimeForApp } from './workspace-db.ts';
|
|
4
5
|
import { decryptSecret, encryptSecret } from './secrets.ts';
|
|
5
6
|
import { WebError, webRequest, type WebResponse } from './webclient.ts';
|
|
6
7
|
|
|
@@ -372,7 +373,8 @@ export const withRest = (rowsQuery: string, sql: string | null | undefined) =>
|
|
|
372
373
|
// ---------------------------------------------------------------- loading
|
|
373
374
|
|
|
374
375
|
export async function loadSource(appId: number, name: string) {
|
|
375
|
-
|
|
376
|
+
// the application's own connection (an isolated workspace's), also from a workflow or job (090)
|
|
377
|
+
const s = await (await runtimeForApp(appId)).one<RestSource>(
|
|
376
378
|
`select id, app_id, name, url, method, credential, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
|
|
377
379
|
from meta.rest_source where app_id = $1 and name = $2`,
|
|
378
380
|
[appId, name.toUpperCase()],
|