@lamalibre/create-lamaste 4.4.1 → 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 (170) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +234 -18
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  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 +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/02-guides/static-sites.md +4 -0
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +3 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
  32. package/vendor/docs/03-architecture/overview.md +13 -17
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +3 -2
  35. package/vendor/docs/03-architecture/state-management.md +4 -8
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
  38. package/vendor/docs/04-api-reference/app-host.md +3 -3
  39. package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
  40. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  41. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  42. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  43. package/vendor/docs/04-api-reference/sites.md +4 -0
  44. package/vendor/docs/04-api-reference/tunnels.md +87 -47
  45. package/vendor/docs/04-api-reference/users.md +241 -45
  46. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  47. package/vendor/docs/05-operations/monitoring.md +39 -0
  48. package/vendor/docs/05-operations/uninstalling.md +2 -1
  49. package/vendor/docs/05-operations/upgrades.md +24 -6
  50. package/vendor/docs/06-reference/config-files.md +100 -202
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  54. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  55. package/vendor/docs/gatekeeper-design.md +10 -8
  56. package/vendor/docs/identity-directory-design.md +48 -14
  57. package/vendor/docs/package.json +1 -1
  58. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  59. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  60. package/vendor/gatekeeper/dist/index.d.ts +4 -3
  61. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/index.js +4 -2
  63. package/vendor/gatekeeper/dist/index.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  65. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  67. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  69. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  71. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  72. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  73. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  74. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  75. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  76. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  77. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  79. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  81. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  82. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  83. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  84. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
  85. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  86. package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
  87. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  88. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  89. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  90. package/vendor/gatekeeper/dist/server/index.js +49 -56
  91. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  92. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  93. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  94. package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
  95. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  96. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  98. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  100. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  101. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  102. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  103. package/vendor/gatekeeper/package.json +2 -2
  104. package/vendor/server/bin/lamaste-server.js +15 -1
  105. package/vendor/server/package.json +2 -2
  106. package/vendor/server/src/commands/plugin-identity.js +335 -0
  107. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  108. package/vendor/server/src/commands/plugins.js +156 -11
  109. package/vendor/server/src/commands/registry-credential.js +2 -1
  110. package/vendor/server/src/commands/sites.js +15 -1
  111. package/vendor/server/src/commands/tunnels.js +102 -2
  112. package/vendor/server/src/commands/uninstall.js +3 -1
  113. package/vendor/server/src/commands/users.js +358 -0
  114. package/vendor/server/src/plugin-approval.js +186 -5
  115. package/vendor/server/src/plugin-identity.js +382 -0
  116. package/vendor/server/src/plugin-web-surface.js +307 -0
  117. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  118. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  119. package/vendor/server-ui/dist/index.html +2 -2
  120. package/vendor/serverd/package.json +2 -2
  121. package/vendor/serverd/src/index.js +34 -5
  122. package/vendor/serverd/src/lib/audience.js +576 -0
  123. package/vendor/serverd/src/lib/authelia.js +78 -7
  124. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  125. package/vendor/serverd/src/lib/chisel.js +10 -2
  126. package/vendor/serverd/src/lib/config.js +68 -43
  127. package/vendor/serverd/src/lib/directory.js +1113 -0
  128. package/vendor/serverd/src/lib/entitlements.js +517 -0
  129. package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
  130. package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
  131. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  132. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  133. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  134. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  135. package/vendor/serverd/src/lib/mtls.js +6 -2
  136. package/vendor/serverd/src/lib/nginx.js +546 -48
  137. package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
  138. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  139. package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
  140. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  141. package/vendor/serverd/src/lib/plugins.js +95 -7
  142. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  143. package/vendor/serverd/src/lib/request-logging.js +19 -0
  144. package/vendor/serverd/src/lib/retained-data.js +50 -4
  145. package/vendor/serverd/src/lib/state.js +36 -0
  146. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  147. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  148. package/vendor/serverd/src/lib/user-caller.js +67 -0
  149. package/vendor/serverd/src/lib/user-surface.js +754 -0
  150. package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
  151. package/vendor/serverd/src/lib/web-surface.js +766 -0
  152. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  153. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  154. package/vendor/serverd/src/routes/invite.js +53 -12
  155. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  156. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  157. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  158. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  159. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  160. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  161. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  162. package/vendor/serverd/src/routes/management/sites.js +143 -65
  163. package/vendor/serverd/src/routes/management/tunnels.js +109 -22
  164. package/vendor/serverd/src/routes/management/users.js +290 -208
  165. package/vendor/serverd/src/routes/management.js +6 -0
  166. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  167. package/vendor/serverd/src/routes/user-access.js +17 -4
  168. package/vendor/serverd/src/routes/user-surface.js +69 -0
  169. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  170. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * `lamaste-server plugins hooks|update|approve-hooks`: why an update waits
