@lamalibre/create-lamaste 4.5.0 → 4.7.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 (178) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +56 -5
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/02-guides/static-sites.md +4 -0
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
  34. package/vendor/docs/03-architecture/overview.md +5 -5
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +3 -3
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
  40. package/vendor/docs/04-api-reference/app-host.md +3 -3
  41. package/vendor/docs/04-api-reference/domains.md +192 -0
  42. package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
  43. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  44. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  45. package/vendor/docs/04-api-reference/overview.md +9 -0
  46. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  47. package/vendor/docs/04-api-reference/sites.md +9 -0
  48. package/vendor/docs/04-api-reference/tunnels.md +35 -28
  49. package/vendor/docs/04-api-reference/users.md +41 -29
  50. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  51. package/vendor/docs/05-operations/uninstalling.md +1 -1
  52. package/vendor/docs/05-operations/upgrades.md +15 -6
  53. package/vendor/docs/06-reference/config-files.md +19 -15
  54. package/vendor/docs/06-reference/glossary.md +3 -3
  55. package/vendor/docs/06-reference/installer-flags.md +13 -13
  56. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  57. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  58. package/vendor/docs/_index.json +105 -19
  59. package/vendor/docs/external-members-design.md +816 -0
  60. package/vendor/docs/gatekeeper-design.md +8 -8
  61. package/vendor/docs/identity-directory-design.md +3 -3
  62. package/vendor/docs/multi-domain-relay-design.md +836 -0
  63. package/vendor/docs/package.json +1 -1
  64. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  65. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  66. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/index.js +2 -2
  68. package/vendor/gatekeeper/dist/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  70. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  72. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  73. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  74. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  76. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  77. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  79. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  81. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  82. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  83. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  84. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  85. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  86. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  88. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  89. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  90. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  91. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  92. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  93. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  94. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  95. package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
  96. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/index.js +56 -11
  98. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  100. package/vendor/gatekeeper/dist/server/routes/authz.js +325 -55
  101. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  102. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  103. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  104. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  105. package/vendor/gatekeeper/package.json +2 -2
  106. package/vendor/server/bin/lamaste-server.js +7 -0
  107. package/vendor/server/package.json +2 -2
  108. package/vendor/server/src/commands/domains.js +214 -0
  109. package/vendor/server/src/commands/plugin-identity.js +2 -16
  110. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  111. package/vendor/server/src/commands/plugins.js +175 -14
  112. package/vendor/server/src/commands/registry-credential.js +10 -3
  113. package/vendor/server/src/commands/sites.js +18 -1
  114. package/vendor/server/src/commands/status.js +19 -0
  115. package/vendor/server/src/commands/tunnels.js +4 -1
  116. package/vendor/server/src/commands/users.js +8 -19
  117. package/vendor/server/src/domains.js +92 -0
  118. package/vendor/server/src/fail.js +21 -0
  119. package/vendor/server/src/oidc-client-grants.js +63 -0
  120. package/vendor/server/src/panel-api.js +15 -0
  121. package/vendor/server/src/plugin-approval.js +80 -1
  122. package/vendor/server/src/plugin-web-surface.js +307 -0
  123. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  124. package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
  125. package/vendor/server-ui/dist/index.html +2 -2
  126. package/vendor/serverd/package.json +2 -2
  127. package/vendor/serverd/src/index.js +50 -19
  128. package/vendor/serverd/src/lib/app-error.js +24 -0
  129. package/vendor/serverd/src/lib/audience.js +12 -3
  130. package/vendor/serverd/src/lib/authelia.js +341 -35
  131. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  132. package/vendor/serverd/src/lib/chisel.js +10 -2
  133. package/vendor/serverd/src/lib/directory.js +67 -52
  134. package/vendor/serverd/src/lib/domains.js +1792 -0
  135. package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
  136. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  137. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  138. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  139. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  140. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  141. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  142. package/vendor/serverd/src/lib/nginx.js +576 -131
  143. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  144. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  145. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  146. package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
  147. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  148. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  149. package/vendor/serverd/src/lib/plugins.js +82 -9
  150. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  151. package/vendor/serverd/src/lib/services.js +9 -1
  152. package/vendor/serverd/src/lib/state.js +29 -0
  153. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  154. package/vendor/serverd/src/lib/user-surface.js +77 -31
  155. package/vendor/serverd/src/lib/users-lock.js +23 -0
  156. package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
  157. package/vendor/serverd/src/lib/web-surface.js +783 -0
  158. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  159. package/vendor/serverd/src/routes/invite.js +15 -5
  160. package/vendor/serverd/src/routes/management/domains.js +154 -0
  161. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  162. package/vendor/serverd/src/routes/management/identity.js +21 -7
  163. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  164. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  165. package/vendor/serverd/src/routes/management/plugins.js +6 -2
  166. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  167. package/vendor/serverd/src/routes/management/sites.js +188 -71
  168. package/vendor/serverd/src/routes/management/tunnels.js +37 -22
  169. package/vendor/serverd/src/routes/management/users.js +184 -64
  170. package/vendor/serverd/src/routes/management.js +4 -0
  171. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  172. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  173. package/vendor/serverd/src/routes/user-access.js +13 -1
  174. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  175. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  176. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  177. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  178. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Exit a command with a usage error or a panel refusal — as text, or as an
