pgkiln 0.72.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/140_item_region_types.sql +27 -0
- package/db/migrations/141_workspace_resources.sql +115 -0
- package/docs/apex-feature-parity.md +91 -12
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +47 -4
- package/docs/guide/04-pages-and-regions.md +41 -1
- package/docs/guide/05-items.md +43 -3
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/12-development.md +25 -3
- package/docs/guide/16-files.md +29 -0
- package/docs/guide/19-rest-data-sources.md +41 -3
- package/examples/hr/hr_75_item_region_types.sql +81 -0
- package/examples/hr/hr_76_remote_servers.sql +48 -0
- package/package.json +1 -1
- package/public/app.css +48 -0
- package/public/app.js +377 -1
- package/src/ai/service.ts +10 -1
- package/src/app.ts +2 -0
- package/src/builder/advisor.ts +18 -2
- package/src/builder/ai.ts +29 -3
- package/src/builder/arrange.ts +7 -0
- package/src/builder/components.ts +5 -5
- package/src/builder/designer.ts +9 -3
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/network-access.ts +33 -0
- package/src/builder/region-help.ts +8 -2
- 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/i18n/ar.ts +13 -0
- package/src/i18n/cs.ts +13 -0
- package/src/i18n/da.ts +13 -0
- package/src/i18n/de.ts +13 -0
- package/src/i18n/el.ts +13 -0
- package/src/i18n/es.ts +13 -0
- package/src/i18n/fi.ts +13 -0
- package/src/i18n/fr.ts +13 -0
- package/src/i18n/he.ts +13 -0
- package/src/i18n/it.ts +13 -0
- package/src/i18n/ja.ts +13 -0
- package/src/i18n/ko.ts +13 -0
- package/src/i18n/nb.ts +13 -0
- package/src/i18n/pl.ts +13 -0
- package/src/i18n/pt.ts +13 -0
- package/src/i18n/ru.ts +13 -0
- package/src/i18n/sv.ts +13 -0
- package/src/i18n/tr.ts +13 -0
- package/src/i18n/uk.ts +13 -0
- package/src/i18n/zh.ts +13 -0
- package/src/i18n.ts +26 -0
- package/src/metadata.ts +22 -5
- 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/items.ts +32 -3
- package/src/runtime/page-regions.ts +50 -0
- package/src/runtime/region-cache.ts +1 -1
- package/src/runtime/regions.ts +11 -0
- package/src/runtime/render.ts +3 -17
- package/src/runtime/report.ts +55 -3
- 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
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
|
}
|