pgkiln 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/SECURITY.md +11 -9
  2. package/db/migrations/087_login_page.sql +175 -0
  3. package/db/migrations/088_region_templates.sql +18 -0
  4. package/db/migrations/089_page_groups.sql +330 -0
  5. package/db/migrations/090_workspace_isolation.sql +512 -0
  6. package/docs/apex-feature-parity.md +23 -21
  7. package/docs/guide/03-builder.md +85 -8
  8. package/docs/guide/04-pages-and-regions.md +42 -3
  9. package/docs/guide/08-security.md +46 -5
  10. package/docs/guide/11-from-apex.md +4 -1
  11. package/docs/guide/12-development.md +13 -3
  12. package/docs/guide/17-mobile.md +3 -1
  13. package/docs/guide/18-cli.md +2 -0
  14. package/docs/guide/20-ai-agents.md +2 -0
  15. package/examples/hr/hr_40_sample_data.sql +1 -1
  16. package/examples/hr/hr_54_region_templates.sql +67 -0
  17. package/examples/hr/hr_55_page_groups.sql +24 -0
  18. package/package.json +1 -1
  19. package/public/app.css +60 -3
  20. package/public/app.js +89 -3
  21. package/public/builder.css +11 -0
  22. package/public/builder.js +50 -0
  23. package/public/code-editor.css +3 -3
  24. package/public/sw.js +5 -3
  25. package/src/app.ts +20 -0
  26. package/src/appfiles.ts +1 -0
  27. package/src/automations.ts +3 -2
  28. package/src/builder/advisor.ts +25 -5
  29. package/src/builder/ai-builder.ts +27 -18
  30. package/src/builder/blueprints.ts +7 -7
  31. package/src/builder/code-editor.ts +3 -2
  32. package/src/builder/components.ts +15 -2
  33. package/src/builder/dataload.ts +24 -14
  34. package/src/builder/ddl-tools.ts +117 -0
  35. package/src/builder/designer.ts +29 -3
  36. package/src/builder/documents.ts +2 -1
  37. package/src/builder/forms.ts +6 -4
  38. package/src/builder/home.ts +6 -2
  39. package/src/builder/login-page.ts +82 -0
  40. package/src/builder/newapp.ts +21 -3
  41. package/src/builder/quicksql.ts +3 -2
  42. package/src/builder/region-help.ts +4 -2
  43. package/src/builder/region-settings.ts +19 -3
  44. package/src/builder/report-settings.ts +4 -2
  45. package/src/builder/routes.ts +18 -6
  46. package/src/builder/sampledata.ts +22 -18
  47. package/src/builder/scripts.ts +27 -16
  48. package/src/builder/shared.ts +1 -1
  49. package/src/builder/sql.ts +18 -10
  50. package/src/builder/ui.ts +2 -2
  51. package/src/builder/unload.ts +3 -2
  52. package/src/builder/workspaces.ts +175 -15
  53. package/src/cli/main.ts +45 -0
  54. package/src/cli/mcp.ts +42 -0
  55. package/src/cli/replace.ts +4 -0
  56. package/src/db.ts +36 -6
  57. package/src/ddl.ts +510 -0
  58. package/src/debug.ts +3 -2
  59. package/src/i18n/ar.ts +17 -0
  60. package/src/i18n/cs.ts +17 -0
  61. package/src/i18n/da.ts +17 -0
  62. package/src/i18n/de.ts +17 -0
  63. package/src/i18n/el.ts +17 -0
  64. package/src/i18n/es.ts +17 -0
  65. package/src/i18n/fi.ts +17 -0
  66. package/src/i18n/fr.ts +17 -0
  67. package/src/i18n/he.ts +17 -0
  68. package/src/i18n/it.ts +17 -0
  69. package/src/i18n/ja.ts +17 -0
  70. package/src/i18n/ko.ts +17 -0
  71. package/src/i18n/nb.ts +17 -0
  72. package/src/i18n/pl.ts +17 -0
  73. package/src/i18n/pt.ts +17 -0
  74. package/src/i18n/ru.ts +17 -0
  75. package/src/i18n/sv.ts +17 -0
  76. package/src/i18n/tr.ts +17 -0
  77. package/src/i18n/uk.ts +17 -0
  78. package/src/i18n/zh.ts +17 -0
  79. package/src/i18n.ts +34 -0
  80. package/src/icons.ts +2 -1
  81. package/src/layout.ts +3 -2
  82. package/src/metadata.ts +10 -4
  83. package/src/migrate.ts +3 -0
  84. package/src/process-jobs.ts +7 -2
  85. package/src/restsync.ts +3 -2
  86. package/src/runtime/devbar.ts +3 -1
  87. package/src/runtime/display-selector.ts +12 -4
  88. package/src/runtime/grid.ts +170 -26
  89. package/src/runtime/lists.ts +11 -1
  90. package/src/runtime/password-reset.ts +171 -0
  91. package/src/runtime/pwa.ts +7 -10
  92. package/src/runtime/regions.ts +33 -0
  93. package/src/runtime/render.ts +15 -0
  94. package/src/runtime/report-views.ts +4 -2
  95. package/src/runtime/report.ts +36 -22
  96. package/src/runtime/routes.ts +39 -33
  97. package/src/runtime/signin.ts +80 -0
  98. package/src/sampledata.ts +3 -3
  99. package/src/session.ts +5 -2
  100. package/src/static-version.ts +34 -0
  101. package/src/websources.ts +3 -1
  102. package/src/workflow.ts +65 -9
  103. package/src/workspace-db.ts +166 -0
@@ -7,12 +7,14 @@ import { clientIp, logActivity, type Session } from '../session.ts';
7
7
  import { runScript, splitScript, type ScriptRun, type StatementResult } from '../sqlscript.ts';
