pgkiln 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/SECURITY.md +5 -1
  3. package/db/migrations/125_app_change.sql +23 -0
  4. package/db/migrations/126_mail_queue_network_access.sql +119 -0
  5. package/docs/apex-feature-parity.md +40 -11
  6. package/docs/guide/01-installation.md +1 -1
  7. package/docs/guide/03-builder.md +76 -3
  8. package/docs/guide/06-processing.md +6 -1
  9. package/docs/guide/08-security.md +55 -0
  10. package/docs/guide/09-reference.md +3 -1
  11. package/docs/guide/11-from-apex.md +1 -0
  12. package/docs/guide/12-development.md +43 -8
  13. package/docs/guide/14-globalization.md +49 -1
  14. package/docs/guide/18-cli.md +21 -1
  15. package/docs/guide/19-rest-data-sources.md +12 -0
  16. package/docs/guide/20-ai-agents.md +8 -2
  17. package/examples/hr/hr_70_data_security.sql +79 -0
  18. package/package.json +1 -1
  19. package/public/app.css +97 -23
  20. package/public/builder.css +50 -0
  21. package/public/builder.js +4 -0
  22. package/src/app.ts +9 -0
  23. package/src/builder/app-updates.ts +55 -0
  24. package/src/builder/data-security.ts +182 -0
  25. package/src/builder/home.ts +16 -5
  26. package/src/builder/mail-queue.ts +212 -0
  27. package/src/builder/mail.ts +72 -45
  28. package/src/builder/network-access.ts +140 -0
  29. package/src/builder/object-actions.ts +1 -0
  30. package/src/builder/object-browser.ts +1 -1
  31. package/src/builder/rest-catalogs.ts +19 -4
  32. package/src/builder/sql.ts +2 -0
  33. package/src/builder/themeroller.ts +47 -6
  34. package/src/builder/ui.ts +5 -0
  35. package/src/builder/websources.ts +1 -1
  36. package/src/builder/workspace-mail.ts +121 -0
  37. package/src/builder/workspaces.ts +1 -0
  38. package/src/cli/main.ts +25 -3
  39. package/src/cli/mcp.ts +40 -4
  40. package/src/cli/only.ts +52 -0
  41. package/src/cli/replace.ts +5 -1
  42. package/src/datasec.ts +343 -0
  43. package/src/mail/mail.ts +50 -11
  44. package/src/mail/providers.ts +6 -3
  45. package/src/metadata.ts +2 -0
  46. package/src/network-acl.ts +126 -0
  47. package/src/objectstore.ts +3 -0
  48. package/src/runtime/engine.ts +3 -1
  49. package/src/runtime/render.ts +4 -2
  50. package/src/runtime/styles.ts +54 -12
  51. package/src/webclient.ts +45 -4
  52. package/src/webrequests.ts +5 -0
  53. package/src/websources.ts +30 -12
package/public/app.css CHANGED
@@ -155,8 +155,8 @@ html[data-style="iris"][data-theme="dark"] {
155
155
  --hot: #ece9e6;
156
156
  --hot-text: #1a1817;
157
157
  }
158
- html[data-style="iris"] .t-header { box-shadow: inset 0 3px 0 var(--accent); }
159
- html[data-style="iris"] .t-nav { border-right-color: transparent; }
158
+ html[data-style="iris"] .t-header { box-shadow: inset 0 3px 0 var(--strip, var(--accent)); }
159
+ html[data-style="iris"] .t-nav { border-inline-end-color: transparent; }
160
160
  html[data-style="iris"] .t-titlebar { background: transparent; border-bottom: 0; padding: 1.25rem 1.5rem 0.25rem; }
161
161
  html[data-style="iris"] .t-titlebar h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; }
162
162
  html[data-style="iris"] .region-header h2 { font-size: 1.05rem; }
@@ -225,7 +225,7 @@ html[data-style="lavender"][data-theme="dark"] {
225
225
  --shadow: 0 1px 2px rgb(0 0 0 / 30%), 0 2px 8px rgb(0 0 0 / 25%);
226
226
  }
227
227
  /* Lavender: rounded navigation pills instead of an edge bar, quieter region headers, pill-shaped primary buttons */
228
- html[data-style="lavender"] .t-nav a[aria-current="page"] { box-shadow: none; border-radius: calc(var(--radius) * 0.75); }
228
+ html[data-style="lavender"] .t-nav a[aria-current="page"] { border-inline-start-color: transparent; border-radius: calc(var(--radius) * 0.75); }
229
229
  html[data-style="lavender"] .t-nav a, html[data-style="lavender"] .t-nav summary { border-radius: calc(var(--radius) * 0.75); }
230
230
  html[data-style="lavender"] .region-header { border-bottom-color: transparent; }
231
231
  html[data-style="lavender"] .region-header h2 { letter-spacing: -0.01em; }
@@ -243,8 +243,8 @@ h2 { font-size: 1rem; margin: 0; font-weight: 600; }
243
243
  .lead { font-size: 1.05rem; margin: 0; }
244
244
  [hidden] { display: none !important; }
245
245
  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
246
- .skip-link { position: absolute; left: -9999px; top: 0; z-index: 100; background: var(--surface); padding: 0.5rem 1rem; }
247
- .skip-link:focus { left: 0.5rem; top: 0.5rem; }
246
+ .skip-link { position: absolute; inset-inline-start: -9999px; top: 0; z-index: 100; background: var(--surface); padding: 0.5rem 1rem; }
247
+ .skip-link:focus { inset-inline-start: 0.5rem; top: 0.5rem; }
248
248
  .icon { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
249
249
  /* (0.31) icon modifiers, like Font APEX's: "users lg", "refresh spin", "truck flip-h success" */
250
250
  .icon.icon-xs { width: 0.75em; height: 0.75em; } .icon.icon-sm { width: 0.875em; height: 0.875em; }
@@ -285,15 +285,15 @@ details.menu { position: relative; }
285
285
  details.menu > summary { list-style: none; }
286
286
  details.menu > summary::-webkit-details-marker { display: none; }
287
287
  .menu-panel {
288
- position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; min-width: 240px;
288
+ position: absolute; z-index: 30; top: calc(100% + 6px); inset-inline-start: 0; min-width: 240px;
289
289
  background: var(--surface); color: var(--text); border: 1px solid var(--border);
290
290
  border-radius: var(--radius); box-shadow: 0 8px 24px rgb(16 24 40 / 16%); padding: 0.4rem 0;
291
291
  max-height: min(80vh, 42rem); overflow: auto;
292
292
  }
293
- .menu-panel.align-right, .actions-menu .menu-panel { left: auto; right: 0; }
293
+ .menu-panel.align-right, .actions-menu .menu-panel { inset-inline-start: auto; inset-inline-end: 0; }
294
294
  .menu-section { padding: 0.5rem 0.9rem; display: flex; flex-direction: column; gap: 0.4rem; }
295
295
  .menu-section + .menu-section { border-top: 1px solid var(--border); }
296
- .menu-links a, .menu-links button { color: var(--text); text-decoration: none; padding: 0.2rem 0; display: flex; align-items: center; gap: 0.45rem; background: none; border: 0; font: inherit; cursor: pointer; text-align: left; }
296
+ .menu-links a, .menu-links button { color: var(--text); text-decoration: none; padding: 0.2rem 0; display: flex; align-items: center; gap: 0.45rem; background: none; border: 0; font: inherit; cursor: pointer; text-align: start; }
297
297
  .menu-links a:hover, .menu-links button:hover { color: var(--accent); }
298
298
  .menu-links .icon { width: 1em; height: 1em; }
299
299
  .filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; min-width: 320px; }
