@lamalibre/create-lamaste 4.6.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 (149) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +22 -3
  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/03-architecture/installer-flow.md +2 -2
  30. package/vendor/docs/03-architecture/installer.md +4 -4
  31. package/vendor/docs/03-architecture/management-flow.md +1 -1
  32. package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
  33. package/vendor/docs/03-architecture/overview.md +5 -5
  34. package/vendor/docs/03-architecture/panel-client.md +6 -6
  35. package/vendor/docs/03-architecture/panel-server.md +2 -2
  36. package/vendor/docs/03-architecture/state-management.md +3 -3
  37. package/vendor/docs/03-architecture/system-overview.md +1 -1
  38. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
  39. package/vendor/docs/04-api-reference/app-host.md +3 -3
  40. package/vendor/docs/04-api-reference/domains.md +192 -0
  41. package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +9 -0
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  46. package/vendor/docs/04-api-reference/sites.md +5 -0
  47. package/vendor/docs/04-api-reference/tunnels.md +15 -10
  48. package/vendor/docs/04-api-reference/users.md +41 -29
  49. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +6 -6
  52. package/vendor/docs/06-reference/config-files.md +1 -1
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  57. package/vendor/docs/_index.json +105 -19
  58. package/vendor/docs/external-members-design.md +816 -0
  59. package/vendor/docs/gatekeeper-design.md +8 -8
  60. package/vendor/docs/identity-directory-design.md +3 -3
  61. package/vendor/docs/multi-domain-relay-design.md +836 -0
  62. package/vendor/docs/package.json +1 -1
  63. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  64. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  65. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  67. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  69. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  70. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  72. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  74. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  76. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  77. package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
  78. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.js +28 -2
  80. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  81. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
  83. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  85. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  87. package/vendor/gatekeeper/package.json +2 -2
  88. package/vendor/server/bin/lamaste-server.js +7 -0
  89. package/vendor/server/package.json +2 -2
  90. package/vendor/server/src/commands/domains.js +214 -0
  91. package/vendor/server/src/commands/plugin-identity.js +2 -16
  92. package/vendor/server/src/commands/plugins.js +112 -8
  93. package/vendor/server/src/commands/registry-credential.js +10 -3
  94. package/vendor/server/src/commands/sites.js +3 -0
  95. package/vendor/server/src/commands/status.js +19 -0
  96. package/vendor/server/src/commands/tunnels.js +4 -1
  97. package/vendor/server/src/commands/users.js +8 -19
  98. package/vendor/server/src/domains.js +92 -0
  99. package/vendor/server/src/fail.js +21 -0
  100. package/vendor/server/src/oidc-client-grants.js +63 -0
  101. package/vendor/server/src/panel-api.js +15 -0
  102. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  103. package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
  104. package/vendor/server-ui/dist/index.html +2 -2
  105. package/vendor/serverd/package.json +2 -2
  106. package/vendor/serverd/src/index.js +42 -16
  107. package/vendor/serverd/src/lib/app-error.js +24 -0
  108. package/vendor/serverd/src/lib/audience.js +12 -3
  109. package/vendor/serverd/src/lib/authelia.js +341 -35
  110. package/vendor/serverd/src/lib/directory.js +67 -52
  111. package/vendor/serverd/src/lib/domains.js +1792 -0
  112. package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
  113. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  114. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  115. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  116. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  117. package/vendor/serverd/src/lib/nginx.js +318 -140
  118. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  119. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  120. package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
  121. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  122. package/vendor/serverd/src/lib/plugins.js +45 -5
  123. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  124. package/vendor/serverd/src/lib/services.js +9 -1
  125. package/vendor/serverd/src/lib/state.js +11 -0
  126. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  127. package/vendor/serverd/src/lib/user-surface.js +29 -4
  128. package/vendor/serverd/src/lib/users-lock.js +23 -0
  129. package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
  130. package/vendor/serverd/src/lib/web-surface.js +49 -32
  131. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  132. package/vendor/serverd/src/routes/invite.js +15 -5
  133. package/vendor/serverd/src/routes/management/domains.js +154 -0
  134. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  135. package/vendor/serverd/src/routes/management/identity.js +21 -7
  136. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  137. package/vendor/serverd/src/routes/management/plugins.js +4 -2
  138. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  139. package/vendor/serverd/src/routes/management/sites.js +50 -11
  140. package/vendor/serverd/src/routes/management/tunnels.js +29 -5
  141. package/vendor/serverd/src/routes/management/users.js +184 -64
  142. package/vendor/serverd/src/routes/management.js +2 -0
  143. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  144. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  145. package/vendor/serverd/src/routes/user-access.js +13 -1
  146. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  147. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  148. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  149. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
@@ -14,15 +14,16 @@
14
14
  *
