pgkiln 0.52.0 → 0.53.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/README.md +1 -1
- package/SECURITY.md +5 -1
- package/db/migrations/125_app_change.sql +23 -0
- package/db/migrations/126_mail_queue_network_access.sql +119 -0
- package/docs/apex-feature-parity.md +40 -11
- package/docs/guide/01-installation.md +1 -1
- package/docs/guide/03-builder.md +76 -3
- package/docs/guide/06-processing.md +6 -1
- package/docs/guide/08-security.md +55 -0
- package/docs/guide/09-reference.md +3 -1
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +43 -8
- package/docs/guide/14-globalization.md +49 -1
- package/docs/guide/18-cli.md +21 -1
- package/docs/guide/19-rest-data-sources.md +12 -0
- package/docs/guide/20-ai-agents.md +8 -2
- package/examples/hr/hr_70_data_security.sql +79 -0
- package/package.json +1 -1
- package/public/app.css +97 -23
- package/public/builder.css +50 -0
- package/public/builder.js +4 -0
- package/src/app.ts +9 -0
- package/src/builder/app-updates.ts +55 -0
- package/src/builder/data-security.ts +182 -0
- package/src/builder/home.ts +16 -5
- package/src/builder/mail-queue.ts +212 -0
- package/src/builder/mail.ts +72 -45
- package/src/builder/network-access.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +1 -1
- package/src/builder/rest-catalogs.ts +19 -4
- package/src/builder/sql.ts +2 -0
- package/src/builder/themeroller.ts +47 -6
- package/src/builder/ui.ts +5 -0
- package/src/builder/websources.ts +1 -1
- package/src/builder/workspace-mail.ts +121 -0
- package/src/builder/workspaces.ts +1 -0
- package/src/cli/main.ts +25 -3
- package/src/cli/mcp.ts +40 -4
- package/src/cli/only.ts +52 -0
- package/src/cli/replace.ts +5 -1
- package/src/datasec.ts +343 -0
- package/src/mail/mail.ts +50 -11
- package/src/mail/providers.ts +6 -3
- package/src/metadata.ts +2 -0
- package/src/network-acl.ts +126 -0
- package/src/objectstore.ts +3 -0
- package/src/runtime/engine.ts +3 -1
- package/src/runtime/render.ts +4 -2
- package/src/runtime/styles.ts +54 -12
- package/src/webclient.ts +45 -4
- package/src/webrequests.ts +5 -0
- package/src/websources.ts +30 -12
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import dns from 'node:dns/promises';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { owner } from './db.ts';
|
|
4
|
+
import { isPrivateAddress, matches, restHosts, scopeProblem, urlProblem, type OutboundScope } from './webclient.ts';
|
|
5
|
+
|
|
6
|
+
// (sprint 118, WS-9; APEX 26.2 workspace network ACLs) A workspace's network
|
|
7
|
+
// access list: when meta.workspace.network_restricted is on, the outgoing web
|
|
8
|
+
// calls made for its applications must match one of meta.workspace.network_hosts
|
|
9
|
+
// as well as the installation's lists (PGKILN_REST_ALLOWED_HOSTS /
|
|
10
|
+
// PGKILN_REST_PRIVATE_HOSTS). It only narrows: the installation's checks
|
|
11
|
+
// (allow-list, private addresses at connect time, redirects) stay as they are.
|
|
12
|
+
//
|
|
13
|
+
// Every caller of src/webclient.ts webRequest() passes a scope built here:
|
|
14
|
+
// websources.ts call() (meta.web_request, REST data sources, write-back,
|
|
15
|
+
// invoke API in pages, workflows and automations, the builder's Test), its
|
|
16
|
+
// token requests, objectstore.ts and the REST source catalogs' "read from a
|
|
17
|
+
// URL". A workspace's own mail service checks its SMTP host with
|
|
18
|
+
// mailHostAddress(). Refusals go to meta.network_refusal (7 days).
|
|
19
|
+
//
|
|
20
|
+
// No cache: one indexed query per call, so a narrowed list applies at once on
|
|
21
|
+
// every server.
|
|
22
|
+
|
|
23
|
+
export const KEEP_DAYS = 7;
|
|
24
|
+
/** At most this many refusals are kept per workspace. */
|
|
25
|
+
const KEEP_PER_WORKSPACE = 2000;
|
|
26
|
+
|
|
27
|
+
/** Entries of a network access list: host, *.domain, host:port, [IPv6]:port. */
|
|
28
|
+
export const HOST_ENTRY = /^(?:(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*|\[[0-9a-f:.]{2,45}\])(?::([1-9][0-9]{0,4}))?$/;
|
|
29
|
+
|
|
30
|
+
/** The entries of a typed list (comma, space or line separated), checked; throws with a message. */
|
|
31
|
+
export function parseHostList(text: string): string[] {
|
|
32
|
+
const entries = [...new Set(text.split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
const m = HOST_ENTRY.exec(e);
|
|
35
|
+
if (!m || (m[1] && Number(m[1]) > 65535))
|
|
36
|
+
throw new Error(`"${e.slice(0, 80)}" is not a host: use api.example.com, *.example.com or api.example.com:8443 (no scheme, no path).`);
|
|
37
|
+
}
|
|
38
|
+
if (entries.length > 200) throw new Error('At most 200 entries.');
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface WorkspaceRow {
|
|
43
|
+
id: number;
|
|
44
|
+
name: string;
|
|
45
|
+
network_restricted: boolean;
|
|
46
|
+
network_hosts: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scopeOf(w: WorkspaceRow, appId: number | null, what: string): OutboundScope {
|
|
50
|
+
return {
|
|
51
|
+
workspaceId: w.id,
|
|
52
|
+
workspaceName: w.name,
|
|
53
|
+
appId,
|
|
54
|
+
what,
|
|
55
|
+
hosts: w.network_restricted ? w.network_hosts : null,
|
|
56
|
+
// a method: a copy of the scope with another "what" (a token endpoint) reports under its own name
|
|
57
|
+
refused(url, list, message) {
|
|
58
|
+
void recordRefusal(this.workspaceId, this.appId, this.what, url, list, message);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The scope of an application's outgoing calls (its workspace's list); null for no application. */
|
|
64
|
+
export async function scopeForApp(appId: number | null | undefined, what: string): Promise<OutboundScope | null> {
|
|
65
|
+
if (appId === null || appId === undefined || !Number.isInteger(Number(appId))) return null;
|
|
66
|
+
const w = await owner.one<WorkspaceRow>(
|
|
67
|
+
'select w.id, w.name, w.network_restricted, w.network_hosts from meta.workspace w where w.id = meta.app_workspace($1)', [appId]);
|
|
68
|
+
return w ? scopeOf(w, Number(appId), what) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The scope of a workspace's own outgoing calls (no application: REST source catalogs, its mail service). */
|
|
72
|
+
export async function scopeForWorkspace(workspaceId: number, what: string): Promise<OutboundScope | null> {
|
|
73
|
+
const w = await owner.one<WorkspaceRow>('select id, name, network_restricted, network_hosts from meta.workspace where id = $1', [workspaceId]);
|
|
74
|
+
return w ? scopeOf(w, null, what) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Keep a refusal (never throws: a refusal is already an error for the caller). */
|
|
78
|
+
export async function recordRefusal(workspaceId: number | null, appId: number | null, what: string, url: URL, list: 'installation' | 'workspace', message: string) {
|
|
79
|
+
try {
|
|
80
|
+
await owner.query(
|
|
81
|
+
`insert into meta.network_refusal (workspace_id, app_id, what, host, url, list, message) values ($1, $2, $3, $4, $5, $6, $7)`,
|
|
82
|
+
[workspaceId, appId, what.slice(0, 100), url.host.slice(0, 300), `${url.protocol}//${url.host}${url.pathname}`.slice(0, 1000), list, message.slice(0, 1000)]);
|
|
83
|
+
} catch {
|
|
84
|
+
// an application deleted meanwhile, or the database is away: nothing to keep
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Forget old refusals (the scheduler's pass, with the web requests' purge). */
|
|
89
|
+
export async function purgeRefusals() {
|
|
90
|
+
await owner.query(`delete from meta.network_refusal where at < now() - make_interval(days => ${KEEP_DAYS})`);
|
|
91
|
+
await owner.query(
|
|
92
|
+
`delete from meta.network_refusal r using (
|
|
93
|
+
select id, row_number() over (partition by workspace_id order by id desc) as n from meta.network_refusal
|
|
94
|
+
) x where r.id = x.id and x.n > ${KEEP_PER_WORKSPACE}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where a workspace's own SMTP service may connect: the host must pass the
|
|
99
|
+
* installation's lists and the workspace's, and it is resolved here once; the
|
|
100
|
+
* connection then goes to the checked address (no DNS rebinding), with the
|
|
101
|
+
* host name kept for TLS. Throws an Error with the reason.
|
|
102
|
+
*/
|
|
103
|
+
export async function mailHostAddress(scope: OutboundScope, host: string, port: number): Promise<{ address: string; servername: string | undefined }> {
|
|
104
|
+
let url: URL;
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(`https://${net.isIPv6(host) ? `[${host}]` : host}:${port}/`);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`The SMTP host ${host} is not valid.`);
|
|
109
|
+
}
|
|
110
|
+
const hosts = restHosts();
|
|
111
|
+
const problem = urlProblem(url, hosts) ?? scopeProblem(scope, url);
|
|
112
|
+
if (problem) {
|
|
113
|
+
scope.refused?.(url, urlProblem(url, hosts) ? 'installation' : 'workspace', problem);
|
|
114
|
+
throw new Error(problem);
|
|
115
|
+
}
|
|
116
|
+
const bare = url.hostname.replace(/^\[|\]$/g, '');
|
|
117
|
+
const addresses = net.isIP(bare) ? [{ address: bare }] : await dns.lookup(bare, { all: true }).catch(() => []);
|
|
118
|
+
if (!addresses.length) throw new Error(`The SMTP host ${host} has no address.`);
|
|
119
|
+
const priv = matches(hosts.private, url);
|
|
120
|
+
if (!priv && addresses.some((a) => isPrivateAddress(a.address))) {
|
|
121
|
+
const msg = `The SMTP host ${host} resolves to a private, loopback or link-local address (allow it with PGKILN_REST_PRIVATE_HOSTS).`;
|
|
122
|
+
scope.refused?.(url, 'installation', msg);
|
|
123
|
+
throw new Error(msg);
|
|
124
|
+
}
|
|
125
|
+
return { address: addresses[0].address, servername: net.isIP(bare) ? undefined : bare };
|
|
126
|
+
}
|
package/src/objectstore.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
|
11
11
|
import { decryptSecret } from './secrets.ts';
|
|
12
12
|
import { WebError, webRequest } from './webclient.ts';
|
|
13
|
+
import { scopeForApp } from './network-acl.ts';
|
|
13
14
|
import { credentialValidFor, loadCredential } from './websources.ts';
|
|
14
15
|
|
|
15
16
|
export interface ObjectStoreConfig {
|
|
@@ -113,6 +114,8 @@ async function send(appId: number, o: ObjectStoreConfig, method: 'PUT' | 'GET' |
|
|
|
113
114
|
};
|
|
114
115
|
const authorization = signV4({ method, url, headers, payloadHash, accessKey: cred.username!, secretKey: decryptSecret(cred.secret_enc!), region: cred.scope || 'us-east-1', amzDate });
|
|
115
116
|
const res = await webRequest(url.toString(), {
|
|
117
|
+
// (sprint 118) the workspace's network access list
|
|
118
|
+
scope: await scopeForApp(appId, `object storage (web credential ${cred.name})`),
|
|
116
119
|
method,
|
|
117
120
|
headers: { ...Object.fromEntries(Object.entries(headers).filter(([k]) => k !== 'host')), authorization, 'accept-encoding': 'identity' },
|
|
118
121
|
secretHeaders: ['authorization'],
|
package/src/runtime/engine.ts
CHANGED
|
@@ -560,8 +560,10 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
|
|
|
560
560
|
default:
|
|
561
561
|
await runSql(ctx, p.code ?? '', names);
|
|
562
562
|
// web requests the process queued (meta.web_request): made now, before the next process
|
|
563
|
+
// (sprint 118) a request an allow-list refused is a warning (level 2), the others trace (level 6)
|
|
563
564
|
await runPending(ctx.client!, ctx.app.id, (r, res, ms) =>
|
|
564
|
-
dbg(ctx,
|
|
565
|
+
dbg(ctx, res.status === 'error' && /allow-list|network access list/.test(res.message ?? '') ? 2 : 6, 'web',
|
|
566
|
+
() => `web request ${r.id} (${r.source ? `source ${r.source}` : r.method}): ${res.status === 'ok' ? `HTTP ${res.statusCode}` : res.message} in ${ms} ms`));
|
|
565
567
|
// AI requests it queued (meta.ai_generate): likewise
|
|
566
568
|
await runPendingAi(ctx.client!, { appId: ctx.app.id, pageNo: ctx.page.page_no, user: ctx.user, debug: (level, text) => dbg(ctx, level, 'ai', text) });
|
|
567
569
|
return p.success_message;
|
package/src/runtime/render.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { pushBody } from './push.ts';
|
|
|
5
5
|
import { mapHead } from './maps.ts';
|
|
6
6
|
import { staticHead } from './static-files.ts';
|
|
7
7
|
import { pluginAttributes, pluginOf } from './plugins.ts';
|
|
8
|
-
import { appStyles, chosenStyle, chosenStyleName, conditionalStyles, styleChoice, themeCss } from './styles.ts';
|
|
8
|
+
import { appStyles, chosenStyle, chosenStyleName, conditionalStyles, navAppearance, styleChoice, themeCss } from './styles.ts';
|
|
9
9
|
import { html, raw, type Raw } from '../html.ts';
|
|
10
10
|
import { icon } from '../icons.ts';
|
|
11
11
|
import { documentShell } from '../layout.ts';
|
|
@@ -260,6 +260,8 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
260
260
|
|
|
261
261
|
const signedIn = ctx.user !== 'nobody';
|
|
262
262
|
const topNav = ctx.app.theme?.nav === 'top';
|
|
263
|
+
// (sprint 117, APEX 26.2) Pillar Light / Pillar Dark: classes on <body> from a fixed list (styles.ts)
|
|
264
|
+
const appearance = navAppearance(ctx.app.theme, chosenStyle(ctx.app, ctx.session, ctx.styleByCondition));
|
|
263
265
|
const nav = await navTree(ctx, topNav);
|
|
264
266
|
const listNodes = ctx.app.navbar_list ? await listTree(ctx, ctx.app.navbar_list) : null;
|
|
265
267
|
// (084) the Feedback entry, after the application's own navigation bar list
|
|
@@ -311,7 +313,7 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
311
313
|
<footer class="t-footer">${ctx.app.version ? html`<span class="t-version">${ctx.locale.tr(ctx.app.version)}</span>` : ''}<a class="t-top" href="#top">${icon('arrow-up')} ${t('common.back_to_top')}</a></footer></main>
|
|
312
314
|
</div>
|
|
313
315
|
${devbarHtml(ctx)}`,
|
|
314
|
-
`t-app${topNav ? ' nav-top' : ''}`,
|
|
316
|
+
`t-app${topNav ? ' nav-top' : ''}${appearance ? ` nav-pillar nav-${appearance === 'pillar_dark' ? 'pillar-dark' : 'pillar-light'}` : ''}`,
|
|
315
317
|
{ 'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user), ...(await pushBody(ctx.app, ctx.session.username)) },
|
|
316
318
|
html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
|
|
317
319
|
root,
|
package/src/runtime/styles.ts
CHANGED
|
@@ -69,6 +69,22 @@ export const RADII: Record<string, { label: string; css: string }> = {
|
|
|
69
69
|
large: { label: 'Large (14 px)', css: '14px' },
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* (sprint 117, APEX 26.2) Navigation appearances: how the header and the
|
|
74
|
+
* navigation menu look, on top of the base style. Pillar Light: the header
|
|
75
|
+
* and menu on the page's own surfaces; Pillar Dark: header and menu as one
|
|
76
|
+
* dark column. Both carry the header pillar strip (theme.strip / a style's
|
|
77
|
+
* strip, else the accent) and a filled "pill" for the current page. The
|
|
78
|
+
* page's <body> gets the classes nav-pillar and nav-pillar-light / -dark.
|
|
79
|
+
*/
|
|
80
|
+
export const NAV_APPEARANCES: Record<string, { label: string }> = {
|
|
81
|
+
pillar_light: { label: 'Pillar Light' },
|
|
82
|
+
pillar_dark: { label: 'Pillar Dark' },
|
|
83
|
+
};
|
|
84
|
+
export type NavAppearance = 'pillar_light' | 'pillar_dark';
|
|
85
|
+
/** A style's appearance "default": the base style's own look, whatever the application's appearance. */
|
|
86
|
+
export const APPEARANCE_DEFAULT = 'default';
|
|
87
|
+
|
|
72
88
|
export interface StyleVariant {
|
|
73
89
|
name: string;
|
|
74
90
|
accent?: string;
|
|
@@ -76,6 +92,11 @@ export interface StyleVariant {
|
|
|
76
92
|
/** (0.29) the colours in dark mode (else dark mode keeps the base style's dark palette) */
|
|
77
93
|
accent_dark?: string;
|
|
78
94
|
header_dark?: string;
|
|
95
|
+
/** (sprint 117) the navigation appearance (a key of NAV_APPEARANCES, or 'default'; absent: as the application) */
|
|
96
|
+
appearance?: string;
|
|
97
|
+
/** (sprint 117) the header pillar strip's colour, light and dark mode (absent: the accent) */
|
|
98
|
+
strip?: string;
|
|
99
|
+
strip_dark?: string;
|
|
79
100
|
font?: string;
|
|
80
101
|
font_size?: string;
|
|
81
102
|
radius?: string;
|
|
@@ -93,8 +114,11 @@ export function parseStyle(v: Record<string, unknown>): StyleVariant | string {
|
|
|
93
114
|
const name = typeof v.name === 'string' ? v.name.trim() : '';
|
|
94
115
|
if (!STYLE_NAME.test(name)) return 'Name: 1 to 30 letters, digits, spaces, - or _, starting with a letter or digit.';
|
|
95
116
|
const out: StyleVariant = { name };
|
|
96
|
-
const colourLabels = {
|
|
97
|
-
|
|
117
|
+
const colourLabels = {
|
|
118
|
+
accent: 'Accent colour', header: 'Header colour', accent_dark: 'Accent colour (dark mode)', header_dark: 'Header colour (dark mode)',
|
|
119
|
+
strip: 'Header strip colour', strip_dark: 'Header strip colour (dark mode)',
|
|
120
|
+
};
|
|
121
|
+
for (const k of ['accent', 'header', 'accent_dark', 'header_dark', 'strip', 'strip_dark'] as const) {
|
|
98
122
|
const x = v[k];
|
|
99
123
|
if (x === undefined || x === null || x === '') continue;
|
|
100
124
|
if (typeof x === 'string' && ITEM_REF.test(x.trim().toUpperCase())) {
|
|
@@ -110,6 +134,11 @@ export function parseStyle(v: Record<string, unknown>): StyleVariant | string {
|
|
|
110
134
|
if (!own(list, x)) return `${label}: choose from ${Object.keys(list).join(', ')}.`;
|
|
111
135
|
out[k] = x as string;
|
|
112
136
|
}
|
|
137
|
+
const ap = v.appearance;
|
|
138
|
+
if (ap !== undefined && ap !== null && ap !== '') {
|
|
139
|
+
if (ap !== APPEARANCE_DEFAULT && !own(NAV_APPEARANCES, ap)) return `Navigation appearance: choose from ${APPEARANCE_DEFAULT}, ${Object.keys(NAV_APPEARANCES).join(', ')}.`;
|
|
140
|
+
out.appearance = ap as string;
|
|
141
|
+
}
|
|
113
142
|
if (typeof v.condition === 'string' && v.condition.trim()) {
|
|
114
143
|
if (v.condition.length > MAX_CONDITION) return `Condition: at most ${MAX_CONDITION} characters.`;
|
|
115
144
|
out.condition = v.condition.trim();
|
|
@@ -162,35 +191,48 @@ export const choosable = (app: Pick<App, 'theme'>, name: unknown): name is strin
|
|
|
162
191
|
/** An item's value for &ITEM. colours (session state), when the CSS is made. */
|
|
163
192
|
export type ItemValue = (name: string) => string | undefined;
|
|
164
193
|
|
|
165
|
-
const colourVars = (accent?:
|
|
194
|
+
const colourVars = (accent?: unknown, header?: unknown, item?: ItemValue, strip?: unknown) => {
|
|
166
195
|
// an item reference becomes the item's value, used only when it is a #rrggbb colour
|
|
167
|
-
const value = (c?:
|
|
168
|
-
|
|
196
|
+
const value = (c?: unknown) => {
|
|
197
|
+
if (typeof c !== 'string') return undefined;
|
|
198
|
+
const ref = ITEM_REF.exec(c);
|
|
169
199
|
return ref ? item?.(ref[1])?.trim() : c;
|
|
170
200
|
};
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
const a = value(accent);
|
|
202
|
+
const h = value(header);
|
|
203
|
+
const st = value(strip);
|
|
173
204
|
const vars: string[] = [];
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
205
|
+
if (a && HEX.test(a)) vars.push(`--accent:${a};--accent-soft:color-mix(in srgb, ${a} 14%, var(--surface));`);
|
|
206
|
+
if (h && HEX.test(h)) vars.push(`--header:${h};`);
|
|
207
|
+
// (sprint 117) the header pillar strip
|
|
208
|
+
if (st && HEX.test(st)) vars.push(`--strip:${st};`);
|
|
176
209
|
return vars;
|
|
177
210
|
};
|
|
178
211
|
|
|
212
|
+
/**
|
|
213
|
+
* (sprint 117) The navigation appearance for this request: the style's own
|
|
214
|
+
* (or 'default' for none), else the application's (theme.appearance).
|
|
215
|
+
*/
|
|
216
|
+
export function navAppearance(theme: App['theme'] | undefined, style: StyleVariant | null): NavAppearance | null {
|
|
217
|
+
const v = style?.appearance ?? theme?.appearance;
|
|
218
|
+
return typeof v === 'string' && v !== APPEARANCE_DEFAULT && own(NAV_APPEARANCES, v) ? (v as NavAppearance) : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
179
221
|
/**
|
|
180
222
|
* The theme's CSS: the base colours (Settings → Theme), then the style
|
|
181
223
|
* variant in use. Only checked hex values and constants from the lists above.
|
|
182
224
|
*/
|
|
183
225
|
export function themeCss(theme: App['theme'] | undefined, style: StyleVariant | null, item?: ItemValue): string {
|
|
184
226
|
const out: string[] = [];
|
|
185
|
-
const base = colourVars(theme?.accent, theme?.header);
|
|
227
|
+
const base = colourVars(theme?.accent, theme?.header, undefined, theme?.strip);
|
|
186
228
|
// html:root ties with app.css's html[data-style] rules and comes later, so own colours win over a base style's
|
|
187
229
|
if (base.length) out.push(`html:root{${base.join('')}}`);
|
|
188
230
|
// dark mode: the same specificity as app.css's dark palettes (html[data-style]…), later in the page
|
|
189
|
-
const dark = [...colourVars(theme?.accent_dark, theme?.header_dark), ...(style ? colourVars(style.accent_dark, style.header_dark, item) : [])];
|
|
231
|
+
const dark = [...colourVars(theme?.accent_dark, theme?.header_dark, undefined, theme?.strip_dark), ...(style ? colourVars(style.accent_dark, style.header_dark, item, style.strip_dark) : [])];
|
|
190
232
|
if (dark.length)
|
|
191
233
|
out.push(`@media (prefers-color-scheme: dark){html:root:not([data-theme="light"]){${dark.join('')}}}html:root[data-theme="dark"]{${dark.join('')}}`);
|
|
192
234
|
if (style) {
|
|
193
|
-
const colours = colourVars(style.accent, style.header, item);
|
|
235
|
+
const colours = colourVars(style.accent, style.header, item, style.strip);
|
|
194
236
|
if (colours.length) out.push(`html:root{${colours.join('')}}`);
|
|
195
237
|
const all: string[] = [];
|
|
196
238
|
if (style.font && own(FONTS, style.font)) all.push(`--font:${FONTS[style.font].css};`);
|
package/src/webclient.ts
CHANGED
|
@@ -21,7 +21,12 @@ import zlib from 'node:zlib';
|
|
|
21
21
|
// checks, and request headers marked secret are dropped when a redirect
|
|
22
22
|
// leaves the origin;
|
|
23
23
|
// - a time limit for the whole exchange and a size limit for the response
|
|
24
|
-
// (PGKILN_REST_MAX_BYTES, default 5 MB, also after decompression)
|
|
24
|
+
// (PGKILN_REST_MAX_BYTES, default 5 MB, also after decompression);
|
|
25
|
+
// - (sprint 118) a workspace's network access list (src/network-acl.ts):
|
|
26
|
+
// when the caller passes the scope of the application's workspace, every
|
|
27
|
+
// hop must match that list as well; it only narrows the lists above (the
|
|
28
|
+
// address checks keep using PGKILN_REST_PRIVATE_HOSTS alone). Refusals are
|
|
29
|
+
// reported to the scope (Administration → Network access).
|
|
25
30
|
//
|
|
26
31
|
// node:http/https rather than fetch(): fetch can't pin the checked address.
|
|
27
32
|
|
|
@@ -45,6 +50,21 @@ export interface WebRequest {
|
|
|
45
50
|
allowLarge?: boolean;
|
|
46
51
|
/** an allow-list of its own instead of PGKILN_REST_ALLOWED_HOSTS / PGKILN_REST_PRIVATE_HOSTS (push services) */
|
|
47
52
|
hosts?: HostLists;
|
|
53
|
+
/** (sprint 118) the workspace the call is made for: its network access list narrows the lists above */
|
|
54
|
+
scope?: OutboundScope | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** (sprint 118) The workspace an outgoing call is made for (src/network-acl.ts builds it). */
|
|
58
|
+
export interface OutboundScope {
|
|
59
|
+
workspaceId: number;
|
|
60
|
+
workspaceName: string;
|
|
61
|
+
appId: number | null;
|
|
62
|
+
/** what makes the call: "web request", "REST data source", … */
|
|
63
|
+
what: string;
|
|
64
|
+
/** the workspace's network access list; null = not restricted (the installation's lists only) */
|
|
65
|
+
hosts: string[] | null;
|
|
66
|
+
/** told about every refused URL (never throws; called as a method of the scope) */
|
|
67
|
+
refused?(url: URL, list: 'installation' | 'workspace', message: string): void;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
export interface HostLists {
|
|
@@ -68,7 +88,7 @@ export const maxResponseBytes = () => {
|
|
|
68
88
|
};
|
|
69
89
|
|
|
70
90
|
/** Whether `url`'s host matches an allow-list entry. */
|
|
71
|
-
function matches(entries: string[], url: URL) {
|
|
91
|
+
export function matches(entries: string[], url: URL) {
|
|
72
92
|
const host = url.hostname.toLowerCase();
|
|
73
93
|
const port = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
74
94
|
return entries.some((e) => {
|
|
@@ -83,7 +103,7 @@ function matches(entries: string[], url: URL) {
|
|
|
83
103
|
});
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
const restHosts = (): HostLists => ({ allowed: list(process.env.PGKILN_REST_ALLOWED_HOSTS), private: list(process.env.PGKILN_REST_PRIVATE_HOSTS) });
|
|
106
|
+
export const restHosts = (): HostLists => ({ allowed: list(process.env.PGKILN_REST_ALLOWED_HOSTS), private: list(process.env.PGKILN_REST_PRIVATE_HOSTS) });
|
|
87
107
|
|
|
88
108
|
/** Why the URL may not be called (null: it may, so far as the host name goes). */
|
|
89
109
|
export function urlProblem(raw: string | URL, hosts: HostLists = restHosts()): string | null {
|
|
@@ -103,6 +123,27 @@ export function urlProblem(raw: string | URL, hosts: HostLists = restHosts()): s
|
|
|
103
123
|
return null;
|
|
104
124
|
}
|
|
105
125
|
|
|
126
|
+
/** (sprint 118) Why the workspace's network access list refuses the URL (null: it doesn't, or there is none). */
|
|
127
|
+
export function scopeProblem(scope: OutboundScope | null | undefined, url: URL): string | null {
|
|
128
|
+
if (!scope?.hosts) return null;
|
|
129
|
+
if (matches(scope.hosts.filter((h) => h !== '*'), url)) return null;
|
|
130
|
+
return `The host ${url.host} is not on the network access list of workspace ${scope.workspaceName}.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The installation's and the scope's checks of one URL; a refusal is reported to the scope. */
|
|
134
|
+
export function checkUrl(url: URL, req: Pick<WebRequest, 'hosts' | 'scope'>): string | null {
|
|
135
|
+
const own = urlProblem(url, req.hosts);
|
|
136
|
+
const problem = own ?? scopeProblem(req.scope, url);
|
|
137
|
+
if (problem && req.scope?.refused) {
|
|
138
|
+
try {
|
|
139
|
+
req.scope.refused(url, own ? 'installation' : 'workspace', problem);
|
|
140
|
+
} catch {
|
|
141
|
+
// reporting never fails the caller
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return problem;
|
|
145
|
+
}
|
|
146
|
+
|
|
106
147
|
// ---------------------------------------------------------------- addresses
|
|
107
148
|
|
|
108
149
|
const blocked = new net.BlockList();
|
|
@@ -225,7 +266,7 @@ export async function webRequest(raw: string, req: WebRequest = {}): Promise<Web
|
|
|
225
266
|
let body = req.body;
|
|
226
267
|
const origin = url.origin;
|
|
227
268
|
for (let hop = 0; ; hop++) {
|
|
228
|
-
const problem =
|
|
269
|
+
const problem = checkUrl(url, req);
|
|
229
270
|
if (problem) throw new WebError(problem);
|
|
230
271
|
const res = await once(url, { ...req, method, headers, body }, deadline, maxBytes);
|
|
231
272
|
const location = res.headers.location;
|
package/src/webrequests.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { owner, type Client } from './db.ts';
|
|
|
2
2
|
import { purgeUnpacked, unpackForSql } from './unpack.ts';
|
|
3
3
|
import { call, invoke, loadCredential } from './websources.ts';
|
|
4
4
|
import { WebError, type WebResponse } from './webclient.ts';
|
|
5
|
+
import { purgeRefusals } from './network-acl.ts';
|
|
5
6
|
|
|
6
7
|
// Web requests from SQL (APEX_WEB_SERVICE; migration 052): application SQL
|
|
7
8
|
// queues a request with meta.web_request(url, …) or
|
|
@@ -77,6 +78,8 @@ export async function execute(appId: number, r: PendingRequest): Promise<Request
|
|
|
77
78
|
);
|
|
78
79
|
const credential = r.credential ? await loadCredential(appId, r.credential) : null;
|
|
79
80
|
res = await call({
|
|
81
|
+
appId,
|
|
82
|
+
what: 'meta.web_request',
|
|
80
83
|
url: r.url,
|
|
81
84
|
method: r.method,
|
|
82
85
|
headers: { accept: '*/*', ...headers },
|
|
@@ -138,6 +141,8 @@ export async function purgeWebRequests(force = false) {
|
|
|
138
141
|
where status = 'queued' and requested_at < now() - interval '${KEEP_HOURS} hours'`,
|
|
139
142
|
);
|
|
140
143
|
await owner.query(`delete from meta.web_request_log where finished_at < now() - interval '${KEEP_HOURS} hours'`);
|
|
144
|
+
// (sprint 118) refusals of the network access lists: 7 days
|
|
145
|
+
await purgeRefusals();
|
|
141
146
|
await owner.query(
|
|
142
147
|
`delete from meta.web_request_log l using (
|
|
143
148
|
select id, row_number() over (partition by app_id order by id desc) as n from meta.web_request_log where status in ('ok', 'error')
|
package/src/websources.ts
CHANGED
|
@@ -3,7 +3,8 @@ import pg from 'pg';
|
|
|
3
3
|
import { owner, runtime } from './db.ts';
|
|
4
4
|
import { runtimeForApp } from './workspace-db.ts';
|
|
5
5
|
import { decryptSecret, encryptSecret } from './secrets.ts';
|
|
6
|
-
import { WebError, webRequest, type WebResponse } from './webclient.ts';
|
|
6
|
+
import { checkUrl, WebError, webRequest, type OutboundScope, type WebResponse } from './webclient.ts';
|
|
7
|
+
import { scopeForApp } from './network-acl.ts';
|
|
7
8
|
|
|
8
9
|
// Web credentials and REST data sources (meta.web_credential,
|
|
9
10
|
// meta.rest_source): building the request (parameters, credential), OAuth2
|
|
@@ -466,9 +467,11 @@ export function tokenRequest(c: WebCredential, refresh: string | null) {
|
|
|
466
467
|
return { headers, body: form.toString() };
|
|
467
468
|
}
|
|
468
469
|
|
|
469
|
-
async function requestToken(c: WebCredential, refresh: string | null, timeoutMs: number): Promise<Token> {
|
|
470
|
+
async function requestToken(c: WebCredential, refresh: string | null, timeoutMs: number, scope: OutboundScope | null): Promise<Token> {
|
|
470
471
|
const { headers, body } = tokenRequest(c, refresh);
|
|
471
472
|
const res = await webRequest(c.token_url!, {
|
|
473
|
+
// (sprint 118) the token endpoint is on the workspace's network access list too
|
|
474
|
+
scope: scope ? { ...scope, what: `token endpoint of web credential ${c.name}` } : null,
|
|
472
475
|
method: 'POST',
|
|
473
476
|
headers,
|
|
474
477
|
secretHeaders: ['authorization'],
|
|
@@ -515,7 +518,7 @@ function storedRefresh(c: WebCredential) {
|
|
|
515
518
|
}
|
|
516
519
|
}
|
|
517
520
|
|
|
518
|
-
async function oauthToken(c: WebCredential, timeoutMs: number, fresh = false): Promise<string> {
|
|
521
|
+
async function oauthToken(c: WebCredential, timeoutMs: number, scope: OutboundScope | null, fresh = false): Promise<string> {
|
|
519
522
|
const key = `${c.id}:${credKey(c)}`;
|
|
520
523
|
const cached = tokens.get(key);
|
|
521
524
|
if (cached && !fresh && cached.expires > Date.now()) return cached.access;
|
|
@@ -525,14 +528,14 @@ async function oauthToken(c: WebCredential, timeoutMs: number, fresh = false): P
|
|
|
525
528
|
const refresh = cached?.refresh ?? storedRefresh(c);
|
|
526
529
|
if (refresh) {
|
|
527
530
|
try {
|
|
528
|
-
return await requestToken(c, refresh, timeoutMs);
|
|
531
|
+
return await requestToken(c, refresh, timeoutMs, scope);
|
|
529
532
|
} catch (e) {
|
|
530
533
|
// the refresh token may have expired: ask again with the grant (a refresh token grant has nothing else)
|
|
531
534
|
if (grantOf(c) === 'refresh_token')
|
|
532
535
|
throw new WebError(`${(e as Error).message} The refresh token of web credential ${c.name} may have expired: enter a new one in the builder.`, (e as WebError).status);
|
|
533
536
|
}
|
|
534
537
|
}
|
|
535
|
-
return requestToken(c, null, timeoutMs);
|
|
538
|
+
return requestToken(c, null, timeoutMs, scope);
|
|
536
539
|
})();
|
|
537
540
|
pendingTokens.set(key, p);
|
|
538
541
|
}
|
|
@@ -546,7 +549,7 @@ async function oauthToken(c: WebCredential, timeoutMs: number, fresh = false): P
|
|
|
546
549
|
}
|
|
547
550
|
|
|
548
551
|
/** Headers that sign a request with the credential; the names in secret are secrets. */
|
|
549
|
-
async function credentialHeaders(c: WebCredential, url: string, timeoutMs: number, fresh = false) {
|
|
552
|
+
async function credentialHeaders(c: WebCredential, url: string, timeoutMs: number, scope: OutboundScope | null, fresh = false) {
|
|
550
553
|
if (!credentialValidFor(c, url)) throw new WebError(`Web credential ${c.name} is not valid for this URL (see its "Valid for" URLs).`);
|
|
551
554
|
switch (c.type) {
|
|
552
555
|
case 'basic':
|
|
@@ -559,7 +562,7 @@ async function credentialHeaders(c: WebCredential, url: string, timeoutMs: numbe
|
|
|
559
562
|
return { [c.header_name!.toLowerCase()]: value };
|
|
560
563
|
}
|
|
561
564
|
case 'oauth2':
|
|
562
|
-
return { authorization: `Bearer ${await oauthToken(c, timeoutMs, fresh)}` };
|
|
565
|
+
return { authorization: `Bearer ${await oauthToken(c, timeoutMs, scope, fresh)}` };
|
|
563
566
|
case 'aws_sigv4':
|
|
564
567
|
throw new WebError(`Web credential ${c.name} (aws_sigv4) signs object storage requests of file items only.`);
|
|
565
568
|
default:
|
|
@@ -570,6 +573,12 @@ async function credentialHeaders(c: WebCredential, url: string, timeoutMs: numbe
|
|
|
570
573
|
// ---------------------------------------------------------------- calling
|
|
571
574
|
|
|
572
575
|
export interface CallOptions {
|
|
576
|
+
/** (sprint 118) the application the call is made for: its workspace's network access list applies (null: none) */
|
|
577
|
+
appId: number | null;
|
|
578
|
+
/** the scope when the caller has it already (else built from appId) */
|
|
579
|
+
scope?: OutboundScope | null;
|
|
580
|
+
/** what makes the call, for refusals (default "web request") */
|
|
581
|
+
what?: string;
|
|
573
582
|
url: string;
|
|
574
583
|
method?: string;
|
|
575
584
|
headers?: Record<string, string>;
|
|
@@ -583,9 +592,14 @@ export interface CallOptions {
|
|
|
583
592
|
/** One request, signed with the credential; an OAuth2 token refused with 401 is renewed once. */
|
|
584
593
|
export async function call(o: CallOptions): Promise<WebResponse> {
|
|
585
594
|
const timeoutMs = o.timeoutMs ?? 10_000;
|
|
595
|
+
const scope = o.scope !== undefined ? o.scope : await scopeForApp(o.appId, o.what ?? 'web request');
|
|
596
|
+
// refused before a credential's token is asked for
|
|
597
|
+
const problem = checkUrl(new URL(o.url), { scope });
|
|
598
|
+
if (problem) throw new WebError(problem);
|
|
586
599
|
const send = async (fresh: boolean) => {
|
|
587
|
-
const auth: Record<string, string> = o.credential ? await credentialHeaders(o.credential, o.url, timeoutMs, fresh) : {};
|
|
600
|
+
const auth: Record<string, string> = o.credential ? await credentialHeaders(o.credential, o.url, timeoutMs, scope, fresh) : {};
|
|
588
601
|
return webRequest(o.url, {
|
|
602
|
+
scope,
|
|
589
603
|
method: o.method ?? 'GET',
|
|
590
604
|
headers: { accept: 'application/json', ...o.headers, ...auth },
|
|
591
605
|
secretHeaders: [...Object.keys(auth), ...(o.secretHeaders ?? [])],
|
|
@@ -748,7 +762,7 @@ export function buildOperation(s: RestSource, name: OperationName, row: Record<s
|
|
|
748
762
|
export async function callOperation(s: RestSource, name: OperationName, row: Record<string, unknown>, params: Record<string, string> = {}) {
|
|
749
763
|
const req = buildOperation(s, name, row, params);
|
|
750
764
|
const credential = s.credential ? await loadCredential(s.app_id, s.credential) : null;
|
|
751
|
-
const res = await call({ ...req, credential, timeoutMs: s.timeout_s * 1000 });
|
|
765
|
+
const res = await call({ ...req, appId: s.app_id, what: `REST data source ${s.name}`, credential, timeoutMs: s.timeout_s * 1000 });
|
|
752
766
|
if (name === 'fetch' && res.status === 404) return { json: null, row: null, status: 404 };
|
|
753
767
|
if (res.status < 200 || res.status > 299) throw new WebError(`REST data source ${s.name}: the ${name} operation was answered with ${res.status}.`, res.status);
|
|
754
768
|
const json = parseJson(res, `REST data source ${s.name}`);
|
|
@@ -778,6 +792,10 @@ export function clearResponseCache(sourceId?: number) {
|
|
|
778
792
|
/** The parsed JSON response of a source (from the cache when it is fresh enough). */
|
|
779
793
|
export async function fetchSource(s: RestSource, values: Record<string, string | null | undefined>) {
|
|
780
794
|
const req = buildRequest(s, values);
|
|
795
|
+
// (sprint 118) the workspace's network access list, before the cache: a narrowed list applies at once
|
|
796
|
+
const scope = await scopeForApp(s.app_id, `REST data source ${s.name}`);
|
|
797
|
+
const problem = checkUrl(new URL(req.url), { scope });
|
|
798
|
+
if (problem) throw new WebError(problem);
|
|
781
799
|
const credential = s.credential ? await loadCredential(s.app_id, s.credential) : null;
|
|
782
800
|
const key = s.cache_seconds > 0
|
|
783
801
|
? `${s.id}:${createHash('sha256').update(JSON.stringify([s, req, credential ? credKey(credential) : null])).digest('base64url')}`
|
|
@@ -786,7 +804,7 @@ export async function fetchSource(s: RestSource, values: Record<string, string |
|
|
|
786
804
|
const hit = responses.get(key);
|
|
787
805
|
if (hit && Date.now() - hit.at < s.cache_seconds * 1000) return { json: hit.json, status: 200, cached: true };
|
|
788
806
|
}
|
|
789
|
-
const res = await call({ ...req, credential, timeoutMs: s.timeout_s * 1000 });
|
|
807
|
+
const res = await call({ ...req, appId: s.app_id, scope, credential, timeoutMs: s.timeout_s * 1000 });
|
|
790
808
|
if (res.status < 200 || res.status > 299) throw new WebError(`REST data source ${s.name}: the web service answered ${res.status}.`, res.status);
|
|
791
809
|
const json = parseJson(res, `REST data source ${s.name}`);
|
|
792
810
|
if (key) {
|
|
@@ -878,7 +896,7 @@ export async function invoke(appId: number, conf: InvokeConfig, lookup: Lookup,
|
|
|
878
896
|
const source = await loadSource(appId, conf.source);
|
|
879
897
|
const req = buildRequest(source, sourceParamValues(source, conf.params, lookup));
|
|
880
898
|
const credential = source.credential ? await loadCredential(appId, source.credential) : null;
|
|
881
|
-
return { res: await call({ ...req, credential, timeoutMs: (timeoutS ?? source.timeout_s) * 1000 }), source };
|
|
899
|
+
return { res: await call({ ...req, appId, what: `${what} (REST data source ${source.name})`, credential, timeoutMs: (timeoutS ?? source.timeout_s) * 1000 }), source };
|
|
882
900
|
}
|
|
883
901
|
// values are URL-encoded and only follow the host (invokeCallProblems), so the host is always the developer's
|
|
884
902
|
const url = conf.url!.replace(SUBST, (m, name: string) => {
|
|
@@ -893,7 +911,7 @@ export async function invoke(appId: number, conf: InvokeConfig, lookup: Lookup,
|
|
|
893
911
|
return JSON.stringify(v ?? m);
|
|
894
912
|
}) : undefined;
|
|
895
913
|
const credential = conf.credential ? await loadCredential(appId, conf.credential) : null;
|
|
896
|
-
const res = await call({ url, method, headers: body ? { 'content-type': 'application/json' } : {}, body, credential, timeoutMs: (timeoutS ?? 10) * 1000 });
|
|
914
|
+
const res = await call({ appId, what, url, method, headers: body ? { 'content-type': 'application/json' } : {}, body, credential, timeoutMs: (timeoutS ?? 10) * 1000 });
|
|
897
915
|
return { res, source: null };
|
|
898
916
|
}
|
|
899
917
|
|