@@ -308,21 +308,31 @@ details.menu > summary::-webkit-details-marker { display: none; }
308
308
  body.nav-collapsed .t-body { grid-template-columns: 0 minmax(0, 1fr); }
309
309
  body.nav-collapsed .t-nav { visibility: hidden; }
310
310
  }
311
- .t-nav { background: var(--nav-bg); border-right: 1px solid var(--border); overflow: hidden auto; }
311
+ .t-nav { background: var(--nav-bg); border-inline-end: 1px solid var(--border); overflow: hidden auto; }
312
312
  .t-nav ul { list-style: none; margin: 0; padding: 0.5rem; }
313
- .t-nav ul ul { padding: 0 0 0 1.4rem; }
313
+ /* (sprint 117) a subtree hangs on a guide line; the open trail's line in the accent */
314
+ .t-nav ul ul { padding: 0; padding-inline-start: 0.35rem; margin-inline-start: 1.05rem; border-inline-start: 1px solid var(--nav-guide, color-mix(in srgb, var(--nav-text, var(--text)) 16%, transparent)); }
315
+ .t-nav details:has(> summary.in-trail) > ul { border-inline-start-color: color-mix(in srgb, var(--nav-current, var(--accent)) 55%, transparent); }
314
316
  .t-nav a, .t-nav summary {
315
317
  display: flex; align-items: center; gap: 0.65rem; padding: 0.5rem 0.65rem; border-radius: 6px;
318
+ /* (sprint 117) the bar at the inline start (left, or right in RTL) marks the current page */
319
+ border-inline-start: 3px solid transparent; padding-inline-start: calc(0.65rem - 3px);
316
320
  color: var(--nav-text, var(--text)); text-decoration: none; cursor: pointer; white-space: nowrap; font-size: 0.93rem;
317
321
  }
318
322
  .t-nav summary { list-style: none; }
319
323
  .t-nav summary::-webkit-details-marker { display: none; }
320
- .t-nav summary::after { content: "›"; margin-left: auto; color: var(--nav-muted, var(--muted)); transition: transform 0.15s; }
324
+ .t-nav summary::after { content: "›"; margin-inline-start: auto; color: var(--nav-muted, var(--muted)); transition: transform 0.15s; }
321
325
  .t-nav details[open] > summary::after { transform: rotate(90deg); }
326
+ /* RTL: the bidi algorithm mirrors "›" to "‹"; opened, it turns the other way to point down */
327
+ [dir="rtl"] .t-nav details[open] > summary::after { transform: rotate(-90deg); }
322
328
  .t-nav a:hover, .t-nav summary:hover { background: var(--nav-hover, var(--surface-3)); }
323
- /* (sprint 72) --nav-* let a style have a dark menu (Iris); without them the menu takes the page's colours */
324
- .t-nav a[aria-current="page"] { background: var(--nav-active); color: var(--nav-current, var(--accent)); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent); }
325
- .t-nav .in-trail { color: var(--nav-current, var(--accent)); }
329
+ /* (sprint 117) the selected entry (keyboard focus) gets a ring inside the menu, which scrolls and would clip one outside */
330
+ .t-nav a:focus-visible, .t-nav summary:focus-visible { outline: 2px solid var(--nav-current, var(--accent)); outline-offset: -2px; }
331
+ /* (sprint 72) --nav-* let a style have a dark menu (Iris); without them the menu takes the page's colours.
332
+ (sprint 117) Current page: filled, the accent bar and text. Its parent entries (the trail): bold, the menu's text
333
+ colour with an accent icon, no fill, so the two are told apart at a glance. */
334
+ .t-nav a[aria-current="page"] { background: var(--nav-active); color: var(--nav-current, var(--accent)); font-weight: 600; border-inline-start-color: var(--accent); }
335
+ .t-nav .in-trail { color: var(--nav-text, var(--text)); font-weight: 600; }
326
336
  .t-nav .icon { color: var(--nav-muted, var(--muted)); }
327
337
  .t-nav a[aria-current="page"] .icon, .t-nav .in-trail .icon { color: var(--nav-current, var(--accent)); }
328
338
 