8
8
  import { clearCompletions } from './code-editor.ts';
9
9
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
10
+ import { currentWorkspace, workshopDb } from './workspaces.ts';
10
11
 
11
12
  // SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
12
13
  // delete), run with a result per statement (stop at the first error or go on,
13
14
  // optionally in one transaction), and the run history. Scripts run on the
14
15
  // owner connection, like SQL Commands, on a connection of their own that is
15
- // closed afterwards (a script may SET ROLE or change settings).
16
+ // closed afterwards (a script may SET ROLE or change settings). Scripts and runs belong
17
+ // to the current workspace; an isolated workspace's run on its developer login (090).
16
18
 
17
19
  /** Largest script (upload or editor), in MB. */
18
20
  export const SCRIPT_MAX_MB = 5;
@@ -30,7 +32,7 @@ export interface RunRequest {
30
32
  /** Run a script, record the run (history + activity log) and return the run's id. */
31
33
  export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promise<{ id: string; run: ScriptRun }> {
32
34
  const statements = splitScript(r.content);
33
- const c = await owner.pool.connect();
35
+ const c = await (await workshopDb(s)).pool.connect();
34
36
  let run: ScriptRun;
35
37
  try {
36
38
  run = await runScript(c, statements, { stopOnError: r.stopOnError, transaction: r.transaction });
@@ -40,12 +42,17 @@ export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promi
40
42
  }
41
43
  clearCompletions(); // the script may have changed tables or grants
42
44
  const saved = await owner.one<{ id: string }>(
43
- `insert into meta.sql_script_run (script_id, script_name, run_by, elapsed_ms, stop_on_error, transactional, statements, succeeded, failed, rolled_back, results)
44
- values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning id`,
45
+ `insert into meta.sql_script_run (script_id, script_name, run_by, elapsed_ms, stop_on_error, transactional, statements, succeeded, failed, rolled_back, results, workspace_id)
46
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) returning id`,
45
47
  [r.scriptId, r.name, s.username, run.ms, r.stopOnError, r.transaction, run.statements, run.succeeded, run.failed, run.rolledBack,
46
- JSON.stringify(run.commitError ? [...run.results, { n: 0, line: 0, sql: 'COMMIT', status: 'error', error: run.commitError, ms: 0 }] : run.results)],
48
+ JSON.stringify(run.commitError ? [...run.results, { n: 0, line: 0, sql: 'COMMIT', status: 'error', error: run.commitError, ms: 0 }] : run.results),
49
+ currentWorkspace(s)],
50
+ );
51
+ await owner.query(
52
+ `delete from meta.sql_script_run where workspace_id = $2
53
+ and id <= (select id from meta.sql_script_run where workspace_id = $2 order by id desc offset $1 limit 1)`,
54
+ [KEEP_RUNS, currentWorkspace(s)],
47
55
  );
48
- await owner.query(`delete from meta.sql_script_run where id <= (select id from meta.sql_script_run order by id desc offset $1 limit 1)`, [KEEP_RUNS]);
49
56
  await logActivity({
50
57
  username: s.username,
51
58
  event: 'sql_script',
@@ -121,10 +128,12 @@ export async function scriptRoutes(app: FastifyInstance) {
121
128
  r.started_at as last_run, r.failed as last_failed
122
129
  from meta.sql_script s
123
130
  left join lateral (select started_at, failed from meta.sql_script_run x where x.script_id = s.id order by x.id desc limit 1) r on true
131
+ where s.workspace_id = $1
124
132
  order by lower(s.name)`,
133
+ [currentWorkspace(s)],
125
134
  )
126
135
  ).rows;
127
- const runs = (await owner.query('select * from meta.sql_script_run order by id desc limit 20')).rows;
136
+ const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
128
137
  const list = scripts.length
129
138
  ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>File</th></tr></thead><tbody>
130
139
  ${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
@@ -193,7 +202,7 @@ export async function scriptRoutes(app: FastifyInstance) {
193
202
  app.get(`${BASE}/sql/scripts/:id`, async (req: Req, reply) => {
194
203
  const s = await developer(req, reply);
195
204
  if (!s) return;
196
- const row = /^\d+$/.test(req.params.id) ? await owner.one('select * from meta.sql_script where id = $1', [req.params.id]) : undefined;
205
+ const row = /^\d+$/.test(req.params.id) ? await owner.one('select * from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
197
206
  if (!row) return reply.code(404).send('Not found');
198
207
  return editor(req, reply, s, row);
199
208
  });
@@ -220,15 +229,15 @@ export async function scriptRoutes(app: FastifyInstance) {
220
229
  try {
221
230
  if (id) {
222
231
  const r = await owner.one(
223
- 'update meta.sql_script set name = $2, description = nullif($3, \'\'), content = $4, updated_by = $5, updated_at = now() where id = $1 returning id',
224
- [id, name, b.description ?? '', content, s.username],
232
+ 'update meta.sql_script set name = $2, description = nullif($3, \'\'), content = $4, updated_by = $5, updated_at = now() where id = $1 and workspace_id = $6 returning id',
233
+ [id, name, b.description ?? '', content, s.username, currentWorkspace(s)],
225
234
  );
226
235
  if (!r) return reply.code(404).send('Not found');
227
236
  scriptId = r.id;
228
237
  } else {
229
238
  const r = await owner.one(
230
- `insert into meta.sql_script (name, description, content, created_by, updated_by) values ($1, nullif($2, ''), $3, $4, $4) returning id`,
231
- [name, b.description ?? '', content, s.username],
239
+ `insert into meta.sql_script (name, description, content, created_by, updated_by, workspace_id) values ($1, nullif($2, ''), $3, $4, $4, $5) returning id`,
240
+ [name, b.description ?? '', content, s.username, currentWorkspace(s)],
232
241
  );
233
242
  scriptId = r!.id;
234
243
  }
@@ -260,6 +269,8 @@ export async function scriptRoutes(app: FastifyInstance) {
260
269
  if (!s) return;
261
270
  if (!/^\d+$/.test(req.params.id)) return reply.code(404).send('Not found');
262
271
  const r = await owner.tx(async (c) => {
272
+ const own = (await c.query('select 1 from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)])).rowCount;
273
+ if (!own) return undefined;
263
274
  await c.query('delete from meta.sql_script_run where script_id = $1', [req.params.id]);
264
275
  return (await c.query('delete from meta.sql_script where id = $1 returning name', [req.params.id])).rows[0];
265
276
  });
@@ -270,7 +281,7 @@ export async function scriptRoutes(app: FastifyInstance) {
270
281
  app.get(`${BASE}/sql/scripts/:id/download`, async (req: Req, reply) => {
271
282
  const s = await developer(req, reply);
272
283
  if (!s) return;
273
- const row = /^\d+$/.test(req.params.id) ? await owner.one('select name, content from meta.sql_script where id = $1', [req.params.id]) : undefined;
284
+ const row = /^\d+$/.test(req.params.id) ? await owner.one('select name, content from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
274
285
  if (!row) return reply.code(404).send('Not found');
275
286
  return reply
276
287
  .header('content-type', 'application/sql; charset=utf-8')
@@ -302,8 +313,8 @@ export async function scriptRoutes(app: FastifyInstance) {
302
313
  if (content.includes('\0')) return fail('The file is not a text file.');
303
314
  const base = file.filename.replace(/^.*[\\/]/, '').replace(/\.sql$/i, '').slice(0, 180) || 'script';
304
315
  let name = base;
305
- for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1', [name]); n++) name = `${base} (${n})`;
306
- const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by) values ($1, $2, $3, $3) returning id`, [name, content, s.username]);
316
+ for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [name, currentWorkspace(s)]); n++) name = `${base} (${n})`;
317
+ const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [name, content, s.username, currentWorkspace(s)]);
307
318
  flash(s, `Script “${name}” uploaded.`);