15
15
  * users.yml is never written from here. Every write of it goes through
16
16
  * {@link changeUsers}: one in-process lock around the read-modify-write,
17
- * then the directory is reconciled against the file, then Authelia
18
- * restarts, then the plugins' audiences are recomputed. The reconcile
19
- * ({@link reconcileDirectory}) also runs at start, every minute and after
20
- * onboarding — read only: writing users.yml restarts Authelia and ends
21
- * every session. It adopts what changed outside Lamaste (Authelia's own
22
- * password reset rewrites the file; an operator may edit it), and only
23
- * after reading the file twice does it take a user for deleted outside —
24
- * Authelia rewrites the file in place, and a read that met a half-written
25
- * file must not tombstone anyone.
17
+ * then the directory is reconciled against the file, then the domains are
18
+ * rendered (the groups Lamaste owns in users.yml among them — Authelia
19
+ * watches the file and applies a change at each user's next request, with
20
+ * no restart: no session ends), then the plugins' audiences are
21
+ * recomputed. The reconcile ({@link reconcileDirectory}) also runs at
22
+ * start, every minute and after onboarding — read only. It adopts what
23
+ * changed outside Lamaste (Authelia's own password reset rewrites the file;
24
+ * an operator may edit it), and only after reading the file twice does it
25
+ * take a user for deleted outside — Authelia rewrites the file in place,
26
+ * and a read that met a half-written file must not tombstone anyone.
26
27
  *
27
28
  * Gatekeeper keys users by username too: the grants naming a user and
28
29
  * their memberships of Lamaste groups would pass to anyone who later holds
@@ -56,21 +57,32 @@
56
57
  * Authelia second factors go; Gatekeeper's cached session verdicts are
57
58
  * dropped. Every reconcile offboards exactly the users it saw change.
58
59
  *
