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.
@@ -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
+ }
@@ -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.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.');
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
- url: serviceUrl(c.base_url, s.path),
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
  );
@@ -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
- import type { FastifyInstance } from 'fastify';
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
- const src = `${ctx.base}/static/${encodeURIComponent(f.name)}?v=${f.v}`;
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
- const etag = `"${Number(f.v).toString(36)}"`;
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
- const col: Lookup = (n) => columns.get(n) ?? extra[n];
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 (typeof s.url !== 'string' || !/^https?:\/\/[^/\s]+/i.test(s.url)) out.push('The URL must start with http:// or https://.');
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
- return s;
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
- /** With the (encrypted) secret: the owner connection, never the runtime role. */
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 id, app_id, name, type, username, header_name, token_url, scope, valid_for, secret_enc, grant_type, oauth_username, password_enc, refresh_token_enc
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.id, c.username, c.token_url, c.scope, c.secret_enc, c.grant_type ?? null, c.oauth_username ?? null, c.password_enc ?? null])).digest('base64url');
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('update meta.web_credential set refresh_token_enc = $2, token_refreshed_at = now() where id = $1', [c.id, enc]);
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.id}:${credKey(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.id);
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(s: RestSource, name: OperationName, row: Record<string, unknown>, params: Record<string, string> = {}) {
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(s: RestSource, values: Record<string, string | null | undefined>) {
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}`);
@@ -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'] as const;
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
  }