@@ -440,7 +450,8 @@ textarea.code { font-family: var(--mono); font-size: 0.84rem; line-height: 1.45;
440
450
  .has-error input, .has-error select, .has-error textarea { border-color: var(--danger); }
441
451
  .error { color: var(--danger); font-size: 0.82rem; font-weight: 500; }
442
452
  .default-submit { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
443
- [aria-busy="true"] { opacity: 0.55; transition: opacity 0.1s; }
453
+ /* (sprint 117) a lighter processing indicator: a slight dim after 150 ms (no flicker on quick answers), a bar on regions (below) */
454
+ [aria-busy="true"] { opacity: 0.8; cursor: progress; transition: opacity 0.2s ease 0.15s; }
444
455
 
445
456
  /* ---------- buttons ---------- */
446
457
  .buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
@@ -902,10 +913,11 @@ pre.source { font-family: var(--mono); font-size: 0.82rem; background: var(--sur
902
913
  .t-body { grid-template-columns: minmax(0, 1fr); }
903
914
  /* Drawer: opened by JS (body.nav-open) or, without JS, by the #t-nav link (:target). */
904
915
  .t-nav {
905
- position: fixed; z-index: 25; top: 52px; bottom: 0; left: 0; width: min(300px, 85vw);
916
+ position: fixed; z-index: 25; top: 52px; bottom: 0; inset-inline-start: 0; width: min(300px, 85vw);
906
917
  box-shadow: 8px 0 24px rgb(0 0 0 / 20%); transition: transform 0.2s ease, visibility 0.2s;
907
918
  transform: translateX(-100%); visibility: hidden;
908
919
  }
920
+ [dir="rtl"] .t-nav { transform: translateX(100%); box-shadow: -8px 0 24px rgb(0 0 0 / 20%); }
909
921
  body.nav-open .t-nav, .t-nav:target { transform: none; visibility: visible; }
910
922
  body.nav-open .t-nav-backdrop { display: block; position: fixed; inset: 52px 0 0 0; z-index: 24; background: rgb(15 20 30 / 40%); }
911
923
  .t-regions > .region { grid-column: span 12 !important; }
@@ -950,7 +962,7 @@ pre.source { font-family: var(--mono); font-size: 0.82rem; background: var(--sur
950
962
  .report-reflow tbody tr:hover { background: var(--surface); }
951
963
  .report-reflow td {
952
964
  display: grid; grid-template-columns: minmax(90px, 38%) minmax(0, 1fr); gap: 0.75rem;
953
- border: 0; padding: 0.3rem 0.8rem; white-space: normal; max-width: none; text-align: left !important; overflow-wrap: anywhere;
965
+ border: 0; padding: 0.3rem 0.8rem; white-space: normal; max-width: none; text-align: start !important; overflow-wrap: anywhere;
954
966
  }
955
967
  .report-reflow td::before { content: attr(data-label); font-size: 0.8rem; font-weight: 650; color: var(--muted); }
956
968
  .report-reflow td.row-edit { width: auto; text-align: left; }
@@ -992,14 +1004,14 @@ fieldset.prop-group > legend { font-size: 0.75rem; text-transform: uppercase; le
992
1004
  body.nav-top .t-body { display: block; }
993
1005
  body.nav-top .t-nav {
994
1006
  position: sticky; top: 52px; z-index: 18; visibility: visible; overflow: visible;
995
- border-right: 0; border-bottom: 1px solid var(--border);
1007
+ border-inline-end: 0; border-bottom: 1px solid var(--border);
996
1008
  }
997
1009
  body.nav-top .t-nav > ul { display: flex; gap: 0.25rem; padding: 0.3rem 0.75rem; }
998
1010
  body.nav-top .t-nav li { position: relative; }
999
- body.nav-top .t-nav > ul > li > a[aria-current="page"] { box-shadow: inset 0 -3px 0 var(--accent); }
1011
+ body.nav-top .t-nav > ul > li > a[aria-current="page"] { box-shadow: inset 0 -3px 0 var(--accent); border-inline-start-color: transparent; }
1000
1012
  body.nav-top .t-nav details > ul {
1001
- position: absolute; top: calc(100% + 4px); left: 0; min-width: 220px; padding: 0.4rem;
1002
- background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 24px rgb(16 24 40 / 16%);
1013
+ position: absolute; top: calc(100% + 4px); inset-inline-start: 0; margin: 0; min-width: 220px; padding: 0.4rem;
1014
+ background: var(--nav-bg, var(--surface)); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 24px rgb(16 24 40 / 16%);
1003
1015
  }
1004
1016
  body.nav-top .t-nav details > summary::after { transform: rotate(90deg); }
1005
1017
  }
@@ -2085,6 +2097,68 @@ dialog.wait-popup { border: 1px solid var(--border); border-radius: var(--radius
2085
2097
  dialog.wait-popup[open] { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
2086
2098
  dialog.wait-popup:focus { outline: none; }
2087
2099
  dialog.wait-popup::backdrop { background: rgb(15 20 30 / 35%); cursor: wait; }
2088
- .wait-popup-spinner { width: 2rem; height: 2rem; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); }
2100
+ .wait-popup-spinner { width: 1.75rem; height: 1.75rem; border-radius: 50%; border: 2.5px solid color-mix(in srgb, var(--accent) 18%, transparent); border-top-color: var(--accent); }
2089
2101
  .wait-popup-text { margin: 0; text-align: center; overflow-wrap: anywhere; }
2090
- @media (prefers-reduced-motion: no-preference) { .wait-popup-spinner { animation: icon-spin 0.9s linear infinite; } }
2102
+ @media (prefers-reduced-motion: no-preference) { .wait-popup-spinner { animation: icon-spin 0.8s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; } }
2103
+
2104
+ /* ---------- (sprint 117, APEX 26.2) Pillar Light / Pillar Dark navigation appearances ----------
2105
+ body.nav-pillar + nav-pillar-light / nav-pillar-dark (theme.appearance or a Theme Roller style's,
2106
+ src/runtime/styles.ts). Header and menu read as one column ("pillar"); a header pillar strip in
2107
+ --strip (Theme Roller), else the accent; the current page is a filled pill in the accent, its
2108
+ parents bold with an accent guide line. Light: the page's own surfaces (dark ones in dark mode).
2109
+ Dark: header and menu in the header colour. Contrast: menu text ≥ 7:1, the pill's text ≥ 4.5:1. */
2110
+ body.nav-pillar .t-header { box-shadow: inset 0 4px 0 var(--strip, var(--accent)); }
2111
+ body.nav-pillar .t-nav a, body.nav-pillar .t-nav summary { border-radius: 999px; }
2112
+ body.nav-pillar .t-nav a[aria-current="page"] { background: var(--accent); color: var(--accent-text); border-inline-start-color: transparent; }
2113
+ body.nav-pillar .t-nav a[aria-current="page"] .icon { color: var(--accent-text); }
2114
+ body.nav-pillar .t-nav a:focus-visible, body.nav-pillar .t-nav summary:focus-visible { outline-color: var(--nav-text); }
2115
+ body.nav-pillar-light {
2116
+ --header: var(--surface-2); --header-text: var(--text);
2117
+ --nav-bg: var(--surface-2); --nav-text: var(--text); --nav-muted: var(--muted);
2118
+ --nav-hover: var(--surface-3); --nav-active: var(--accent-soft); --nav-current: var(--accent);
2119
+ }
2120
+ body.nav-pillar-light .t-header { border-bottom: 1px solid var(--border); }
2121
+ body.nav-pillar-light .t-nav { border-inline-end-color: var(--border); }
2122
+ body.nav-pillar-light .icon-button:hover, body.nav-pillar-light .t-user > summary:hover,
2123
+ body.nav-pillar-light .t-navbar > ul > li > a:hover, body.nav-pillar-light .t-navbar > ul > li > details > summary:hover { background: var(--surface-3); }
2124
+ body.nav-pillar-light .t-navbar a[aria-current="page"] { background: var(--accent-soft); }
2125
+ body.nav-pillar-dark {
2126
+ --nav-bg: var(--header); --nav-text: var(--header-text);
2127
+ --nav-muted: color-mix(in srgb, var(--header-text) 72%, var(--header));
2128
+ --nav-hover: color-mix(in srgb, var(--header) 86%, #ffffff); --nav-active: color-mix(in srgb, var(--header) 76%, #ffffff);
2129
+ --nav-current: var(--header-text);
2130
+ --nav-guide: color-mix(in srgb, var(--header-text) 22%, transparent);
2131
+ }
2132
+ body.nav-pillar-dark .t-nav { border-inline-end-color: transparent; }
2133
+ body.nav-pillar-dark .t-nav .in-trail .icon, body.nav-pillar-dark .t-nav a[aria-current="page"] .icon { color: inherit; }
2134
+ body.nav-pillar-dark .t-nav details:has(> summary.in-trail) > ul { border-inline-start-color: color-mix(in srgb, var(--strip, var(--accent)) 70%, var(--header-text)); }
2135
+ @media (min-width: 1024px) {
2136
+ body.nav-pillar.nav-top .t-nav > ul > li > a[aria-current="page"] { box-shadow: none; }
2137
+ body.nav-pillar-dark.nav-top .t-nav { border-bottom-color: transparent; }
2138
+ }
2139
+
2140
+ /* ---------- (sprint 117, APEX 26.2) subtle colour utilities: a tinted background with readable text,
2141
+ in light and dark mode (text mostly the page's text colour, so contrast holds for every hue).
2142
+ u-color-1-subtle … u-color-15-subtle and u-color-{success,danger,warning,info,neutral}-subtle. */
2143
+ [class*="-subtle"][class*="u-color-"] {
2144
+ background: color-mix(in srgb, var(--u-hue) 14%, var(--surface)); color: color-mix(in srgb, var(--u-hue) 38%, var(--text));
2145
+ border-color: color-mix(in srgb, var(--u-hue) 32%, var(--surface));
2146
+ }
2147
+ .u-color-1-subtle { --u-hue: #309fdb; } .u-color-2-subtle { --u-hue: #13b6cf; } .u-color-3-subtle { --u-hue: #2ebfbc; }
2148
+ .u-color-4-subtle { --u-hue: #3caf85; } .u-color-5-subtle { --u-hue: #81bb5f; } .u-color-6-subtle { --u-hue: #c9cf1f; }
2149
+ .u-color-7-subtle { --u-hue: #f2bf2c; } .u-color-8-subtle { --u-hue: #f18b48; } .u-color-9-subtle { --u-hue: #e95b54; }
2150
+ .u-color-10-subtle { --u-hue: #e85d88; } .u-color-11-subtle { --u-hue: #ca589d; } .u-color-12-subtle { --u-hue: #8f5ea8; }
2151
+ .u-color-13-subtle { --u-hue: #5a68ad; } .u-color-14-subtle { --u-hue: #627c94; } .u-color-15-subtle { --u-hue: #8a7a6b; }
2152
+ .u-color-success-subtle { --u-hue: var(--success); } .u-color-danger-subtle { --u-hue: var(--danger); }
2153
+ .u-color-warning-subtle { --u-hue: #d18b00; } .u-color-info-subtle { --u-hue: var(--accent); } .u-color-neutral-subtle { --u-hue: var(--muted); }
2154
+
2155
+ /* ---------- (sprint 117) the processing bar on a busy region (dynamic actions, refreshes) */
2156
+ .region[aria-busy="true"] { position: relative; }
2157
+ .region[aria-busy="true"]::after {
2158
+ content: ""; position: absolute; z-index: 2; top: 0; inset-inline: 0; height: 3px; border-radius: var(--radius) var(--radius) 0 0; pointer-events: none;
2159
+ background: linear-gradient(90deg, transparent, var(--accent), transparent) 0 0 / 40% 100% no-repeat, color-mix(in srgb, var(--accent) 15%, transparent);
2160
+ }
2161
+ @media (prefers-reduced-motion: no-preference) {
2162
+ .region[aria-busy="true"]::after { animation: busy-slide 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; }
2163
+ }
2164
+ @keyframes busy-slide { from { background-position: -60% 0, 0 0; } to { background-position: 160% 0, 0 0; } }
@@ -1016,6 +1016,30 @@ button.is-busy { opacity: 0.7; cursor: progress; }
1016
1016
  .tr-preview .btn { background: var(--surface); color: var(--text); border-color: var(--border); border-radius: calc(var(--radius) * 0.75); font: inherit; }
1017
1017
  .tr-preview .btn-hot { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
1018
1018
  .tr-preview-lavender .btn-hot { border-radius: 999px; }
1019
+ /* (sprint 117) the preview's menu: current page, its parent, and the Pillar Light / Dark appearances */
1020
+ .tr-preview { --nav-bg: var(--surface); --nav-text: var(--text); --nav-active: var(--accent-soft); --nav-current: var(--accent); }
1021
+ .tr-preview-iris { --nav-bg: #2f2b28; --nav-text: #ece8e4; --nav-active: #4a443f; --nav-current: #ffffff; }
1022
+ .tr-preview-main { display: grid; grid-template-columns: minmax(8rem, 11rem) minmax(0, 1fr); }
1023
+ .tr-preview-nav, .tr-preview-nav ul { list-style: none; margin: 0; padding: 0; }
1024
+ .tr-preview-nav { padding: 0.5rem; background: var(--nav-bg); color: var(--nav-text); border-inline-end: 1px solid var(--border); font-size: 0.85rem; }
1025
+ .tr-preview-nav ul { margin-inline-start: 0.9rem; padding-inline-start: 0.3rem; border-inline-start: 1px solid color-mix(in srgb, var(--nav-current) 55%, transparent); }
1026
+ .tr-preview-nav span { display: flex; align-items: center; gap: 0.4rem; }
1027
+ .tr-preview-nav li > span { padding: 0.3rem 0.5rem; border-radius: 6px; border-inline-start: 3px solid transparent; }
1028
+ .tr-preview-nav .icon { width: 1em; height: 1em; }
1029
+ .tr-preview-nav .tr-nav-parent { font-weight: 600; }
1030
+ .tr-preview-nav .tr-nav-parent .icon { color: var(--nav-current); }
1031
+ .tr-preview-nav .tr-nav-current { background: var(--nav-active); color: var(--nav-current); font-weight: 600; border-inline-start-color: var(--accent); }
1032
+ .tr-preview[data-appearance^="pillar"] .tr-preview-header { box-shadow: inset 0 4px 0 var(--strip, var(--accent)); }
1033
+ .tr-preview[data-appearance^="pillar"] .tr-preview-nav li > span { border-radius: 999px; }
1034
+ .tr-preview[data-appearance^="pillar"] .tr-nav-current { background: var(--accent); color: var(--accent-text); border-inline-start-color: transparent; }
1035
+ .tr-preview[data-appearance="pillar_light"] { --header: var(--surface-2); --header-text: var(--text); --nav-bg: var(--surface-2); --nav-text: var(--text); --nav-current: var(--accent); }
1036
+ .tr-preview[data-appearance="pillar_light"] .tr-preview-header { border-bottom: 1px solid var(--border); }
1037
+ .tr-preview[data-appearance="pillar_dark"] { --nav-bg: var(--header); --nav-text: var(--header-text); --nav-current: var(--header-text); }
1038
+ .tr-preview[data-appearance="pillar_dark"] .tr-preview-nav { border-inline-end-color: transparent; }
1039
+ @media (max-width: 640px) {
1040
+ .tr-preview-main { grid-template-columns: minmax(0, 1fr); }
1041
+ .tr-preview-nav { border-inline-end: 0; border-bottom: 1px solid var(--border); }
1042
+ }
1019
1043
 
1020
1044
  /* icon picker (forms.ts iconPicker) */
1021
1045
  .icon-picker > summary { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 34px; padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; list-style: none; background: var(--surface); }
@@ -1274,6 +1298,13 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
1274
1298
  .dbu-members { max-height: 16rem; overflow: auto; }
1275
1299
  .dbu-schemas .te-c { text-align: center; width: 1%; white-space: nowrap; }
1276
1300
  .dbu-sql { white-space: pre-wrap; overflow-wrap: anywhere; }
1301
+ /* (sprint 119) a table's Security: roles × columns and masks */
1302
+ .ds-section { margin-block: 1.25rem; }
1303
+ .ds-section h2 { display: flex; align-items: center; gap: 0.4rem; }
1304
+ .ds-table .ds-cell .check { display: flex; white-space: nowrap; margin: 0; }
1305
+ .ds-table .ds-c { text-align: center; width: 1%; }
1306
+ .ds-table th code { white-space: nowrap; }
1307
+ .ds .source { white-space: pre-wrap; overflow-wrap: anywhere; }
1277
1308
  .dbu-list td { white-space: normal; overflow: visible; max-width: 24rem; }
1278
1309
  .dbu-list td .tag { margin: 0.1rem 0; }
1279
1310
  .dbu-facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1rem; margin: 0; }
@@ -1475,3 +1506,22 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1475
1506
  .team-page-issues .icon { flex: none; width: 14px; height: 14px; margin-top: 2px; }
1476
1507
  .wh-team-n { margin-left: auto; padding-left: 8px; }
1477
1508
  .feedback-issue { margin: 4px 0; }
1509
+
1510
+ /* (116) App Builder list: updated by, working copies and the application lock (WS-12) */
1511
+ .ab-name .ab-lock { display: inline-flex; vertical-align: middle; margin-left: 8px; color: var(--muted); }
1512
+ .ab-name .ab-lock .icon { width: 15px; height: 15px; }
1513
+ .ab-by { overflow-wrap: anywhere; }
1514
+ .ab-card-badges { display: flex; flex-wrap: wrap; gap: 6px; }
1515
+ .ab-card-badges .tag { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; text-decoration: none; }
1516
+ .ab-card-badges .tag .icon { width: 13px; height: 13px; flex: none; }
1517
+ /* (sprint 118) Administration → Mail queue, Network access */
1518
+ .mail-queue-filters { display: flex; flex-wrap: wrap; gap: 0 12px; align-items: flex-end; margin-bottom: 12px; }
1519
+ .mail-queue-filters .field { min-width: 0; flex: 1 1 160px; max-width: 260px; }
1520
+ .mail-queue-stats { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
1521
+ .mail-queue-stats .stat b { font-size: 1.3rem; }
1522
+ .stat.stat-bad b { color: var(--danger, #b42318); }
1523
+ .inline-form { display: inline; margin: 0; }
1524
+ .mail-failures .buttons { flex-wrap: wrap; gap: 6px; }
1525
+ .mail-answer { overflow-wrap: anywhere; font-family: var(--font-mono, monospace); font-size: 0.85em; }
1526
+ .host-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
1527
+ .host-list code { overflow-wrap: anywhere; }
package/public/builder.js CHANGED
@@ -823,6 +823,10 @@
823
823
  set('--font', from(lists.fonts, form.elements.font?.value));
824
824
  set('--font-size', from(lists.sizes, form.elements.font_size?.value));
825
825
  set('--radius', from(lists.radii, form.elements.radius?.value));
826
+ // (sprint 117) the navigation appearance ('' = the application's, 'default' = none) and the header strip
827
+ const ap = form.elements.appearance?.value ?? '';
828
+ preview.dataset.appearance = ap === 'default' ? '' : ap || preview.dataset.trAppAppearance || '';
829
+ set('--strip', own('strip') ? hex(form.elements.strip?.value) : '');
826
830
  };
827
831
  form.addEventListener('input', update);
828
832
  form.addEventListener('change', update);
package/src/app.ts CHANGED
@@ -39,6 +39,7 @@ import { reportSettingsRoutes } from './builder/report-settings.ts';
39
39
  import { layoutRoutes } from './builder/layouts.ts';
40
40
  import { dataLoadRoutes } from './builder/dataload.ts';
41
41
  import { builderRoutes } from './builder/routes.ts';
42
+ import { appUpdateHooks } from './builder/app-updates.ts';
42
43
  import { dbUserRoutes } from './builder/db-users.ts';
43
44
  import { dataPumpRoutes } from './builder/datapump.ts';
44
45
  import { schedulingRoutes } from './builder/scheduling.ts';
@@ -86,6 +87,10 @@ import { workspaceBackupRoutes } from './builder/workspace-backups.ts';
86
87
  import { restCatalogRoutes } from './builder/rest-catalogs.ts';
87
88
  import { workspaceTransferRoutes } from './builder/workspace-transfer.ts';
88
89
  import { findWorkspaceRoutes } from './builder/find-workspace.ts';
90
+ // (sprint 118) Administration → Mail queue, Network access; a workspace's own mail service
91
+ import { mailQueueRoutes } from './builder/mail-queue.ts';
92
+ import { networkAccessRoutes } from './builder/network-access.ts';
93
+ import { workspaceMailRoutes } from './builder/workspace-mail.ts';
89
94
  import { runtimeScope } from './db.ts';
90
95
 
91
96
  export async function buildApp(opts: { logger?: boolean } = {}) {
@@ -158,6 +163,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
158
163
  return reply.send(err);
159
164
  });
160
165
  builderErrorPages(app);
166
+ appUpdateHooks(app); // (116) who changed an application last
161
167
  await app.register(cookie);
162
168
  await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
163
169
  // file upload items; oversized files are cut off and reported, not thrown
@@ -257,6 +263,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
257
263
  await app.register(blueprintRoutes);
258
264
  await app.register(workspaceHomeRoutes);
259
265
  await app.register(teamRoutes);
266
+ await app.register(mailQueueRoutes);
267
+ await app.register(networkAccessRoutes);
268
+ await app.register(workspaceMailRoutes);
260
269
  app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
261
270
  return app;
262
271
  }
@@ -0,0 +1,55 @@
1
+ import type { FastifyInstance, FastifyRequest } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import type { Session } from '../session.ts';
4
+ import { BASE } from './ui.ts';
5
+
6
+ // (sprint 116, WS-12) Who changed an application last: the App Builder list's
7
+ // Updated by (APEX: Applications report, Updated By). developer() notes a
8
+ // builder POST under /apps/:id or /pages/:pid that passed its checks; after
9
+ // the handler, a success (status below 400) records the developer and the
10
+ // time in meta.app_change (migration 125). A Create or Import that redirects
11
+ // to the new application records it too. A redirect with an error message,
12
+ // locks, comments and actions that change nothing are not changes.
13
+
14
+ /** Builder POSTs under an application that don't change it. */
15
+ const NOT_A_CHANGE = /^\/apps\/\d+\/(lock|unlock|comments(\/\d+\/delete)?|feedback\/\d+|mail\/\d+\/(cancel|retry)|pwa\/push-test|rest-sources\/\d+\/test|shared\/automation\/\d+\/run|working-copies)$|^\/pages\/\d+\/region\/\d+\/ai-filter$/;
16
+ /** Builder POSTs that create an application and redirect to it. */
17
+ const CREATES = /^\/(apps|import|create(\/[a-z]+(\/\d+)?)?|gallery\/(samples|starters)\/[\w-]+|blueprints\/create|working-copies)$/;
18
+
19
+ const noted = new WeakMap<FastifyRequest, { appId: number | null; s: Session & { username: string }; error: unknown }>();
20
+
21
+ /** Called by developer() for a POST that may change an application (appId) or create one (null). */
22
+ export function noteChange(req: FastifyRequest, s: Session & { username: string }, appId: number | null) {
23
+ const rel = req.url.split('?')[0].slice(BASE.length);
24
+ if (appId !== null ? NOT_A_CHANGE.test(rel) : !CREATES.test(rel)) return;
25
+ // an error message of an earlier request may still wait to be shown: only a new one is a refusal
26
+ noted.set(req, { appId, s, error: s.state.__ERROR });
27
+ }
28
+
29
+ /** Record the change after a successful response. */
30
+ export async function recordChange(appId: number, username: string) {
31
+ await owner.query(
32
+ `insert into meta.app_change (app_id, updated_by, updated_at) select id, $2, now() from meta.app where id = $1
33
+ on conflict (app_id) do update set updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
34
+ [appId, username.slice(0, 200)],
35
+ );
36
+ }
37
+
38
+ export function appUpdateHooks(app: FastifyInstance) {
39
+ // onSend, not onResponse: recorded before the response goes out, so the next request sees it
40
+ app.addHook('onSend', async (req, reply, payload) => {
41
+ const n = noted.get(req);
42
+ // a refusal: an error status, or a redirect with an error message (flash(s, …, 'error'))
43
+ if (!n || reply.statusCode >= 400 || (n.s.state.__ERROR && n.s.state.__ERROR !== n.error)) return payload;
44
+ noted.delete(req);
45
+ let appId = n.appId;
46
+ if (appId === null) {
47
+ // a Create or Import: the application it redirected to
48
+ const m = reply.statusCode >= 300 && reply.statusCode < 400 ? new RegExp(`^${BASE}/apps/(\\d{1,9})(?:[/?#]|$)`).exec(String(reply.getHeader('location') ?? '')) : null;
49
+ if (!m) return payload;
50
+ appId = Number(m[1]);
51
+ }
52
+ await recordChange(appId, n.s.username).catch((err) => req.log.warn({ err }, 'app_change not recorded'));
53
+ return payload;
54
+ });
55
+ }
@@ -0,0 +1,182 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { html, raw, type Raw } from '../html.ts';
3
+ import { icon } from '../icons.ts';
4
+ import { owner } from '../db.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
7
+ import { SYSTEM_SCHEMAS } from './object-browser.ts';
8
+ import { clearCompletions } from './code-editor.ts';
9
+ import { roleHints, splitRoles } from './users.ts';
10
+ import { currentWorkspace, inWorkspaceSql, usableSchemas, workshopDb } from './workspaces.ts';
11
+ import { joinStatements, recordChanges } from '../changelog.ts';
12
+ import {
13
+ APP_ROLE_RE, checkSecurity, currentDef, MASK_LABELS, MASKS, maskedColumns, readSecurity, securitySql, sees,
14
+ type ColumnMask, type SecurityDef, type TableSecurity,
15
+ } from '../datasec.ts';
16
+
17
+ // (sprint 119, WS-13: data security per table) SQL Workshop › Object Browser › a table › Security: the table's row
18
+ // level security policies, a matrix of database roles × columns (read, update) and masked values per application
19
+ // role, in one form. Preview SQL shows the GRANT / REVOKE statements and the masking view (src/datasec.ts); Apply runs
20
+ // them in one transaction with the workshop's connection and records a change set; Open in the worksheet puts them
21
+ // there. Like Table Properties, the statements are made against the catalog as it is at the time of the post.
22
+
23
+ /** The workspace's applications with a database role, and the application roles they check. */
24
+ async function workspaceApps(s: Session) {
25
+ const apps = (await owner.query<{ id: number; alias: string; role: string }>(
26
+ `select a.id, a.alias, a.db_role as role from meta.app a where a.db_role is not null and ${inWorkspaceSql('a', 1)} order by a.alias`,
27
+ [currentWorkspace(s)])).rows;
28
+ const hints = await roleHints(apps.map((a) => a.id));
29
+ const appRoles = [...new Set([...hints.values()].flat().map((h) => h.role))].filter((r) => APP_ROLE_RE.test(r));
30
+ return { appDbRoles: apps.map((a) => ({ role: a.role, alias: a.alias })), appRoles };
31
+ }
32
+
33
+ /** The definition the form posted. Grant rows and columns are matched by index against the catalog's state. */
34
+ function fromForm(b: Body, cur: TableSecurity): SecurityDef {
35
+ const on = (k: string) => b[k] === 'on';
36
+ const all = cur.columns.map((c) => c.name);
37
+ const grants = cur.grants.map((g, i) => ({
38
+ role: g.role,
39
+ select: all.filter((_, j) => on(`g.${i}.s.${j}`)),
40
+ update: all.filter((_, j) => on(`g.${i}.u.${j}`)),
41
+ }));
42
+ // the roles the matrix's checkboxes were drawn for (a changed list applies after the next Preview)
43
+ const shown = splitRoles(String(b.shown_roles ?? ''));
44
+ const roles = splitRoles(String(b.roles ?? ''));
45
+ const cols: Record<string, ColumnMask> = {};
46
+ all.forEach((c, j) => {
47
+ const mask = MASKS.find((m) => m === b[`m.${j}.mask`]);
48
+ if (!mask) return;
49
+ cols[c] = { mask, others: on(`m.${j}.others`), see: shown.filter((r, k) => roles.includes(r) && on(`m.${j}.see.${k}`)) };
50
+ });
51
+ return {
52
+ grants,
53
+ view: { enabled: on('view'), name: String(b.view_name ?? '').trim(), roles, cols },
54
+ };
55
+ }
56
+
57
+ function form(s: Session, cur: TableSecurity, def: SecurityDef, opts: { ref: string; sql: string[]; warnings: string[]; errors: string[]; output: Raw | '' }): Raw {
58
+ const chk = (x: boolean) => (x ? raw(' checked') : '');
59
+ const all = cur.columns.map((c) => c.name);
60
+ const v = def.view;
61
+ const masked = new Set(maskedColumns(v));
62
+ const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref })}`;
63
+ const policyUrl = `${BASE}/sql/objects/action?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref, do: 'policy' })}`;
64
+ const rows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Database roles and columns</caption>
65
+ <thead><tr><th scope="col">Database role</th>${all.map((c) => html`<th scope="col"><code>${c}</code>${masked.has(c) ? html` <span class="badge" title="Masked in ${v.name}">masked</span>` : ''}</th>`)}</tr></thead>
66
+ <tbody>${cur.grants.length ? def.grants.map((g, i) => {
67
+ const info = cur.grants[i];
68
+ return html`<tr><th scope="row"><code>${g.role === 'PUBLIC' ? 'PUBLIC' : g.role}</code>${info.apps.length ? html`<br><small class="muted">runs ${info.apps.join(', ')}</small>` : ''}</th>
69
+ ${all.map((c, j) => html`<td class="ds-cell"><label class="check"><input type="checkbox" name="g.${i}.s.${j}"${chk(g.select.includes(c))}> read</label>
70
+ <label class="check"><input type="checkbox" name="g.${i}.u.${j}"${chk(g.update.includes(c))}> update</label></td>`)}</tr>`;
71
+ }) : html`<tr><td colspan="${all.length + 1}" class="muted">No database role has privileges on this table, and no application of the workspace runs with a database role.</td></tr>`}</tbody></table></div>
72
+ ${cur.excluded.length ? html`<p class="muted small">Not in the matrix, because column privileges can't restrict them: ${cur.excluded.map((x) => html`<code>${x.role}</code> (${x.why}) `)}</p>` : ''}`;
73
+ const maskRows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Masks per column</caption>
74
+ <thead><tr><th scope="col">Column</th><th scope="col">Mask</th>${v.roles.map((r) => html`<th scope="col">${r} sees it</th>`)}<th scope="col">Everyone else sees it</th></tr></thead>
75
+ <tbody>${all.map((c, j) => {
76
+ const m = v.cols[c];
77
+ return html`<tr><th scope="row"><code>${c}</code> <small class="muted">${cur.columns[j].type}</small></th>
78
+ <td><select name="m.${j}.mask" aria-label="Mask of ${c}"><option value="">Not masked</option>${MASKS.map((x) => html`<option value="${x}"${m?.mask === x ? raw(' selected') : ''}>${MASK_LABELS[x]}</option>`)}</select></td>
79
+ ${v.roles.map((r, k) => html`<td class="ds-c"><input type="checkbox" aria-label="${r} sees ${c}" name="m.${j}.see.${k}"${chk(!!m && sees(v, c, r))}></td>`)}
80
+ <td class="ds-c"><input type="checkbox" aria-label="Everyone else sees ${c}" name="m.${j}.others"${chk(!!m && m.others)}></td></tr>`;
81
+ })}</tbody></table></div>`;
82
+ return html`<form method="post" action="${BASE}/sql/objects/security" class="ds">${csrf(s)}
83
+ <input type="hidden" name="ref" value="${opts.ref}"><input type="hidden" name="shown_roles" value="${v.roles.join(', ')}">
84
+ ${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
85
+ <section class="ds-section"><h2>${icon('shield')} Rows</h2>
86
+ <p>Row level security is <strong>${cur.rls ? (cur.forceRls ? 'on, also for the owner' : 'on') : 'off'}</strong>${cur.rls ? '' : ': every role that may read the table reads every row'}.
87
+ <a href="${BASE}/sql/objects/table?${new URLSearchParams({ o: opts.ref })}">Table Properties</a> switches it on; <a href="${policyUrl}">Add policy</a> writes one in the worksheet.</p>
88
+ ${cur.policies.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Policy</th><th>Command</th><th>Roles</th><th>Using</th><th>With check</th></tr></thead><tbody>
89
+ ${cur.policies.map((p) => html`<tr><td>${p.name}${p.permissive === 'RESTRICTIVE' ? html` <span class="badge">restrictive</span>` : ''}</td><td>${p.command}</td><td><code>${p.roles}</code></td><td><code>${p.using ?? ''}</code></td><td><code>${p.check ?? ''}</code></td></tr>`)}</tbody></table></div>`
90
+ : html`<p class="muted">No policies${cur.rls ? ': with row level security on, the roles below read no rows at all' : ''}.</p>`}
91
+ </section>
92
+ <section class="ds-section"><h2>${icon('table')} Columns</h2>
93
+ <p class="muted">What each database role may read and update, per column. A column a role may not read is refused by PostgreSQL whoever sends the query: pages, REST handlers, AutoREST, AI tools, MCP or psql.</p>
94
+ ${rows}
95
+ </section>
96
+ <section class="ds-section"><h2>${icon('eye')} Values</h2>
97
+ <p class="muted">Masked columns show their value only to the application roles you tick (<code>meta.has_role()</code> of the signed-in user); others see the mask. The masks live in a view next to the table that the roles above read instead of the table.</p>
98
+ <label class="check"><input type="checkbox" name="view"${chk(v.enabled)}> Masking view</label>
99
+ <div class="form-grid">
100
+ <div class="field"><label class="label" for="ds-view">View name</label><input id="ds-view" name="view_name" value="${v.name}" autocomplete="off" spellcheck="false"></div>
101
+ <div class="field"><label class="label" for="ds-roles">Application roles</label><input id="ds-roles" name="roles" value="${v.roles.join(', ')}" autocomplete="off" spellcheck="false">
102
+ <small class="help">Comma-separated. Changed roles get their column in the table below after Preview SQL.</small></div>
103
+ </div>
104
+ ${maskRows}
105
+ </section>
106
+ <section class="ds-section"><h2>${icon('code')} SQL</h2>
107
+ ${opts.warnings.length ? html`<div class="alert alert-warning" role="status"><ul>${opts.warnings.map((w) => html`<li>${w}</li>`)}</ul></div>` : ''}
108
+ ${opts.sql.length ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="SQL">${opts.sql.join(';\n\n')};</pre>` : html`<p class="muted">No changes: the table's security is as the form shows it.</p>`}
109
+ ${opts.output}
110
+ </section>
111
+ <div class="buttons">
112
+ <button class="btn" name="mode" value="preview">${icon('eye')} Preview SQL</button>
113
+ <button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
114
+ <button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
115
+ <a class="btn" href="${objUrl}">Cancel</a>
116
+ </div></form>`;
117
+ }
118
+
119
+ async function page(req: Req, reply: FastifyReply) {
120
+ const s = await developer(req, reply);
121
+ if (!s) return;
122
+ const db = await workshopDb(s);
123
+ const isPost = req.method === 'POST';
124
+ const b: Body = isPost ? (req.body ?? {}) : (req.query ?? {});
125
+ const ref = String((isPost ? b.ref : b.o) ?? '');
126
+ const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM_SCHEMAS.includes(n));
127
+ const { appDbRoles, appRoles } = await workspaceApps(s);
128
+ const cur = ref ? await readSecurity(db, ref, appDbRoles, appRoles) : null;
129
+ if (!cur || !schemas.includes(cur.schema)) return reply.code(404).send('Not found');
130
+ let def = currentDef(cur);
131
+ let errors: string[] = [];
132
+ let sql: string[] = [];
133
+ let warnings: string[] = [];
134
+ let output: Raw | '' = '';
135
+ if (isPost) {
136
+ def = fromForm(b, cur);
137
+ const taken = (await db.query<{ n: string }>(`select relname as n from pg_class where relnamespace = $1::regnamespace`, [cur.schema])).rows.map((r) => r.n);
138
+ errors = checkSecurity(def, cur, taken);
139
+ if (!errors.length) ({ statements: sql, warnings } = securitySql(def, cur));
140
+ if (!errors.length && b.mode === 'worksheet') {
141
+ s.state.__SQLDRAFT = sql.length ? `${sql.join(';\n\n')};` : '-- no changes';
142
+ flash(s, 'Review the statements and run them: nothing has been changed yet.');
143
+ return back(reply, s, `${BASE}/sql/worksheet`);
144
+ }
145
+ if (!errors.length && b.mode === 'apply' && sql.length) {
146
+ const c = await db.pool.connect();
147
+ let failed = '';
148
+ let at = '';
149
+ try {
150
+ await c.query('begin');
151
+ for (const x of sql) {
152
+ at = x;
153
+ await c.query(x);
154
+ }
155
+ await c.query('commit');
156
+ } catch (e) {
157
+ failed = (e as Error).message;
158
+ await c.query('rollback').catch(() => {});
159
+ } finally {
160
+ c.release(true);
161
+ }
162
+ await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: sql.join(';\n').slice(0, 2000) });
163
+ clearCompletions();
164
+ if (!failed) {
165
+ await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', name: `security_${cur.table}`.slice(0, 63), defaultSchema: cur.schema, statements: [joinStatements(sql)] });
166
+ flash(s, `Changed the security of ${cur.schema}.${cur.table}: ${sql.length} statement${sql.length === 1 ? '' : 's'}.`);
167
+ return back(reply, s, `${BASE}/sql/objects/security?${new URLSearchParams({ o: ref })}`);
168
+ }
169
+ output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
170
+ }
171
+ }
172
+ const title = `Security of ${cur.schema}.${cur.table}`;
173
+ const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: ref })}`;
174
+ const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects')}${form(s, cur, def, { ref, sql, warnings, errors, output })}`;
175
+ return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], ['Security']], main, 'sql'));
176
+ }
177
+
178
+ export async function dataSecurityRoutes(app: FastifyInstance) {
179
+ app.get(`${BASE}/sql/objects/security`, page);
180
+ app.post(`${BASE}/sql/objects/security`, page);
181
+ }
182
+