60
+ * **Domains** (lib/domains.js): a user created here becomes a member of
61
+ * the domains they are created with (`changeUsers`' `domains`) — or, while
62
+ * the relay has one domain, of that one, whoever created them; a removed
63
+ * user's memberships end in the same transaction. Every `changeUsers`
64
+ * renders the domains' members into users.yml's owned groups, Gatekeeper's
65
+ * projection and Authelia's configuration.
66
+ *
59
67
  * Audit entries name users by their id alone: never a username, a name or
60
68
  * an address.
61
69
  */
62
70
 
63
71
  import crypto from 'node:crypto';
64
- import { PromiseChainMutex } from '@lamalibre/lamaste';
65
72
  import { httpError } from './app-error.js';
66
73
  import { recordAuditBestEffort } from './audit.js';
67
74
  import {
68
75
  autheliaHoldsOidcIdentity,
69
76
  purgeAutheliaUserSecondFactors,
70
77
  readUsersRaw,
71
- reloadAuthelia,
72
78
  } from './authelia.js';
73
79
  import { bumpUserEpoch } from './config.js';
80
+ import {
81
+ deleteMembershipsSync,
82
+ domainsForNewUser,
83
+ insertMembershipsSync,
84
+ syncDomainAccess,
85
+ } from './domains.js';
74
86
  import {
75
87
  bustGatekeeperSessions,
76
88
  purgeAccessLogForUser,
@@ -80,6 +92,7 @@ import { revokeUserRegistryTokens } from './registry-credential.js';
80
92
  import { getStateDb } from './state-db.js';
81
93
  import { readInvitations, withInvitationsLock, writeInvitations } from './state.js';
82
94
  import { removeGrantsForUser } from './user-access.js';
95
+ import { withUsersLock } from './users-lock.js';
83
96
 
84
97
  /** How long the reconcile waits before reading users.yml again to confirm a user is gone. */
85
98
  const DIRECTORY_CONFIRM_DELAY_MS = 500;
@@ -118,25 +131,6 @@ export const ADMIN_GROUP = 'admins';
118
131
  * @property {string[]} disabled the ids of users this reconcile saw become disabled
119
132
  */
120
133
 
121
- // ---------------------------------------------------------------------------
122
- // The users lock
123
- // ---------------------------------------------------------------------------
124
-
125
- const usersMutex = new PromiseChainMutex();
126
-
127
- /**
128
- * Run `fn` holding the users lock — the one lock around every
129
- * read-modify-write of users.yml and every change of the directory in this
130
- * process (core's users functions take none).
131
- *
132
- * @template T
133
- * @param {() => Promise<T>} fn
134
- * @returns {Promise<T>}
135
- */
136
- function withUsersLock(fn) {
137
- return usersMutex.run(fn);
138
- }
139
-
140
134
  // ---------------------------------------------------------------------------
141
135
  // Reading
142
136
  // ---------------------------------------------------------------------------
@@ -533,9 +527,11 @@ function assertNotEmpty(snapshot, live) {
533
527
  }
534
528
 
535
529
  /**
536
- * {@link reconcileDirectory} with the users lock already held.
530
+ * {@link reconcileDirectory} with the users lock already held. A user it
531
+ * creates becomes a member of `domains[username]` when given, else of the
532
+ * relay's one domain (none with two or more).
537
533
  *
538
- * @param {{ actor: string, logger?: import('pino').Logger, deleted?: string[], confirmDelayMs?: number }} opts
534
+ * @param {{ actor: string, logger?: import('pino').Logger, deleted?: string[], confirmDelayMs?: number, domains?: Readonly<Record<string, readonly string[]>> }} opts
539
535
  * @returns {Promise<DirectoryReconcileResult>}
540
536
  */
541
537
  async function reconcileUnlocked({
@@ -543,6 +539,7 @@ async function reconcileUnlocked({
543
539
  logger,
544
540
  deleted = [],
545
541
  confirmDelayMs = DIRECTORY_CONFIRM_DELAY_MS,
542
+ domains = {},
546
543
  }) {
547
544
  const db = await getStateDb();
548
545
  const live = db
@@ -566,6 +563,12 @@ async function reconcileUnlocked({
566
563
  const missing = live.filter((u) => absentFirst.has(u.username) && !snapshot.has(u.username));
567
564
 
568
565
  const now = new Date().toISOString();
566
+ // The domains of a user this reconcile creates without being told theirs:
567
+ // read only when it creates one.
568
+ const creating = [...snapshot.keys()].some(
569
+ (username) => !Object.hasOwn(domains, username) && !live.some((u) => u.username === username),
570
+ );
571
+ const defaultDomains = creating ? await domainsForNewUser(undefined) : [];
569
572
  const events = [];
570
573
  const result = { created: 0, updated: 0, removed: [], disabled: [] };
571
574
  const selectLive = db.prepare(
@@ -594,6 +597,7 @@ async function reconcileUnlocked({
594
597
  remove.run(reason, now, now, user.userId);
595
598
  result.removed.push({ userId: user.userId, username: user.username, reason });
596
599
  events.push({ action: 'directory.user.removed', details: { userId: user.userId, reason } });
600
+ events.push(...deleteMembershipsSync(db, user.userId, reason));
597
601
  }
598
602
  for (const [username, attrs] of snapshot) {
599
603
  const row = selectLive.get(username);
@@ -605,6 +609,8 @@ async function reconcileUnlocked({
605
609
  action: 'directory.user.created',
606
610
  details: { userId, status: attrs.status },
607
611
  });
612
+ const memberOf = Object.hasOwn(domains, username) ? domains[username] : defaultDomains;
613
+ events.push(...insertMembershipsSync(db, userId, memberOf, { actor, now }));
608
614
  continue;
609
615
  }
610
616
  const changed = [];
@@ -654,7 +660,7 @@ async function reconcileUnlocked({
654
660
  * before the username is given to anyone). Each step is best effort; what
655
661
  * failed comes back as `warnings` (`{ step, message }`, never naming the
656
662
  * user). Gatekeeper's cached session verdicts are not dropped here: the
657
- * caller does that once, after Authelia restarted where it restarts it.
663
+ * caller does that once, after the domains were rendered.
658
664
  *
659
665
  * @param {string} userId
660
666
  * @param {{ kind: 'disabled' | 'deleted', actor: string, logger?: import('pino').Logger }} opts
@@ -798,16 +804,21 @@ export function reconcileDirectory({ actor = 'lamaste', logger, confirmDelayMs }
798
804
  * that cannot be done: a new user never inherits an earlier one's grants,
799
805
  * groups or second factors);
800
806
  * 3. `mutate`, then reconcile the directory (`deleted` names the usernames
801
- * `mutate` deletes: removed as `deleted`), offboarding every user that
807
+ * `mutate` deletes: removed as `deleted`; `domains` the domains of the
808
+ * users it creates — lib/domains.js), offboarding every user that
802
809
  * reconcile saw removed or newly disabled ({@link offboardUserUnlocked});
803
- * 4. restart Authelia (unless `restartAuthelia` is false: onboarding starts
804
- * it next) — which ends every session — and drop Gatekeeper's cached
805
- * session verdicts with them;
810
+ * 4. render the domains' members (lib/domains.js `syncDomainAccess`: the
811
+ * owned groups in users.yml, the projection, Authelia's configuration)
812
+ * and drop Gatekeeper's cached session verdicts. Authelia is not
813
+ * restarted: it watches users.yml, and every user's next request sees
814
+ * the change — a disabled or deleted user's session ends there. With
815
+ * `renderDomains: false` (onboarding, before any domain or Gatekeeper
816
+ * exists) neither happens: the caller renders;
806
817
  * 5. with `expect` (`(users) => boolean` over users.yml's `users` map: does
807
818
  * the file hold the intended change?), read users.yml again: Authelia's
808
- * own password reset rewrites the file from what it read at start, so a
809
- * write made just before its restart can be lost. When the change is not
810
- * there it is made once more (steps 3 and 4); when it is still not
819
+ * own password reset rewrites the file from what it last read, so a
820
+ * write made within its reload cooldown can be lost. When the change is
821
+ * not there it is made once more (steps 3 and 4); when it is still not
811
822
  * there, `persisted` is false and a `persist` warning says so.
812
823
  *
813
824
  * Then the plugins' audiences are recomputed. A refusal `mutate` (or step
@@ -818,7 +829,7 @@ export function reconcileDirectory({ actor = 'lamaste', logger, confirmDelayMs }
818
829
  *
819
830
  * @template T
820
831
  * @param {() => Promise<T>} mutate
821
- * @param {{ actor: string, logger?: import('pino').Logger, creates?: string[], deleted?: string[], restartAuthelia?: boolean, autheliaStorage?: boolean, confirmDelayMs?: number, expect?: (users: Record<string, unknown>) => boolean }} opts
832
+ * @param {{ actor: string, logger?: import('pino').Logger, creates?: string[], deleted?: string[], domains?: Readonly<Record<string, ReadonlyArray<string | { name: string, kind: 'member' | 'external' }>>>, renderDomains?: boolean, autheliaStorage?: boolean, confirmDelayMs?: number, expect?: (users: Record<string, unknown>) => boolean }} opts
822
833
  * @returns {Promise<{ result: T, directory: DirectoryReconcileResult | null, warnings: Array<{ step: string, message: string }>, persisted: boolean }>}
823
834
  */
824
835
  export async function changeUsers(
@@ -828,7 +839,8 @@ export async function changeUsers(
828
839
  logger,
829
840
  creates = [],
830
841
  deleted = [],
831
- restartAuthelia = true,
842
+ domains = {},
843
+ renderDomains = true,
832
844
  autheliaStorage = true,
833
845
  confirmDelayMs,
834
846
  expect,
@@ -840,7 +852,7 @@ export async function changeUsers(
840
852
  const settle = async () => {
841
853
  let reconciled = null;
842
854
  try {
843
- reconciled = await reconcileUnlocked({ actor, logger, deleted, confirmDelayMs });
855
+ reconciled = await reconcileUnlocked({ actor, logger, deleted, confirmDelayMs, domains });
844
856
  } catch (err) {
845
857
  logger?.error({ err: err.message }, 'Could not bring the user directory in line');
846
858
  warnings.push({
@@ -868,13 +880,16 @@ export async function changeUsers(
868
880
  return reconciled;
869
881
  };
870
882
 
871
- const restart = async () => {
872
- if (!restartAuthelia) return;
883
+ const render = async () => {
884
+ if (!renderDomains) return;
873
885
  try {
874
- await reloadAuthelia();
886
+ await syncDomainAccess({ logger });
875
887
  } catch (err) {
876
- logger?.warn({ err: err.message }, 'Failed to restart Authelia after a users change');
877
- warnings.push({ step: 'authelia', message: `Authelia was not restarted: ${err.message}` });
888
+ logger?.error({ err: err.message }, 'Rendering domain access after a users change failed');
889
+ warnings.push({
890
+ step: 'domains',
891
+ message: `Who may reach each domain was not rendered (${err.message}); the panel renders it again within a minute`,
892
+ });
878
893
  }
879
894
  warnings.push(...(await bustCachedSessions(logger)));
880
895
  };
@@ -907,13 +922,13 @@ export async function changeUsers(
907
922
  }
908
923
  let value = await mutate();
909
924
  let reconciled = await settle();
910
- await restart();
925
+ await render();
911
926
 
912
927
  let persisted = true;
913
928
  if (expect && !(await holds())) {
914
929
  // Authelia rewrote users.yml over the change (its own password reset
915
- // writes the file from what it read at start): make it once more.
916
- logger?.warn('users.yml lost a change while Authelia restarted — making it again');
930
+ // writes the file from what it last read): make it once more.
931
+ logger?.warn('users.yml lost a change to a write of Authelia — making it again');
917
932
  try {
918
933
  value = await mutate();
919
934
  const again = await settle();
@@ -927,7 +942,7 @@ export async function changeUsers(
927
942
  }
928
943
  : again;
929
944
  }
930
- await restart();
945
+ await render();
931
946
  persisted = await holds();
932
947
  } catch (err) {
933
948
  logger?.warn({ err: err.message }, 'Making a lost users change again failed');