@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
@@ -2,11 +2,18 @@
2
2
  * Shim — Authelia lifecycle + config management now lives in
3
3
  * `@lamalibre/lamaste/server`. This file wires the daemon's execa instance
4
4
  * and bcryptjs hasher to the parameterized core API.
5
+ *
6
+ * Users go through here alone: every write of users.yml is a locked
7
+ * read-modify-write in `lib/directory.js` (`changeUsers`), which brings the
8
+ * user directory in line before Authelia restarts. Tests point the users
9
+ * file, the restart and the TOTP writer elsewhere
10
+ * ({@link setAutheliaForTests}) — there is no Authelia to restart there.
5
11
  */
6
12
 
7
13
  import { execa } from 'execa';
8
14
  import bcrypt from 'bcryptjs';
9
15
  import {
16
+ AUTHELIA_USERS,
10
17
  isAutheliaInstalled as isAutheliaInstalledCore,
11
18
  writeAutheliaConfig as writeAutheliaConfigCore,
12
19
  createAutheliaUser,
@@ -24,11 +31,65 @@ import {
24
31
  base32Decode,
25
32
  generateTotpSecret,
26
33
  writeTotpToDatabase as writeTotpToDatabaseCore,
34
+ purgeAutheliaSecondFactors,
35
+ autheliaHoldsOpaqueIdentifier,
27
36
  } from '@lamalibre/lamaste/server';
28
37
 
29
38
  // bcryptjs hasher — signature matches the core lib's BcryptHashFn.
30
39
  const bcryptHash = (password, cost) => bcrypt.hash(password, cost);
31
40
 
41
+ const restartRealAuthelia = () => reloadAutheliaCore(execa);
42
+ const writeRealTotp = (username, base32Secret) =>
43
+ writeTotpToDatabaseCore(username, base32Secret, execa);
44
+
45
+ /** Authelia's per-user storage through the root helper — replaced in tests. */
46
+ const realUserStorage = Object.freeze({
47
+ purgeSecondFactors: (username) => purgeAutheliaSecondFactors(username, execa),
48
+ holdsOidcIdentifier: (username) => autheliaHoldsOpaqueIdentifier(username, execa),
49
+ });
50
+
51
+ /** Authelia's users file — another one in tests. */
52
+ let usersFile = AUTHELIA_USERS;
53
+ /** Restart Authelia (it reads users.yml when it starts) — replaced in tests. */
54
+ let restartAuthelia = restartRealAuthelia;
55
+ /** Store a TOTP secret in Authelia's database — replaced in tests. */
56
+ let totpWriter = writeRealTotp;
57
+ /** Authelia's per-user storage — replaced in tests. */
58
+ let userStorage = realUserStorage;
59
+
60
+ /**
61
+ * Point the users file, the Authelia restart, the TOTP writer and the
62
+ * per-user storage elsewhere (tests); `null` restores Authelia's.
63
+ *
64
+ * @param {{ usersFile?: string, restart?: () => Promise<unknown>, writeTotp?: (username: string, secret: string) => Promise<void>, userStorage?: { purgeSecondFactors: (username: string) => Promise<void>, holdsOidcIdentifier: (username: string) => Promise<boolean> } } | null} overrides
65
+ */
66
+ export function setAutheliaForTests(overrides) {
67
+ usersFile = overrides?.usersFile ?? AUTHELIA_USERS;
68
+ restartAuthelia = overrides?.restart ?? restartRealAuthelia;
69
+ totpWriter = overrides?.writeTotp ?? writeRealTotp;
70
+ userStorage = overrides?.userStorage ?? realUserStorage;
71
+ }
72
+
73
+ /**
74
+ * Delete `username`'s TOTP configuration and WebAuthn credentials from
75
+ * Authelia's storage (idempotent; throws when the helper fails).
76
+ *
77
+ * @param {string} username
78
+ */
79
+ export function purgeAutheliaUserSecondFactors(username) {
80
+ return userStorage.purgeSecondFactors(username);
81
+ }
82
+
83
+ /**
84
+ * Whether Authelia's storage holds an OpenID Connect opaque identifier for
85
+ * `username` (throws when it cannot be told).
86
+ *
87
+ * @param {string} username
88
+ */
89
+ export function autheliaHoldsOidcIdentity(username) {
90
+ return userStorage.holdsOidcIdentifier(username);
91
+ }
92
+
32
93
  /** True once create-lamaste has installed Authelia. */
33
94
  export function isAutheliaInstalled() {
34
95
  return isAutheliaInstalledCore();
@@ -38,20 +99,22 @@ export function writeAutheliaConfig(domain, secrets) {
38
99
  return writeAutheliaConfigCore(domain, secrets);
39
100
  }
40
101
 
102
+ /** Add the default administrator to users.yml (onboarding) — inside `changeUsers`. */
41
103
  export function createUser(username, password) {
42
- return createAutheliaUser(username, password, bcryptHash);
104
+ return createAutheliaUser(username, password, bcryptHash, usersFile);
43
105
  }
44
106
 
45
107
  export function readUsers() {
46
- return readAutheliaUsers();
108
+ return readAutheliaUsers(usersFile);
47
109
  }
48
110
 
49
111
  export function writeUsers(usersData) {
50
- return writeAutheliaUsers(usersData);
112
+ return writeAutheliaUsers(usersData, usersFile);
51
113
  }
52
114
 
115
+ /** users.yml as written, password hashes included; throws when it cannot be read or parsed. */
53
116
  export function readUsersRaw() {
54
- return readAutheliaUsersRaw();
117
+ return readAutheliaUsersRaw(usersFile);
55
118
  }
56
119
 
57
120
  export function hashPassword(password) {
@@ -62,8 +125,9 @@ export function startAuthelia() {
62
125
  return startAutheliaCore(execa);
63
126
  }
64
127
 
128
+ /** Restart Authelia: it reads users.yml when it starts, and every session ends. */
65
129
  export function reloadAuthelia() {
66
- return reloadAutheliaCore(execa);
130
+ return restartAuthelia();
67
131
  }
68
132
 
69
133
  export function isAutheliaRunning() {
@@ -84,12 +148,19 @@ export function updateOidcProvider(domain, clients, options = {}) {
84
148
  return updateAutheliaOidcProvider(domain, clients, execa, options);
85
149
  }
86
150
 
151
+ /**
152
+ * Add an invited user to users.yml — inside `changeUsers`, which restarts
153
+ * Authelia once the directory knows the user.
154
+ */
87
155
  export function createUserFromInvitation(username, email, groups, hashedPassword) {
88
- return createUserFromInvitationCore(username, email, groups, hashedPassword, execa);
156
+ return createUserFromInvitationCore(username, email, groups, hashedPassword, execa, {
157
+ restart: false,
158
+ usersFile,
159
+ });
89
160
  }
90
161
 
91
162
  export function writeTotpToDatabase(username, base32Secret) {
92
- return writeTotpToDatabaseCore(username, base32Secret, execa);
163
+ return totpWriter(username, base32Secret);
93
164
  }
94
165
 
95
166
  // Pure helpers — no dependency injection needed
@@ -30,17 +30,29 @@ export function chiselReloadsAuthfile() {
30
30
  }
31
31
 
32
32
  /**
33
- * Restart chisel if the change requires it. `try-restart` leaves a stopped
34
- * chisel stopped (see chisel-reconcile.js: a server that failed to reconcile
35
- * keeps chisel down until it succeeds).
33
+ * Whether an authfile change must restart chisel: a grant withdrawn (so
34
+ * live sessions drop the binding) — unless `keepLiveSessions` — or any
35
+ * change a chisel too old to reload its authfile would not see.
36
+ *
37
+ * @param {{ changed: boolean, revoked: boolean }} change
38
+ * @param {{ keepLiveSessions?: boolean }} [opts]
39
+ */
40
+ export function authfileChangeNeedsRestart(change, { keepLiveSessions = false } = {}) {
41
+ return (change.revoked && !keepLiveSessions) || (change.changed && !runningPinnedRelease);
42
+ }
43
+
44
+ /**
45
+ * Restart chisel if the change requires it ({@link authfileChangeNeedsRestart}).
46
+ * `try-restart` leaves a stopped chisel stopped (see chisel-reconcile.js: a
47
+ * server that failed to reconcile keeps chisel down until it succeeds).
36
48
  *
37
49
  * @param {{ changed: boolean, revoked: boolean }} change
38
50
  * @param {import('pino').Logger} [logger]
51
+ * @param {{ keepLiveSessions?: boolean }} [opts]
39
52
  * @returns {Promise<{ restartOk: boolean, restartError?: string }>}
40
53
  */
41
- export async function applyAuthfileChange(change, logger) {
42
- const needsRestart = change.revoked || (change.changed && !runningPinnedRelease);
43
- if (!needsRestart) return { restartOk: true };
54
+ export async function applyAuthfileChange(change, logger, opts = {}) {
55
+ if (!authfileChangeNeedsRestart(change, opts)) return { restartOk: true };
44
56
  const result = await reloadChiselAuth(execa, logger);
45
57
  return result.ok ? { restartOk: true } : { restartOk: false, restartError: result.error };
46
58
  }
@@ -71,10 +71,18 @@ export function getChiselStatus() {
71
71
  * grant, chisel is restarted so live sessions drop the binding. Call only
72
72
  * after the tunnel state has been written. Throws if the restart fails,
73
73
  * since a withdrawn grant would otherwise stay live.
74
+ *
75
+ * `keepLiveSessions`: withdraw the grant for new sessions only, without the
76
+ * restart — which drops every session, the hosting agents' internal plugin
77
+ * tunnels with them. Only for a port nothing routes to any more, whose
78
+ * agent drops the binding at its next sync (a tunnel moved to a plugin's
79
+ * web surface).
80
+ *
81
+ * @param {{ keepLiveSessions?: boolean }} [opts]
74
82
  */
75
- export async function syncChisel() {
83
+ export async function syncChisel(opts = {}) {
76
84
  const change = await syncChiselAuthfile();
77
- const { restartOk, restartError } = await applyAuthfileChange(change);
85
+ const { restartOk, restartError } = await applyAuthfileChange(change, undefined, opts);
78
86
  if (!restartOk) {
79
87
  throw new Error(`Chisel restart failed after withdrawing a port grant: ${restartError}`);
80
88
  }
@@ -3,6 +3,8 @@ import { readFile, writeFile, rename, open } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import path from 'node:path';
5
5
  import { z } from 'zod';
6
+ import { isIdentityUuid } from '@lamalibre/lamaste';
7
+ import { SESSION_MAX_AGE } from './user-access-session.js';
6
8
 
7
9
  const ConfigSchema = z.object({
8
10
  ip: z.string().min(1),
@@ -25,11 +27,13 @@ const ConfigSchema = z.object({
25
27
  .optional()
26
28
  .default({ enabled: false, secret: null, setupComplete: false }),
27
29
  sessionSecret: z.string().nullable().optional().default(null),
28
- // Per-user invalidation epochs (Unix seconds). When a user is deleted from
29
- // Authelia, their epoch is bumped so any in-flight user-access session token
30
- // issued before that timestamp is rejected by the session middleware. The
31
- // entry is preserved (never deleted) so that re-creating the same username
32
- // does not resurrect old sessions.
30
+ // Per-user invalidation epochs (Unix seconds), keyed by the directory's
31
+ // user id — never by username, so panel.json names nobody. Offboarding a
32
+ // user (disable, delete) bumps theirs, and the session middleware rejects
33
+ // every user-access session issued before it. Sessions are bound to the
34
+ // user id too, so a username given to someone else later never revives
35
+ // one. An epoch older than a session's longest life can reject nothing and
36
+ // is dropped (`pruneUserEpochs`).
33
37
  userEpochs: z.record(z.string(), z.number().int().nonnegative()).optional().default({}),
34
38
  onboarding: z.object({
35
39
  status: z.enum(['FRESH', 'DOMAIN_SET', 'DNS_READY', 'PROVISIONING', 'COMPLETED']),
@@ -45,8 +49,8 @@ let configPath = null;
45
49
 
46
50
  /**
47
51
  * Promise-chain mutex serializing all panel.json writes so concurrent
48
- * updateConfig() calls cannot race (e.g. two simultaneous user deletes both
49
- * bumping userEpochs).
52
+ * updateConfig() / bumpUserEpoch() calls cannot race (e.g. two simultaneous
53
+ * user deletes both bumping userEpochs).
50
54
  */
51
55
  let configWriteLock = Promise.resolve();
52
56
  function withConfigLock(fn) {
@@ -58,6 +62,36 @@ function withConfigLock(fn) {
58
62
  return prev.then(fn).finally(resolve);
59
63
  }
60
64
 
65
+ /**
66
+ * The epochs worth keeping: keyed by a user id (keys of earlier versions
67
+ * were usernames — dropped, as the sessions they guarded no longer
68
+ * validate: a session now names its user id) and younger than a session's
69
+ * longest life.
70
+ *
71
+ * @param {Record<string, number>} epochs
72
+ * @param {number} nowSeconds
73
+ */
74
+ export function pruneUserEpochs(epochs, nowSeconds) {
75
+ const out = {};
76
+ for (const [userId, epoch] of Object.entries(epochs ?? {})) {
77
+ if (isIdentityUuid(userId) && epoch > nowSeconds - SESSION_MAX_AGE) out[userId] = epoch;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Write `validated` to panel.json atomically (temp → fsync → rename), 0600. */
83
+ async function persistConfig(validated) {
84
+ const tmpPath = `${configPath}.tmp`;
85
+ await writeFile(tmpPath, JSON.stringify(validated, null, 2) + '\n', {
86
+ encoding: 'utf-8',
87
+ mode: 0o600,
88
+ });
89
+ const fd = await open(tmpPath, 'r');
90
+ await fd.sync();
91
+ await fd.close();
92
+ await rename(tmpPath, configPath);
93
+ }
94
+
61
95
  function resolveConfigPath() {
62
96
  if (process.env.LAMALIBRE_LAMASTE_CONFIG) {
63
97
  return process.env.LAMALIBRE_LAMASTE_CONFIG;
@@ -95,18 +129,18 @@ export async function loadConfig() {
95
129
  const validated = ConfigSchema.parse(parsed);
96
130
 
97
131
  // Auto-generate serverId if missing — used as bucket prefix for multi-server isolation
132
+ let rewrite = false;
98
133
  if (!validated.serverId) {
99
134
  validated.serverId = crypto.randomUUID();
100
- const tmpPath = `${configPath}.tmp`;
101
- await writeFile(tmpPath, JSON.stringify(validated, null, 2) + '\n', {
102
- encoding: 'utf-8',
103
- mode: 0o600,
104
- });
105
- const fd = await open(tmpPath, 'r');
106
- await fd.sync();
107
- await fd.close();
108
- await rename(tmpPath, configPath);
135
+ rewrite = true;
109
136
  }
137
+ // Epochs keyed by username (before 4.5) or too old to reject anything go.
138
+ const epochs = pruneUserEpochs(validated.userEpochs, Math.floor(Date.now() / 1000));
139
+ if (Object.keys(epochs).length !== Object.keys(validated.userEpochs).length) {
140
+ validated.userEpochs = epochs;
141
+ rewrite = true;
142
+ }
143
+ if (rewrite) await persistConfig(validated);
110
144
 
111
145
  config = validated;
112
146
  return structuredClone(config);
@@ -140,28 +174,13 @@ export function updateConfig(patch) {
140
174
  patch.panel2fa !== null
141
175
  ) {
142
176
  merged.panel2fa = { ...merged.panel2fa, ...patch.panel2fa };
143
- } else if (
144
- key === 'userEpochs' &&
145
- typeof patch.userEpochs === 'object' &&
146
- patch.userEpochs !== null
147
- ) {
148
- merged.userEpochs = { ...(merged.userEpochs ?? {}), ...patch.userEpochs };
149
177
  } else {
150
178
  merged[key] = patch[key];
151
179
  }
152
180
  }
153
181
 
154
182
  const validated = ConfigSchema.parse(merged);
155
-
156
- const tmpPath = `${configPath}.tmp`;
157
- await writeFile(tmpPath, JSON.stringify(validated, null, 2) + '\n', {
158
- encoding: 'utf-8',
159
- mode: 0o600,
160
- });
161
- const fd = await open(tmpPath, 'r');
162
- await fd.sync();
163
- await fd.close();
164
- await rename(tmpPath, configPath);
183
+ await persistConfig(validated);
165
184
 
166
185
  config = validated;
167
186
  return structuredClone(config);
@@ -169,20 +188,26 @@ export function updateConfig(patch) {
169
188
  }
170
189
 
171
190
  /**
172
- * Bump a user's session-invalidation epoch to "now" (Unix seconds). Any
173
- * user-access session whose `iat` (issued-at) is older than this epoch
174
- * is considered revoked by the session middleware.
191
+ * Bump a user's session-invalidation epoch to "now" (Unix seconds): every
192
+ * user-access session of `userId` issued before it is revoked (the session
193
+ * middleware). Epochs that can no longer reject anything are dropped in
194
+ * the same write ({@link pruneUserEpochs}). Idempotent.
175
195
  *
176
- * Idempotent: calling twice in quick succession is safe (the later
177
- * timestamp simply replaces the earlier one).
178
- *
179
- * @param {string} username
196
+ * @param {string} userId the directory's id of the user
180
197
  * @returns {Promise<number>} The new epoch (Unix seconds)
181
198
  */
182
- export async function bumpUserEpoch(username) {
183
- const nowSeconds = Math.floor(Date.now() / 1000);
184
- await updateConfig({ userEpochs: { [username]: nowSeconds } });
185
- return nowSeconds;
199
+ export function bumpUserEpoch(userId) {
200
+ if (!isIdentityUuid(userId)) throw new Error('A session epoch is keyed by a directory user id');
201
+ if (config === null) throw new Error('Config not loaded. Call loadConfig() first.');
202
+ return withConfigLock(async () => {
203
+ const nowSeconds = Math.floor(Date.now() / 1000);
204
+ const next = structuredClone(config);
205
+ next.userEpochs = { ...pruneUserEpochs(next.userEpochs, nowSeconds), [userId]: nowSeconds };
206
+ const validated = ConfigSchema.parse(next);
207
+ await persistConfig(validated);
208
+ config = validated;
209
+ return nowSeconds;
210
+ });
186
211
  }
187
212
 
188
213
  export function getConfigPath() {