pgkiln 0.73.0 → 0.74.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/141_workspace_resources.sql +115 -0
- package/docs/apex-feature-parity.md +10 -7
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +47 -4
- package/docs/guide/12-development.md +15 -1
- package/docs/guide/16-files.md +29 -0
- package/docs/guide/19-rest-data-sources.md +41 -3
- package/examples/hr/hr_76_remote_servers.sql +48 -0
- package/package.json +1 -1
- package/src/ai/service.ts +10 -1
- package/src/app.ts +2 -0
- package/src/builder/advisor.ts +13 -1
- package/src/builder/ai.ts +29 -3
- package/src/builder/designer.ts +1 -1
- package/src/builder/home.ts +3 -0
- package/src/builder/network-access.ts +33 -0
- package/src/builder/rest-catalogs.ts +22 -14
- package/src/builder/routes.ts +5 -1
- package/src/builder/static-files.ts +14 -4
- package/src/builder/websources.ts +12 -6
- package/src/builder/workflows.ts +2 -1
- package/src/builder/workspace-resources.ts +528 -0
- package/src/metadata.ts +17 -3
- package/src/remote-servers.ts +133 -0
- package/src/restcatalog.ts +21 -10
- package/src/restsync.ts +1 -1
- package/src/runtime/context.ts +2 -0
- package/src/runtime/static-files.ts +46 -17
- package/src/runtime/template-components.ts +2 -1
- package/src/websources.ts +43 -18
- package/src/workspace-export.ts +80 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { owner } from './db.ts';
|
|
2
|
+
import { WebError } from './webclient.ts';
|
|
3
|
+
|
|
4
|
+
// (sprint 141, AP-7; APEX: Remote Servers) A named base URL per workspace
|
|
5
|
+
// (meta.remote_server, migration 141): REST data sources (rest_source.server),
|
|
6
|
+
// REST source catalogs (rest_catalog.server) and AI services
|
|
7
|
+
// (ai_service.server_id) name a server and a path instead of a full URL, so
|
|
8
|
+
// moving from development to production changes one value. The URL is this
|
|
9
|
+
// installation's (base_url), or the server's environment variable
|
|
10
|
+
// PGKILN_SERVER_<...> when it is set (env_var wins). Only the prefix
|
|
11
|
+
// PGKILN_SERVER_ may be named, so no other variable of the server's
|
|
12
|
+
// environment (DATABASE_URL, keys) can become a URL or show up in a message.
|
|
13
|
+
//
|
|
14
|
+
// Resolving happens here, with the owner pool (the runtime role can't read
|
|
15
|
+
// the table), always within the application's own workspace: another
|
|
16
|
+
// workspace's server is never found. The URL it gives goes through every
|
|
17
|
+
// check a full URL went through (src/webclient.ts: allow-list, the
|
|
18
|
+
// workspace's network access list, address checks at connect time).
|
|
19
|
+
|
|
20
|
+
export interface RemoteServer {
|
|
21
|
+
id: number;
|
|
22
|
+
workspace_id: number;
|
|
23
|
+
static_id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string | null;
|
|
26
|
+
base_url: string | null;
|
|
27
|
+
env_var: string | null;
|
|
28
|
+
credential: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const STATIC_ID = /^[A-Z][A-Z0-9_]{0,59}$/;
|
|
32
|
+
export const ENV_PREFIX = 'PGKILN_SERVER_';
|
|
33
|
+
export const ENV_VAR = /^PGKILN_SERVER_[A-Z0-9_]{1,60}$/;
|
|
34
|
+
const BASE_URL = /^https?:\/\/[^\s<>"{}?#]+$/i;
|
|
35
|
+
|
|
36
|
+
/** Problems with a base URL (empty: fine): http(s), no trailing slash, query, fragment or user name. */
|
|
37
|
+
export function baseUrlProblem(url: string): string | null {
|
|
38
|
+
if (!BASE_URL.test(url) || url.length > 2000) return 'The base URL starts with http:// or https://, without spaces, {parameters}, a query or a fragment, e.g. https://crm.example.com/api.';
|
|
39
|
+
if (url.endsWith('/')) return 'The base URL ends without a slash: the paths after it start with one.';
|
|
40
|
+
try {
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
if (u.username || u.password) return 'The base URL may not contain a user name or password: use a web credential.';
|
|
43
|
+
} catch {
|
|
44
|
+
return 'The base URL is not valid.';
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Problems with a server's fields (empty: fine). */
|
|
50
|
+
export function serverProblems(v: { static_id?: unknown; name?: unknown; base_url?: unknown; env_var?: unknown; credential?: unknown; description?: unknown }): string[] {
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
if (typeof v.static_id !== 'string' || !STATIC_ID.test(v.static_id)) out.push('The static id is upper case letters, digits and _, starting with a letter (at most 60), e.g. CRM.');
|
|
53
|
+
if (typeof v.name !== 'string' || !/^\S(.{0,98}\S)?$/.test(v.name)) out.push('The name is 1 to 100 characters, without spaces at the start or end.');
|
|
54
|
+
if (v.base_url != null && v.base_url !== '') {
|
|
55
|
+
const p = baseUrlProblem(String(v.base_url));
|
|
56
|
+
if (p) out.push(p);
|
|
57
|
+
}
|
|
58
|
+
if (v.env_var != null && v.env_var !== '' && (typeof v.env_var !== 'string' || !ENV_VAR.test(v.env_var)))
|
|
59
|
+
out.push(`The environment variable starts with ${ENV_PREFIX}, then upper case letters, digits and _, e.g. ${ENV_PREFIX}CRM.`);
|
|
60
|
+
if (!v.base_url && !v.env_var) out.push('Give the base URL, an environment variable that holds it, or both.');
|
|
61
|
+
if (v.credential != null && v.credential !== '' && (typeof v.credential !== 'string' || !STATIC_ID.test(v.credential))) out.push('The web credential is the name of one, e.g. CRM_API.');
|
|
62
|
+
if (v.description != null && (typeof v.description !== 'string' || v.description.length > 2000)) out.push('The description is at most 2000 characters.');
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The URL a server stands for now: its environment variable when that is set (and valid), else its
|
|
68
|
+
* stored base URL. `problem` says why there is none (the variable's value is never repeated).
|
|
69
|
+
*/
|
|
70
|
+
export function serverUrl(s: Pick<RemoteServer, 'static_id' | 'base_url' | 'env_var'>): { url: string | null; from: 'env' | 'stored' | null; problem: string | null } {
|
|
71
|
+
if (s.env_var && ENV_VAR.test(s.env_var)) {
|
|
72
|
+
const v = (process.env[s.env_var] ?? '').trim().replace(/\/+$/, '');
|
|
73
|
+
if (v) {
|
|
74
|
+
if (baseUrlProblem(v)) return { url: null, from: 'env', problem: `Remote server ${s.static_id}: the server's ${s.env_var} is not a valid base URL (http:// or https://, no query).` };
|
|
75
|
+
return { url: v, from: 'env', problem: null };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (s.base_url && !baseUrlProblem(s.base_url)) return { url: s.base_url, from: 'stored', problem: null };
|
|
79
|
+
return { url: null, from: null, problem: `Remote server ${s.static_id} has no base URL${s.env_var ? ` (and ${s.env_var} is not set on the server)` : ''}.` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** "https://crm.example.com/api" + "/orders/{id}" (a path, a query, or nothing). */
|
|
83
|
+
export function joinUrl(base: string, path: string | null | undefined) {
|
|
84
|
+
const p = (path ?? '').trim();
|
|
85
|
+
if (!p) return base;
|
|
86
|
+
return p.startsWith('/') || p.startsWith('?') ? `${base}${p}` : `${base}/${p}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A server of a workspace by static id (owner pool), or null. */
|
|
90
|
+
export async function workspaceServer(workspaceId: number, staticId: string): Promise<RemoteServer | null> {
|
|
91
|
+
return (await owner.one<RemoteServer>('select * from meta.remote_server where workspace_id = $1 and static_id = $2', [workspaceId, staticId.toUpperCase()])) ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A server of the application's own workspace by static id, or null. */
|
|
95
|
+
export async function serverForApp(appId: number, staticId: string): Promise<RemoteServer | null> {
|
|
96
|
+
return (await owner.one<RemoteServer>(
|
|
97
|
+
'select * from meta.remote_server where workspace_id = meta.app_workspace($1) and static_id = $2', [appId, staticId.toUpperCase()])) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The full URL of a path on a server of the application's workspace; throws a WebError when it can't be made. */
|
|
101
|
+
export async function appServerUrl(appId: number, staticId: string, path: string | null | undefined) {
|
|
102
|
+
const s = await serverForApp(appId, staticId);
|
|
103
|
+
if (!s) throw new WebError(`Remote server ${staticId.toUpperCase()} does not exist in this application's workspace (Workspace utilities → Remote servers).`);
|
|
104
|
+
const u = serverUrl(s);
|
|
105
|
+
if (!u.url) throw new WebError(u.problem!);
|
|
106
|
+
return { url: joinUrl(u.url, path), server: s };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A REST data source as the request code expects it: on a server, its URL becomes the server's base
|
|
111
|
+
* URL plus the source's path, and a source without a credential of its own uses the server's.
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveSource<T extends { app_id: number; url: string; credential: string | null; server?: string | null }>(src: T): Promise<T> {
|
|
114
|
+
if (!src.server) return src;
|
|
115
|
+
const { url, server } = await appServerUrl(src.app_id, src.server, src.url);
|
|
116
|
+
// resolved once: server null, so resolving again changes nothing
|
|
117
|
+
return { ...src, url, credential: src.credential || server.credential || null, server: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The base URL of a catalog on a server of its workspace (null: none, or the catalog has no server). */
|
|
121
|
+
export async function catalogServerUrl(workspaceId: number, staticId: string | null | undefined): Promise<string | null> {
|
|
122
|
+
if (!staticId) return null;
|
|
123
|
+
const s = await workspaceServer(workspaceId, staticId);
|
|
124
|
+
return s ? serverUrl(s).url : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The base URL of an AI service on a server (any workspace: administrators chose it), or an error message. */
|
|
128
|
+
export async function aiServerUrl(serverId: number, path: string | null | undefined): Promise<{ url: string | null; problem: string | null }> {
|
|
129
|
+
const s = await owner.one<RemoteServer>('select * from meta.remote_server where id = $1', [serverId]);
|
|
130
|
+
if (!s) return { url: null, problem: 'Its remote server no longer exists.' };
|
|
131
|
+
const u = serverUrl(s);
|
|
132
|
+
return u.url ? { url: joinUrl(u.url, path), problem: null } : { url: null, problem: u.problem };
|
|
133
|
+
}
|
package/src/restcatalog.ts
CHANGED
|
@@ -32,7 +32,10 @@ export interface CatalogService {
|
|
|
32
32
|
export interface Catalog {
|
|
33
33
|
name: string;
|
|
34
34
|
description: string | null;
|
|
35
|
+
/** (141) may be empty when the catalog is on a remote server */
|
|
35
36
|
base_url: string;
|
|
37
|
+
/** (141) a remote server of the workspace (static id): data sources made from the catalog use it */
|
|
38
|
+
server?: string | null;
|
|
36
39
|
source: 'manual' | 'openapi';
|
|
37
40
|
api_version: string | null;
|
|
38
41
|
services: CatalogService[];
|
|
@@ -54,10 +57,14 @@ export const trimBase = (url: string) => url.trim().replace(/\/+$/, '');
|
|
|
54
57
|
export const serviceUrl = (baseUrl: string, path: string) => `${trimBase(baseUrl)}${path}`;
|
|
55
58
|
|
|
56
59
|
/** Problems with a catalog's own fields (empty: fine). */
|
|
57
|
-
export function catalogProblems(c: { name?: unknown; base_url?: unknown; description?: unknown; api_version?: unknown }): string[] {
|
|
60
|
+
export function catalogProblems(c: { name?: unknown; base_url?: unknown; server?: unknown; description?: unknown; api_version?: unknown }): string[] {
|
|
58
61
|
const out: string[] = [];
|
|
59
62
|
if (typeof c.name !== 'string' || !CATALOG_NAME.test(c.name)) out.push('The name is 1 to 60 characters, without spaces at the start or end.');
|
|
60
|
-
if (typeof c.
|
|
63
|
+
if (c.server != null && c.server !== '' && (typeof c.server !== 'string' || !SERVICE_NAME.test(c.server))) out.push('The remote server is its static id, e.g. CRM.');
|
|
64
|
+
// (141) on a remote server the base URL is optional (the document's, kept for reference)
|
|
65
|
+
if (c.server && (c.base_url == null || c.base_url === '')) {
|
|
66
|
+
// nothing to check
|
|
67
|
+
} else if (typeof c.base_url !== 'string' || !/^https?:\/\/[^\s<>"{}]+$/i.test(c.base_url) || c.base_url.length > 2000) out.push('The base URL starts with http:// or https://, without spaces or {parameters}, e.g. https://api.example.com/v1.');
|
|
61
68
|
else if (c.base_url.endsWith('/')) out.push('The base URL ends without a slash: the services\' paths start with one.');
|
|
62
69
|
else {
|
|
63
70
|
try {
|
|
@@ -97,7 +104,7 @@ export function wholeCatalogProblems(c: Catalog): string[] {
|
|
|
97
104
|
if (seen.has(s.name)) out.push(`Service ${s.name} is there twice.`);
|
|
98
105
|
seen.add(s.name);
|
|
99
106
|
// (enough to fix the first ones: a big broken document would list thousands)
|
|
100
|
-
if (out.length < 50) out.push(...serviceProblems(typeof c.base_url === 'string' ? c.base_url : 'https://x', s));
|
|
107
|
+
if (out.length < 50) out.push(...serviceProblems(typeof c.base_url === 'string' && c.base_url ? c.base_url : 'https://x', s));
|
|
101
108
|
}
|
|
102
109
|
return out;
|
|
103
110
|
}
|
|
@@ -470,6 +477,7 @@ export function catalogDocument(c: Catalog) {
|
|
|
470
477
|
name: c.name,
|
|
471
478
|
description: c.description,
|
|
472
479
|
base_url: c.base_url,
|
|
480
|
+
...(c.server ? { server: c.server } : {}),
|
|
473
481
|
source: c.source,
|
|
474
482
|
api_version: c.api_version,
|
|
475
483
|
services: c.services.map((s) => ({
|
|
@@ -487,6 +495,7 @@ export function fromCatalogDocument(doc: Json): Catalog {
|
|
|
487
495
|
name: String(doc.name ?? ''),
|
|
488
496
|
description: str(doc.description),
|
|
489
497
|
base_url: String(doc.base_url ?? ''),
|
|
498
|
+
...(typeof doc.server === 'string' && doc.server ? { server: doc.server } : {}),
|
|
490
499
|
source: doc.source === 'openapi' ? 'openapi' : 'manual',
|
|
491
500
|
api_version: str(doc.api_version),
|
|
492
501
|
services: (Array.isArray(doc.services) ? doc.services : []).map((s: Json) => ({
|
|
@@ -554,9 +563,9 @@ export async function loadCatalog(id: number | string, db: Q = owner): Promise<S
|
|
|
554
563
|
/** Insert a checked catalog into a workspace (inside the caller's transaction); its id. */
|
|
555
564
|
export async function insertCatalog(db: Q, workspaceId: number, c: Catalog, by: string | null): Promise<number> {
|
|
556
565
|
const { id } = (await db.query(
|
|
557
|
-
`insert into meta.rest_catalog (workspace_id, name, description, base_url, source, api_version, created_by)
|
|
558
|
-
values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
559
|
-
[workspaceId, c.name, c.description, c.base_url, c.source, c.api_version, by],
|
|
566
|
+
`insert into meta.rest_catalog (workspace_id, name, description, base_url, source, api_version, created_by, server)
|
|
567
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8) returning id`,
|
|
568
|
+
[workspaceId, c.name, c.description, c.base_url || null, c.source, c.api_version, by, c.server || null],
|
|
560
569
|
)).rows[0];
|
|
561
570
|
await insertServices(db, id, c.services);
|
|
562
571
|
return id;
|
|
@@ -565,8 +574,8 @@ export async function insertCatalog(db: Q, workspaceId: number, c: Catalog, by:
|
|
|
565
574
|
/** Replace a catalog's fields and services (inside the caller's transaction). */
|
|
566
575
|
export async function replaceCatalog(db: Q, id: number, c: Catalog) {
|
|
567
576
|
await db.query(
|
|
568
|
-
`update meta.rest_catalog set name = $2, description = $3, base_url = $4, source = $5, api_version = $6, updated_at = now() where id = $1`,
|
|
569
|
-
[id, c.name, c.description, c.base_url, c.source, c.api_version],
|
|
577
|
+
`update meta.rest_catalog set name = $2, description = $3, base_url = $4, source = $5, api_version = $6, server = $7, updated_at = now() where id = $1`,
|
|
578
|
+
[id, c.name, c.description, c.base_url || null, c.source, c.api_version, c.server || null],
|
|
570
579
|
);
|
|
571
580
|
await db.query('delete from meta.rest_catalog_service where catalog_id = $1', [id]);
|
|
572
581
|
await insertServices(db, id, c.services);
|
|
@@ -587,11 +596,13 @@ async function insertServices(db: Q, catalogId: number, services: CatalogService
|
|
|
587
596
|
* The values of a REST data source made from a catalog service (meta.rest_source
|
|
588
597
|
* columns): the full URL and a copy of the request and response settings.
|
|
589
598
|
*/
|
|
590
|
-
export function sourceFromService(c: { base_url: string; name: string }, s: CatalogService, opts: { name?: string; credential?: string | null } = {}) {
|
|
599
|
+
export function sourceFromService(c: { base_url: string | null; name: string; server?: string | null }, s: CatalogService, opts: { name?: string; credential?: string | null } = {}) {
|
|
591
600
|
return {
|
|
592
601
|
name: (opts.name?.trim() || s.name).toUpperCase(),
|
|
593
602
|
description: s.description ?? `From the REST source catalog ${c.name}.`,
|
|
594
|
-
|
|
603
|
+
// (141) a catalog on a remote server: the source names the server and the service's path
|
|
604
|
+
url: c.server ? s.path : serviceUrl(c.base_url ?? '', s.path),
|
|
605
|
+
server: c.server || null,
|
|
595
606
|
method: s.method,
|
|
596
607
|
credential: opts.credential?.trim() ? opts.credential.trim().toUpperCase() : null,
|
|
597
608
|
params: s.params,
|
package/src/restsync.ts
CHANGED
|
@@ -74,7 +74,7 @@ async function loadSyncSource(id: number) {
|
|
|
74
74
|
return owner.one<SyncSource>(
|
|
75
75
|
`select s.id, s.app_id, s.name, s.url, s.method, s.credential, s.headers, s.params, s.body, s.row_selector, s.columns, s.cache_seconds,
|
|
76
76
|
s.timeout_s, s.max_rows, s.key_columns, s.operations, s.sync_table, s.sync_mode, s.sync_delete, s.sync_schedule, s.sync_time_zone,
|
|
77
|
-
s.sync_enabled, a.db_role
|
|
77
|
+
s.sync_enabled, s.server, a.db_role
|
|
78
78
|
from meta.rest_source s join meta.app a on a.id = s.app_id where s.id = $1`,
|
|
79
79
|
[id],
|
|
80
80
|
);
|
package/src/runtime/context.ts
CHANGED
|
@@ -120,6 +120,8 @@ export function bindValues(ctx: PageContext): BindValues {
|
|
|
120
120
|
/** Replace &NAME. substitution strings (APEX syntax); `encode` escapes each value. */
|
|
121
121
|
export function substitute(text: string, ctx: PageContext, encode: (v: string) => string) {
|
|
122
122
|
const values = bindValues(ctx);
|
|
123
|
+
// (141) #APP_FILES# and #WORKSPACE_FILES#: where the application's and its workspace's static files are served
|
|
124
|
+
if (text.includes('_FILES#')) text = text.replace(/#(APP|WORKSPACE)_FILES#/g, (_, kind: string) => `${ctx.base}/${kind === 'APP' ? 'static' : 'workspace-files'}/`);
|
|
123
125
|
return text.replace(/&([A-Za-z][A-Za-z0-9_]*(?:\$[A-Za-z0-9_.-]+?)?)\./g, (m, name: string) => {
|
|
124
126
|
const upper = name.toUpperCase();
|
|
125
127
|
if (upper.startsWith('APP_TEXT$')) {
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
// inline. HTML (and anything else a browser would run as a document) is
|
|
7
7
|
// refused by the database and by the type list below, and SVG is served
|
|
8
8
|
// with a policy of its own so a script in it never runs.
|
|
9
|
-
|
|
9
|
+
// (sprint 141, AP-7) Static workspace files (meta.workspace_file) are served
|
|
10
|
+
// the same way to every application of their workspace, at
|
|
11
|
+
// /a/<alias>/workspace-files/<name>; lists name them #WORKSPACE_FILES#<name>,
|
|
12
|
+
// templates and settings use #APP_FILES# and #WORKSPACE_FILES# (context.ts
|
|
13
|
+
// substitute, template-components.ts rowLookup).
|
|
14
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
10
15
|
import { html } from '../html.ts';
|
|
11
|
-
import { runtime } from '../db.ts';
|
|
12
|
-
import { loadApp } from '../metadata.ts';
|
|
16
|
+
import { owner, runtime } from '../db.ts';
|
|
17
|
+
import { loadApp, type StaticInclude } from '../metadata.ts';
|
|
13
18
|
import type { PageContext } from './context.ts';
|
|
14
19
|
import { pluginsOnPage } from './plugins.ts';
|
|
15
20
|
|
|
@@ -43,17 +48,51 @@ export function staticHead(ctx: PageContext) {
|
|
|
43
48
|
// the plug-ins' files (at their version) after the application's, then the page's
|
|
44
49
|
const version = new Map<string, number>((ctx.app.static_files ?? []).map((f) => [f.name, f.v]));
|
|
45
50
|
const pluginFiles = pluginsOnPage(ctx).flatMap((p) => p.files.filter((n) => version.has(n)).map((name) => ({ name, v: version.get(name)! })));
|
|
46
|
-
const files = [...(ctx.app.static_includes ?? []), ...pluginFiles, ...(ctx.page.static_includes ?? [])];
|
|
51
|
+
const files: StaticInclude[] = [...(ctx.app.static_includes ?? []), ...pluginFiles, ...(ctx.page.static_includes ?? [])];
|
|
47
52
|
const seen = new Set<string>();
|
|
48
53
|
return files
|
|
49
|
-
.filter((f) => includable(f.name) && !seen.has(f.name) && seen.add(f.name))
|
|
54
|
+
.filter((f) => includable(f.name) && !seen.has(`${f.ws ? 'w' : 'a'}:${f.name}`) && seen.add(`${f.ws ? 'w' : 'a'}:${f.name}`))
|
|
50
55
|
.map((f) => {
|
|
51
|
-
|
|
56
|
+
// (141) a static workspace file: from the workspace of this application, on its own origin
|
|
57
|
+
const src = `${ctx.base}/${f.ws ? 'workspace-files' : 'static'}/${encodeURIComponent(f.name)}?v=${f.v}`;
|
|
52
58
|
return /\.css$/i.test(f.name) ? html`<link rel="stylesheet" href="${src}">` : html`<script src="${src}" defer></script>`;
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/** Send a static file with the headers both kinds share (type, cache, a policy for SVG and PDF). */
|
|
63
|
+
function sendFile(req: FastifyRequest<{ Querystring: { v?: string } }>, reply: FastifyReply, type: string, f: { content: Buffer; v: string }) {
|
|
64
|
+
const etag = `"${Number(f.v).toString(36)}"`;
|
|
65
|
+
reply
|
|
66
|
+
.type(type.startsWith('text/') ? `${type}; charset=utf-8` : type)
|
|
67
|
+
.header('etag', etag)
|
|
68
|
+
// never sniffed into something else (the app's pages set it too; a file may be fetched on its own)
|
|
69
|
+
.header('x-content-type-options', 'nosniff')
|
|
70
|
+
// a versioned link (?v=…, as pages write them) never changes; a bare one is checked again
|
|
71
|
+
.header('cache-control', req.query.v ? 'public, max-age=31536000, immutable' : 'no-cache');
|
|
72
|
+
// opened on its own, an SVG or PDF is a document: no scripts, no requests
|
|
73
|
+
if (type === 'image/svg+xml' || type === 'application/pdf')
|
|
74
|
+
reply.header('content-security-policy', `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'${type === 'image/svg+xml' ? '; sandbox' : ''}`);
|
|
75
|
+
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
|
|
76
|
+
return reply.send(f.content);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** (141) A static workspace file to serve to an application: its own workspace's only (the owner pool). */
|
|
80
|
+
export async function workspaceFileFor(alias: string, name: string) {
|
|
81
|
+
return owner.one<{ content: Buffer; v: string }>(
|
|
82
|
+
`select f.content, floor(extract(epoch from f.updated_at) * 1000)::bigint::text as v
|
|
83
|
+
from meta.app a join meta.workspace_file f on f.workspace_id = meta.app_workspace(a.id) and f.name = $2
|
|
84
|
+
where a.alias = $1`, [alias, name]);
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
export async function staticFileRoutes(app: FastifyInstance) {
|
|
88
|
+
// (141) static workspace files, served to every application of the workspace on its own origin
|
|
89
|
+
app.get<{ Params: { alias: string; name: string }; Querystring: { v?: string } }>('/a/:alias/workspace-files/:name', async (req, reply) => {
|
|
90
|
+
const type = staticType(req.params.name);
|
|
91
|
+
const f = type ? await workspaceFileFor(req.params.alias, req.params.name) : null;
|
|
92
|
+
if (!f) return reply.code(404).type('text/plain').send('Not found');
|
|
93
|
+
return sendFile(req, reply, type!, f);
|
|
94
|
+
});
|
|
95
|
+
|
|
57
96
|
app.get<{ Params: { alias: string; name: string }; Querystring: { v?: string } }>('/a/:alias/static/:name', async (req, reply) => {
|
|
58
97
|
const type = staticType(req.params.name);
|
|
59
98
|
const a = type ? await loadApp(req.params.alias) : null;
|
|
@@ -61,16 +100,6 @@ export async function staticFileRoutes(app: FastifyInstance) {
|
|
|
61
100
|
? await runtime.one<{ content: Buffer; v: string }>('select content, floor(extract(epoch from updated_at) * 1000)::bigint::text as v from meta.static_file where app_id = $1 and name = $2', [a.id, req.params.name])
|
|
62
101
|
: null;
|
|
63
102
|
if (!f) return reply.code(404).type('text/plain').send('Not found');
|
|
64
|
-
|
|
65
|
-
reply
|
|
66
|
-
.type(type!.startsWith('text/') ? `${type}; charset=utf-8` : type!)
|
|
67
|
-
.header('etag', etag)
|
|
68
|
-
// a versioned link (?v=…, as pages write them) never changes; a bare one is checked again
|
|
69
|
-
.header('cache-control', req.query.v ? 'public, max-age=31536000, immutable' : 'no-cache');
|
|
70
|
-
// opened on its own, an SVG or PDF is a document: no scripts, no requests
|
|
71
|
-
if (type === 'image/svg+xml' || type === 'application/pdf')
|
|
72
|
-
reply.header('content-security-policy', `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'${type === 'image/svg+xml' ? '; sandbox' : ''}`);
|
|
73
|
-
if (req.headers['if-none-match'] === etag) return reply.code(304).send();
|
|
74
|
-
return reply.send(f.content);
|
|
103
|
+
return sendFile(req, reply, type!, f);
|
|
75
104
|
});
|
|
76
105
|
}
|
|
@@ -573,7 +573,8 @@ export function attributeValues(c: TemplateComponent, use: TcUse) {
|
|
|
573
573
|
* filled in), then LINK, APEX$ROW_NUM, then the row's columns.
|
|
574
574
|
*/
|
|
575
575
|
export function rowLookup(ctx: PageContext | null, attrs: Map<string, string>, columns: Map<string, string>, extra: Record<string, string | undefined> = {}): Lookup {
|
|
576
|
-
|
|
576
|
+
// (141) #APP_FILES# / #WORKSPACE_FILES#: where the application's and its workspace's static files are served
|
|
577
|
+
const col: Lookup = (n) => columns.get(n) ?? extra[n] ?? (ctx && (n === 'APP_FILES' || n === 'WORKSPACE_FILES') ? `${ctx.base}/${n === 'APP_FILES' ? 'static' : 'workspace-files'}/` : undefined);
|
|
577
578
|
const memo = new Map<string, string>();
|
|
578
579
|
return (name) => {
|
|
579
580
|
if (attrs.has(name)) {
|
package/src/websources.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { runtimeForApp } from './workspace-db.ts';
|
|
|
5
5
|
import { decryptSecret, encryptSecret } from './secrets.ts';
|
|
6
6
|
import { checkUrl, WebError, webRequest, type OutboundScope, type WebResponse } from './webclient.ts';
|
|
7
7
|
import { scopeForApp } from './network-acl.ts';
|
|
8
|
+
import { resolveSource } from './remote-servers.ts';
|
|
8
9
|
|
|
9
10
|
// Web credentials and REST data sources (meta.web_credential,
|
|
10
11
|
// meta.rest_source): building the request (parameters, credential), OAuth2
|
|
@@ -34,6 +35,8 @@ export interface WebCredential {
|
|
|
34
35
|
oauth_username?: string | null;
|
|
35
36
|
password_enc?: string | null;
|
|
36
37
|
refresh_token_enc?: string | null;
|
|
38
|
+
/** (141) a credential of the application's workspace (meta.workspace_credential) */
|
|
39
|
+
workspace?: boolean;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export type ParamLocation = 'query' | 'path' | 'header' | 'body';
|
|
@@ -56,9 +59,12 @@ export interface RestSource {
|
|
|
56
59
|
id: number;
|
|
57
60
|
app_id: number;
|
|
58
61
|
name: string;
|
|
62
|
+
/** a full URL; (141) with a server, the path after the server's base URL (resolveSource makes it full) */
|
|
59
63
|
url: string;
|
|
60
64
|
method: string;
|
|
61
65
|
credential: string | null;
|
|
66
|
+
/** (141) a remote server of the application's workspace (static id) */
|
|
67
|
+
server?: string | null;
|
|
62
68
|
headers: Record<string, string>;
|
|
63
69
|
params: RestParam[];
|
|
64
70
|
body: string | null;
|
|
@@ -101,9 +107,14 @@ const RESERVED_HEADERS = new Set(['authorization', 'proxy-authorization', 'cooki
|
|
|
101
107
|
// ---------------------------------------------------------------- checks (builder, import)
|
|
102
108
|
|
|
103
109
|
/** Problems with a REST data source definition (empty: fine). */
|
|
104
|
-
export function sourceProblems(s: { url?: unknown; params?: unknown; columns?: unknown; headers?: unknown; row_selector?: unknown; key_columns?: unknown; operations?: unknown }): string[] {
|
|
110
|
+
export function sourceProblems(s: { url?: unknown; server?: unknown; params?: unknown; columns?: unknown; headers?: unknown; row_selector?: unknown; key_columns?: unknown; operations?: unknown }): string[] {
|
|
105
111
|
const out: string[] = [];
|
|
106
|
-
if (
|
|
112
|
+
if (s.server != null && s.server !== '') {
|
|
113
|
+
// (141) on a remote server: a path after its base URL (or nothing)
|
|
114
|
+
if (typeof s.server !== 'string' || !/^[A-Z][A-Z0-9_]{0,59}$/.test(s.server)) out.push('The remote server is its static id, e.g. CRM.');
|
|
115
|
+
if (typeof s.url !== 'string' || !/^([/?][^\s]*)?$/.test(s.url)) out.push('With a remote server the URL is the path after its base URL, e.g. /v1/orders/{id} (or empty).');
|
|
116
|
+
else if (s.url.includes('//') || /(^|\/)\.\.?(\/|$|\?)/.test(s.url)) out.push('The path may not contain // or .. segments.');
|
|
117
|
+
} else if (typeof s.url !== 'string' || !/^https?:\/\/[^/\s]+/i.test(s.url)) out.push('The URL must start with http:// or https://.');
|
|
107
118
|
else if (/[{&]/.test(/^https?:\/\/[^/?#]*/i.exec(s.url)![0])) out.push('The URL\'s host is fixed: parameters can only follow it.');
|
|
108
119
|
else {
|
|
109
120
|
try {
|
|
@@ -384,21 +395,29 @@ export const withRest = (rowsQuery: string, sql: string | null | undefined) =>
|
|
|
384
395
|
export async function loadSource(appId: number, name: string) {
|
|
385
396
|
// the application's own connection (an isolated workspace's), also from a workflow or job (090)
|
|
386
397
|
const s = await (await runtimeForApp(appId)).one<RestSource>(
|
|
387
|
-
`select id, app_id, name, url, method, credential, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
|
|
398
|
+
`select id, app_id, name, url, method, credential, server, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
|
|
388
399
|
from meta.rest_source where app_id = $1 and name = $2`,
|
|
389
400
|
[appId, name.toUpperCase()],
|
|
390
401
|
);
|
|
391
402
|
if (!s) throw new WebError(`REST data source ${name} does not exist.`);
|
|
392
|
-
|
|
403
|
+
// (141) on a remote server: the server's URL of this moment, and its credential when the source names none
|
|
404
|
+
return resolveSource(s);
|
|
393
405
|
}
|
|
394
406
|
|
|
395
|
-
|
|
407
|
+
const CREDENTIAL_COLUMNS = 'id, name, type, username, header_name, token_url, scope, valid_for, secret_enc, grant_type, oauth_username, password_enc, refresh_token_enc';
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* With the (encrypted) secret: the owner connection, never the runtime role. The application's own
|
|
411
|
+
* credential of that name, else (141) one of its workspace's (never another workspace's).
|
|
412
|
+
*/
|
|
396
413
|
export async function loadCredential(appId: number, name: string) {
|
|
397
|
-
const c = await owner.one<WebCredential>(
|
|
398
|
-
`select
|
|
399
|
-
from meta.web_credential where app_id = $1 and name = $2`,
|
|
414
|
+
const c = (await owner.one<WebCredential>(
|
|
415
|
+
`select ${CREDENTIAL_COLUMNS}, app_id from meta.web_credential where app_id = $1 and name = $2`,
|
|
400
416
|
[appId, name.toUpperCase()],
|
|
401
|
-
)
|
|
417
|
+
)) ?? (await owner.one<WebCredential>(
|
|
418
|
+
`select ${CREDENTIAL_COLUMNS}, $1::int as app_id, true as workspace from meta.workspace_credential where workspace_id = meta.app_workspace($1) and name = $2`,
|
|
419
|
+
[appId, name.toUpperCase()],
|
|
420
|
+
));
|
|
402
421
|
if (!c) throw new WebError(`Web credential ${name} does not exist.`);
|
|
403
422
|
return c;
|
|
404
423
|
}
|
|
@@ -428,15 +447,18 @@ interface Token {
|
|
|
428
447
|
const tokens = new Map<string, Token>();
|
|
429
448
|
const pendingTokens = new Map<string, Promise<Token>>();
|
|
430
449
|
const credKey = (c: WebCredential) =>
|
|
431
|
-
createHash('sha256').update(JSON.stringify([c
|
|
450
|
+
createHash('sha256').update(JSON.stringify([tokenId(c), c.username, c.token_url, c.scope, c.secret_enc, c.grant_type ?? null, c.oauth_username ?? null, c.password_enc ?? null])).digest('base64url');
|
|
451
|
+
|
|
452
|
+
/** The token cache's id of a credential: (141) a workspace credential's starts with w. */
|
|
453
|
+
const tokenId = (c: Pick<WebCredential, 'id' | 'workspace'>) => (c.workspace ? `w${c.id}` : String(c.id));
|
|
432
454
|
|
|
433
|
-
/** Forget cached tokens (all, or one credential's). */
|
|
434
|
-
export function clearTokens(credentialId?: number) {
|
|
455
|
+
/** Forget cached tokens (all, or one credential's: its id, or w<id> for a workspace credential). */
|
|
456
|
+
export function clearTokens(credentialId?: number | string) {
|
|
435
457
|
for (const k of [...tokens.keys()]) if (credentialId === undefined || k.startsWith(`${credentialId}:`)) tokens.delete(k);
|
|
436
458
|
}
|
|
437
459
|
|
|
438
460
|
const secretOf = (c: WebCredential) => {
|
|
439
|
-
if (!c.secret_enc) throw new WebError(`Web credential ${c.name} has no secret yet: enter it in the builder (Shared Components → Web credentials).`);
|
|
461
|
+
if (!c.secret_enc) throw new WebError(`Web credential ${c.name} has no secret yet: enter it in the builder (${c.workspace ? 'Workspace utilities' : 'Shared Components'} → Web credentials).`);
|
|
440
462
|
return decryptSecret(c.secret_enc);
|
|
441
463
|
};
|
|
442
464
|
|
|
@@ -504,7 +526,7 @@ async function requestToken(c: WebCredential, refresh: string | null, timeoutMs:
|
|
|
504
526
|
/** Keep a refresh token (encrypted); written with the owner connection only. */
|
|
505
527
|
async function storeRefreshToken(c: WebCredential, token: string) {
|
|
506
528
|
const enc = encryptSecret(token);
|
|
507
|
-
await owner.query(
|
|
529
|
+
await owner.query(`update meta.${c.workspace ? 'workspace_credential' : 'web_credential'} set refresh_token_enc = $2, token_refreshed_at = now() where id = $1`, [c.id, enc]);
|
|
508
530
|
c.refresh_token_enc = enc;
|
|
509
531
|
}
|
|
510
532
|
|
|
@@ -519,7 +541,7 @@ function storedRefresh(c: WebCredential) {
|
|
|
519
541
|
}
|
|
520
542
|
|
|
521
543
|
async function oauthToken(c: WebCredential, timeoutMs: number, scope: OutboundScope | null, fresh = false): Promise<string> {
|
|
522
|
-
const key = `${c
|
|
544
|
+
const key = `${tokenId(c)}:${credKey(c)}`;
|
|
523
545
|
const cached = tokens.get(key);
|
|
524
546
|
if (cached && !fresh && cached.expires > Date.now()) return cached.access;
|
|
525
547
|
let p = pendingTokens.get(key);
|
|
@@ -609,7 +631,7 @@ export async function call(o: CallOptions): Promise<WebResponse> {
|
|
|
609
631
|
};
|
|
610
632
|
const res = await send(false);
|
|
611
633
|
if (res.status === 401 && o.credential?.type === 'oauth2') {
|
|
612
|
-
clearTokens(o.credential
|
|
634
|
+
clearTokens(tokenId(o.credential));
|
|
613
635
|
return send(true);
|
|
614
636
|
}
|
|
615
637
|
return res;
|
|
@@ -759,7 +781,8 @@ export function buildOperation(s: RestSource, name: OperationName, row: Record<s
|
|
|
759
781
|
* 200–299 fails. Returns the response's JSON (null when empty) and, for
|
|
760
782
|
* insert and fetch, the row in it (by the operation's row selector).
|
|
761
783
|
*/
|
|
762
|
-
export async function callOperation(
|
|
784
|
+
export async function callOperation(source: RestSource, name: OperationName, row: Record<string, unknown>, params: Record<string, string> = {}) {
|
|
785
|
+
const s = await resolveSource(source);
|
|
763
786
|
const req = buildOperation(s, name, row, params);
|
|
764
787
|
const credential = s.credential ? await loadCredential(s.app_id, s.credential) : null;
|
|
765
788
|
const res = await call({ ...req, appId: s.app_id, what: `REST data source ${s.name}`, credential, timeoutMs: s.timeout_s * 1000 });
|
|
@@ -790,7 +813,9 @@ export function clearResponseCache(sourceId?: number) {
|
|
|
790
813
|
}
|
|
791
814
|
|
|
792
815
|
/** The parsed JSON response of a source (from the cache when it is fresh enough). */
|
|
793
|
-
export async function fetchSource(
|
|
816
|
+
export async function fetchSource(source: RestSource, values: Record<string, string | null | undefined>) {
|
|
817
|
+
// (141) on a remote server: its URL now (a source from loadSource is resolved already)
|
|
818
|
+
const s = await resolveSource(source);
|
|
794
819
|
const req = buildRequest(s, values);
|
|
795
820
|
// (sprint 118) the workspace's network access list, before the cache: a narrowed list applies at once
|
|
796
821
|
const scope = await scopeForApp(s.app_id, `REST data source ${s.name}`);
|
package/src/workspace-export.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { Client } from './db.ts';
|
|
|
2
2
|
import { owner } from './db.ts';
|
|
3
3
|
import { checkDataset, datasetOf, storeDataset } from './datasets.ts';
|
|
4
4
|
import { describeTeam, exportTeam, importTeam } from './team.ts';
|
|
5
|
+
import { serverProblems } from './remote-servers.ts';
|
|
6
|
+
import { STATIC_MAX, staticType } from './runtime/static-files.ts';
|
|
7
|
+
import { credentialProblems, GRANT_TYPES } from './websources.ts';
|
|
5
8
|
import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, replaceCatalog, wholeCatalogProblems } from './restcatalog.ts';
|
|
6
9
|
|
|
7
10
|
// Workspace utilities → Export / import (APEX: Workspace Utilities → Export,
|
|
@@ -21,6 +24,10 @@ import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, repla
|
|
|
21
24
|
// datasets (sprint 110) Data Reporter datasets (pgkiln-dataset/1 definitions)
|
|
22
25
|
// team (sprint 113) Team Development: label groups, labels, milestones, templates and issues
|
|
23
26
|
// (with comments and history; applications by alias), an object (src/team.ts)
|
|
27
|
+
// workspace_files (sprint 141) static workspace files (content in base64)
|
|
28
|
+
// remote_servers (sprint 141) remote servers; an existing server keeps its own base URL on import
|
|
29
|
+
// (the URL is per installation: development, test, production)
|
|
30
|
+
// web_credentials (sprint 141) the workspace's web credentials, never their secrets (entered again after an import)
|
|
24
31
|
//
|
|
25
32
|
// Not exported: the workspace's name (the importer's workspace keeps its own),
|
|
26
33
|
// isolation and its database roles (installation-specific), script runs and
|
|
@@ -29,7 +36,8 @@ import { catalogDocument, fromCatalogDocument, insertCatalog, loadCatalog, repla
|
|
|
29
36
|
// installation has a developer of that name.
|
|
30
37
|
|
|
31
38
|
export const WORKSPACE_FORMAT = 'pgkiln-workspace/1';
|
|
32
|
-
export const PARTS = ['settings', 'developers', 'app_groups', 'extension_links', 'rest_catalogs', 'sql_scripts', 'blueprints', 'data_generators', 'datasets', 'team'
|
|
39
|
+
export const PARTS = ['settings', 'developers', 'app_groups', 'extension_links', 'rest_catalogs', 'sql_scripts', 'blueprints', 'data_generators', 'datasets', 'team',
|
|
40
|
+
'workspace_files', 'remote_servers', 'web_credentials'] as const;
|
|
33
41
|
export type Part = (typeof PARTS)[number];
|
|
34
42
|
export const PART_LABELS: Record<Part, string> = {
|
|
35
43
|
settings: 'Settings: description, message, banner and preferences',
|
|
@@ -42,6 +50,9 @@ export const PART_LABELS: Record<Part, string> = {
|
|
|
42
50
|
data_generators: 'Data generators',
|
|
43
51
|
datasets: 'Data Reporter datasets',
|
|
44
52
|
team: 'Team Development: labels, milestones, templates and issues',
|
|
53
|
+
workspace_files: 'Static workspace files',
|
|
54
|
+
remote_servers: 'Remote servers (an existing server keeps its base URL)',
|
|
55
|
+
web_credentials: 'Web credentials (without their secrets)',
|
|
45
56
|
};
|
|
46
57
|
|
|
47
58
|
const MAX_ITEMS = 5000;
|
|
@@ -82,6 +93,14 @@ export async function exportWorkspace(workspaceId: number, parts: readonly Part[
|
|
|
82
93
|
if (want('datasets'))
|
|
83
94
|
doc.datasets = (await owner.query('select * from meta.dataset where workspace_id = $1 order by name', [workspaceId])).rows.map(datasetOf);
|
|
84
95
|
if (want('team')) doc.team = await exportTeam(workspaceId);
|
|
96
|
+
if (want('workspace_files'))
|
|
97
|
+
doc.workspace_files = (await owner.query(`select name, mime, translate(encode(content, 'base64'), E'\n', '') as content from meta.workspace_file where workspace_id = $1 order by name`, [workspaceId])).rows;
|
|
98
|
+
if (want('remote_servers'))
|
|
99
|
+
doc.remote_servers = (await owner.query('select static_id, name, description, base_url, env_var, credential from meta.remote_server where workspace_id = $1 order by static_id', [workspaceId])).rows;
|
|
100
|
+
if (want('web_credentials'))
|
|
101
|
+
// secrets never leave the installation
|
|
102
|
+
doc.web_credentials = (await owner.query(
|
|
103
|
+
'select name, description, type, username, header_name, token_url, scope, valid_for, grant_type, oauth_username from meta.workspace_credential where workspace_id = $1 order by name', [workspaceId])).rows;
|
|
85
104
|
return doc;
|
|
86
105
|
}
|
|
87
106
|
|
|
@@ -271,5 +290,65 @@ export async function importWorkspace(c: Client, workspaceId: number, doc: unkno
|
|
|
271
290
|
const t = await importTeam(c, workspaceId, d.team, opts);
|
|
272
291
|
tally('Team Development', t.added, t.replaced, t.skipped, t.issues ? `; ${t.issues} issue(s) with their comments` : '');
|
|
273
292
|
}
|
|
293
|
+
|
|
294
|
+
if (want('workspace_files')) {
|
|
295
|
+
// (sprint 141) the same names, types and sizes as an upload
|
|
296
|
+
const n = count();
|
|
297
|
+
for (const x of d.workspace_files as Json[]) {
|
|
298
|
+
const name = isObj(x) && typeof x.name === 'string' ? x.name : '';
|
|
299
|
+
const type = staticType(name);
|
|
300
|
+
if (!type) throw new Error(`Static workspace file ${JSON.stringify(name).slice(0, 110)}: not an allowed name or type.`);
|
|
301
|
+
const content = typeof x.content === 'string' ? Buffer.from(x.content, 'base64') : Buffer.alloc(0);
|
|
302
|
+
if (content.length > STATIC_MAX) throw new Error(`Static workspace file ${name}: larger than ${STATIC_MAX / 1024 / 1024} MB.`);
|
|
303
|
+
const existing = (await c.query<{ id: number }>('select id from meta.workspace_file where workspace_id = $1 and name = $2', [workspaceId, name])).rows[0];
|
|
304
|
+
n[await upsert(existing,
|
|
305
|
+
() => c.query('insert into meta.workspace_file (workspace_id, name, mime, content, updated_by) values ($1, $2, $3, $4, $5)', [workspaceId, name, type, content, opts.by]),
|
|
306
|
+
(id) => c.query('update meta.workspace_file set mime = $2, content = $3, updated_by = $4, updated_at = now() where id = $1', [id, type, content, opts.by]))]++;
|
|
307
|
+
}
|
|
308
|
+
tally('Static workspace files', n.added, n.replaced, n.skipped);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (want('remote_servers')) {
|
|
312
|
+
// (sprint 141) the base URL is this installation's: an existing server keeps its own, a new one takes the file's
|
|
313
|
+
const n = count();
|
|
314
|
+
for (const x of d.remote_servers as Json[]) {
|
|
315
|
+
if (!isObj(x)) continue;
|
|
316
|
+
const v = { static_id: x.static_id, name: x.name, description: text(x.description, 2000), base_url: text(x.base_url, 2000), env_var: text(x.env_var, 80), credential: text(x.credential, 60) };
|
|
317
|
+
const problems = serverProblems(v);
|
|
318
|
+
if (problems.length) throw new Error(`Remote server ${typeof x.static_id === 'string' ? x.static_id : '?'}: ${problems.join(' ')}`);
|
|
319
|
+
const existing = (await c.query<{ id: number }>('select id from meta.remote_server where workspace_id = $1 and static_id = $2', [workspaceId, v.static_id])).rows[0];
|
|
320
|
+
n[await upsert(existing,
|
|
321
|
+
() => c.query('insert into meta.remote_server (workspace_id, static_id, name, description, base_url, env_var, credential, updated_by) values ($1, $2, $3, $4, $5, $6, $7, $8)',
|
|
322
|
+
[workspaceId, v.static_id, v.name, v.description, v.base_url, v.env_var, v.credential, opts.by]),
|
|
323
|
+
(id) => c.query(`update meta.remote_server set name = $2, description = $3, env_var = $4, credential = $5, base_url = coalesce(base_url, $6),
|
|
324
|
+
updated_by = $7, updated_at = now() where id = $1`, [id, v.name, v.description, v.env_var, v.credential, v.base_url, opts.by]))]++;
|
|
325
|
+
}
|
|
326
|
+
tally('Remote servers', n.added, n.replaced, n.skipped, n.replaced ? '; existing servers kept their base URL' : '');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (want('web_credentials')) {
|
|
330
|
+
// (sprint 141) without secrets: an existing credential keeps its own, a new one gets them entered in the builder
|
|
331
|
+
const n = count();
|
|
332
|
+
for (const x of d.web_credentials as Json[]) {
|
|
333
|
+
if (!isObj(x)) continue;
|
|
334
|
+
const v = {
|
|
335
|
+
name: String(x.name ?? ''), description: text(x.description, 2000),
|
|
336
|
+
type: ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'].includes(x.type) ? x.type : 'basic',
|
|
337
|
+
username: text(x.username, 500), header_name: text(x.header_name, 64), token_url: text(x.token_url, 2000), scope: text(x.scope, 500),
|
|
338
|
+
valid_for: Array.isArray(x.valid_for) ? x.valid_for.filter((u: unknown) => typeof u === 'string') : [],
|
|
339
|
+
grant_type: GRANT_TYPES.includes(x.grant_type) ? x.grant_type : 'client_credentials', oauth_username: text(x.oauth_username, 500),
|
|
340
|
+
};
|
|
341
|
+
const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : ['the name is upper case letters, digits and _.']), ...credentialProblems(v)];
|
|
342
|
+
if (problems.length) throw new Error(`Web credential ${v.name || '?'}: ${problems.join(' ')}`);
|
|
343
|
+
const vals = [v.description, v.type, v.username, v.header_name, v.token_url, v.scope, v.valid_for, v.grant_type, v.oauth_username];
|
|
344
|
+
const existing = (await c.query<{ id: number }>('select id from meta.workspace_credential where workspace_id = $1 and name = $2', [workspaceId, v.name])).rows[0];
|
|
345
|
+
n[await upsert(existing,
|
|
346
|
+
() => c.query(`insert into meta.workspace_credential (workspace_id, name, description, type, username, header_name, token_url, scope, valid_for, grant_type, oauth_username, updated_by)
|
|
347
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [workspaceId, v.name, ...vals, opts.by]),
|
|
348
|
+
(id) => c.query(`update meta.workspace_credential set description = $2, type = $3, username = $4, header_name = $5, token_url = $6, scope = $7, valid_for = $8,
|
|
349
|
+
grant_type = $9, oauth_username = $10, updated_by = $11, updated_at = now() where id = $1`, [id, ...vals, opts.by]))]++;
|
|
350
|
+
}
|
|
351
|
+
tally('Web credentials', n.added, n.replaced, n.skipped, n.added ? '; enter their secrets under Workspace utilities → Web credentials' : '');
|
|
352
|
+
}
|
|
274
353
|
return r;
|
|
275
354
|
}
|