3
- * for approval, the command that approves it, and the arguments that
3
+ * for approval, what an identity block lets a plugin learn and how an
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
4
6
  * command takes. Pure — no output — so they are tested as they are.
5
7
  */
6
8
 
7
9
  import { terminalText } from './plugin-settings.js';
10
+ import { bytesText, shortDigest } from './plugin-web-surface.js';
8
11
 
9
12
  const DIGEST_RE = /^[a-f0-9]{64}$/;
10
13
  const VERSION_RE = /^[0-9A-Za-z.+-]{1,64}$/;
11
14
  /** The reason an update turns secret settings into plain ones. */
12
15
  const SETTINGS_SECRET_TO_PLAIN = 'settings.secret-to-plain';
16
+ /** The reason an update adds or removes roles, or widens the attributes its users' records carry. */
17
+ const IDENTITY_CHANGED = 'identity.changed';
13
18
 
14
19
  /** How `approve-hooks` is called. */
15
20
  export const APPROVE_HOOKS_USAGE =
@@ -18,12 +23,13 @@ export const APPROVE_HOOKS_USAGE =
18
23
  /**
19
24
  * Why a pending update waits, as the terminal shows it: each reason of
20
25
  * `approvalRequired` (`[{ code, reason }]`; a secret turned plain is a
21
- * warning), and the sentences of `autoApproveWithheld` that do not repeat
22
- * one of them — what auto-approve, when on, could not cover beyond that.
23
- * Every text as {@link terminalText}.
26
+ * warning; an identity change carries a note that auto-approve never
27
+ * covers it), and the sentences of `autoApproveWithheld` that do not
28
+ * repeat one of them — what auto-approve, when on, could not cover beyond
29
+ * that. Every text as {@link terminalText}.
24
30
  *
25
31
  * @param {{ approvalRequired?: unknown, autoApproveWithheld?: unknown }} update
26
- * @returns {{ required: Array<{ text: string, warning: boolean }>, notCovered: string[] }}
32
+ * @returns {{ required: Array<{ text: string, warning: boolean, note: string | null }>, notCovered: string[] }}
27
33
  */
