@lamalibre/create-lamaste 4.5.0 → 4.6.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 (105) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +34 -2
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +2 -2
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +5 -5
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/02-guides/static-sites.md +4 -0
  19. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  20. package/vendor/docs/03-architecture/installer.md +4 -4
  21. package/vendor/docs/03-architecture/management-flow.md +1 -1
  22. package/vendor/docs/03-architecture/nginx-configuration.md +65 -7
  23. package/vendor/docs/03-architecture/overview.md +5 -5
  24. package/vendor/docs/03-architecture/panel-client.md +6 -6
  25. package/vendor/docs/03-architecture/panel-server.md +2 -2
  26. package/vendor/docs/03-architecture/state-management.md +2 -2
  27. package/vendor/docs/03-architecture/system-overview.md +1 -1
  28. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +112 -37
  29. package/vendor/docs/04-api-reference/app-host.md +3 -3
  30. package/vendor/docs/04-api-reference/gatekeeper.md +19 -1
  31. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  32. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  33. package/vendor/docs/04-api-reference/sites.md +4 -0
  34. package/vendor/docs/04-api-reference/tunnels.md +21 -19
  35. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  36. package/vendor/docs/05-operations/uninstalling.md +1 -1
  37. package/vendor/docs/05-operations/upgrades.md +15 -6
  38. package/vendor/docs/06-reference/config-files.md +19 -15
  39. package/vendor/docs/06-reference/glossary.md +3 -3
  40. package/vendor/docs/06-reference/installer-flags.md +13 -13
  41. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  42. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  43. package/vendor/docs/gatekeeper-design.md +8 -8
  44. package/vendor/docs/identity-directory-design.md +3 -3
  45. package/vendor/docs/package.json +1 -1
  46. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  47. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  48. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  49. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  50. package/vendor/gatekeeper/dist/index.js +2 -2
  51. package/vendor/gatekeeper/dist/index.js.map +1 -1
  52. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  53. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  54. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  55. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  56. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  57. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  58. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  59. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  60. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  61. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  63. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  65. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  67. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  68. package/vendor/gatekeeper/dist/server/index.js +29 -10
  69. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/server/routes/authz.js +103 -22
  72. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  73. package/vendor/gatekeeper/package.json +2 -2
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  76. package/vendor/server/src/commands/plugins.js +63 -6
  77. package/vendor/server/src/commands/sites.js +15 -1
  78. package/vendor/server/src/plugin-approval.js +80 -1
  79. package/vendor/server/src/plugin-web-surface.js +307 -0
  80. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  81. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  82. package/vendor/server-ui/dist/index.html +2 -2
  83. package/vendor/serverd/package.json +2 -2
  84. package/vendor/serverd/src/index.js +9 -4
  85. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  86. package/vendor/serverd/src/lib/chisel.js +10 -2
  87. package/vendor/serverd/src/lib/gatekeeper-client.js +18 -7
  88. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  89. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  90. package/vendor/serverd/src/lib/nginx.js +269 -2
  91. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  92. package/vendor/serverd/src/lib/plugin-lifecycle.js +128 -5
  93. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  94. package/vendor/serverd/src/lib/plugins.js +39 -6
  95. package/vendor/serverd/src/lib/state.js +18 -0
  96. package/vendor/serverd/src/lib/user-surface.js +48 -27
  97. package/vendor/serverd/src/lib/vhost-reconcile.js +5 -3
  98. package/vendor/serverd/src/lib/web-surface.js +766 -0
  99. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  100. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  101. package/vendor/serverd/src/routes/management/sites.js +143 -65
  102. package/vendor/serverd/src/routes/management/tunnels.js +11 -20
  103. package/vendor/serverd/src/routes/management.js +2 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -59,6 +59,10 @@
59
59
  * Who holds which of its roles; give or take them away
60
60
  * identity-sync <name> [--reconcile]
61
61
  * Sync its audience to its agent half now