308
319
  return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
309
320
  });
@@ -312,7 +323,7 @@ export async function scriptRoutes(app: FastifyInstance) {
312
323
  app.get(`${BASE}/sql/scripts/runs/:run`, async (req: Req, reply) => {
313
324
  const s = await developer(req, reply);
314
325
  if (!s) return;
315
- const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1', [req.params.run]) : undefined;
326
+ const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1 and workspace_id = $2', [req.params.run, currentWorkspace(s)]) : undefined;
316
327
  if (!run) return reply.code(404).send('Not found');
317
328
  const exists = run.script_id ? await owner.one('select id from meta.sql_script where id = $1', [run.script_id]) : undefined;
318
329
  const options = `${run.stop_on_error ? 'stop on error' : 'continue on error'}${run.transactional ? ', one transaction' : ''}`;
@@ -33,7 +33,7 @@ function listExtras(appId: number, list: any, entries: any[]): Raw {
33
33
 
34
34
  export async function sharedRoutes(app: FastifyInstance) {
35
35
  // ---------------------------------------------------------------- shared components
36
- const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
36
+ const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
37
37
 
38
38
  const ORDER: Record<string, string> = {
39
39
  search_config: 'seq, name', dynamic_translation: 'language, source',
@@ -1,6 +1,5 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import pg from 'pg';
3
- import { owner } from '../db.ts';
4
3
  import { html, raw, type Raw } from '../html.ts';
5
4
  import { icon } from '../icons.ts';
6
5
  import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
@@ -10,8 +9,11 @@ import { scriptRoutes } from './scripts.ts';
10
9
  import { quickSqlRoutes } from './quicksql.ts';
11
10
  import { queryBuilderRoutes } from './querybuilder.ts';
12
11
  import { unloadRoutes } from './unload.ts';
12
+ import { ddlToolRoutes } from './ddl-tools.ts';
13
+ import { workshopDb, workshopRunsAs } from './workspaces.ts';
13
14
 
14
- // SQL Workshop: SQL commands and the object browser (owner connection).
15
+ // SQL Workshop: SQL commands and the object browser (owner connection; an isolated
16
+ // workspace's developer login for its developers, see workshopDb).
15
17
 
16
18
  export async function sqlRoutes(app: FastifyInstance) {
17
19
  // SQL Scripts, Quick SQL and the query builder (sprint 31)
@@ -20,6 +22,8 @@ export async function sqlRoutes(app: FastifyInstance) {
20
22
  await app.register(queryBuilderRoutes);
21
23
  // Unload Data (sprint 32)
22
24
  await app.register(unloadRoutes);
25
+ // Generate DDL and Schema Comparison (sprint 56)
26
+ await app.register(ddlToolRoutes);
23
27
  // ---------------------------------------------------------------- SQL workshop
24
28
  const resultTable = (res: pg.QueryResult<any[]>, limit = 500) => {
25
29
  const rows = (res.rows ?? []).slice(0, limit);
@@ -32,12 +36,13 @@ export async function sqlRoutes(app: FastifyInstance) {
32
36
  if (!s) return;
33
37
  const sql = req.body?.sql ?? '';
34
38
  let result: Raw | '' = '';
39
+ const db = await workshopDb(s);
35
40
  if (req.method === 'POST' && sql.trim()) {
36
41
  const started = performance.now();
37
42
  try {
38
43
  // a connection of its own, closed afterwards: the SQL may SET ROLE or change settings,
39
44
  // which must not leak into the builder's pooled owner connections
40
- const c = await owner.pool.connect();
45
+ const c = await db.pool.connect();
41
46
  let out;
42
47
  try {
43
48
  out = await c.query({ text: sql, rowMode: 'array' });
@@ -56,7 +61,7 @@ export async function sqlRoutes(app: FastifyInstance) {
56
61
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
57
62
  }
58
63
  const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
59
- <p class="muted">Runs as the builder's owner connection (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
64
+ <p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
60
65
  <form method="post">${csrf(s)}
61
66
  <textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${sql || 'select current_user, version();'}</textarea>
62
67
  <div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
@@ -70,17 +75,20 @@ export async function sqlRoutes(app: FastifyInstance) {
70
75
  app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
71
76
  const s = await developer(req, reply);
72
77
  if (!s) return;
78
+ const db = await workshopDb(s);
73
79
  const objects = (
74
- await owner.query(
80
+ await db.query(
75
81
  `select n.nspname as schema, c.relname as name, c.oid::regclass::text as qname,
76
82
  case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'view' end as kind,
77
83
  c.relrowsecurity as rls
78
84
  from pg_class c join pg_namespace n on n.oid = c.relnamespace
79
85
  where c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema')
86
+ and has_schema_privilege(n.oid, 'USAGE')
80
87
  union all
81
88
  select n.nspname, p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', p.oid::regprocedure::text, 'function', false
82
89
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace
83
90
  where n.nspname !~ '^pg_' and n.nspname not in ('information_schema') and p.prokind in ('f', 'p')
91
+ and has_schema_privilege(n.oid, 'USAGE')
84
92
  order by 1, 4 desc, 2`,
85
93
  )
86
94
  ).rows;
@@ -89,24 +97,24 @@ export async function sqlRoutes(app: FastifyInstance) {
89
97
  let detail: Raw = html`<p class="muted">Select a table, view or function.</p>`;
90
98
  if (selected) {
91
99
  if (selected.kind === 'function') {
92
- const src = await owner.one('select pg_get_functiondef($1::regprocedure) as def', [selected.qname]);
100
+ const src = await db.one('select pg_get_functiondef($1::regprocedure) as def', [selected.qname]);
93
101
  detail = region(selected.qname, html`<pre class="source">${src.def}</pre>`);
94
102
  } else {
95
103
  const [cols, policies, grants, data] = await Promise.all([
96
- owner.query(
104
+ db.query(
97
105
  `select a.attname as column, format_type(a.atttypid, a.atttypmod) as type, not a.attnotnull as nullable,
98
106
  pg_get_expr(d.adbin, d.adrelid) as "default"
99
107
  from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
100
108
  where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped order by a.attnum`,
101
109
  [selected.qname],
102
110
  ),
103
- owner.query('select policyname as policy, cmd, roles::text, qual as using, with_check from pg_policies where schemaname = $1 and tablename = $2', [selected.schema, selected.name]),
104
- owner.query(
111
+ db.query('select policyname as policy, cmd, roles::text, qual as using, with_check from pg_policies where schemaname = $1 and tablename = $2', [selected.schema, selected.name]),
112
+ db.query(
105
113
  `select grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges
106
114
  from information_schema.role_table_grants where table_schema = $1 and table_name = $2 group by grantee order by grantee`,
107
115
  [selected.schema, selected.name],
108
116
  ),
109
- owner.pool.query({ text: `select * from ${selected.qname} limit 25`, rowMode: 'array' }).catch((e) => e as Error),
117
+ db.pool.query({ text: `select * from ${selected.qname} limit 25`, rowMode: 'array' }).catch((e) => e as Error),
110
118
  ]);
111
119
  const asArray = (r: pg.QueryResult) => ({ ...r, rows: r.rows.map((x) => Object.values(x)) }) as unknown as pg.QueryResult<any[]>;
112
120
  detail = html`${region(`${selected.kind === 'view' ? 'View' : 'Table'} ${selected.qname}`, resultTable(asArray(cols)),
package/src/builder/ui.ts CHANGED
@@ -209,13 +209,13 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
209
209
  };
210
210
 
211
211
  /** Tabs of the SQL Workshop. */
212
- export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai';
212
+ export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
213
213
  export const workshopTabs = (active: WorkshopTab) => {
214
214
  const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
215
215
  html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
216
216
  return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
217
217
  ${tab('sql', '/sql', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
218
- ${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
218
+ ${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
219
219
  };
220
220
 
221
221
  /** Number of app tile colours (.app-color-0 … -7 in app.css). */
@@ -21,6 +21,7 @@ import {
21
21
  type UnloadOptions,
22
22
  } from '../unload.ts';
23
23
  import { BASE, csrf, developer, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
24
+ import { workshopDb } from './workspaces.ts';
24
25
 
25
26
  // SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
26
27
  // view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
@@ -125,7 +126,7 @@ export async function unloadRoutes(app: FastifyInstance) {
125
126
  <a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>Query</span></a></nav>`;
126
127
 
127
128
  const intro = html`<p class="muted">Download the rows of a table, a view or a query as CSV, JSON, Excel or XML (up to ${DOWNLOAD_MAX_ROWS.toLocaleString('en')} rows).
128
- Runs as the builder's owner connection in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
129
+ Runs as the builder's owner connection (an isolated workspace's developer login for its developers) in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
129
130
 
130
131
  const form = async (s: Session, b: Body, error?: string) => {
131
132
  const alert = error ? html`<div class="alert alert-error" role="alert">${error}</div>` : '';
@@ -212,7 +213,7 @@ export async function unloadRoutes(app: FastifyInstance) {
212
213
  if (typeof opts === 'string') return refuse(opts);
213
214
 
214
215
  const started = performance.now();
215
- const c = await owner.pool.connect();
216
+ const c = await (await workshopDb(s)).pool.connect();
216
217
  let opened;
217
218
  try {
218
219
  await c.query('begin transaction read only');
@@ -1,5 +1,8 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
- import { owner } from '../db.ts';
2
+ import { owner, type Db } from '../db.ts';
3
+ import { devDbForWorkspace, workspacesChanged } from '../workspace-db.ts';
4
+ import { ownerUrl, runtimeUrl } from '../db.ts';
5
+ import { secretKeyConfigured } from '../secrets.ts';
3
6
  import { html, raw } from '../html.ts';
4
7
  import { icon } from '../icons.ts';
5
8
  import { clientIp, logActivity, type Session } from '../session.ts';
@@ -68,6 +71,19 @@ export async function appAllowed(s: Session, appId: number | string) {
68
71
  return true;
69
72
  }
70
73
 
74
+ /**
75
+ * The connection for a developer's own SQL (the SQL Workshop's tools): the owner, or in an
76
+ * isolated workspace (090) its developer login, which reaches the workspace's application
77
+ * roles only. Administrators keep the owner.
78
+ */
79
+ export async function workshopDb(s: Session): Promise<Db> {
80
+ if (!s.workspace || (await isAdmin(s.username))) return owner;
81
+ return (await devDbForWorkspace(s.workspace.id)) ?? owner;
82
+ }
83
+
84
+ /** Who the SQL Workshop runs as, for its notes. */
85
+ export const workshopRunsAs = (db: Db) => (db === owner ? 'the builder\'s owner connection' : 'this isolated workspace\'s developer login (its applications\' roles, nothing else)');
86
+
71
87
  /** Put an application in a workspace (inside the caller's transaction, or on the owner pool). */
72
88
  export async function placeApp(db: { query: (sql: string, params?: unknown[]) => Promise<unknown> }, appId: number, workspaceId: number) {
73
89
  await db.query(
@@ -99,6 +115,18 @@ async function adminOnly(s: Session, reply: FastifyReply) {
99
115
  return false;
100
116
  }
101
117
 
118
+ /** An administrator, or a workspace administrator of this workspace (090). */
119
+ export async function workspaceAdmin(username: string | null, workspaceId: number | string) {
120
+ if (!username) return false;
121
+ if (await isAdmin(username)) return true;
122
+ return !!(await owner.one('select 1 as ok from meta.workspace_member where workspace_id = $1 and username = $2 and is_admin', [workspaceId, username]));
123
+ }
124
+
125
+ async function workspaceAdminOnly(s: Session, reply: FastifyReply, workspaceId: string) {
126
+ if (await workspaceAdmin(s.username, workspaceId)) return true;
127
+ return adminOnly(s, reply);
128
+ }
129
+
102
130
  const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Workspaces', `${BASE}/workspaces`], ...extra];
103
131
 
104
132
  const nameError = (e: unknown) => {
@@ -108,6 +136,49 @@ const nameError = (e: unknown) => {
108
136
  return m;
109
137
  };
110
138
 
139
+ const ROLE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
140
+
141
+ /** Why this installation can't isolate a workspace (090), or null. */
142
+ export function isolationProblem(): string | null {
143
+ if (!secretKeyConfigured()) return 'set PGKILN_SECRET_KEY (at least 32 characters) in the server\'s environment: the workspace\'s database logins get passwords derived from it.';
144
+ if (runtimeUrl === ownerUrl) return 'set RUNTIME_DATABASE_URL: applications run on the owner connection, so no workspace can be isolated.';
145
+ return null;
146
+ }
147
+
148
+ /** A role problem from meta.workspace_sync_roles (or a duplicate role) in words. */
149
+ const roleError = (e: unknown) => {
150
+ const m = (e as Error).message;
151
+ if (m.includes('workspace_role_role_name_key')) return 'this role belongs to another workspace already.';
152
+ if (m.includes('workspace_role_pkey')) return 'the workspace has this role already.';
153
+ return m;
154
+ };
155
+
156
+ /** The Isolation region of a workspace's page. */
157
+ function isolationRegion(s: Session, w: { id: number; name: string; isolated: boolean; run_role: string | null; dev_role: string | null },
158
+ roles: { role_name: string; exists: boolean; apps: string | null }[]) {
159
+ const problem = w.isolated ? null : isolationProblem();
160
+ const body = w.isolated
161
+ ? html`<p class="u-mt0"><span class="tag">${icon('lock')} isolated</span> Its applications run as <code>${w.run_role}</code> and its developers' SQL (SQL Workshop, code checks, the Advisor) as <code>${w.dev_role}</code>. Both reach only the roles below, and see only this workspace's applications, sessions and accounts in pgkiln's own tables. Developers who also belong to a shared workspace keep that workspace's rights there.</p>
162
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Database role</th><th>Used by</th><th></th></tr></thead><tbody>
163
+ ${roles.length
164
+ ? roles.map((r) => html`<tr><td data-label="Database role"><code>${r.role_name}</code>${r.exists ? '' : html` <span class="tag tag-error">missing</span>`}</td>
165
+ <td data-label="Used by">${r.apps ?? html`<span class="muted">no application</span>`}</td>
166
+ <td><form method="post" action="${BASE}/workspaces/${w.id}/roles">${csrf(s)}<input type="hidden" name="role" value="${r.role_name}">
167
+ <button class="btn btn-sm" name="action" value="remove">Remove</button></form></td></tr>`)
168
+ : html`<tr><td colspan="3" class="empty">No database roles: applications here can't run as a role of their own.</td></tr>`}</tbody></table></div>
169
+ <form method="post" action="${BASE}/workspaces/${w.id}/roles" class="u-mt1">${csrf(s)}
170
+ ${input('role', 'Add a database role', '', { help: 'An existing role (not a superuser, not one that creates roles or bypasses row-level security). Its rights become this workspace\'s; it can\'t belong to another workspace.' })}
171
+ <div class="buttons"><button class="btn" name="action" value="add">${icon('plus')} Add role</button></div></form>
172
+ <form method="post" action="${BASE}/workspaces/${w.id}/isolation" class="danger-zone">${csrf(s)}<input type="hidden" name="isolated" value="false">
173
+ <button class="btn btn-danger" data-confirm="Make ${w.name} shared again? Its applications then run on the shared runtime connection, and its developers' SQL as the owner.">Make shared</button></form>`
174
+ : html`<p class="u-mt0">Shared: the applications run on the shared runtime connection and the developers' SQL as the owner, like every shared workspace's.</p>
175
+ <p class="muted small">Isolating gives the workspace two database logins of its own and the database roles its applications use now. Application code then reaches only those roles and this workspace's applications; its developers' SQL Workshop runs on the workspace's login instead of the owner. New applications get a new role and schema of their own.</p>
176
+ ${problem ? html`<div class="alert alert-error" role="alert">To isolate a workspace, ${problem}</div>` : ''}
177
+ <form method="post" action="${BASE}/workspaces/${w.id}/isolation">${csrf(s)}<input type="hidden" name="isolated" value="true">
178
+ <div class="buttons"><button class="btn btn-hot"${problem ? raw(' disabled') : ''} data-confirm="Isolate ${w.name}?">${icon('lock')} Isolate this workspace</button></div></form>`;
179
+ return region('Isolation', body);
180
+ }
181
+
111
182
  export async function workspaceRoutes(app: FastifyInstance) {
112
183
  // switch the current workspace (any of the developer's own)
113
184
  app.post(`${BASE}/workspace`, async (req: Req, reply) => {
@@ -125,7 +196,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
125
196
  if (!s || !(await adminOnly(s, reply))) return;
126
197
  const rows = (
127
198
  await owner.query(
128
- `select w.id, w.name, w.description,
199
+ `select w.id, w.name, w.description, w.isolated,
129
200
  (select count(*) from meta.app a where meta.app_workspace(a.id) = w.id)::int as apps,
130
201
  (select string_agg(m.username, ', ' order by m.username) from meta.workspace_member m where m.workspace_id = w.id) as members
131
202
  from meta.workspace w order by w.id = 1 desc, lower(w.name)`,
@@ -133,12 +204,12 @@ export async function workspaceRoutes(app: FastifyInstance) {
133
204
  ).rows;
134
205
  const main = html`
135
206
  <div class="title-row"><h1>Workspaces</h1></div>
136
- <p class="muted u-mt0">A workspace groups applications and the developers who build them. Developers see and change the applications of their own workspaces; administrators see all of them. Workspaces are not a security boundary between developers who write SQL: the SQL Workshop and application code run with this installation's rights. Give tenants who must not see each other separate installations.</p>
207
+ <p class="muted u-mt0">A workspace groups applications and the developers who build them. Developers see and change the applications of their own workspaces; administrators see all of them. A shared workspace is not a security boundary between developers who write SQL: its SQL Workshop and application code run with this installation's rights. An <strong>isolated</strong> workspace is: its applications and SQL run on database logins of its own that reach only its database roles and its own applications.</p>
137
208
  <div class="columns wide-left">
138
209
  ${region('Workspaces', html`<div class="table-wrap"><table class="report report-reflow">
139
210
  <thead><tr><th>Workspace</th><th class="num">Applications</th><th>Developers</th></tr></thead>
140
211
  <tbody>${rows.map((w) => html`<tr>
141
- <td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}">${w.name}</a>${w.description ? html` <span class="muted">${w.description}</span>` : ''}</td>
212
+ <td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}">${w.name}</a>${w.isolated ? html` <span class="tag">${icon('lock')} isolated</span>` : ''}${w.description ? html` <span class="muted">${w.description}</span>` : ''}</td>
142
213
  <td class="num" data-label="Applications">${w.apps}</td>
143
214
  <td data-label="Developers">${w.members ?? html`<span class="muted">administrators only</span>`}</td></tr>`)}</tbody></table></div>`)}
144
215
  ${region('Add workspace', html`<form method="post" action="${BASE}/workspaces">${csrf(s)}
@@ -175,18 +246,26 @@ export async function workspaceRoutes(app: FastifyInstance) {
175
246
 
176
247
  app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
177
248
  const s = await developer(req, reply);
178
- if (!s || !(await adminOnly(s, reply))) return;
249
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
250
+ const full = await isAdmin(s.username);
179
251
  const w = await owner.one('select * from meta.workspace where id = $1', [req.params.id]);
180
252
  if (!w) return reply.code(404).send('Not found');
181
- const [devs, apps, others] = await Promise.all([
253
+ const [devs, apps, others, rolesQ] = await Promise.all([
182
254
  owner.query(
183
- `select d.username, d.is_admin, m.username is not null as member
255
+ `select d.username, d.is_admin, m.username is not null as member, coalesce(m.is_admin, false) as ws_admin
184
256
  from meta.developer d left join meta.workspace_member m on m.username = d.username and m.workspace_id = $1 order by d.username`,
185
257
  [w.id],
186
258
  ),
187
259
  owner.query(`select a.id, a.name, a.alias from meta.app a where meta.app_workspace(a.id) = $1 order by lower(a.name), a.id`, [w.id]),
188
260
  owner.query('select id, name from meta.workspace where id <> $1 order by id = 1 desc, lower(name)', [w.id]),
261
+ owner.query(
262
+ `select x.role_name, exists (select 1 from pg_roles r where r.rolname = x.role_name) as exists,
263
+ (select string_agg(a.alias, ', ' order by a.alias) from meta.app a where a.db_role = x.role_name) as apps
264
+ from meta.workspace_role x where x.workspace_id = $1 order by 1`,
265
+ [w.id],
266
+ ),
189
267
  ]);
268
+ const roles = rolesQ;
190
269
  const main = html`
191
270
  <div class="title-row"><h1>${w.name}</h1></div>
192
271
  <div class="columns wide-left">
@@ -194,7 +273,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
194
273
  ${input('name', 'Name', w.name, { required: true })}
195
274
  ${input('description', 'Description', w.description ?? '')}
196
275
  <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
197
- ${w.id === 1
276
+ ${!full ? '' : w.id === 1
198
277
  ? html`<p class="muted small">The Default workspace can't be deleted: imported applications and applications made with the command line start here.</p>`
199
278
  : html`<form method="post" action="${BASE}/workspaces/${w.id}/delete" class="danger-zone">${csrf(s)}
200
279
  <button class="btn btn-danger" data-confirm="Delete workspace ${w.name}?"${apps.rows.length ? raw(' disabled') : ''}>Delete workspace</button>
@@ -204,9 +283,13 @@ export async function workspaceRoutes(app: FastifyInstance) {
204
283
  <fieldset class="field"><legend class="sr-only">Developers</legend><div class="radio-group">
205
284
  ${devs.rows.map((d) => html`<label class="check"><input type="checkbox" name="member" value="${d.username}"${d.member ? raw(' checked') : ''}> ${d.username}${d.is_admin ? html` <span class="muted">(administrator)</span>` : ''}</label>`)}
206
285
  </div></fieldset>
286
+ <fieldset class="field"><legend class="label">Workspace administrators</legend><div class="radio-group">
287
+ ${devs.rows.filter((d) => d.member && !d.is_admin).map((d) => html`<label class="check"><input type="checkbox" name="admin" value="${d.username}"${d.ws_admin ? raw(' checked') : ''}> ${d.username}</label>`)}
288
+ </div><small class="help">They manage this workspace's name and developers. Isolation, database roles, moving applications and deleting the workspace stay with administrators.</small></fieldset>
207
289
  <div class="buttons"><button class="btn">Save developers</button></div></form>`)}
208
290
  </div>
209
- ${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
291
+ ${full ? isolationRegion(s, w, roles.rows) : region('Isolation', html`<p class="u-mt0">${w.isolated ? 'Isolated: its applications and SQL run on database logins of its own.' : 'Shared.'} Administrators change it.</p>`)}
292
+ ${!full ? '' : region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
210
293
  <thead><tr><th>Application</th><th>Move to</th></tr></thead>
211
294
  <tbody>${apps.rows.length
212
295
  ? apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">${a.id} · /a/${a.alias}</span></td>
@@ -217,12 +300,12 @@ export async function workspaceRoutes(app: FastifyInstance) {
217
300
  <button class="btn">Move</button></form>`
218
301
  : html`<span class="muted">no other workspace</span>`}</td></tr>`)
219
302
  : html`<tr><td colspan="2" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}`;
220
- return send(reply, s, shell(s, w.name, crumbs([[w.name]]), main));
303
+ return send(reply, s, shell(s, w.name, full ? crumbs([[w.name]]) : [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name]], main));
221
304
  });
222
305
 
223
306
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
224
307
  const s = await developer(req, reply);
225
- if (!s || !(await adminOnly(s, reply))) return;
308
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
226
309
  const name = String(req.body?.name ?? '').trim();
227
310
  try {
228
311
  if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters.');
@@ -236,16 +319,19 @@ export async function workspaceRoutes(app: FastifyInstance) {
236
319
 
237
320
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/members`, async (req: Req, reply) => {
238
321
  const s = await developer(req, reply);
239
- if (!s || !(await adminOnly(s, reply))) return;
322
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
240
323
  if (!(await owner.one('select 1 as ok from meta.workspace where id = $1', [req.params.id]))) return reply.code(404).send('Not found');
241
324
  const posted = req.body?.member as unknown;
242
325
  const names = (Array.isArray(posted) ? posted : posted == null ? [] : [posted]).map(String).slice(0, 10000);
326
+ const postedAdmins = req.body?.admin as unknown;
327
+ const admins = (Array.isArray(postedAdmins) ? postedAdmins : postedAdmins == null ? [] : [postedAdmins]).map(String).slice(0, 10000);
243
328
  await owner.tx(async (c) => {
244
329
  await c.query('delete from meta.workspace_member where workspace_id = $1', [req.params.id]);
245
330
  // only existing developers (unknown names are ignored)
246
331
  await c.query(
247
- `insert into meta.workspace_member (workspace_id, username) select $1, d.username from meta.developer d where d.username = any($2::text[])`,
248
- [req.params.id, names],
332
+ `insert into meta.workspace_member (workspace_id, username, is_admin)
333
+ select $1, d.username, d.username = any($3::text[]) from meta.developer d where d.username = any($2::text[])`,
334
+ [req.params.id, names, admins],
249
335
  );
250
336
  });
251
337
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `developers of workspace ${req.params.id}: ${names.join(', ').slice(0, 1000)}` });
@@ -260,7 +346,28 @@ export async function workspaceRoutes(app: FastifyInstance) {
260
346
  const appId = /^\d{1,9}$/.test(req.body?.app ?? '') ? Number(req.body!.app) : null;
261
347
  const to = (await owner.one<{ id: number; name: string }>('select id, name from meta.workspace where id = $1', [/^\d{1,9}$/.test(req.body?.to ?? '') ? req.body!.to : null]));
262
348
  if (appId === null || !to || (await appWorkspace(appId)) !== from) return reply.code(404).send('Not found');
263
- await placeApp(owner, appId, to.id);
349
+ try {
350
+ await owner.tx(async (c) => {
351
+ // (090) the application's database role goes along: out of the old workspace's roles (when no other
352
+ // application there uses it), into the new one's when that one is isolated
353
+ const role = (await c.query<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId])).rows[0]?.db_role;
354
+ if (role) {
355
+ await c.query(
356
+ `delete from meta.workspace_role x where x.workspace_id = $1 and x.role_name = $2
357
+ and not exists (select 1 from meta.app a where a.db_role = $2 and a.id <> $3 and meta.app_workspace(a.id) = $1)`,
358
+ [from, role, appId],
359
+ );
360
+ if ((await c.query('select 1 from meta.workspace where id = $1 and isolated', [to.id])).rowCount)
361
+ await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [to.id, role]);
362
+ }
363
+ await placeApp(c, appId, to.id);
364
+ });
365
+ } catch (e) {
366
+ flash(s, `Not moved: ${roleError(e)}`, 'error');
367
+ return back(reply, s, `${BASE}/workspaces/${from}`);
368
+ } finally {
369
+ await workspacesChanged();
370
+ }
264
371
  await logActivity({ appId, username: s.username, event: 'workspace', ip: clientIp(req), detail: `moved from workspace ${from} to ${to.id}` });
265
372
  flash(s, `Application moved to ${to.name}.`);
266
373
  return back(reply, s, `${BASE}/workspaces/${from}`);
@@ -274,6 +381,10 @@ export async function workspaceRoutes(app: FastifyInstance) {
274
381
  flash(s, 'The Default workspace cannot be deleted.', 'error');
275
382
  return back(reply, s, `${BASE}/workspaces/${id}`);
276
383
  }
384
+ if (await owner.one('select 1 as ok from meta.workspace where id = $1 and isolated', [id])) {
385
+ flash(s, 'Make the workspace shared (not isolated) before deleting it.', 'error');
386
+ return back(reply, s, `${BASE}/workspaces/${id}`);
387
+ }
277
388
  if (await owner.one('select 1 as ok from meta.workspace_app where workspace_id = $1 limit 1', [id])) {
278
389
  flash(s, 'Move the applications of this workspace to another workspace first.', 'error');
279
390
  return back(reply, s, `${BASE}/workspaces/${id}`);
@@ -283,4 +394,53 @@ export async function workspaceRoutes(app: FastifyInstance) {
283
394
  flash(s, r ? `Workspace ${r.name} deleted.` : 'Not found.', r ? 'ok' : 'error');
284
395
  return back(reply, s, `${BASE}/workspaces`);
285
396
  });
397
+
398
+ // (090) isolate a workspace, or make it shared again
399
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/isolation`, async (req: Req, reply) => {
400
+ const s = await developer(req, reply);
401
+ if (!s || !(await adminOnly(s, reply))) return;
402
+ const id = Number(req.params.id);
403
+ const on = req.body?.isolated === 'true';
404
+ try {
405
+ const problem = on ? isolationProblem() : null;
406
+ if (problem) throw new Error(problem);
407
+ const r = await owner.query('update meta.workspace set isolated = $2 where id = $1', [id, on]);
408
+ if (!r.rowCount) return reply.code(404).send('Not found');
409
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id} ${on ? 'isolated' : 'shared'}` });
410
+ flash(s, on ? 'The workspace is isolated: its applications and SQL now run on its own database logins.' : 'The workspace is shared again: its applications run on the shared runtime connection.');
411
+ } catch (e) {
412
+ flash(s, `${on ? 'Not isolated' : 'Not changed'}: ${roleError(e)}`, 'error');
413
+ } finally {
414
+ await workspacesChanged();
415
+ }
416
+ return back(reply, s, `${BASE}/workspaces/${id}`);
417
+ });
418
+
419
+ // (090) the database roles of an isolated workspace
420
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/roles`, async (req: Req, reply) => {
421
+ const s = await developer(req, reply);
422
+ if (!s || !(await adminOnly(s, reply))) return;
423
+ const id = Number(req.params.id);
424
+ const role = String(req.body?.role ?? '').trim();
425
+ const remove = req.body?.action === 'remove';
426
+ try {
427
+ if (!ROLE.test(role)) throw new Error('A role name is a letter or _ followed by letters, digits, _ or $ (at most 63).');
428
+ if (!remove && !(await owner.one('select 1 as ok from pg_roles where rolname = $1', [role]))) throw new Error(`There is no database role ${role}.`);
429
+ if (remove) {
430
+ const used = await owner.one<{ aliases: string }>(
431
+ `select string_agg(alias, ', ') as aliases from meta.app a where a.db_role = $2 and meta.app_workspace(a.id) = $1 having count(*) > 0`,
432
+ [id, role],
433
+ );
434
+ if (used) throw new Error(`${used.aliases} run${used.aliases.includes(',') ? '' : 's'} as ${role}: give ${used.aliases.includes(',') ? 'them' : 'it'} another role first.`);
435
+ await owner.query('delete from meta.workspace_role where workspace_id = $1 and role_name = $2', [id, role]);
436
+ } else await owner.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2)', [id, role]);
437
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id}: ${remove ? 'removed' : 'added'} role ${role}` });
438
+ flash(s, remove ? `Role ${role} removed.` : `Role ${role} added.`);
439
+ } catch (e) {
440
+ flash(s, `Not saved: ${roleError(e)}`, 'error');
441
+ } finally {
442
+ await workspacesChanged();
443
+ }
444
+ return back(reply, s, `${BASE}/workspaces/${id}`);
445
+ });
286
446
  }