@lamalibre/create-lamaste 4.4.0 → 4.5.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 (150) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +200 -16
  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/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +83 -189
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugin-packages.js +30 -22
  125. package/vendor/serverd/src/lib/plugins.js +94 -15
  126. package/vendor/serverd/src/lib/registry-credential.js +46 -6
  127. package/vendor/serverd/src/lib/request-logging.js +19 -0
  128. package/vendor/serverd/src/lib/retained-data.js +50 -4
  129. package/vendor/serverd/src/lib/state.js +18 -0
  130. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  131. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  132. package/vendor/serverd/src/lib/user-caller.js +67 -0
  133. package/vendor/serverd/src/lib/user-surface.js +733 -0
  134. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  135. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  136. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  137. package/vendor/serverd/src/routes/invite.js +53 -12
  138. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  139. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  140. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  141. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  142. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  143. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  144. package/vendor/serverd/src/routes/management/users.js +290 -208
  145. package/vendor/serverd/src/routes/management.js +4 -0
  146. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  147. package/vendor/serverd/src/routes/user-access.js +17 -4
  148. package/vendor/serverd/src/routes/user-surface.js +69 -0
  149. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  150. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
@@ -0,0 +1,905 @@
1
+ /**
2
+ * The identity feed — each plugin's audience (`lib/audience.js`) pushed to
3
+ * its agent half through the internal tunnel, which the agent half cannot
4
+ * reach the panel through (core `identity.ts`):
5
+ *
6
+ * POST /admin/identity/notify { protocol, directoryId, seq: head }
7
+ * → { protocol, directoryId, cursor } what the half holds
8
+ * POST /admin/identity/changes { protocol, directoryId, from, to, reset,
9
+ * reconcile, pass, final, changes: sealed }
10
+ *
11
+ * Lamaste's own calls (`callAgentHalf`: caller `lamaste`, the body hash
12
+ * bound, the answer authenticated by its responder signature over the
13
+ * body). A batch's `changes` are sealed to the request (core
14
+ * `sealIdentityChanges`: AES-256-GCM, a key derived from the installation
15
+ * secret, bound to the assertion's nonce and the envelope), so neither the
16
+ * chisel server nor anything holding a loopback end of the tunnel sees a
17
+ * member in clear. A half is *ready* for the installed version once it has
18
+ * applied everything up to the head — a sync that ends with its cursor at
19
+ * the head after at least one complete replay since it was hosted
20
+ * (`ready_version`): users reach its `/app` only then
21
+ * ({@link identityReadiness}), never with a half that answered the
22
+ * notification but is still behind. It gets what it lacks:
23
+ *
24
+ * - **incremental** — the rows stamped after its cursor, in batches of at
25
+ * most `IDENTITY_BATCH_MAX_CHANGES` changes and `IDENTITY_BATCH_MAX_BYTES`;
26
+ * - **replay from 0** — every row, the first batch `reset` from 0, the
27
+ * others stamping the same `pass` (`reconcile`), the last `final`: the
28
+ * half then marks every row the pass did not mention as removed. A replay
29
+ * is the snapshot: for a half that follows another directory (or none), a
30
+ * cursor ahead of the head, a half that has not completed one (the first
31
+ * sync since the plugin was installed or hosted, or after a replay was
32
+ * cut short), and the daily reconcile — every batch `reconcile`, the
33
+ * mismatches the half reports recorded and audited
34
+ * (`identity.reconcile.mismatches`) when there are any.
35
+ *
36
+ * When a hosting is removed, what the panel knew of its half goes with it
37
+ * ({@link forgetIdentityHalf}).
38
+ *
39
+ * A 409 `cursor_mismatch` restarts from the cursor the half reports (or
40
+ * replays, when it follows another directory). One sync per plugin at a
41
+ * time; requests made while one runs share the next. Preconditions —
42
+ * installed, declares `identity` (nothing identity is ever sent to a plugin
43
+ * that does not: a half without the routes answers an unsigned 404), its
44
+ * installed version approved, hosted, the hosting active and not down —
45
+ * are checked first, and a sync that cannot run records why. Triggers: a
46
+ * recompute that changed the plugin's rows, its half coming up
47
+ * (`tunnel-hooks.js`), a hosting or update that succeeded, approval, the
48
+ * panel's start, a minute's tick for a plugin that is behind or failed, the
49
+ * daily reconcile, and the manual route.
50
+ *
51
+ * Logs carry the plugin, counts and cursors — never a member's attributes.
52
+ */
53
+
54
+ import crypto from 'node:crypto';
55
+ import {
56
+ AGENT_HALF_IDENTITY_CHANGES_PATH,
57
+ AGENT_HALF_IDENTITY_NOTIFY_PATH,
58
+ AudienceChangeSchema,
59
+ IDENTITY_ANSWER_MAX_BYTES,
60
+ IDENTITY_BATCH_MAX_BYTES,
61
+ IDENTITY_BATCH_MAX_CHANGES,
62
+ IDENTITY_PROTOCOL_VERSION,
63
+ callAgentHalf,
64
+ parseIdentityChangesAnswer,
65
+ parseIdentityCursorConflict,
66
+ parseIdentityNotifyAnswer,
67
+ sealIdentityChanges,
68
+ } from '@lamalibre/lamaste';
69
+ import { pluginHostingPhase, signingSecretFor } from '@lamalibre/lamaste/server';
70
+ import { recordAuditBestEffort } from './audit.js';
71
+ import {
72
+ audienceHead,
73
+ audienceRowsAfter,
74
+ audiencesIdle,
75
+ audienceSummary,
76
+ recomputeAudiences,
77
+ } from './audience.js';
78
+ import { getConfig } from './config.js';
79
+ import { clearPendingPrincipals, getDirectoryId, reconcileDirectory } from './directory.js';
80
+ import { installedVersionApproved } from './plugin-hooks.js';
81
+ import { findPlugin, findPluginByRoute, readPlugins } from './plugins.js';
82
+ import { retainedIdentityPositions } from './retained-data.js';
83
+ import { getStateDb } from './state-db.js';
84
+
85
+ /** How long the half may take to answer a notification. */
86
+ const IDENTITY_NOTIFY_TIMEOUT_MS = 5_000;
87
+
88
+ /** How long the half may take to apply a batch (up to 1 MiB of changes). */
89
+ const IDENTITY_CHANGES_TIMEOUT_MS = 15_000;
90
+
91
+ /** The daily reconcile: a full replay at most this long after the last. */
92
+ const IDENTITY_RECONCILE_INTERVAL_MS = 24 * 60 * 60_000;
93
+
94
+ /** How often the panel reconciles the directory, recomputes the audiences and retries syncs. */
95
+ const IDENTITY_TICK_INTERVAL_MS = 60_000;
96
+
97
+ /** How many times one sync restarts after a 409 before it gives up until the next trigger. */
98
+ const SYNC_ATTEMPTS = 3;
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Sync state
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * @typedef {object} IdentitySyncState
106
+ * @property {string | null} directoryId the directory the half follows, as it last answered
107
+ * @property {number | null} cursor the seq it has applied up to, as it last answered
108
+ * @property {string | null} readyVersion the plugin version whose half applied everything up to
109
+ * the head after a complete replay
110
+ * @property {string | null} lastSyncAt the last sync that left the half up to date
111
+ * @property {string | null} lastReconcileAt the last full replay
112
+ * @property {number | null} lastMismatches what the last daily reconcile found
113
+ * @property {string | null} lastError why the last sync did not finish, or was not tried
114
+ * @property {string} updatedAt
115
+ */
116
+
117
+ function rowToState(row) {
118
+ if (!row) return null;
119
+ return {
120
+ directoryId: row.directory_id,
121
+ cursor: row.cursor === null ? null : Number(row.cursor),
122
+ readyVersion: row.ready_version,
123
+ lastSyncAt: row.last_sync_at,
124
+ lastReconcileAt: row.last_reconcile_at,
125
+ lastMismatches: row.last_mismatches === null ? null : Number(row.last_mismatches),
126
+ lastError: row.last_error,
127
+ updatedAt: row.updated_at,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * What the panel knows of plugin `name`'s feed, or null before its first
133
+ * sync.
134
+ *
135
+ * @param {string} name
136
+ * @returns {Promise<IdentitySyncState | null>}
137
+ */
138
+ export async function readIdentitySyncState(name) {
139
+ const db = await getStateDb();
140
+ return rowToState(db.prepare('SELECT * FROM plugin_identity_sync WHERE plugin = ?').get(name));
141
+ }
142
+
143
+ /** Merge `patch` into plugin `name`'s sync state. */
144
+ async function writeSyncState(name, patch) {
145
+ const db = await getStateDb();
146
+ const current = (await readIdentitySyncState(name)) ?? {
147
+ directoryId: null,
148
+ cursor: null,
149
+ readyVersion: null,
150
+ lastSyncAt: null,
151
+ lastReconcileAt: null,
152
+ lastMismatches: null,
153
+ lastError: null,
154
+ };
155
+ const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
156
+ db.prepare(
157
+ `INSERT INTO plugin_identity_sync
158
+ (plugin, directory_id, cursor, ready_version, last_sync_at, last_reconcile_at,
159
+ last_mismatches, last_error, updated_at)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
161
+ ON CONFLICT(plugin) DO UPDATE SET
162
+ directory_id = excluded.directory_id,
163
+ cursor = excluded.cursor,
164
+ ready_version = excluded.ready_version,
165
+ last_sync_at = excluded.last_sync_at,
166
+ last_reconcile_at = excluded.last_reconcile_at,
167
+ last_mismatches = excluded.last_mismatches,
168
+ last_error = excluded.last_error,
169
+ updated_at = excluded.updated_at`,
170
+ ).run(
171
+ name,
172
+ next.directoryId,
173
+ next.cursor,
174
+ next.readyVersion,
175
+ next.lastSyncAt,
176
+ next.lastReconcileAt,
177
+ next.lastMismatches,
178
+ next.lastError,
179
+ next.updatedAt,
180
+ );
181
+ return next;
182
+ }
183
+
184
+ /**
185
+ * Forget what the panel knew of plugin `name`'s agent half: its hosting is
186
+ * gone. The half of a later hosting gets the whole audience (a replay) and
187
+ * is ready for users only once it has applied it. Where the removed half's
188
+ * projection stood went with its data, when an agent retains that
189
+ * (`recordRetainedData`), so the erasures owed there stay pending. The
190
+ * audience itself stays: it is the plugin's ledger, not the hosting's.
191
+ *
192
+ * @param {string} name
193
+ */
194
+ export async function forgetIdentityHalf(name) {
195
+ const db = await getStateDb();
196
+ db.prepare('DELETE FROM plugin_identity_sync WHERE plugin = ?').run(name);
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Readiness and pending erasures
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** The hosting installed plugin `plugin` owns, or null (see `ownHosting`). */
204
+ async function ownHostingOf(plugin) {
205
+ const { ownHosting } = await import('./plugin-hosting.js');
206
+ return ownHosting(plugin);
207
+ }
208
+
209
+ /**
210
+ * @typedef {object} IdentityReadiness
211
+ * @property {boolean} ready users may reach the plugin's `/app`
212
+ * @property {boolean} declared the installed version declares `identity`
213
+ * @property {boolean} approved the installed version is approved
214
+ * @property {boolean} hostingActive its hosting is active
215
+ * @property {string | null} version the installed version
216
+ * @property {string | null} readyVersion the version whose half last applied everything up to
217
+ * the head after a complete replay
218
+ * @property {'not_installed' | 'no_identity' | 'not_approved' | 'not_hosted' | 'not_answered' | null} reason
219
+ */
220
+
221
+ /**
222
+ * Whether the plugin on `route` is ready for user callers: it declares
223
+ * `identity`, its installed version is approved, its hosting is active, and
224
+ * its agent half — since this version was installed — applied every change
225
+ * up to the head after a complete replay (answering protocol 1 alone is not
226
+ * enough: a half that is still behind would serve stale roles and names).
227
+ *
228
+ * @param {string} route
229
+ * @returns {Promise<IdentityReadiness>}
230
+ */
231
+ export async function identityReadiness(route) {
232
+ return readinessOf(await findPluginByRoute(route));
233
+ }
234
+
235
+ /** {@link identityReadiness} of an installed plugin's registry entry (null: not installed). */
236
+ async function readinessOf(plugin) {
237
+ if (!plugin?.identity) {
238
+ return {
239
+ ready: false,
240
+ declared: false,
241
+ approved: false,
242
+ hostingActive: false,
243
+ version: plugin?.version ?? null,
244
+ readyVersion: null,
245
+ reason: plugin ? 'no_identity' : 'not_installed',
246
+ };
247
+ }
248
+ // A version whose approval cannot even be judged (an agent half without
249
+ // its pinned integrity) is not approved.
250
+ const approved = await installedVersionApproved(plugin).catch(() => false);
251
+ const record = await ownHostingOf(plugin);
252
+ const hostingActive = record !== null && pluginHostingPhase(record) === 'active';
253
+ const state = await readIdentitySyncState(plugin.name);
254
+ const readyVersion = state?.readyVersion ?? null;
255
+ const reason = !approved
256
+ ? 'not_approved'
257
+ : !hostingActive
258
+ ? 'not_hosted'
259
+ : readyVersion !== plugin.version
260
+ ? 'not_answered'
261
+ : null;
262
+ return {
263
+ ready: reason === null,
264
+ declared: true,
265
+ approved,
266
+ hostingActive,
267
+ version: plugin.version,
268
+ readyVersion,
269
+ reason,
270
+ };
271
+ }
272
+
273
+ /** The seq up to which the half holds this directory's feed (0 when it follows another, or none). */
274
+ function acknowledgedCursor(state, directoryId) {
275
+ return state && state.directoryId === directoryId && state.cursor !== null ? state.cursor : 0;
276
+ }
277
+
278
+ /**
279
+ * The erasures plugin `name` has not acknowledged — wherever its data is:
280
+ * while it is installed, the `remove/erased` rows of its ledger stamped
281
+ * after the cursor its hosting's agent half last answered (every one when
282
+ * no half has: the next hosting's half is sent them); for every agent that
283
+ * retains data of it, those stamped after the position recorded with that
284
+ * data (`retained-data.js`; every one when none was). User ids in `seq`
285
+ * order, each once. An uninstalled plugin no agent keeps data of owes
286
+ * nothing.
287
+ *
288
+ * @param {string} name
289
+ * @returns {Promise<string[]>}
290
+ */
291
+ export async function pendingErasures(name) {
292
+ const directoryId = await getDirectoryId();
293
+ const cursors = [];
294
+ if (await findPlugin(name)) {
295
+ cursors.push(acknowledgedCursor(await readIdentitySyncState(name), directoryId));
296
+ }
297
+ for (const position of await retainedIdentityPositions(name)) {
298
+ cursors.push(
299
+ position.directoryId === directoryId && position.cursor !== null ? position.cursor : 0,
300
+ );
301
+ }
302
+ if (cursors.length === 0) return [];
303
+ const db = await getStateDb();
304
+ return db
305
+ .prepare(
306
+ `SELECT user_id FROM plugin_audience
307
+ WHERE plugin = ? AND json_extract(change, '$.reason') = 'erased' AND seq > ?
308
+ ORDER BY seq`,
309
+ )
310
+ .all(name, Math.min(...cursors))
311
+ .map((row) => row.user_id);
312
+ }
313
+
314
+ /**
315
+ * For every erased user some plugin has not acknowledged yet, those
316
+ * plugins: `{ plugin, inactive }` — `inactive` when the plugin is not
317
+ * installed or no longer declares identity, so the feed does not reach it:
318
+ * an agent keeps its data (retained, or a hosted half that no longer takes
319
+ * the feed), and the erasure reaches that data when the plugin comes back
320
+ * — or goes with it when the data is purged.
321
+ *
322
+ * @returns {Promise<Map<string, Array<{ plugin: string, inactive: boolean }>>>}
323
+ */
324
+ export async function pendingErasuresByUser() {
325
+ const db = await getStateDb();
326
+ const plugins = db
327
+ .prepare(
328
+ `SELECT DISTINCT plugin FROM plugin_audience
329
+ WHERE json_extract(change, '$.reason') = 'erased' ORDER BY plugin`,
330
+ )
331
+ .all()
332
+ .map((row) => row.plugin);
333
+ const out = new Map();
334
+ for (const plugin of plugins) {
335
+ const inactive = !(await findPlugin(plugin))?.identity;
336
+ for (const userId of await pendingErasures(plugin)) {
337
+ out.set(userId, [...(out.get(userId) ?? []), { plugin, inactive }]);
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * Everything the panel shows about plugin `name`'s identity: the declared
345
+ * block, readiness, the feed's state (with the head and how far behind the
346
+ * half is), the audience counts and the erasures pending. Null when the
347
+ * plugin is not installed.
348
+ *
349
+ * @param {string} name
350
+ */
351
+ export async function identityView(name) {
352
+ const plugin = await findPlugin(name);
353
+ if (!plugin) return null;
354
+ const readiness = await readinessOf(plugin);
355
+ if (!plugin.identity) {
356
+ // A plugin that stopped declaring identity may still owe erasures (its ledger).
357
+ return {
358
+ declared: null,
359
+ readiness,
360
+ sync: null,
361
+ audience: null,
362
+ pendingErasures: await pendingErasures(name),
363
+ };
364
+ }
365
+ const directoryId = await getDirectoryId();
366
+ const state = await readIdentitySyncState(name);
367
+ const summary = await audienceSummary(name);
368
+ const cursor = acknowledgedCursor(state, directoryId);
369
+ return {
370
+ declared: plugin.identity,
371
+ readiness,
372
+ sync: {
373
+ directoryId,
374
+ halfDirectoryId: state?.directoryId ?? null,
375
+ cursor: state?.cursor ?? null,
376
+ head: summary.head,
377
+ behind: (await audienceRowsAfter(name, cursor)).length,
378
+ readyVersion: state?.readyVersion ?? null,
379
+ lastSyncAt: state?.lastSyncAt ?? null,
380
+ lastReconcileAt: state?.lastReconcileAt ?? null,
381
+ lastMismatches: state?.lastMismatches ?? null,
382
+ lastError: state?.lastError ?? null,
383
+ },
384
+ audience: {
385
+ active: summary.active,
386
+ disabled: summary.disabled,
387
+ removed: summary.removed,
388
+ erased: summary.erased,
389
+ },
390
+ pendingErasures: await pendingErasures(name),
391
+ };
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // One sync
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * The bytes of a batch request at its largest without its ciphertext:
400
+ * numbers at their widest, flags `false`, the sealed changes' `v` and IV.
401
+ */
402
+ function envelopeBytes(directoryId) {
403
+ return Buffer.byteLength(
404
+ JSON.stringify({
405
+ protocol: IDENTITY_PROTOCOL_VERSION,
406
+ directoryId,
407
+ from: Number.MAX_SAFE_INTEGER,
408
+ to: Number.MAX_SAFE_INTEGER,
409
+ reset: false,
410
+ reconcile: false,
411
+ pass: crypto.randomUUID(),
412
+ final: false,
413
+ changes: { v: 1, iv: 'A'.repeat(16), ct: '' },
414
+ }),
415
+ );
416
+ }
417
+
418
+ /**
419
+ * The largest JSON array of changes whose sealed request stays within
420
+ * `IDENTITY_BATCH_MAX_BYTES` (the ciphertext is base64url: four characters
421
+ * for every three bytes, the tag included).
422
+ */
423
+ function maxChangesBytes(directoryId) {
424
+ const room = IDENTITY_BATCH_MAX_BYTES - envelopeBytes(directoryId);
425
+ // ceil((n + 16) * 4 / 3) <= room for this n (the 16 bytes: GCM's tag).
426
+ return Math.floor((room * 3) / 4) - 16;
427
+ }
428
+
429
+ /**
430
+ * The stored rows as the feed's changes, in batches of at most
431
+ * `IDENTITY_BATCH_MAX_CHANGES` changes whose sealed request stays within
432
+ * `IDENTITY_BATCH_MAX_BYTES`. Throws on a row that is not the contract.
433
+ */
434
+ function batchesOf(rows, directoryId) {
435
+ const limit = maxChangesBytes(directoryId);
436
+ const batches = [];
437
+ let current = [];
438
+ // The array's brackets.
439
+ let size = 2;
440
+ for (const row of rows) {
441
+ const change = { seq: row.seq, ...row.change };
442
+ if (!AudienceChangeSchema.safeParse(change).success) {
443
+ const err = new Error(
444
+ `The audience holds a row that is not an identity change (user ${row.userId})`,
445
+ );
446
+ err.code = 'corrupt_row';
447
+ throw err;
448
+ }
449
+ // One more byte for the comma between changes.
450
+ const bytes = Buffer.byteLength(JSON.stringify(change)) + 1;
451
+ if (
452
+ current.length > 0 &&
453
+ (current.length >= IDENTITY_BATCH_MAX_CHANGES || size + bytes > limit)
454
+ ) {
455
+ batches.push(current);
456
+ current = [];
457
+ size = 2;
458
+ }
459
+ current.push(change);
460
+ size += bytes;
461
+ }
462
+ if (current.length > 0) batches.push(current);
463
+ return batches;
464
+ }
465
+
466
+ /** What an answer that is not the contract is called — never its body. */
467
+ function describeFailure(result) {
468
+ if (!result.ok) return `${result.code}: ${result.error}`;
469
+ if (result.status === 404) return 'the agent half does not take the identity feed (404)';
470
+ if (result.status === 400) {
471
+ const protocol = result.body?.protocol;
472
+ return protocol != null && protocol !== IDENTITY_PROTOCOL_VERSION
473
+ ? `the agent half speaks identity protocol ${protocol}, not ${IDENTITY_PROTOCOL_VERSION}`
474
+ : 'the agent half refused the identity call (400)';
475
+ }
476
+ return `the agent half answered the identity call with status ${result.status}`;
477
+ }
478
+
479
+ /**
480
+ * Send `batches` from `from`: a replay (`replay`) resets with its first
481
+ * batch and stamps `pass` with every batch, the last `final`. Each batch's
482
+ * changes are sealed (`seal`) to the request that carries them. Returns
483
+ * `{ kind: 'done', cursor, mismatches }`, `{ kind: 'conflict', position }`
484
+ * (a 409 with what the half holds) or `{ kind: 'failed', error }`.
485
+ */
486
+ async function sendBatches(
487
+ call,
488
+ seal,
489
+ { name, directoryId, batches, from, replay, reconcile, logger },
490
+ ) {
491
+ const pass = crypto.randomUUID();
492
+ let cursor = from;
493
+ let mismatches = 0;
494
+ // A replay with nothing to say still resets and ends the pass.
495
+ const list = batches.length > 0 ? batches : replay ? [[]] : [];
496
+ for (let i = 0; i < list.length; i += 1) {
497
+ const changes = list[i];
498
+ const to = changes.length > 0 ? changes[changes.length - 1].seq : cursor;
499
+ const envelope = {
500
+ directoryId,
501
+ from: cursor,
502
+ to,
503
+ reset: replay && i === 0,
504
+ reconcile: replay && (reconcile || i > 0),
505
+ pass,
506
+ final: replay && i === list.length - 1,
507
+ };
508
+ const result = await call(
509
+ AGENT_HALF_IDENTITY_CHANGES_PATH,
510
+ // Sealed to the nonce of the very request that carries them.
511
+ (nonce) => ({
512
+ protocol: IDENTITY_PROTOCOL_VERSION,
513
+ ...envelope,
514
+ changes: seal(nonce, envelope, changes),
515
+ }),
516
+ IDENTITY_CHANGES_TIMEOUT_MS,
517
+ );
518
+ if (result.ok && result.status === 409) {
519
+ const conflict = parseIdentityCursorConflict(result.body);
520
+ if (!conflict)
521
+ return { kind: 'failed', error: 'the agent half answered 409 in an unexpected shape' };
522
+ return {
523
+ kind: 'conflict',
524
+ position: { directoryId: conflict.directoryId, cursor: conflict.cursor },
525
+ };
526
+ }
527
+ if (!result.ok || result.status !== 200) {
528
+ return { kind: 'failed', error: describeFailure(result) };
529
+ }
530
+ const answer = parseIdentityChangesAnswer(result.body);
531
+ if (!answer || answer.directoryId !== directoryId || answer.cursor !== to) {
532
+ return { kind: 'failed', error: 'the agent half answered a batch with another cursor' };
533
+ }
534
+ cursor = to;
535
+ mismatches += answer.mismatches ?? 0;
536
+ await writeSyncState(name, { directoryId, cursor });
537
+ logger?.info(
538
+ { plugin: name, from: envelope.from, to, changes: changes.length, replay, reconcile },
539
+ 'Sent an identity batch',
540
+ );
541
+ }
542
+ return { kind: 'done', cursor, mismatches };
543
+ }
544
+
545
+ /**
546
+ * @typedef {object} IdentitySyncResult
547
+ * @property {'synced' | 'up-to-date' | 'skipped' | 'failed'} status
548
+ * @property {string} [reason] why it was skipped
549
+ * @property {string} [error] why it failed (or was skipped)
550
+ * @property {number} [cursor] where the half is now
551
+ * @property {boolean} [replay] whether everything was sent from 0
552
+ * @property {number} [mismatches] what the reconcile found
553
+ */
554
+
555
+ /** Record why a sync did not run — written only when the reason changed. */
556
+ async function recordSkip(name, reason, message, logger) {
557
+ const state = await readIdentitySyncState(name);
558
+ if (state?.lastError !== message) {
559
+ await writeSyncState(name, { lastError: message });
560
+ logger?.info({ plugin: name, reason }, 'Identity sync not run');
561
+ }
562
+ return { status: 'skipped', reason, error: message };
563
+ }
564
+
565
+ async function recordFailure(name, error, logger) {
566
+ await writeSyncState(name, { lastError: error });
567
+ logger?.warn({ plugin: name, error }, 'Identity sync failed');
568
+ return { status: 'failed', error };
569
+ }
570
+
571
+ /** One sync of plugin `name` (see the module comment). */
572
+ async function syncOnce(name, { reconcile, logger }) {
573
+ const plugin = await findPlugin(name);
574
+ if (!plugin) return { status: 'skipped', reason: 'not_installed', error: 'not installed' };
575
+ // Nothing identity is ever sent to a plugin that does not declare it.
576
+ if (!plugin.identity) {
577
+ return { status: 'skipped', reason: 'no_identity', error: 'declares no identity' };
578
+ }
579
+ if (!(await installedVersionApproved(plugin))) {
580
+ return recordSkip(
581
+ name,
582
+ 'not_approved',
583
+ `Version ${plugin.version} waits for approval — its users are not sent until it is approved`,
584
+ logger,
585
+ );
586
+ }
587
+ const record = await ownHostingOf(plugin);
588
+ if (!record) {
589
+ return recordSkip(name, 'not_hosted', 'The plugin is not hosted on an agent', logger);
590
+ }
591
+ const phase = pluginHostingPhase(record);
592
+ if (phase !== 'active') {
593
+ return recordSkip(name, 'hosting_not_active', `The hosting is ${phase}`, logger);
594
+ }
595
+ if (record.health?.state === 'down') {
596
+ return recordSkip(
597
+ name,
598
+ 'down',
599
+ `The agent half is down (${record.health.reason ?? 'unknown'}) since ${record.health.since ?? 'unknown'}`,
600
+ logger,
601
+ );
602
+ }
603
+
604
+ const directoryId = await getDirectoryId();
605
+ const before = await readIdentitySyncState(name);
606
+ // One secret for the whole sync: requests are signed and changes sealed with it.
607
+ const secret = signingSecretFor(record);
608
+ const call = (path, body, timeoutMs) =>
609
+ callAgentHalf({
610
+ plugin: record.plugin,
611
+ port: record.port,
612
+ secret,
613
+ method: 'POST',
614
+ path,
615
+ body,
616
+ timeoutMs,
617
+ maxResponseBytes: IDENTITY_ANSWER_MAX_BYTES,
618
+ });
619
+ const seal = (nonce, envelope, changes) =>
620
+ sealIdentityChanges({ secret, plugin: record.plugin, nonce, envelope, changes });
621
+
622
+ const head = await audienceHead(name);
623
+ const notified = await call(
624
+ AGENT_HALF_IDENTITY_NOTIFY_PATH,
625
+ { protocol: IDENTITY_PROTOCOL_VERSION, directoryId, seq: head },
626
+ IDENTITY_NOTIFY_TIMEOUT_MS,
627
+ );
628
+ if (!notified.ok || notified.status !== 200) {
629
+ return recordFailure(name, describeFailure(notified), logger);
630
+ }
631
+ const answer = parseIdentityNotifyAnswer(notified.body);
632
+ if (!answer) {
633
+ return recordFailure(
634
+ name,
635
+ 'the agent half answered the notification in an unexpected shape',
636
+ logger,
637
+ );
638
+ }
639
+ // What the half holds. It is not ready for user callers yet: that waits
640
+ // until it has applied everything up to the head (below).
641
+ await writeSyncState(name, { directoryId: answer.directoryId, cursor: answer.cursor });
642
+
643
+ // A plugin that has not completed a full replay since it was installed
644
+ // (its first sync), or since a replay was cut short, replays — whatever
645
+ // its half holds was not built from this installation's audience.
646
+ const replayOwed = !before?.lastReconcileAt;
647
+ let position = { directoryId: answer.directoryId, cursor: answer.cursor };
648
+ for (let attempt = 0; attempt < SYNC_ATTEMPTS; attempt += 1) {
649
+ const rows = await audienceRowsAfter(name, 0);
650
+ const currentHead = rows.length > 0 ? rows[rows.length - 1].seq : 0;
651
+ const replay =
652
+ reconcile ||
653
+ replayOwed ||
654
+ position.directoryId !== directoryId ||
655
+ position.cursor > currentHead;
656
+ if (!replay && position.cursor === currentHead) {
657
+ // At the head, a complete replay behind it: ready for this version.
658
+ await writeSyncState(name, {
659
+ directoryId,
660
+ cursor: position.cursor,
661
+ readyVersion: plugin.version,
662
+ lastSyncAt: new Date().toISOString(),
663
+ lastError: null,
664
+ });
665
+ return { status: 'up-to-date', cursor: position.cursor, replay: false };
666
+ }
667
+ let batches;
668
+ try {
669
+ batches = batchesOf(
670
+ replay ? rows : rows.filter((row) => row.seq > position.cursor),
671
+ directoryId,
672
+ );
673
+ } catch (err) {
674
+ return recordFailure(name, err.message, logger);
675
+ }
676
+ // A replay cut short leaves a half that was reset but never told which
677
+ // rows the pass did not mention: until one completes, every sync replays.
678
+ if (replay) await writeSyncState(name, { lastReconcileAt: null });
679
+ const sent = await sendBatches(call, seal, {
680
+ name,
681
+ directoryId,
682
+ batches,
683
+ from: replay ? 0 : position.cursor,
684
+ replay,
685
+ reconcile,
686
+ logger,
687
+ });
688
+ if (sent.kind === 'failed') return recordFailure(name, sent.error, logger);
689
+ if (sent.kind === 'conflict') {
690
+ logger?.warn(
691
+ {
692
+ plugin: name,
693
+ cursor: sent.position.cursor,
694
+ sameDirectory: sent.position.directoryId === directoryId,
695
+ },
696
+ 'The agent half holds another cursor — continuing from it',
697
+ );
698
+ position = sent.position;
699
+ continue;
700
+ }
701
+ const now = new Date().toISOString();
702
+ // Every row up to the head applied (after a complete replay, this one or
703
+ // an earlier one): ready for this version.
704
+ await writeSyncState(name, {
705
+ directoryId,
706
+ cursor: sent.cursor,
707
+ readyVersion: plugin.version,
708
+ lastSyncAt: now,
709
+ lastError: null,
710
+ ...(replay
711
+ ? { lastReconcileAt: now, lastMismatches: reconcile ? sent.mismatches : null }
712
+ : {}),
713
+ });
714
+ if (reconcile && sent.mismatches > 0) {
715
+ logger?.warn(
716
+ { plugin: name, mismatches: sent.mismatches },
717
+ 'The identity reconcile found the agent half’s projection out of step',
718
+ );
719
+ await recordAuditBestEffort(
720
+ {
721
+ actor: 'lamaste',
722
+ action: 'identity.reconcile.mismatches',
723
+ plugin: name,
724
+ details: { mismatches: sent.mismatches, directoryId },
725
+ },
726
+ logger,
727
+ );
728
+ }
729
+ return {
730
+ status: 'synced',
731
+ cursor: sent.cursor,
732
+ replay,
733
+ ...(reconcile ? { mismatches: sent.mismatches } : {}),
734
+ };
735
+ }
736
+ return recordFailure(
737
+ name,
738
+ `the agent half answered ${SYNC_ATTEMPTS} batches with another cursor — will try again`,
739
+ logger,
740
+ );
741
+ }
742
+
743
+ // ---------------------------------------------------------------------------
744
+ // One sync per plugin at a time
745
+ // ---------------------------------------------------------------------------
746
+
747
+ /** plugin → { tail, queued: { reconcile, promise } | null } */
748
+ const syncs = new Map();
749
+ /** The logger of the latest request — background syncs log through it. */
750
+ let lastLogger = null;
751
+
752
+ /**
753
+ * Sync plugin `name`'s audience to its agent half. One sync per plugin at a
754
+ * time: a request made while one runs waits for the next, which every
755
+ * request made meanwhile shares (a `reconcile` among them makes it a
756
+ * reconcile). Never rejects.
757
+ *
758
+ * @param {string} name
759
+ * @param {{ reconcile?: boolean, logger?: import('pino').Logger }} [opts]
760
+ * @returns {Promise<IdentitySyncResult>}
761
+ */
762
+ export function syncPluginIdentity(name, { reconcile = false, logger } = {}) {
763
+ if (logger) lastLogger = logger;
764
+ let entry = syncs.get(name);
765
+ if (!entry) {
766
+ entry = { tail: Promise.resolve(), queued: null };
767
+ syncs.set(name, entry);
768
+ }
769
+ if (entry.queued) {
770
+ entry.queued.reconcile = entry.queued.reconcile || reconcile;
771
+ return entry.queued.promise;
772
+ }
773
+ const queued = { reconcile, promise: null };
774
+ queued.promise = entry.tail.then(async () => {
775
+ entry.queued = null;
776
+ try {
777
+ return await syncOnce(name, { reconcile: queued.reconcile, logger: lastLogger });
778
+ } catch (err) {
779
+ lastLogger?.error({ plugin: name, err: err.message }, 'Identity sync failed unexpectedly');
780
+ return { status: 'failed', error: err.message };
781
+ }
782
+ });
783
+ entry.queued = queued;
784
+ const tail = queued.promise.then(() => {
785
+ if (entry.tail === tail && entry.queued === null) syncs.delete(name);
786
+ });
787
+ entry.tail = tail;
788
+ return queued.promise;
789
+ }
790
+
791
+ /**
792
+ * After the plugin's identity may have changed (approval, a hosting, an
793
+ * update or its revert): once its installed version is approved, drop from
794
+ * its role grants every role that version does not declare (a role a
795
+ * manifest removed stays removed — `pruneUndeclaredRoles`); recompute the
796
+ * audiences; then sync the plugin when it declares identity. Never throws;
797
+ * null when nothing was synced.
798
+ *
799
+ * @param {string} name
800
+ * @param {import('pino').Logger} [logger]
801
+ * @returns {Promise<IdentitySyncResult | null>}
802
+ */
803
+ export async function refreshPluginIdentity(name, logger) {
804
+ let plugin = null;
805
+ try {
806
+ plugin = await findPlugin(name);
807
+ if (plugin && (await installedVersionApproved(plugin).catch(() => false))) {
808
+ const { pruneUndeclaredRoles } = await import('./entitlements.js');
809
+ await pruneUndeclaredRoles(plugin, { actor: 'lamaste', logger });
810
+ }
811
+ } catch (err) {
812
+ logger?.warn({ plugin: name, err: err.message }, 'Could not read the plugin to sync its users');
813
+ }
814
+ await recomputeAudiences({ logger });
815
+ return plugin?.identity ? syncPluginIdentity(name, { logger }) : null;
816
+ }
817
+
818
+ /** Resolves once no recompute and no sync runs or waits (tests). */
819
+ export async function identityIdle() {
820
+ for (;;) {
821
+ await audiencesIdle();
822
+ const tails = [...syncs.values()].map((entry) => entry.tail);
823
+ if (tails.length === 0) return;
824
+ await Promise.all(tails);
825
+ }
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // The minute's tick
830
+ // ---------------------------------------------------------------------------
831
+
832
+ /**
833
+ * Sync every plugin that declares identity and is behind — no state yet,
834
+ * another directory or cursor than the head, a version that has not
835
+ * answered, or a last sync that did not finish — and reconcile those whose
836
+ * last full replay is a day old.
837
+ */
838
+ async function syncDuePlugins(logger) {
839
+ const directoryId = await getDirectoryId();
840
+ const plugins = (await readPlugins()).plugins.filter((p) => p.identity);
841
+ const now = Date.now();
842
+ for (const plugin of plugins) {
843
+ const state = await readIdentitySyncState(plugin.name);
844
+ const reconcileDue =
845
+ state?.lastReconcileAt != null &&
846
+ now - Date.parse(state.lastReconcileAt) >= IDENTITY_RECONCILE_INTERVAL_MS;
847
+ const behind =
848
+ !state ||
849
+ state.directoryId !== directoryId ||
850
+ state.cursor !== (await audienceHead(plugin.name)) ||
851
+ state.readyVersion !== plugin.version ||
852
+ state.lastError !== null;
853
+ if (reconcileDue || behind) {
854
+ void syncPluginIdentity(plugin.name, { reconcile: reconcileDue, logger });
855
+ }
856
+ }
857
+ }
858
+
859
+ let tickTimer = null;
860
+ let ticking = false;
861
+
862
+ /**
863
+ * One tick: reconcile the directory with users.yml (once onboarding is
864
+ * done), clear the Gatekeeper principals of removed users still pending,
865
+ * recompute the audiences (catching Gatekeeper CLI edits), then start the
866
+ * syncs that are due. Never throws.
867
+ */
868
+ export async function identityTick(logger) {
869
+ if (ticking) return;
870
+ ticking = true;
871
+ try {
872
+ if (getConfig().onboarding?.status === 'COMPLETED') {
873
+ try {
874
+ await reconcileDirectory({ actor: 'lamaste', logger });
875
+ } catch (err) {
876
+ logger?.warn({ err: err.message }, 'Could not reconcile the user directory');
877
+ }
878
+ }
879
+ await clearPendingPrincipals({ logger });
880
+ await recomputeAudiences({ logger });
881
+ await syncDuePlugins(logger);
882
+ } catch (err) {
883
+ logger?.error({ err: err.message }, 'Identity tick failed');
884
+ } finally {
885
+ ticking = false;
886
+ }
887
+ }
888
+
889
+ /**
890
+ * Start the identity services at panel start: one tick now (reconcile,
891
+ * recompute, the first syncs), then one every minute. Idempotent.
892
+ *
893
+ * @param {import('pino').Logger} logger
894
+ */
895
+ export function startIdentityServices(logger) {
896
+ if (tickTimer) return;
897
+ void identityTick(logger);
898
+ tickTimer = setInterval(() => void identityTick(logger), IDENTITY_TICK_INTERVAL_MS);
899
+ tickTimer.unref();
900
+ }
901
+
902
+ export function stopIdentityServices() {
903
+ if (tickTimer) clearInterval(tickTimer);
904
+ tickTimer = null;
905
+ }