3
+ * NDJSON error carrying the refusal's `code` (e.g. `last_admin`,
4
+ * `domain_in_use`). The message is printed only as terminal text.
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+ import { emitError } from './ndjson.js';
9
+ import { terminalText } from './plugin-settings.js';
10
+
11
+ /**
12
+ * @param {string} message
13
+ * @param {boolean} json
14
+ * @param {{ code?: string }} [err]
15
+ * @returns {never}
16
+ */
17
+ export function fail(message, json, err = {}) {
18
+ if (json) emitError(message, false, typeof err.code === 'string' ? { code: err.code } : {});
19
+ else console.error(`\n ${chalk.red(terminalText(message))}\n`);
20
+ process.exit(1);
21
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Grants on a plugin's user-facing OIDC clients as `lamaste-server plugins
3
+ * oidc-clients` takes and prints them — pure helpers, exported for tests
4
+ * (the command is src/commands/plugins.js).
5
+ *
6
+ * A grant is a Gatekeeper grant with `resourceType: 'oidc-client'` and the
7
+ * client id as `resourceId`; members of the plugin's domain sign in without
8
+ * one, an external member only with one (to them or a Lamaste group they
9
+ * belong to).
10
+ */
11
+
12
+ import { terminalText } from './plugin-settings.js';
13
+
14
+ /** The Gatekeeper resource type of a grant on an OIDC client. */
15
+ export const OIDC_CLIENT_RESOURCE = 'oidc-client';
16
+
17
+ /**
18
+ * The principal `--user <username>` or `--group <name>` names — exactly one
19
+ * of them — or null.
20
+ *
21
+ * @param {string[]} args
22
+ * @returns {{ principalType: 'user' | 'group', principalId: string } | null}
23
+ */
24
+ export function parsePrincipal(args) {
25
+ if (args.length !== 2) return null;
26
+ const [flag, id] = args;
27
+ if (!id || id.startsWith('--')) return null;
28
+ if (flag === '--user') return { principalType: 'user', principalId: id };
29
+ if (flag === '--group') return { principalType: 'group', principalId: id };
30
+ return null;
31
+ }
32
+
33
+ /** A grant's principal in words: the username, or `group:<name>`. */
34
+ export function principalText(grant) {
35
+ return terminalText(
36
+ grant.principalType === 'group' ? `group:${grant.principalId}` : grant.principalId,
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Every grant on `clientId` that names `principal`, among the clients
42
+ * `GET /api/plugins/:name/oidc-clients` lists — Gatekeeper may hold more
43
+ * than one (grants differing only in their context), and revoking means
44
+ * revoking them all — or why there is none to revoke: `no_client` (not a
45
+ * user-facing client of the plugin), `unreadable` (Gatekeeper could not be
46
+ * read), `no_grant`.
47
+ *
48
+ * @param {ReadonlyArray<Record<string, any>>} clients
49
+ * @param {string} clientId
50
+ * @param {{ principalType: string, principalId: string }} principal
51
+ * @returns {{ grantIds: string[] } | { error: 'no_client' | 'unreadable' | 'no_grant' }}
52
+ */
53
+ export function findClientGrants(clients, clientId, principal) {
54
+ const client = clients.find((c) => c.id === clientId && c.userFacing);
55
+ if (!client) return { error: 'no_client' };
56
+ if (!Array.isArray(client.grants)) return { error: 'unreadable' };
57
+ const grantIds = client.grants
58
+ .filter(
59
+ (g) => g.principalType === principal.principalType && g.principalId === principal.principalId,
60
+ )
61
+ .map((g) => g.grantId);
62
+ return grantIds.length > 0 ? { grantIds } : { error: 'no_grant' };
63
+ }
@@ -129,6 +129,20 @@ export async function withCurlConfig(content, fn) {
129
129
  * @param {{ identity: { certPath: string, address: string, port: number }, method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, configPath: string }} request
130
130
  * @returns {{ args: string[], input: string | undefined }}
131
131
  */
132
+ /**
133
+ * Refuse a request path with a `.` or `..` segment: curl and `fetch`
134
+ * resolve them (`/api/domains/../activate` → `/api/activate`), so a value
135
+ * put into a path — a domain, a user, a plugin — could name another route.
136
+ * Every path the client sends passes here; values are percent-encoded as
137
+ * usual where the path is built.
138
+ */
139
+ export function assertPanelPath(path) {
140
+ const route = String(path).split(/[?#]/, 1)[0];
141
+ if (route.split('/').some((segment) => segment === '.' || segment === '..')) {
142
+ throw new Error('A panel request path may not have a "." or ".." segment');
143
+ }
144
+ }
145
+
132
146
  export function buildPanelCurlRequest({
133
147
  identity,
134
148
  method,
@@ -137,6 +151,7 @@ export function buildPanelCurlRequest({
137
151
  p12Path,
138
152
  configPath,
139
153
  }) {
154
+ assertPanelPath(path);
140
155
  const { certPath, address, port } = identity;
141
156
  const args = [
142
157
  '-q', // No .curlrc — it must be the first argument
@@ -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
+ }