@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-lamaste",
3
- "version": "4.5.0",
3
+ "version": "4.7.0",
4
4
  "description": "One-command setup for secure reverse tunnels with a management dashboard",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -43,7 +43,7 @@
43
43
  "mtls"
44
44
  ],
45
45
  "dependencies": {
46
- "@lamalibre/lamaste": "^4.5.0",
46
+ "@lamalibre/lamaste": "^4.7.0",
47
47
  "chalk": "^5.3.0",
48
48
  "execa": "^9.0.0",
49
49
  "listr2": "^8.0.0"
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { assertProductionBundle } from '../../../../scripts/assert-production-bundle.js';
16
+ import { workspaceVersions, writePinnedManifest } from './pin-release.js';
16
17
 
17
18
  const thisDir = dirname(fileURLToPath(import.meta.url));
18
19
  const packageRoot = join(thisDir, '..');
@@ -24,6 +25,10 @@ async function main() {
24
25
  await rm(vendorDir, { recursive: true, force: true });
25
26
  await mkdir(vendorDir, { recursive: true });
26
27
 
28
+ // Each vendored manifest names this release's own packages exactly
29
+ // (pin-release.js), so the relay's `npm install` never mixes releases.
30
+ const versions = await workspaceVersions(monorepoRoot);
31
+
27
32
  // --- lamaste-serverd: package.json + src/ ---
28
33
  const serverSrc = join(monorepoRoot, 'packages', 'server', 'daemon');
29
34
  if (!existsSync(serverSrc)) {
@@ -32,7 +37,11 @@ async function main() {
32
37
 
33
38
  const serverDest = join(vendorDir, 'serverd');
34
39
  await mkdir(serverDest, { recursive: true });
35
- await cp(join(serverSrc, 'package.json'), join(serverDest, 'package.json'));
40
+ await writePinnedManifest(
41
+ join(serverSrc, 'package.json'),
42
+ join(serverDest, 'package.json'),
43
+ versions,
44
+ );
36
45
  await cp(join(serverSrc, 'src'), join(serverDest, 'src'), {
37
46
  recursive: true,
38
47
  });
@@ -49,7 +58,11 @@ async function main() {
49
58
  }
50
59
  const serverCliDest = join(vendorDir, 'server');
51
60
  await mkdir(serverCliDest, { recursive: true });
52
- await cp(join(serverCliSrc, 'package.json'), join(serverCliDest, 'package.json'));
61
+ await writePinnedManifest(
62
+ join(serverCliSrc, 'package.json'),
63
+ join(serverCliDest, 'package.json'),
64
+ versions,
65
+ );
53
66
  await cp(join(serverCliSrc, 'bin'), join(serverCliDest, 'bin'), { recursive: true });
54
67
  await cp(join(serverCliSrc, 'src'), join(serverCliDest, 'src'), { recursive: true });
55
68
  console.error('Bundled vendor/server from lamaste-server CLI (package.json + bin/ + src/)');
@@ -81,7 +94,11 @@ async function main() {
81
94
  }
82
95
  const gatekeeperDest = join(vendorDir, 'gatekeeper');
83
96
  await mkdir(gatekeeperDest, { recursive: true });
84
- await cp(join(gatekeeperSrc, 'package.json'), join(gatekeeperDest, 'package.json'));
97
+ await writePinnedManifest(
98
+ join(gatekeeperSrc, 'package.json'),
99
+ join(gatekeeperDest, 'package.json'),
100
+ versions,
101
+ );
85
102
  await cp(gatekeeperDist, join(gatekeeperDest, 'dist'), { recursive: true });
86
103
  console.error('Bundled vendor/gatekeeper (package.json + dist/)');
87
104
 
@@ -81,21 +81,37 @@ class UsageError extends Error {}
81
81
  // ---------------------------------------------------------------------------
82
82
 
83
83
  const LABEL = '[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?';
84
+ /** A full hostname, lower case, two labels at least — what names a vhost since the multi-domain relay. */
85
+ const HOST = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+';
84
86
  const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
85
87
 
86
- /** Vhosts the panel writes. */
88
+ /**
89
+ * Vhosts the panel writes: the panel's and chisel's (primary domain), one
90
+ * auth portal per domain, a tunnel's by its full hostname, a site's by id.
91
+ */
87
92
  const PANEL_SITE_RE = new RegExp(
88
- `^lamalibre-lamaste-(?:panel-domain|auth|tunnel|app-${LABEL}|agent-panel-${LABEL}|site-${UUID})$`,
93
+ `^lamalibre-lamaste-(?:panel-domain|tunnel|auth-${HOST}|app-${HOST}|agent-panel-${HOST}|site-${UUID})$`,
89
94
  );
90
95
 
96
+ /**
97
+ * Vhost names before the multi-domain relay — the auth portal and tunnels
98
+ * by subdomain alone. The panel renames them at start; it may still link,
99
+ * unlink, back up, restore and remove them, but never write one.
100
+ */
101
+ const LEGACY_SITE_RE = new RegExp(`^lamalibre-lamaste-(?:auth|app-${LABEL}|agent-panel-${LABEL})$`);
102
+
91
103
  /** Vhosts create-lamaste writes that the panel may only switch on and off. */
92
104
  const TOGGLE_ONLY_SITES = new Set(['lamalibre-lamaste-panel-ip']);
93
105
 
106
+ /** The longest site name: a file name, with room for its `.bak`. */
107
+ const MAX_SITE_NAME = 250;
108
+
94
109
  function assertSiteName(name, action) {
95
- if (typeof name !== 'string' || name.length > 128) {
110
+ if (typeof name !== 'string' || name.length > MAX_SITE_NAME) {
96
111
  throw new UsageError('invalid site name');
97
112
  }
98
113
  if (PANEL_SITE_RE.test(name)) return;
114
+ if (action !== 'write' && LEGACY_SITE_RE.test(name)) return;
99
115
  if ((action === 'enable' || action === 'disable') && TOGGLE_ONLY_SITES.has(name)) return;
100
116
  throw new UsageError(`site name not allowed for ${action}: ${name}`);
101
117
  }
@@ -251,6 +267,12 @@ const WEBROOT_RE = /^\/var\/www\/lamaste\/[A-Za-z0-9._/-]*$/;
251
267
  const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,64}$/;
252
268
  const VARIABLE_RE = /^\$[A-Za-z_][A-Za-z0-9_]{0,63}$/;
253
269
  const URI_PATH_RE = /^\/[A-Za-z0-9._~/-]*$/;
270
+ /** A named location (`location @previous`), the target of a try_files fallback. */
271
+ const NAMED_LOCATION_RE = /^@[a-z][a-z0-9_]{0,31}$/;
272
+ const TRY_FILES_ARG_RE = /^[A-Za-z0-9._/$=-]{1,256}$/;
273
+ /** A `types` entry: a media type, then the extensions served with it. */
274
+ const MEDIA_TYPE_RE = /^[a-z]+\/[a-z0-9.+-]{1,64}$/;
275
+ const EXTENSION_RE = /^[a-z0-9]{1,16}$/;
254
276
 
255
277
  function hasDotDotSegment(p) {
256
278
  return p.split('/').some((seg) => seg === '..' || seg === '.');
@@ -317,18 +339,27 @@ const RULES = {
317
339
  error_page: { args: errorPageArgs },
318
340
  return: { args: argc(1, 2) },
319
341
  rewrite: { args: rewriteArgs },
342
+ // A host whose every location asks the same authorization (a plugin's
343
+ // web surface): a URI of this server, never a file.
344
+ auth_request: { args: (a) => a.length === 1 && (a[0] === 'off' || URI_PATH_RE.test(a[0])) },
320
345
  location: { block: 'location', args: locationArgs },
321
346
  },
322
347
  location: {
323
348
  location: { block: 'location', args: locationArgs },
324
349
  if: { block: 'if', args: argc(1, 5) },
350
+ // The content types of a plugin's web surface, named by the vhost
351
+ // rather than the host's mime.types (entries checked below).
352
+ types: { block: 'types', args: argc(0, 0) },
325
353
  internal: { args: argc(0, 0) },
354
+ // The type of a body the location writes itself (a user surface's JSON
355
+ // refusal): a media type, never a file.
356
+ default_type: { args: (a) => a.length === 1 && /^[a-z]+\/[a-z0-9.+-]{1,64}$/.test(a[0]) },
326
357
  return: { args: argc(1, 2) },
327
358
  rewrite: { args: rewriteArgs },
328
359
  root: { args: (a) => a.length === 1 && webrootPath(a[0]) },
329
360
  alias: { args: (a) => a.length === 1 && webrootPath(a[0]) },
330
361
  index: { args: each(/^[A-Za-z0-9._-]{1,64}$/) },
331
- try_files: { args: each(/^[A-Za-z0-9._/$=-]{1,256}$/) },
362
+ try_files: { args: tryFilesArgs },
332
363
  add_header: {
333
364
  args: (a) =>
334
365
  (a.length === 2 || (a.length === 3 && a[2] === 'always')) && HEADER_NAME_RE.test(a[0]),
@@ -386,8 +417,20 @@ function rewriteArgs(a) {
386
417
  );
387
418
  }
388
419
 
420
+ /** Files to try under the location's root; the last may instead name a location. */
421
+ function tryFilesArgs(a) {
422
+ if (a.length === 0) return false;
423
+ const last = a[a.length - 1];
424
+ return (
425
+ a.slice(0, -1).every((v) => TRY_FILES_ARG_RE.test(v)) &&
426
+ (TRY_FILES_ARG_RE.test(last) || NAMED_LOCATION_RE.test(last))
427
+ );
428
+ }
429
+
389
430
  function locationArgs(a) {
390
- if (a.length === 1) return a[0].length <= 512 && a[0].startsWith('/');
431
+ if (a.length === 1) {
432
+ return (a[0].length <= 512 && a[0].startsWith('/')) || NAMED_LOCATION_RE.test(a[0]);
433
+ }
391
434
  return a.length === 2 && ['=', '~', '~*', '^~'].includes(a[0]) && a[1].length <= 512;
392
435
  }
393
436
 
@@ -407,6 +450,14 @@ function validateDirectives(directives, context) {
407
450
  }
408
451
  continue;
409
452
  }
453
+ if (context === 'types') {
454
+ // types entries are "media/type ext...;" — no block, no include.
455
+ if (d.block !== null) throw new UsageError(`vhost line ${d.line}: block inside types`);
456
+ if (!MEDIA_TYPE_RE.test(d.name) || !each(EXTENSION_RE)(d.args)) {
457
+ throw new UsageError(`vhost line ${d.line}: malformed types entry`);
458
+ }
459
+ continue;
460
+ }
410
461
  const rule = rules[d.name];
411
462
  if (!rule) {
412
463
  throw new UsageError(
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pins a vendored package's dependencies on this monorepo's packages to the
3
+ * exact versions it was built with.
4
+ *
5
+ * create-lamaste vendors serverd, the server CLI and Gatekeeper, then runs
6
+ * `npm install` for them on the relay. Their manifests name the core
7
+ * library with a caret range (`^4.6.0`), so an install of release X made
8
+ * after X+1 shipped resolved X+1's core — and 4.6.0's serverd on 4.7.0's
9
+ * core did not start. Pinned, create-lamaste@X always installs the core,
10
+ * tickets and identity libraries of release X; third-party dependencies
11
+ * keep their ranges.
12
+ */
13
+
14
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies'];
19
+
20
+ /**
21
+ * The name → version of every workspace package (`packages/<group>/<name>`).
22
+ *
23
+ * @param {string} monorepoRoot
24
+ * @returns {Promise<Map<string, string>>}
25
+ */
26
+ export async function workspaceVersions(monorepoRoot) {
27
+ const versions = new Map();
28
+ const packagesDir = join(monorepoRoot, 'packages');
29
+ for (const group of await readdir(packagesDir, { withFileTypes: true })) {
30
+ if (!group.isDirectory()) continue;
31
+ for (const entry of await readdir(join(packagesDir, group.name), { withFileTypes: true })) {
32
+ const manifestPath = join(packagesDir, group.name, entry.name, 'package.json');
33
+ if (!entry.isDirectory() || !existsSync(manifestPath)) continue;
34
+ const { name, version } = JSON.parse(await readFile(manifestPath, 'utf8'));
35
+ if (typeof name === 'string' && typeof version === 'string') versions.set(name, version);
36
+ }
37
+ }
38
+ return versions;
39
+ }
40
+
41
+ /**
42
+ * The manifest with each dependency on a workspace package set to that
43
+ * package's exact version. Pure; throws on a workspace dependency whose
44
+ * range the workspace's version does not satisfy as a caret or exact range
45
+ * (the vendored code would then be pinned to something it was not written
46
+ * against).
47
+ *
48
+ * @param {Record<string, unknown>} manifest
49
+ * @param {Map<string, string>} versions
50
+ * @returns {Record<string, unknown>}
51
+ */
52
+ export function pinWorkspaceDependencies(manifest, versions) {
53
+ const pinned = { ...manifest };
54
+ for (const field of DEPENDENCY_FIELDS) {
55
+ const deps = manifest[field];
56
+ if (deps === null || typeof deps !== 'object') continue;
57
+ const next = { ...deps };
58
+ for (const [name, range] of Object.entries(deps)) {
59
+ const version = versions.get(name);
60
+ if (version === undefined) continue;
61
+ if (range !== version && !caretAdmits(range, version)) {
62
+ throw new Error(
63
+ `${manifest.name}: ${field}.${name} is "${range}", the workspace has ${version}`,
64
+ );
65
+ }
66
+ next[name] = version;
67
+ }
68
+ pinned[field] = next;
69
+ }
70
+ return pinned;
71
+ }
72
+
73
+ /** Whether `^a.b.c` admits `version` (same major, not lower). */
74
+ function caretAdmits(range, version) {
75
+ const match = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(String(range));
76
+ const have = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
77
+ if (!match || !have) return false;
78
+ const [want, got] = [match.slice(1).map(Number), have.slice(1).map(Number)];
79
+ if (want[0] !== got[0]) return false;
80
+ for (let i = 1; i < 3; i++) if (got[i] !== want[i]) return got[i] > want[i];
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Copy `source` (a package.json) to `dest` with its workspace dependencies
86
+ * pinned.
87
+ *
88
+ * @param {string} source
89
+ * @param {string} dest
90
+ * @param {Map<string, string>} versions
91
+ */
92
+ export async function writePinnedManifest(source, dest, versions) {
93
+ const manifest = JSON.parse(await readFile(source, 'utf8'));
94
+ await writeFile(
95
+ dest,
96
+ `${JSON.stringify(pinWorkspaceDependencies(manifest, versions), null, 2)}\n`,
97
+ );
98
+ }
package/src/index.js CHANGED
@@ -12,7 +12,7 @@ import { existsSync } from 'node:fs';
12
12
  import { Listr } from 'listr2';
13
13
  import chalk from 'chalk';
14
14
  import { execa } from 'execa';
15
- import { detectOS, detectIP, checkRoot } from './lib/env.js';
15
+ import { detectOS, detectIP, checkRoot, installedPanelIp } from './lib/env.js';
16
16
  import { printSummary } from './lib/summary.js';
17
17
  import { readPanelKeyPin } from './lib/panel-pin.js';
18
18
  import { hardenTasks } from './tasks/harden.js';
@@ -485,6 +485,13 @@ export async function main() {
485
485
  {
486
486
  title: 'Detecting IP address',
487
487
  task: async (_ctx, subtask) => {
488
+ // A redeploy keeps the installed relay's IP (installedPanelIp).
489
+ const installedIp = flags.forceFull ? null : await installedPanelIp();
490
+ if (installedIp) {
491
+ ctx.ip = installedIp;
492
+ subtask.output = `${installedIp} (the installed relay's, from panel.json)`;
493
+ return;
494
+ }
488
495
  ctx.ip = await detectIP({ allowPrivate: flags.dev });
489
496
  if (flags.dev) {
490
497
  subtask.output = `${ctx.ip} (dev mode — private IP accepted)`;
package/src/lib/env.js CHANGED
@@ -123,6 +123,25 @@ function isPublicIP(ip) {
123
123
  * Verify the process is running as root.
124
124
  * @throws {Error} if not root
125
125
  */
126
+ /**
127
+ * The IP an installed relay was set up with (`panel.json` `ip`), or null —
128
+ * no install, no readable file, or no valid IPv4 in it. A redeploy keeps
129
+ * it: the panel's IP certificate, agents and admins know the relay by it,
130
+ * and detecting again could change it (or fail, where the installer's
131
+ * public-IP check does not hold — a dev install on a private address).
132
+ *
133
+ * @param {string} [panelJsonPath]
134
+ * @returns {Promise<string | null>}
135
+ */
136
+ export async function installedPanelIp(panelJsonPath = '/etc/lamalibre/lamaste/panel.json') {
137
+ try {
138
+ const { ip } = JSON.parse(await readFile(panelJsonPath, 'utf8'));
139
+ return typeof ip === 'string' && isValidIPv4(ip) ? ip : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
126
145
  export function checkRoot() {
127
146
  if (process.getuid() !== 0) {
128
147
  throw new Error(
@@ -265,12 +265,19 @@ WantedBy=multi-user.target
265
265
  * Installed by create-lamaste only; onboarding writes the configuration,
266
266
  * then enables and starts it.
267
267
  *
268
+ * No start rate limit (`StartLimitIntervalSec=0`): the panel restarts
269
+ * Authelia for every change of membership, domains or OIDC clients, and
270
+ * systemd's default (5 starts in 10 s) refused the sixth of a quick series
271
+ * of admin changes — Authelia then stayed down on every domain until
272
+ * something started it again. A crash loop stays bounded by `RestartSec=5`.
273
+ *
268
274
  * @returns {string}
269
275
  */
270
276
  export function generateAutheliaUnit() {
271
277
  return `[Unit]
272
278
  Description=Authelia Authentication Server
273
279
  After=network.target
280
+ StartLimitIntervalSec=0
274
281
 
275
282
  [Service]
276
283
  Type=simple
@@ -24,6 +24,7 @@ import {
24
24
  nodeVersionTooNew,
25
25
  } from '../lib/node-version.js';
26
26
  import { deployGatekeeperPackage } from './gatekeeper.js';
27
+ import { autheliaReady } from '@lamalibre/lamaste/server';
27
28
 
28
29
  const GATEKEEPER_SERVICE = 'lamalibre-lamaste-gatekeeper';
29
30
 
@@ -447,6 +448,13 @@ export function redeployTasks(ctx, task) {
447
448
  if (ctx.autheliaWasActive) {
448
449
  subtask.output = 'Starting Authelia...';
449
450
  await execa('systemctl', ['start', 'authelia']);
451
+ // systemctl returns when the process is spawned; the panel, which
452
+ // starts next, talks to Authelia at once.
453
+ if (!(await autheliaReady(execa))) {
454
+ throw new Error(
455
+ 'Authelia is not serving requests after the redeploy — see journalctl -u authelia',
456
+ );
457
+ }
450
458
  }
451
459
  if (ctx.gatekeeperWasActive) {
452
460
  subtask.output = 'Starting gatekeeper...';
@@ -55,14 +55,14 @@ SSH into a fresh Ubuntu droplet and run one command:
55
55
 
56
56
  ```bash
57
57
  apt install -y npm
58
- npx @lamalibre/create-lamaste@4.5.0
58
+ npx @lamalibre/create-lamaste@4.7.0
59
59
  ```
60
60
 
61
61
  This installs everything the relay needs: nginx, Node.js, firewall rules, certificates, and the management panel. At the end, it prints a client certificate file and password. You download the certificate, import it into your browser, and disconnect SSH. You never SSH in again.
62
62
 
63
63
  **Option B: Desktop app (cloud provisioning)**
64
64
 
65
- Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.5.0`) and use the Servers tab to create a DigitalOcean droplet. The app handles everything — droplet creation, Lamaste installation, certificate download, and connection — without you ever touching SSH. Cloud API tokens are stored in your OS credential store (macOS Keychain or Linux libsecret), never in plaintext. See the [Cloud Provisioning guide](../02-guides/cloud-provisioning.md) for a step-by-step walkthrough.
65
+ Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.7.0`) and use the Servers tab to create a DigitalOcean droplet. The app handles everything — droplet creation, Lamaste installation, certificate download, and connection — without you ever touching SSH. Cloud API tokens are stored in your OS credential store (macOS Keychain or Linux libsecret), never in plaintext. See the [Cloud Provisioning guide](../02-guides/cloud-provisioning.md) for a step-by-step walkthrough.
66
66
 
67
67
  **Phase 2: Onboarding (10 minutes, browser)**
68
68
 
@@ -59,7 +59,7 @@ Once connected:
59
59
 
60
60
  ```bash
61
61
  apt install -y npm
62
- npx @lamalibre/create-lamaste@4.5.0
62
+ npx @lamalibre/create-lamaste@4.7.0
63
63
  ```
64
64
 
65
65
  The installer displays a confirmation banner showing what it will do:
@@ -253,7 +253,7 @@ Every tunnel is carried by one **agent** — the machine that runs the app. Enro
253
253
  Install the Lamaste Desktop app, which provides a GUI with automatic service discovery and one-click tunnel creation:
254
254
 
255
255
  ```bash
256
- npx @lamalibre/create-lamaste-desktop@4.5.0
256
+ npx @lamalibre/create-lamaste-desktop@4.7.0
257
257
  ```
258
258
 
259
259
  The app auto-detects local services (Ollama, ComfyUI, PostgreSQL, Docker containers, etc.) and lets you expose them with one click. See the [Desktop App Setup](../02-guides/desktop-app-setup.md) guide for details.
@@ -263,7 +263,7 @@ The app auto-detects local services (Ollama, ComfyUI, PostgreSQL, Docker contain
263
263
  In the panel, go to **Certificates → Enrollment Token** and create a token for a label such as `laptop`. Then, on the machine that runs your app, install the agent globally and run setup:
264
264
 
265
265
  ```bash
266
- npm install -g @lamalibre/lamaste-agent@4.5.0
266
+ npm install -g @lamalibre/lamaste-agent@4.7.0
267
267
  LAMALIBRE_LAMASTE_ENROLLMENT_TOKEN=<token> \
268
268
  lamaste-agent setup --label laptop --panel-url https://203.0.113.42:9292
269
269
  ```
@@ -345,7 +345,7 @@ Your Mac DigitalOcean Droplet Internet
345
345
  The installer accepts optional flags:
346
346
 
347
347
  ```bash
348
- npx @lamalibre/create-lamaste@4.5.0 [flags]
348
+ npx @lamalibre/create-lamaste@4.7.0 [flags]
349
349
  ```
350
350
 
351
351
  | Flag | Effect |
@@ -435,7 +435,7 @@ In development mode (`NODE_ENV=development` or `NODE_ENV` unset), the panel serv
435
435
  # 2. SSH in and install
436
436
  ssh root@203.0.113.42
437
437
  apt install -y npm
438
- npx @lamalibre/create-lamaste@4.5.0
438
+ npx @lamalibre/create-lamaste@4.7.0
439
439
 
440
440
  # 3. Download cert (from your local machine)
441
441
  scp root@203.0.113.42:/etc/lamalibre/lamaste/pki/client.p12 .
@@ -36,7 +36,7 @@ Lamaste solves the problem of exposing local services to the internet securely.
36
36
  **Option A: Manual setup (SSH)**
37
37
 
38
38
  1. Create a fresh Ubuntu 24.04 droplet on DigitalOcean
39
- 2. SSH in once and run `npx @lamalibre/create-lamaste@4.5.0`
39
+ 2. SSH in once and run `npx @lamalibre/create-lamaste@4.7.0`
40
40
  3. Download the client certificate it prints
41
41
  4. Import the certificate into your browser
42
42
  5. Open `https://<droplet-ip>:9292` — you are in the admin panel
@@ -46,7 +46,7 @@ Lamaste solves the problem of exposing local services to the internet securely.
46
46
 
47
47
  **Option B: Desktop app cloud provisioning**
48
48
 
49
- 1. Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.5.0`)
49
+ 1. Install the desktop app (`npx @lamalibre/create-lamaste-desktop@4.7.0`)
50
50
  2. Go to the Servers tab and click "Create a new server"
51
51
  3. Enter your DigitalOcean API token — the app validates scopes and stores it securely in your OS credential store
52
52
  4. Pick a region (the app measures latency to each) and a domain name
@@ -93,14 +93,14 @@ All backend services bind `127.0.0.1` only — nginx is the sole public-facing s
93
93
 
94
94
  State is stored in JSON files (`/etc/lamalibre/lamaste/panel.json`, `tunnels.json`) with atomic writes (write to temp → fsync → rename). No database is needed at this scale.
95
95
 
96
- The installer (`npx @lamalibre/create-lamaste@4.5.0`) is completely non-interactive — it detects the environment, provisions everything, and prints a summary. All configuration happens through the browser-based onboarding wizard after installation.
96
+ The installer (`npx @lamalibre/create-lamaste@4.7.0`) is completely non-interactive — it detects the environment, provisions everything, and prints a summary. All configuration happens through the browser-based onboarding wizard after installation.
97
97
 
98
98
  ## Quick Reference
99
99
 
100
100
  | Item | Value |
101
101
  | -------------------- | ------------------------------- |
102
102
  | **Minimum VPS** | 512MB RAM, Ubuntu 24.04 |
103
- | **Install command** | `npx @lamalibre/create-lamaste@4.5.0` |
103
+ | **Install command** | `npx @lamalibre/create-lamaste@4.7.0` |
104
104
  | **Admin panel** | `https://<ip>:9292` (mTLS) |
105
105
  | **Auth for admin** | Client certificate (zero-login) |
106
106
  | **Auth for apps** | TOTP 2FA via Authelia |
@@ -108,5 +108,5 @@ The installer (`npx @lamalibre/create-lamaste@4.5.0`) is completely non-interact
108
108
  | **TLS certificates** | Let's Encrypt (auto-renewing) |
109
109
  | **State storage** | JSON files (no database) |
110
110
  | **RAM usage** | ~245MB total (all services) |
111
- | **npm package** | `@lamalibre/create-lamaste@4.5.0` |
111
+ | **npm package** | `@lamalibre/create-lamaste@4.7.0` |
112
112
  | **License** | Polyform Noncommercial 1.0.0 |
@@ -40,7 +40,7 @@ From the Users page in the management panel, you can:
40
40
  1. Navigate to the Users page in the management panel
41
41
  2. Click "Add User"
42
42
  3. Enter a username, display name, email, and password
43
- 4. Lamaste creates the user in `users.yml` and restarts Authelia
43
+ 4. Lamaste creates the user in `users.yml`; Authelia reloads it in place
44
44
  5. Share the credentials with the user — they enroll in TOTP on first login
45
45
 
46
46
  ### TOTP enrollment flow
@@ -221,7 +221,7 @@ users:
221
221
 
222
222
  Lamaste manages this file through the panel server API. All writes are atomic: content is written to a temporary file first, then moved into place. This prevents Authelia from reading a partially written file.
223
223
 
224
- After every write to `users.yml`, the panel server reloads Authelia via `systemctl restart authelia` so the changes take effect.
224
+ Authelia watches `users.yml` (`authentication_backend.file.watch: true`) and reloads it in place; with the watch it re-reads a user's details at each of their requests, so a change applies at the user's next request without a restart and without ending anyone else's session. It skips a reload within 0.5 s of the previous one, so the panel writes the file once more 0.6 s after its last write.
225
225
 
226
226
  ### Creating users programmatically
227
227
 
@@ -420,6 +420,10 @@ Key choices:
420
420
 
421
421
  See [Registry Credential and OIDC Clients](../04-api-reference/registry-and-oidc.md) for the manifest fields and the API.
422
422
 
423
+ ### Several domains
424
+
425
+ On a relay with several domains Authelia carries one `session.cookies` entry per active domain (each `authelia_url: https://auth.<domain>`), so each domain's sign-in sets and reads its own `authelia_session` cookie — same name, scoped by the browser. `access_control` admits each domain's active members on the domain and every host under it (protected sites' rules narrowed to members), then denies; `default_policy` is `deny`. OIDC sign-ins are gated by per-domain `authorization_policies`, since `access_control` does not apply to them, and each domain is an issuer of its own (`https://auth.<domain>`). The panel renders all of it from the directory's memberships ([Domains API](../04-api-reference/domains.md)); on a one-domain relay every user is the domain's member, which admits whom it did before. TOTP is per person, not per domain.
426
+
423
427
  ### Safety rule: never delete the last user
424
428
 
425
429
  The panel server API prevents deleting the last Authelia user. If only one user exists and you try to delete them, the API returns an error. This prevents a lockout scenario where no one can authenticate to reach the tunneled apps.
@@ -102,6 +102,10 @@ From the Gatekeeper dashboard in the admin panel:
102
102
 
103
103
  Gatekeeper reads groups and grants from its SQLite database (`gatekeeper.db`) on every check, so a group, member or grant created through the admin panel takes effect on the next request — no service restart required. The tunnel list (the panel's `tunnels.json`) and Gatekeeper's settings (`gatekeeper.json`) are watched and reloaded when they change. Changes through the panel also recompute the audiences of agent-hosted plugins at once; edits made with Gatekeeper's own CLI are picked up by the panel within a minute.
104
104
 
105
+ ### Domains and membership
106
+
107
+ On a relay with several domains, membership of a host's domain comes before every access mode: `authenticated` means "any signed-in member of this domain", and `restricted` still needs a grant on top. Membership is rendered into Authelia and checked again by Gatekeeper from the panel's `domains.json`; a signed-in non-member gets a "not a member of this domain" page — with the admin contact and a sign-out link — rather than the sign-in page. Members are managed per domain ([Domains API](../04-api-reference/domains.md)); the admin certificate manages every domain, and Authelia's `admins` group needs membership like everyone else (inside a domain it still passes `restricted` sites).
108
+
105
109
  ## For Developers
106
110
 
107
111
  ### Architecture
@@ -29,7 +29,7 @@ For the IP-based admin panel (`https://<ip>:9292`), there is no domain to verify
29
29
 
30
30
  ### When certificates are issued
31
31
 
32
- - **Self-signed + mTLS certs** — created during `npx @lamalibre/create-lamaste@4.5.0` installation
32
+ - **Self-signed + mTLS certs** — created during `npx @lamalibre/create-lamaste@4.7.0` installation
33
33
  - **Core Let's Encrypt certs** — issued during onboarding for `panel`, `auth`, and `tunnel` subdomains
34
34
  - **App Let's Encrypt certs** — issued when you create a new tunnel or a managed static site
35
35
  - **Custom-domain site certs** — issued when a custom-domain site's DNS is verified: one certificate lineage, named after the site's domain, covering the domain and every redirect alias (re-issued when aliases change)
@@ -82,7 +82,7 @@ The most critical scenario is the mTLS CA expiring, but it has a 10-year validit
82
82
 
83
83
  ### Let's Encrypt implementation
84
84
 
85
- Lamaste uses certbot with the nginx plugin for certificate issuance. The implementation lives in `@lamalibre/lamaste@4.5.0/server` (`packages/core/lib/src/server/certbot.ts`), wrapped by `packages/server/daemon/src/lib/certbot.js`.
85
+ Lamaste uses certbot with the nginx plugin for certificate issuance. The implementation lives in `@lamalibre/lamaste@4.7.0/server` (`packages/core/lib/src/server/certbot.ts`), wrapped by `packages/server/daemon/src/lib/certbot.js`.
86
86
 
87
87
  The panel runs as the unprivileged `lamaste` user and never runs `certbot` or `openssl` on Let's Encrypt files directly. It calls two root-owned wrapper scripts through sudo:
88
88
 
@@ -193,7 +193,7 @@ This three-step check prevents unnecessary certificate issuance and respects rat
193
193
 
194
194
  #### Custom-domain site certificate issuance
195
195
 
196
- A custom-domain static site may have redirect aliases (e.g. `www.myblog.net`). `issueSiteCert(fqdn, aliases, email)` in `@lamalibre/lamaste@4.5.0/server` keeps them on **one** lineage named after the primary domain:
196
+ A custom-domain static site may have redirect aliases (e.g. `www.myblog.net`). `issueSiteCert(fqdn, aliases, email)` in `@lamalibre/lamaste@4.7.0/server` keeps them on **one** lineage named after the primary domain:
197
197
 
198
198
  ```bash
199
199
  sudo /usr/local/sbin/lamaste-certbot issue admin@example.com myblog.net myblog.net www.myblog.net
@@ -260,6 +260,15 @@ Once set during onboarding, the domain is used throughout the system for constru
260
260
  | `packages/lamaste-serverd/src/lib/nginx.js` | Vhost creation with domain-based server_name |
261
261
  | `packages/lamaste-serverd/src/lib/state.js` | Domain storage in panel.json |
262
262
 
263
+ ## Multiple domains
264
+
265
+ One relay can serve several base domains: the **primary** (set at onboarding — `panel.`, `tunnel.` and agent panels live there) and **added** domains, each with its own sign-in at `auth.<domain>` and its own member list. People have one account across all of them; a person reaches a domain's protected hosts only as a member, and signs in once per domain (browsers never share cookies between domains).
266
+
267
+ - **DNS for an added domain:** an A record for `auth.<domain>`, and either a wildcard `*.<domain>` or one A record per hostname, all to the relay. Explicit records (apex, `www`, mail) keep precedence over a wildcard. Steps: [Adding a Second Domain](../02-guides/second-domain.md).
268
+ - **Domains never overlap:** `a.samplepark.tr` cannot be added beside `samplepark.tr` — a domain's cookies reach every host under it.
269
+ - **Hostnames:** a tunnel or managed site is `<label>.<domain>`, unique as a whole; the reserved labels hold on every domain; the domain of a hostname is the one it is under (never stored separately). A domain's apex and `www.` may be a custom site or an alias.
270
+ - **Records** live in the panel's `state.db` (`domains`, `domain_members`); `panel.json` `domain` stays the primary.
271
+
263
272
  ## Quick Reference
264
273
 
265
274
  ### Required DNS records (onboarding)
@@ -25,7 +25,7 @@ You encounter mTLS during two moments:
25
25
 
26
26
  ### Importing the certificate
27
27
 
28
- After running `npx @lamalibre/create-lamaste@4.5.0`, the installer prints something like:
28
+ After running `npx @lamalibre/create-lamaste@4.7.0`, the installer prints something like:
29
29
 
30
30
  ```
31
31
  ╔══════════════════════════════════════════════════════════════╗
@@ -101,7 +101,7 @@ When connecting a machine to Lamaste using `lamaste-agent`, you should **not** u
101
101
  2. Click "Generate" and enter a label (e.g., `macbook-pro`)
102
102
  3. Save the displayed password — it cannot be retrieved later
103
103
  4. Download the `.p12` file
104
- 5. Share the `.p12` file and password with the agent machine through a secure channel, then install the agent there (`npm install -g @lamalibre/lamaste-agent@4.5.0`) and run `lamaste-agent setup` (an enrollment token avoids moving the key at all — see [Agent Setup](../02-guides/agent-setup.md))
104
+ 5. Share the `.p12` file and password with the agent machine through a secure channel, then install the agent there (`npm install -g @lamalibre/lamaste-agent@4.7.0`) and run `lamaste-agent setup` (an enrollment token avoids moving the key at all — see [Agent Setup](../02-guides/agent-setup.md))
105
105
 
106
106
  Agent certificates have capability-based access. By default, a new agent can only read tunnel configuration. Admins can grant additional capabilities per-agent from the panel UI:
107
107