28
34
  export function pendingUpdateReasons(update) {
29
35
  const required = (Array.isArray(update?.approvalRequired) ? update.approvalRequired : [])
@@ -31,6 +37,10 @@ export function pendingUpdateReasons(update) {
31
37
  .map((entry) => ({
32
38
  text: terminalText(entry.reason),
33
39
  warning: entry.code === SETTINGS_SECRET_TO_PLAIN,
40
+ note:
41
+ entry.code === IDENTITY_CHANGED
42
+ ? 'who its users are and what it learns about them — auto-approve never covers it'
43
+ : null,
34
44
  }));
35
45
  const said = new Set(required.map((line) => line.text));
36
46
  const notCovered = (Array.isArray(update?.autoApproveWithheld) ? update.autoApproveWithheld : [])
@@ -39,6 +49,177 @@ export function pendingUpdateReasons(update) {
39
49
  return { required, notCovered };
40
50
  }
41
51
 
52
+ /** What a plugin may declare to receive about its users, in words. */
53
+ const ATTRIBUTE_WORDS = { displayName: 'display name', email: 'email address' };
54
+
55
+ /** An identity attribute in words (`displayName` → display name). */
56
+ function attributeWords(attribute) {
57
+ return ATTRIBUTE_WORDS[attribute] ?? terminalText(attribute);
58
+ }
59
+
60
+ /**
61
+ * An identity block (normalized: `{ roles: [{ id, label, description? }],
62
+ * attributes }`) as two lines — its roles, and what it receives about each
63
+ * member. Empty for anything that is not a block. Every line as
64
+ * {@link terminalText}.
65
+ *
66
+ * @param {unknown} block
67
+ * @returns {string[]}
68
+ */
69
+ export function identityBlockLines(block) {
70
+ if (block === null || typeof block !== 'object' || !Array.isArray(block.roles)) return [];
71
+ const roles = block.roles
72
+ .filter((role) => role !== null && typeof role === 'object')
73
+ .map((role) => `${role.id} (${role.label}${role.description ? ` — ${role.description}` : ''})`);
74
+ const attributes = Array.isArray(block.attributes) ? block.attributes.map(attributeWords) : [];
75
+ return [
76
+ terminalText(`roles: ${roles.length > 0 ? roles.join(', ') : 'none'}`),
77
+ terminalText(
78
+ `receives: ${['user id', 'username', 'roles', 'status', ...attributes].join(', ')} — of the users entitled to it`,
79
+ ),
80
+ ];
81
+ }
82
+
83
+ /**
84
+ * How an update changes a plugin's identity block (`pendingUpdate.
85
+ * identity`: `{ before, after, rolesAdded, rolesRemoved, attributesAdded,
86
+ * attributesRemoved, labelsChanged, grantsAffected? }` — `grantsAffected`:
87
+ * how many role grants approving it rewrites or revokes, null when unknown):
88
+ * a headline and a line per kind of
89
+ * change — roles added or removed and attributes widened wait for an
90
+ * administrator (`approval`), narrowing and label edits only change the
91
+ * digest. Null when neither version declares a block, or it is unchanged.
92
+ * Every text as {@link terminalText}.
93
+ *
94
+ * @param {unknown} diff
95
+ * @returns {{ headline: string, lines: Array<{ text: string, approval: boolean }> } | null}
96
+ */
97
+ export function identityDiffLines(diff) {
98
+ if (diff === null || typeof diff !== 'object') return null;
99
+ const list = (value) => (Array.isArray(value) ? value.map(String) : []);
100
+ const labelOf = (block, id) =>
101
+ Array.isArray(block?.roles) ? (block.roles.find((r) => r?.id === id)?.label ?? null) : null;
102
+ const role = (block) => (id) => {
103
+ const label = labelOf(block, id);
104
+ return label ? `${id} (${label})` : id;
105
+ };
106
+ const lines = [];
107
+ const add = (label, entries, approval) => {
108
+ if (entries.length === 0) return;
109
+ lines.push({
110
+ text: terminalText(
111
+ `${label}: ${entries.join(', ')}${approval ? ' — waits for approval' : ''}`,
112
+ ),
113
+ approval,
114
+ });
115
+ };
116
+ add('roles added', list(diff.rolesAdded).map(role(diff.after)), true);
117
+ add('roles removed', list(diff.rolesRemoved).map(role(diff.before)), true);
118
+ add('receives more about its users', list(diff.attributesAdded).map(attributeWords), true);
119
+ add('receives less about its users', list(diff.attributesRemoved).map(attributeWords), false);
120
+ add('role labels changed', list(diff.labelsChanged).map(role(diff.after)), false);
121
+ // A removed role stays removed: approving revokes it from the grants that hold it.
122
+ if (list(diff.rolesRemoved).length > 0 && 'grantsAffected' in diff) {
123
+ const affected = diff.grantsAffected;
124
+ const parts =
125
+ affected === null || typeof affected !== 'object'
126
+ ? ['not known (Gatekeeper could not be read)']
127
+ : [
128
+ ...(affected.revoked > 0 ? [`${affected.revoked} revoked`] : []),
129
+ ...(affected.rewritten > 0
130
+ ? [`${affected.rewritten} narrowed to the roles that stay`]
131
+ : []),
132
+ ];
133
+ add('role grants affected', parts.length > 0 ? parts : ['none'], true);
134
+ }
135
+ const before = diff.before != null;
136
+ const after = diff.after != null;
137
+ if (lines.length === 0 && before === after) return null;
138
+ const headline = !before
139
+ ? 'declares identity for the first time'
140
+ : !after
141
+ ? 'no longer declares identity — it is sent no users, its role grants are revoked and its user surface is no longer served'
142
+ : 'changes its identity';
143
+ return { headline, lines };
144
+ }
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
+
42
223
  /**
43
224
  * The command that approves what plugin `name` brings: the installed
44
225
  * version by its digest (`{ digest }`), or a pending update by its version
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Users and identity in requests at the terminal — the arguments of
3
+ * `lamaste-server users …`, `plugins entitlements|identity-sync` and
4
+ * `tunnels bind-user-surface|unbind-user-surface`, and the words their
5
+ * output is made of. Pure — no output, no panel — so they are tested as
6
+ * they are. Every string from the panel goes through `terminalText` before
7
+ * it is printed; `--json` carries the data as it came.
8
+ */
9
+
10
+ import {
11
+ IDENTITY_ROLE_ID_REGEX,
12
+ IDENTITY_ROLES_MAX,
13
+ isIdentityUuid,
14
+ isLamasteUsername,
15
+ } from '@lamalibre/lamaste';
16
+ import { formatMoment, terminalText } from './plugin-settings.js';
17
+
18
+ /** A Lamaste (Gatekeeper) group name — Gatekeeper's rule. */
19
+ const GROUP_NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Arguments
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** How `plugins entitlements` is called. */
26
+ export const ENTITLEMENTS_USAGE =
27
+ 'Usage: lamaste-server plugins entitlements <name> [--user <username> | --group <group> --roles <a,b>] [--revoke --user <username> | --group <group>]';
28
+
29
+ /** The value after `flag` (at `args[index]`): `{ value }`, or `{ error }` when it has none. */
30
+ function flagValue(args, index, flag, usage) {
31
+ const value = args[index + 1];
32
+ if (value === undefined || value.startsWith('--')) {
33
+ return { error: `${flag} needs a value — ${usage}` };
34
+ }
35
+ return { value };
36
+ }
37
+
38
+ /**
39
+ * The arguments of `plugins entitlements <name> …` after the subcommand:
40
+ * the plugin and, to change something, a principal — `--user <username>`
41
+ * or `--group <group>` — with `--roles a,b` (give exactly these roles) or
42
+ * `--revoke` (take every role away). Without a principal it lists them.
43
+ *
44
+ * @param {string[]} args
45
+ * @returns {{ ok: true, name: string, action: 'list' }
46
+ * | { ok: true, name: string, action: 'set', principalType: 'user' | 'group', principalId: string, roles: string[] }
47
+ * | { ok: true, name: string, action: 'revoke', principalType: 'user' | 'group', principalId: string }
48
+ * | { ok: false, error: string }}
49
+ */
50
+ export function parseEntitlementsArgs(args) {
51
+ const usage = ENTITLEMENTS_USAGE;
52
+ let name = null;
53
+ let principal = null;
54
+ let roles = null;
55
+ let revoke = false;
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i];
58
+ if (arg === '--user' || arg === '--group') {
59
+ const read = flagValue(args, i, arg, usage);
60
+ if (read.error) return { ok: false, error: read.error };
61
+ if (principal !== null) {
62
+ return { ok: false, error: `Name one principal: --user or --group, once — ${usage}` };
63
+ }
64
+ principal = { principalType: arg === '--user' ? 'user' : 'group', principalId: read.value };
65
+ i++;
66
+ } else if (arg === '--roles') {
67
+ const read = flagValue(args, i, arg, usage);
68
+ if (read.error) return { ok: false, error: read.error };
69
+ roles = read.value.split(',').map((role) => role.trim());
70
+ i++;
71
+ } else if (arg === '--revoke') {
72
+ revoke = true;
73
+ } else if (arg.startsWith('--')) {
74
+ return { ok: false, error: `Unknown option ${arg} — ${usage}` };
75
+ } else if (name === null) {
76
+ name = arg;
77
+ } else {
78
+ return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
79
+ }
80
+ }
81
+ if (name === null) return { ok: false, error: usage };
82
+ if (principal === null) {
83
+ if (roles !== null || revoke) {
84
+ return {
85
+ ok: false,
86
+ error: `Name whose roles: --user <username> or --group <group> — ${usage}`,
87
+ };
88
+ }
89
+ return { ok: true, name, action: 'list' };
90
+ }
91
+ if (principal.principalType === 'user' && !isLamasteUsername(principal.principalId)) {
92
+ return { ok: false, error: `"${principal.principalId}" is not a username` };
93
+ }
94
+ if (
95
+ principal.principalType === 'group' &&
96
+ !(
97
+ principal.principalId.length >= 2 &&
98
+ principal.principalId.length <= 63 &&
99
+ GROUP_NAME_RE.test(principal.principalId)
100
+ )
101
+ ) {
102
+ return { ok: false, error: `"${principal.principalId}" is not a group name` };
103
+ }
104
+ if (revoke) {
105
+ if (roles !== null) {
106
+ return { ok: false, error: `--revoke takes every role away; it takes no --roles — ${usage}` };
107
+ }
108
+ return { ok: true, name, action: 'revoke', ...principal };
109
+ }
110
+ if (roles === null) {
111
+ return { ok: false, error: `Give --roles <a,b>, or --revoke — ${usage}` };
112
+ }
113
+ const unique = [...new Set(roles)].sort();
114
+ if (unique.length === 0 || unique.length > IDENTITY_ROLES_MAX || unique.includes('')) {
115
+ return { ok: false, error: `--roles is a comma-separated list of role ids — ${usage}` };
116
+ }
117
+ const bad = unique.find((role) => !IDENTITY_ROLE_ID_REGEX.test(role));
118
+ if (bad !== undefined) return { ok: false, error: `"${bad}" is not a role id` };
119
+ return { ok: true, name, action: 'set', ...principal, roles: unique };
120
+ }
121
+
122
+ /**
123
+ * The arguments of `plugins identity-sync <name> [--reconcile]`.
124
+ *
125
+ * @param {string[]} args
126
+ * @returns {{ ok: true, name: string, reconcile: boolean } | { ok: false, error: string }}
127
+ */
128
+ export function parseIdentitySyncArgs(args) {
129
+ const usage = 'Usage: lamaste-server plugins identity-sync <name> [--reconcile]';
130
+ let name = null;
131
+ let reconcile = false;
132
+ for (const arg of args) {
133
+ if (arg === '--reconcile') reconcile = true;
134
+ else if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg} — ${usage}` };
135
+ else if (name === null) name = arg;
136
+ else return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
137
+ }
138
+ if (name === null) return { ok: false, error: usage };
139
+ return { ok: true, name, reconcile };
140
+ }
141
+
142
+ /**
143
+ * The arguments of `users disable|enable|delete <username>` (`--erase` for
144
+ * delete) and `users erase <userId>`.
145
+ *
146
+ * @param {'disable' | 'enable' | 'delete' | 'erase'} sub
147
+ * @param {string[]} args what follows the subcommand
148
+ * @returns {{ ok: true, username: string, erase: boolean } | { ok: true, userId: string } | { ok: false, error: string }}
149
+ */
150
+ export function parseUserArgs(sub, args) {
151
+ const usage =
152
+ sub === 'erase'
153
+ ? 'Usage: lamaste-server users erase <userId> (a deleted user, as `users directory` lists them)'
154
+ : sub === 'delete'
155
+ ? 'Usage: lamaste-server users delete <username> [--erase]'
156
+ : `Usage: lamaste-server users ${sub} <username>`;
157
+ let target = null;
158
+ let erase = false;
159
+ for (const arg of args) {
160
+ if (arg === '--erase' && sub === 'delete') erase = true;
161
+ else if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg} — ${usage}` };
162
+ else if (target === null) target = arg;
163
+ else return { ok: false, error: `Unexpected argument "${arg}" — ${usage}` };
164
+ }
165
+ if (target === null) return { ok: false, error: usage };
166
+ if (sub === 'erase') {
167
+ if (!isIdentityUuid(target)) {
168
+ return { ok: false, error: `"${target}" is not a user id — ${usage}` };
169
+ }
170
+ return { ok: true, userId: target };
171
+ }
172
+ if (!isLamasteUsername(target)) return { ok: false, error: `"${target}" is not a username` };
173
+ return { ok: true, username: target, erase };
174
+ }
175
+
176
+ /**
177
+ * The arguments of `tunnels bind-user-surface|unbind-user-surface <tunnel>
178
+ * <plugin>`: the tunnel (its id, subdomain or FQDN) and the plugin (its name
179
+ * or route).
180
+ *
181
+ * @param {'bind-user-surface' | 'unbind-user-surface'} sub
182
+ * @param {string[]} args
183
+ * @returns {{ ok: true, tunnel: string, plugin: string } | { ok: false, error: string }}
184
+ */
185
+ export function parseUserSurfaceArgs(sub, args) {
186
+ const usage = `Usage: lamaste-server tunnels ${sub} <tunnel id | subdomain | fqdn> <plugin>`;
187
+ const positional = [];
188
+ for (const arg of args) {
189
+ if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg} — ${usage}` };
190
+ positional.push(arg);
191
+ }
192
+ if (positional.length !== 2) return { ok: false, error: usage };
193
+ return { ok: true, tunnel: positional[0], plugin: positional[1] };
194
+ }
195
+
196
+ /**
197
+ * The tunnel `ref` names among `tunnels` (as `tunnels.json` holds them): by
198
+ * id, subdomain or FQDN — a host, never an internal plugin tunnel.
199
+ *
200
+ * @param {Array<Record<string, unknown>>} tunnels
201
+ * @param {string} ref
202
+ * @returns {{ tunnel: Record<string, unknown> } | { error: string }}
203
+ */
204
+ export function findHostTunnel(tunnels, ref) {
205
+ const hosts = tunnels.filter((t) => typeof t.subdomain === 'string' && t.subdomain.length > 0);
206
+ const tunnel =
207
+ hosts.find((t) => t.id === ref) ??
208
+ hosts.find((t) => t.fqdn === ref) ??
209
+ hosts.find((t) => t.subdomain === ref);
210
+ if (!tunnel) return { error: `No tunnel with the id, subdomain or FQDN "${ref}"` };
211
+ return { tunnel };
212
+ }
213
+
214
+ /**
215
+ * The installed agent-hosted plugin `ref` names (its name or route) among
216
+ * the panel's plugin list.
217
+ *
218
+ * @param {Array<Record<string, unknown>>} plugins
219
+ * @param {string} ref
220
+ * @returns {{ plugin: Record<string, unknown> } | { error: string }}
221
+ */
222
+ export function findPluginByNameOrRoute(plugins, ref) {
223
+ const plugin = plugins.find((p) => p.name === ref) ?? plugins.find((p) => p.route === ref);
224
+ if (!plugin) return { error: `No installed plugin named "${ref}" or holding that route` };
225
+ if (typeof plugin.route !== 'string' || plugin.route.length === 0) {
226
+ return { error: `Plugin "${ref}" has no route` };
227
+ }
228
+ return { plugin };
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Output
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Why users cannot reach a plugin's `/app`, or that they can — the identity
237
+ * view's `readiness` in words.
238
+ *
239
+ * @param {{ ready?: boolean, reason?: string | null, version?: string | null } | null | undefined} readiness
240
+ * @returns {{ ready: boolean, text: string }}
241
+ */
242
+ export function readinessText(readiness) {
243
+ if (!readiness) return { ready: false, text: 'unknown' };
244
+ if (readiness.ready) return { ready: true, text: 'ready — users reach its application' };
245
+ const version = readiness.version ? `version ${terminalText(readiness.version)}` : 'it';
246
+ switch (readiness.reason) {
247
+ case 'not_installed':
248
+ return { ready: false, text: 'not installed' };
249
+ case 'no_identity':
250
+ return { ready: false, text: `${version} declares no identity` };
251
+ case 'not_approved':
252
+ return { ready: false, text: `${version} waits for approval (plugins hooks <name>)` };
253
+ case 'not_hosted':
254
+ return { ready: false, text: 'not hosted on an agent, or its hosting is not active' };
255
+ case 'not_answered':
256
+ return {
257
+ ready: false,
258
+ text: `its agent half has not answered the identity feed for ${version} yet`,
259
+ };
260
+ default:
261
+ return { ready: false, text: terminalText(readiness.reason ?? 'not ready') };
262
+ }
263
+ }
264
+
265
+ /** Why a route bound to a tunnel is not served there (`userSurfaceStatus`'s reason), in words. */
266
+ export function userSurfaceStatusText(status) {
267
+ if (!status) return 'status unknown';
268
+ if (status.served) return 'served';
269
+ switch (status.reason) {
270
+ case 'not_installed':
271
+ return 'not served — no installed plugin holds the route';
272
+ case 'no_identity':
273
+ return 'not served yet — its installed version declares no identity (requests go down the tunnel)';
274
+ case 'not_approved':
275
+ return 'not served yet — its installed version waits for approval (requests go down the tunnel)';
276
+ case 'not_hosting_agent':
277
+ return 'not served — the agent carrying this tunnel does not host the plugin (moved, revoked, or hosted elsewhere); requests go down the tunnel';
278
+ default:
279
+ return `not served (${terminalText(status.reason)})`;
280
+ }
281
+ }
282
+
283
+ /** Why a user left an audience, as the terminal says it. */
284
+ const REMOVED_STATUS = new Map([
285
+ ['unentitled', 'removed (no longer entitled)'],
286
+ ['deleted', 'removed (deleted)'],
287
+ ['erased', 'erased'],
288
+ ]);
289
+
290
+ /**
291
+ * An audience row (`{ userId, seq, version, updatedAt, change }`) as the
292
+ * terminal lists it: who (the member's, or the last known, name), the roles
293
+ * held and where they stand.
294
+ *
295
+ * @param {{ userId: string, seq: number, change: Record<string, any> | null }} row
296
+ * @returns {{ userId: string, seq: number, who: string, roles: string, status: string, member: boolean }}
297
+ */
298
+ export function audienceRowText(row) {
299
+ const change = row.change ?? {};
300
+ const name = (user) =>
301
+ user?.displayName && user?.username
302
+ ? `${user.displayName} (${user.username})`
303
+ : (user?.username ?? '—');
304
+ // A row the panel cannot read.
305
+ const text = {
306
+ userId: terminalText(row.userId),
307
+ seq: row.seq,
308
+ who: '—',
309
+ roles: '',
310
+ status: 'unreadable',
311
+ member: false,
312
+ };
313
+ if (change.kind === 'upsert' && change.member) {
314
+ return {
315
+ ...text,
316
+ who: terminalText(name(change.member)),
317
+ roles: terminalText((change.member.roles ?? []).join(', ')),
318
+ status: change.member.status === 'disabled' ? 'disabled' : 'active',
319
+ member: true,
320
+ };
321
+ }
322
+ if (change.kind === 'remove') {
323
+ return {
324
+ ...text,
325
+ who: change.reason === 'erased' ? '—' : terminalText(name(change.last)),
326
+ status: REMOVED_STATUS.get(change.reason) ?? 'removed',
327
+ };
328
+ }
329
+ return text;
330
+ }
331
+
332
+ /**
333
+ * The feed's state (the identity view's `sync`) as `[label, value]` rows:
334
+ * the directory, what the agent half follows, its cursor against the head,
335
+ * the version that answered, the last sync and reconcile with what it
336
+ * found, the last error.
337
+ *
338
+ * @param {Record<string, any>} sync
339
+ * @param {number} now epoch ms
340
+ * @returns {Array<[string, string]>}
341
+ */
342
+ export function syncRows(sync, now) {
343
+ const follows =
344
+ sync.halfDirectoryId === null || sync.halfDirectoryId === undefined
345
+ ? 'nothing yet'
346
+ : sync.halfDirectoryId === sync.directoryId
347
+ ? 'this directory'
348
+ : `another directory (${terminalText(sync.halfDirectoryId)}) — the next sync replays`;
349
+ const rows = [
350
+ ['directory', terminalText(sync.directoryId)],
351
+ ['half follows', follows],
352
+ [
353
+ 'position',
354
+ `cursor ${sync.cursor ?? '—'} of head ${sync.head}${sync.behind > 0 ? ` — ${sync.behind} change(s) behind` : ' — up to date'}`,
355
+ ],
356
+ ['answered for', sync.readyVersion ? `version ${terminalText(sync.readyVersion)}` : '—'],
357
+ ['last sync', sync.lastSyncAt ? formatMoment(sync.lastSyncAt, now) : '—'],
358
+ [
359
+ 'last reconcile',
360
+ sync.lastReconcileAt
361
+ ? `${formatMoment(sync.lastReconcileAt, now)}${sync.lastMismatches != null ? ` — ${sync.lastMismatches} out of step` : ''}`
362
+ : '—',
363
+ ],
364
+ ];
365
+ if (sync.lastError) rows.push(['last error', terminalText(sync.lastError)]);
366
+ return rows;
367
+ }
368
+
369
+ /**
370
+ * A warning a users or entitlement change came back with — a sentence, or
371
+ * `{ step, message }` — as one line.
372
+ *
373
+ * @param {unknown} warning
374
+ * @returns {string}
375
+ */
376
+ export function warningLine(warning) {
377
+ if (typeof warning === 'string') return terminalText(warning);
378
+ if (warning !== null && typeof warning === 'object' && typeof warning.message === 'string') {
379
+ return terminalText(warning.step ? `${warning.step}: ${warning.message}` : warning.message);
380
+ }
381
+ return terminalText(warning);
382
+ }