62
+ * web-surface <name> [create [--subdomain <s>] [--access-mode <m>] | move --tunnel <t> | delete [--yes]]
63
+ * The site an agent-hosted plugin owns for its web surface: what it
64
+ * declares, whether it is served, its trees; create, move a bound
65
+ * tunnel's host to it, delete it
62
66
  *
63
67
  * Every subcommand goes through the running panel (`/api/plugins/*`, admin
64
68
  * certificate): the panel owns the plugin registry (SQLite `state.db`), runs
@@ -77,6 +81,7 @@ import { panelRequest } from '../panel-api.js';
77
81
  import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
78
82
  import { configureCmd, settingsCmd } from './plugin-settings.js';
79
83
  import { audienceCmd, entitlementsCmd, identityCmd, identitySyncCmd } from './plugin-identity.js';
84
+ import { webSurfaceCmd } from './plugin-web-surface.js';
80
85
  import { settingsDiffLines, terminalText } from '../plugin-settings.js';
81
86
  import {
82
87
  APPROVE_HOOKS_USAGE,
@@ -85,6 +90,8 @@ import {
85
90
  identityDiffLines,
86
91
  parseApproveArgs,
87
92
  pendingUpdateReasons,
93
+ webSurfaceBlockLines,
94
+ webTreeDiffLines,
88
95
  } from '../plugin-approval.js';
89
96
 
90
97
  /** npm runs inside the panel for install and uninstall (its own limit is 120 s). */
@@ -152,6 +159,8 @@ export async function runPlugins(args, { json }) {
152
159
  return entitlementsCmd(args.slice(1), { json });
153
160
  case 'identity-sync':
154
161
  return identitySyncCmd(args.slice(1), { json });
162
+ case 'web-surface':
163
+ return webSurfaceCmd(args.slice(1), { json });
155
164
  default:
156
165
  printPluginUsage();
157
166
  process.exit(sub ? 1 : 0);
@@ -219,6 +228,14 @@ ${b('Subcommands:')}
219
228
  Take every role away
220
229
  ${c('identity-sync <name> [--reconcile]')}
221
230
  Sync its audience to its agent half now; --reconcile replays all of it
231
+ ${c('web-surface <name>')} The site a plugin owns for its web surface: what it declares, its site,
232
+ whether it is served (and why not), its trees, the bound tunnel, the offer
233
+ ${c('web-surface <name> create [--subdomain <label>] [--access-mode authenticated|restricted]')}
234
+ Give it its site (default: the subdomain and access mode it declares)
235
+ ${c('web-surface <name> move --tunnel <id | subdomain | fqdn>')}
236
+ Hand the host of the tunnel bound as its user surface to its site
237
+ ${c('web-surface <name> delete [--yes]')}
238
+ Delete its site, its trees and the grants on it (typed confirmation)
222
239
  `);
223
240
  }
224
241
 
@@ -796,16 +813,25 @@ function printIdentityBlock(block, title) {
796
813
  for (const line of identityBlockLines(block)) console.log(` ${line}`);
797
814
  }
798
815
 
816
+ /**
817
+ * Print a web surface — the web tree an approval covers and the site
818
+ * Lamaste offers for it (the hook status's `web`).
819
+ */
820
+ function printWebSurfaceBlock(web, title) {
821
+ console.log(chalk.bold(` ${title}`));
822
+ for (const line of webSurfaceBlockLines(web)) console.log(` ${line}`);
823
+ }
824
+
799
825
  /**
800
826
  * Print what a version brings that needs approval — the approval digest
801
827
  * (what `approve-hooks` takes; none for an update that brings neither
802
- * relay hooks, nor an agent half Lamaste installs, nor an identity block),
803
- * its relay hook set, the agent half Lamaste installs and its identity
804
- * block.
828
+ * relay hooks, nor an agent half Lamaste installs, nor an identity block,
829
+ * nor a web surface), its relay hook set, the agent half Lamaste installs,
830
+ * its identity block and its web tree.
805
831
  */
806
- function printApprovalContents({ digest, relay, agentHalf, identity = null }) {
832
+ function printApprovalContents({ digest, relay, agentHalf, identity = null, web = null }) {
807
833
  console.log(
808
- ` approval digest: ${digest ? chalk.cyan(digest) : chalk.dim('none — no relay hooks, no agent half Lamaste installs, no identity block')}`,
834
+ ` approval digest: ${digest ? chalk.cyan(digest) : chalk.dim('none — no relay hooks, no agent half Lamaste installs, no identity block, no web surface')}`,
809
835
  );
810
836
  if (relay) {
811
837
  console.log('');
@@ -822,6 +848,27 @@ function printApprovalContents({ digest, relay, agentHalf, identity = null }) {
822
848
  'Identity — Lamaste sends its agent half the users entitled to these roles',
823
849
  );
824
850
  }
851
+ if (web?.declared) {
852
+ console.log('');
853
+ printWebSurfaceBlock(
854
+ web,
855
+ 'Web surface — the relay serves this tree on the site the plugin owns',
856
+ );
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Print how an update changes the plugin's web tree (part of the digest):
862
+ * the tree before and after, its file count and size.
863
+ */
864
+ function printWebTreeChanges(web) {
865
+ const shown = webTreeDiffLines(web);
866
+ if (!shown) return;
867
+ console.log('');
868
+ console.log(chalk.bold(` Web surface — this version ${shown.headline}`));
869
+ for (const line of shown.lines) {
870
+ console.log(` ${shown.changed ? line : chalk.dim(line)}`);
871
+ }
825
872
  }
826
873
 
827
874
  /**
@@ -874,6 +921,7 @@ function printPendingUpdate(name, update, title) {
874
921
  agentHalf: update.agentHalf,
875
922
  });
876
923
  printIdentityChanges(update.identity);
924
+ printWebTreeChanges(update.web);
877
925
  const lines = settingsDiffLines(update.settings);
878
926
  if (lines.length > 0) {
879
927
  console.log('');
@@ -916,7 +964,7 @@ async function hooksCmd(name, { json }) {
916
964
  console.log('');
917
965
  if (!approval.required && !hooks.agent && !hooks.pendingUpdate) {
918
966
  console.log(
919
- ` Plugin ${chalk.cyan(name)} declares no lifecycle hooks, no agent half for Lamaste to install and no identity.\n`,
967
+ ` Plugin ${chalk.cyan(name)} declares no lifecycle hooks, no agent half for Lamaste to install, no identity and no web surface.\n`,
920
968
  );
921
969
  return;
922
970
  }
@@ -933,6 +981,7 @@ async function hooksCmd(name, { json }) {
933
981
  relay: hooks.relay,
934
982
  agentHalf: hooks.agentHalf,
935
983
  identity: hooks.identity?.declared ? hooks.identity.block : null,
984
+ web: hooks.web ?? null,
936
985
  });
937
986
  console.log(
938
987
  `\n auto-approve for later versions: ${hooks.autoApprove ? chalk.yellow('on') : 'off'}`,
@@ -947,6 +996,13 @@ async function hooksCmd(name, { json }) {
947
996
  ),
948
997
  );
949
998
  }
999
+ if (hooks.web?.declared) {
1000
+ console.log(
1001
+ chalk.dim(
1002
+ ` Its site (served, trees, offer): lamaste-server plugins web-surface ${terminalText(name)}`,
1003
+ ),
1004
+ );
1005
+ }
950
1006
  }
951
1007
  if (hooks.agent) {
952
1008
  console.log(chalk.bold(`\n Agent hooks (run by the agent half; no approval)`));
@@ -1262,6 +1318,7 @@ async function installPluginCmd(packageName, { json }) {
1262
1318
  relay: result.hooks.relay,
1263
1319
  agentHalf: result.hooks.agentHalf,
1264
1320
  identity: result.hooks.identity?.declared ? result.hooks.identity.block : null,
1321
+ web: result.hooks.web ?? null,
1265
1322
  });
1266
1323
  if (approval.state === 'pending') {
1267
1324
  console.log(
@@ -7,6 +7,10 @@
7
7
  * delete <id> Delete a site by ID
8
8
  * aliases <id> --set <a,b|""> Replace a custom site's redirect aliases
9
9
  *
10
+ * A site a plugin owns (its web surface) is marked in the list and managed
11
+ * through `lamaste-server plugins web-surface <plugin>`; the panel refuses
12
+ * to change or delete it here.
13
+ *
10
14
  * List reads directly from the state file.
11
15
  * Create/delete use the panel REST API (which has the full
12
16
  * nginx/certbot/directory dependency stack).
@@ -16,6 +20,7 @@ import chalk from 'chalk';
16
20
  import { readSites } from '../state.js';
17
21
  import { panelRequest } from '../panel-api.js';
18
22
  import { emit, emitError, emitComplete } from '../ndjson.js';
23
+ import { siteOwnerTag } from '../plugin-web-surface.js';
19
24
 
20
25
  /**
21
26
  * @param {string[]} args
@@ -92,12 +97,21 @@ async function listSites({ json }) {
92
97
  const certStatus = s.certIssued ? g('cert issued') : y('no cert');
93
98
  const spaTag = s.spaMode ? d('[SPA]') : '';
94
99
  const authTag = s.autheliaProtected ? d('[protected]') : '';
100
+ const owner = siteOwnerTag(s);
101
+ const ownerTag = owner ? c(owner) : '';
95
102
 
96
103
  console.log(
97
- ` ${c(s.name)} ${d(s.fqdn)} ${s.type} ${dnsStatus} ${certStatus} ${spaTag} ${authTag}`.trimEnd(),
104
+ ` ${c(s.name)} ${d(s.fqdn)} ${s.type} ${dnsStatus} ${certStatus} ${spaTag} ${authTag} ${ownerTag}`.trimEnd(),
98
105
  );
99
106
  console.log(` ${d(`id: ${s.id}`)}`);
100
107
  }
108
+ if (sites.some((s) => siteOwnerTag(s) !== null)) {
109
+ console.log(
110
+ d(
111
+ '\n A [plugin …] site is the web surface of that plugin: manage it with lamaste-server plugins web-surface <plugin>',
112
+ ),
113
+ );
114
+ }
101
115
  console.log('');
102
116
  }
103
117
 
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * `lamaste-server plugins hooks|update|approve-hooks`: why an update waits
3
3
  * for approval, what an identity block lets a plugin learn and how an
4
- * update changes it, the command that approves it, and the arguments that
4
+ * update changes it, the web tree a version brings and how an update
5
+ * changes it, the command that approves it, and the arguments that
5
6
  * command takes. Pure — no output — so they are tested as they are.
6
7
  */
7
8
 
8
9
  import { terminalText } from './plugin-settings.js';
10
+ import { bytesText, shortDigest } from './plugin-web-surface.js';
9
11
 
10
12
  const DIGEST_RE = /^[a-f0-9]{64}$/;
11
13
  const VERSION_RE = /^[0-9A-Za-z.+-]{1,64}$/;
@@ -141,6 +143,83 @@ export function identityDiffLines(diff) {
141
143
  return { headline, lines };
142
144
  }
143
145
 
146
+ /**
147
+ * What the installed version's web surface brings (the hook status's
148
+ * `web`: `{ declared, approved, treeSha256, surface }`) as lines — the tree
149
+ * the approval covers, and the site Lamaste offers for it. Empty when it
150
+ * declares none.
151
+ *
152
+ * @param {unknown} web
153
+ * @returns {string[]}
154
+ */
155
+ export function webSurfaceBlockLines(web) {
156
+ if (web === null || typeof web !== 'object' || !web.declared) return [];
157
+ const surface = web.surface !== null && typeof web.surface === 'object' ? web.surface : null;
158
+ const lines = [terminalText(`tree: ${web.treeSha256 ?? 'unknown'}`)];
159
+ if (surface) {
160
+ lines.push(
161
+ terminalText(
162
+ `site: offered on ${surface.defaultSubdomain} (${surface.accessMode}), the shell from ${surface.root}/ of its server package`,
163
+ ),
164
+ );
165
+ }
166
+ return lines;
167
+ }
168
+
169
+ /**
170
+ * How an update changes the plugin's web tree (`pendingUpdate.web`:
171
+ * `{ before: sha256 | null, after: { sha256, files, totalBytes } | null }`
172
+ * — part of the digest): a headline and its lines. Null when neither
173
+ * version declares a web surface.
174
+ *
175
+ * @param {unknown} web
176
+ * @returns {{ headline: string, lines: string[], changed: boolean } | null}
177
+ */
178
+ export function webTreeDiffLines(web) {
179
+ if (web === null || typeof web !== 'object') return null;
180
+ const before = typeof web.before === 'string' ? web.before : null;
181
+ const after =
182
+ web.after !== null && typeof web.after === 'object' && typeof web.after.sha256 === 'string'
183
+ ? web.after
184
+ : null;
185
+ if (before === null && after === null) return null;
186
+ const contents = (tree) => `${tree.files} file(s), ${bytesText(tree.totalBytes)}`;
187
+ if (after === null) {
188
+ return {
189
+ headline:
190
+ 'no longer declares a web surface — its site answers 503 until a version that does is approved',
191
+ lines: [terminalText(`tree: ${shortDigest(before)} → none`)],
192
+ changed: true,
193
+ };
194
+ }
195
+ if (before === null) {
196
+ return {
197
+ headline: 'declares a web surface for the first time',
198
+ lines: [
199
+ terminalText(`tree: ${after.sha256}`),
200
+ terminalText(`contents: ${contents(after)}`),
201
+ ],
202
+ changed: true,
203
+ };
204
+ }
205
+ if (before === after.sha256) {
206
+ return {
207
+ headline: 'keeps its web tree',
208
+ lines: [terminalText(`tree: ${after.sha256} (${contents(after)})`)],
209
+ changed: false,
210
+ };
211
+ }
212
+ return {
213
+ headline: 'changes its web tree — its site serves the new shell once approved',
214
+ lines: [
215
+ terminalText(`tree: ${shortDigest(before)} → ${shortDigest(after.sha256)}`),
216
+ terminalText(`new tree: ${after.sha256}`),
217
+ terminalText(`contents: ${contents(after)}`),
218
+ ],
219
+ changed: true,
220
+ };
221
+ }
222
+
144
223
  /**
145
224
  * The command that approves what plugin `name` brings: the installed
146
225
  * version by its digest (`{ digest }`), or a pending update by its version
@@ -0,0 +1,307 @@
1
+ /**
2
+ * A plugin's web surface at the terminal — the arguments of
3
+ * `lamaste-server plugins web-surface <name> [create|move|delete]`, the
4
+ * words its output is made of, and how `sites list` marks a site a plugin
5
+ * owns (what an approval shows about a web tree is in `plugin-approval.js`). Pure — no output, no
6
+ * panel — so they are tested as they are. Every string from the panel goes
7
+ * through `terminalText` before it is printed; `--json` carries the data as
8
+ * it came.
9
+ */
10
+
11
+ import { WEB_SURFACE_ACCESS_MODES, isWebSurfaceSubdomain } from '@lamalibre/lamaste';
12
+ import { terminalText } from './plugin-settings.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Arguments
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** How `plugins web-surface` is called. */
19
+ export const WEB_SURFACE_USAGE =
20
+ 'Usage: lamaste-server plugins web-surface <name> [create [--subdomain <label>] [--access-mode authenticated|restricted] | move --tunnel <id | subdomain | fqdn> | delete [--yes]]';
21
+
22
+ /** The flags each action takes, and whether each takes a value. */
23
+ const ACTION_FLAGS = {
24
+ status: {},
25
+ create: { '--subdomain': true, '--access-mode': true },
26
+ move: { '--tunnel': true },
27
+ delete: { '--yes': false },
28
+ };
29
+
30
+ /** Every flag that takes a value, whichever action it belongs to. */
31
+ const VALUE_FLAGS = new Set(
32
+ Object.values(ACTION_FLAGS).flatMap((flags) =>
33
+ Object.entries(flags)
34
+ .filter(([, takesValue]) => takesValue)
35
+ .map(([flag]) => flag),
36
+ ),
37
+ );
38
+
39
+ /**
40
+ * The arguments of `plugins web-surface …` after the subcommand: the
41
+ * plugin, then an action — none (its status), `create` (with `--subdomain`
42
+ * and `--access-mode`, each defaulting to what the plugin declares),
43
+ * `move --tunnel <ref>` (a tunnel's id, subdomain or FQDN), or `delete`
44
+ * (`--yes` skips the typed confirmation).
45
+ *
46
+ * @param {string[]} args
47
+ * @returns {{ ok: true, name: string, action: 'status' }
48
+ * | { ok: true, name: string, action: 'create', subdomain: string | null, accessMode: string | null }
49
+ * | { ok: true, name: string, action: 'move', tunnel: string }
50
+ * | { ok: true, name: string, action: 'delete', yes: boolean }
51
+ * | { ok: false, error: string }}
52
+ */
53
+ export function parseWebSurfaceArgs(args) {
54
+ const usage = WEB_SURFACE_USAGE;
55
+ const positional = [];
56
+ /** @type {Map<string, string | true>} */
57
+ const flags = new Map();
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i];
60
+ if (!arg.startsWith('--')) {
61
+ positional.push(arg);
62
+ continue;
63
+ }
64
+ if (flags.has(arg)) return { ok: false, error: `${arg} is given twice — ${usage}` };
65
+ if (!VALUE_FLAGS.has(arg)) {
66
+ flags.set(arg, true);
67
+ continue;
68
+ }
69
+ const value = args[i + 1];
70
+ if (value === undefined || value.startsWith('--')) {
71
+ return { ok: false, error: `${arg} needs a value — ${usage}` };
72
+ }
73
+ flags.set(arg, value);
74
+ i++;
75
+ }
76
+ if (positional.length === 0) return { ok: false, error: usage };
77
+ if (positional.length > 2) {
78
+ return { ok: false, error: `Unexpected argument "${positional[2]}" — ${usage}` };
79
+ }
80
+ const [name, action = 'status'] = positional;
81
+ if (positional.length === 2 && !['create', 'move', 'delete'].includes(action)) {
82
+ return { ok: false, error: `Unknown action "${action}" — ${usage}` };
83
+ }
84
+ const allowed = ACTION_FLAGS[action];
85
+ for (const flag of flags.keys()) {
86
+ if (!Object.hasOwn(allowed, flag)) {
87
+ return {
88
+ ok: false,
89
+ error:
90
+ action === 'status'
91
+ ? `Unknown option ${flag} — ${usage}`
92
+ : `${action} takes no ${flag} — ${usage}`,
93
+ };
94
+ }
95
+ }
96
+
97
+ switch (action) {
98
+ case 'create': {
99
+ const subdomain = flags.get('--subdomain') ?? null;
100
+ const accessMode = flags.get('--access-mode') ?? null;
101
+ if (subdomain !== null && !isWebSurfaceSubdomain(subdomain)) {
102
+ return {
103
+ ok: false,
104
+ error: `"${subdomain}" cannot be a web surface's subdomain: a DNS label, not reserved (panel, auth, tunnel, www, mail, ftp, api) and not agent-*`,
105
+ };
106
+ }
107
+ if (accessMode !== null && !WEB_SURFACE_ACCESS_MODES.includes(accessMode)) {
108
+ return {
109
+ ok: false,
110
+ error: `--access-mode is ${WEB_SURFACE_ACCESS_MODES.join(' or ')} — a web surface is never public`,
111
+ };
112
+ }
113
+ return { ok: true, name, action, subdomain, accessMode };
114
+ }
115
+ case 'move': {
116
+ const tunnel = flags.get('--tunnel');
117
+ if (tunnel === undefined) {
118
+ return {
119
+ ok: false,
120
+ error: `Name the tunnel to move: --tunnel <id | subdomain | fqdn> — ${usage}`,
121
+ };
122
+ }
123
+ return { ok: true, name, action, tunnel };
124
+ }
125
+ case 'delete':
126
+ return { ok: true, name, action, yes: flags.has('--yes') };
127
+ default:
128
+ return { ok: true, name, action: 'status' };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The body of `POST /api/plugins/:name/web-surface`: only what was given —
134
+ * the panel falls back to what the plugin declares.
135
+ *
136
+ * @param {{ subdomain: string | null, accessMode: string | null }} parsed
137
+ * @returns {{ subdomain?: string, accessMode?: string }}
138
+ */
139
+ export function createBody({ subdomain, accessMode }) {
140
+ return {
141
+ ...(subdomain !== null ? { subdomain } : {}),
142
+ ...(accessMode !== null ? { accessMode } : {}),
143
+ };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Output
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /** A tree digest as the terminal shows it beside others: its first 12 hex digits. */
151
+ export function shortDigest(sha256) {
152
+ if (typeof sha256 !== 'string' || sha256.length === 0) return '—';
153
+ return terminalText(sha256.slice(0, 12));
154
+ }
155
+
156
+ /** A size in bytes, in words (`1.5 MiB`). */
157
+ export function bytesText(bytes) {
158
+ if (!Number.isFinite(bytes) || bytes < 0) return 'an unknown size';
159
+ if (bytes < 1024) return `${bytes} B`;
160
+ const units = ['KiB', 'MiB', 'GiB'];
161
+ let value = bytes / 1024;
162
+ let unit = 0;
163
+ while (value >= 1024 && unit < units.length - 1) {
164
+ value /= 1024;
165
+ unit++;
166
+ }
167
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
168
+ }
169
+
170
+ /**
171
+ * Whether the web surface's site serves the shell and the API, and why not
172
+ * — the status's `served`/`reason` (and whether it owns a site at all) in
173
+ * a sentence.
174
+ *
175
+ * @param {{ served?: boolean, reason?: string | null, site?: unknown }} view
176
+ * @param {string} name the plugin
177
+ * @returns {{ served: boolean, text: string }}
178
+ */
179
+ export function webSurfaceServedText(view, name) {
180
+ const plugin = terminalText(name);
181
+ const hasSite = view?.site !== null && view?.site !== undefined;
182
+ if (view?.served) {
183
+ return hasSite
184
+ ? { served: true, text: 'served — its site serves the shell and the API' }
185
+ : {
186
+ served: false,
187
+ text: 'ready to be served, but it owns no site yet — create one, or move a bound tunnel to it',
188
+ };
189
+ }
190
+ const where = hasSite ? 'its site answers 503' : 'it owns no site yet';
191
+ const notServed = (why) => ({ served: false, text: `${why} (${where})` });
192
+ switch (view?.reason) {
193
+ case 'not_installed':
194
+ return notServed('not served — the plugin is not installed');
195
+ case 'no_identity':
196
+ return notServed(
197
+ 'not served — its installed version declares no identity, which a web surface fronts',
198
+ );
199
+ case 'no_web_surface':
200
+ return notServed('not served — its installed version declares no web surface');
201
+ case 'not_owner':
202
+ return notServed(
203
+ `not served — its site was created for another package under the same route: delete it (lamaste-server plugins web-surface ${plugin} delete --yes), then create one`,
204
+ );
205
+ case 'not_approved':
206
+ return notServed(
207
+ `not served — its installed version waits for approval: lamaste-server plugins hooks ${plugin}`,
208
+ );
209
+ case 'not_hosted':
210
+ return notServed(
211
+ `not served — it is not hosted on an agent, or its hosting is not active: lamaste-server plugins hosting ${plugin}`,
212
+ );
213
+ case 'tree_not_current':
214
+ return notServed(
215
+ 'not served yet — the relay does not serve its approved web tree yet; it switches when the approved version is installed',
216
+ );
217
+ default:
218
+ return {
219
+ served: false,
220
+ text: `not served (${terminalText(view?.reason ?? 'unknown')}; ${where})`,
221
+ };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * The trees the relay holds for a web surface (the status's `tree`:
227
+ * `{ approved, current, previous }`) as `[label, value]` rows — short
228
+ * digests, with whether the one served is the approved one.
229
+ *
230
+ * @param {{ approved?: string | null, current?: string | null, previous?: string | null } | null | undefined} tree
231
+ * @returns {Array<[string, string]>}
232
+ */
233
+ export function webTreeRows(tree) {
234
+ const approved = tree?.approved ?? null;
235
+ const current = tree?.current ?? null;
236
+ const previous = tree?.previous ?? null;
237
+ const serving =
238
+ current === null
239
+ ? 'none'
240
+ : approved !== null && current === approved
241
+ ? `${shortDigest(current)} (the approved tree)`
242
+ : `${shortDigest(current)} (not the approved tree)`;
243
+ return [
244
+ ['approved tree', approved === null ? 'none' : shortDigest(approved)],
245
+ ['serving', serving],
246
+ [
247
+ 'previous',
248
+ previous === null ? 'none' : `${shortDigest(previous)} (fallback for pages still open)`,
249
+ ],
250
+ ];
251
+ }
252
+
253
+ /** What each refusal of the web-surface routes asks of the administrator, beside its message. */
254
+ const REFUSAL_HINTS = {
255
+ no_web_surface: () =>
256
+ 'Its installed version declares no userSurface.web; update to a version that does.',
257
+ no_identity: () =>
258
+ 'A web surface is the front of its user surface, which needs an identity block.',
259
+ exists: (name) => `It owns a site already: lamaste-server plugins web-surface ${name}`,
260
+ bound_to_tunnel: (name) =>
261
+ `Move that tunnel's host to it instead: lamaste-server plugins web-surface ${name} move --tunnel <id | fqdn>`,
262
+ hostname_taken: (name) =>
263
+ `Choose another subdomain: lamaste-server plugins web-surface ${name} create --subdomain <label>`,
264
+ not_bound: () =>
265
+ 'Only a tunnel bound as its user surface moves (lamaste-server tunnels bind-user-surface).',
266
+ shared_tunnel: () =>
267
+ 'Unbind the other plugins first: lamaste-server tunnels unbind-user-surface <tunnel> <plugin>',
268
+ tunnel_disabled: () => 'Enable the tunnel first: lamaste-server tunnels toggle <id> --enable',
269
+ not_hosted: (name) =>
270
+ `The agent carrying the tunnel must host it: lamaste-server plugins hosting ${name}`,
271
+ not_approved: (name) =>
272
+ `Approve its installed version first: lamaste-server plugins hooks ${name}`,
273
+ web_tree: () =>
274
+ "The approved web tree could not be put in place; nothing changed. The panel's log names the file.",
275
+ gatekeeper: () => 'Gatekeeper could not be reached; nothing changed. Try again once it runs.',
276
+ };
277
+
278
+ /**
279
+ * The next step a refusal (`code`) of the web-surface routes points to, or
280
+ * null when its message says all there is.
281
+ *
282
+ * @param {unknown} code
283
+ * @param {string} name the plugin
284
+ * @returns {string | null}
285
+ */
286
+ export function webSurfaceRefusalHint(code, name) {
287
+ if (typeof code !== 'string' || !Object.hasOwn(REFUSAL_HINTS, code)) return null;
288
+ return terminalText(REFUSAL_HINTS[code](terminalText(name)));
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Sites
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * How `sites list` marks a site: `[plugin <route>, <access mode>]` for a
297
+ * site a plugin owns as its web surface, null for any other.
298
+ *
299
+ * @param {Record<string, any>} site
300
+ * @returns {string | null}
301
+ */
302
+ export function siteOwnerTag(site) {
303
+ const owner = site?.owner;
304
+ if (owner === null || typeof owner !== 'object' || typeof owner.plugin !== 'string') return null;
305
+ const mode = typeof site.accessMode === 'string' ? `, ${site.accessMode}` : '';
306
+ return terminalText(`[plugin ${owner.plugin}${mode}]`);
307
+ }