@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
@@ -0,0 +1,576 @@
1
+ /**
2
+ * Plugin audiences — who each agent-hosted plugin's users are, as its agent
3
+ * half is told (`identity.ts` in the core library: `AudienceChange`).
4
+ *
5
+ * For every installed plugin that declares `identity`, the audience is the
6
+ * directory's users who hold at least one of its roles: the union of the
7
+ * roles of their direct entitlements and of the Lamaste groups they are in
8
+ * (`lib/entitlements.js` — only plugin grants with roles count), restricted
9
+ * to the roles the installed version declares. A member is carried in full
10
+ * (`upsert`: id, username, the declared attributes the directory has, the
11
+ * roles, `active` or `disabled`, the record's version); a user who leaves is
12
+ * a `remove` — `unentitled` (lost every role) or `deleted` (removed from the
13
+ * directory), each with the last known declared attributes, or `erased`
14
+ * (scrubbed: nothing but the id). Users who were never members have no row.
15
+ *
16
+ * The audience is materialised in `plugin_audience` (state.db): one row per
17
+ * plugin and user, the change as the feed delivers it (without `seq`),
18
+ * written only when it differs from the stored one — its version goes up by
19
+ * one and the triggers stamp the next store-wide `seq`. A removal carries
20
+ * what the plugin last received about the user (less any attribute it no
21
+ * longer declares): what the directory learns about someone who left is no
22
+ * longer the plugin's.
23
+ *
24
+ * **The rows are the plugin's ledger** of everyone its agent halves were
25
+ * ever sent, and so of the erasures it owes. They stay when the plugin is
26
+ * uninstalled or stops declaring identity — its data stays on an agent
27
+ * (retained data, or a hosted half that no longer takes the feed) — and go
28
+ * only once no data of the plugin is left anywhere: it is not installed and
29
+ * no agent retains its data ({@link dropIdentityLedgerIfNoData}: an
30
+ * uninstall with purge, a purge or forget of retained data). Such an
31
+ * *inactive* plugin's rows are recomputed for erasures alone: erasing a
32
+ * user rewrites the user's row in every plugin that has one, inactive ones
33
+ * included (a new `seq`), so a plugin that comes back — reinstalled, its
34
+ * half reusing the data an agent kept, or declaring identity again —
35
+ * replays the erasure (its next hosting replays everything). The erasures an
36
+ * inactive plugin has not acknowledged stay pending, marked as such
37
+ * (`lib/identity-sync.js` `pendingErasuresByUser`).
38
+ *
39
+ * Recomputed after every change of users, of the directory and of
40
+ * entitlements made through the panel (the Gatekeeper proxy included), on
41
+ * plugin install, update and approval, every minute (Gatekeeper's own CLI
42
+ * edits), and at start. Recomputes are serialised and coalesce. Erasures
43
+ * reach every ledger first, whatever Gatekeeper answers; when Gatekeeper
44
+ * cannot be read the rest of the stored audience stays as it is, and the
45
+ * next recompute tries again. A declaring plugin whose rows changed is
46
+ * synced to its agent half (`lib/identity-sync.js`).
47
+ */
48
+
49
+ import {
50
+ AudienceChangeSchema,
51
+ AudienceMemberSchema,
52
+ IDENTITY_ATTRIBUTES,
53
+ isLamasteUsername,
54
+ } from '@lamalibre/lamaste';
55
+ import { listDirectoryUsers, pendingPrincipalCleanups } from './directory.js';
56
+ import { declaredRoleIds, grantRoles } from './entitlements.js';
57
+ import { listGatekeeperGroups, listPluginGrants } from './gatekeeper-client.js';
58
+ import { findPlugin, readPlugins } from './plugins.js';
59
+ import { getStateDb } from './state-db.js';
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Reading the stored audience
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function parseChange(text) {
66
+ try {
67
+ return JSON.parse(text);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * @typedef {object} AudienceRow
75
+ * @property {string} userId
76
+ * @property {object} change the AudienceChange without `seq`
77
+ * @property {number} version
78
+ * @property {number} seq
79
+ * @property {string} updatedAt
80
+ */
81
+
82
+ function rowToAudience(row) {
83
+ return {
84
+ userId: row.user_id,
85
+ change: parseChange(row.change),
86
+ version: row.version,
87
+ seq: row.seq,
88
+ updatedAt: row.updated_at,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The plugin's audience rows in `seq` order — members and tombstones.
94
+ *
95
+ * @param {string} plugin
96
+ * @returns {Promise<AudienceRow[]>}
97
+ */
98
+ export async function listAudience(plugin) {
99
+ const db = await getStateDb();
100
+ return db
101
+ .prepare('SELECT * FROM plugin_audience WHERE plugin = ? ORDER BY seq')
102
+ .all(plugin)
103
+ .map(rowToAudience);
104
+ }
105
+
106
+ /**
107
+ * The plugin's rows stamped after `cursor`, in `seq` order — what a half at
108
+ * that cursor has not applied.
109
+ *
110
+ * @param {string} plugin
111
+ * @param {number} cursor
112
+ * @returns {Promise<AudienceRow[]>}
113
+ */
114
+ export async function audienceRowsAfter(plugin, cursor) {
115
+ const db = await getStateDb();
116
+ return db
117
+ .prepare('SELECT * FROM plugin_audience WHERE plugin = ? AND seq > ? ORDER BY seq')
118
+ .all(plugin, cursor)
119
+ .map(rowToAudience);
120
+ }
121
+
122
+ /**
123
+ * The plugin's head: the highest `seq` of its rows, 0 when it has none.
124
+ *
125
+ * @param {string} plugin
126
+ */
127
+ export async function audienceHead(plugin) {
128
+ const db = await getStateDb();
129
+ const row = db
130
+ .prepare('SELECT COALESCE(MAX(seq), 0) AS head FROM plugin_audience WHERE plugin = ?')
131
+ .get(plugin);
132
+ return Number(row.head);
133
+ }
134
+
135
+ /**
136
+ * One user's row in the plugin's audience, or null.
137
+ *
138
+ * @param {string} plugin
139
+ * @param {string} userId
140
+ * @returns {Promise<AudienceRow | null>}
141
+ */
142
+ export async function readAudienceRow(plugin, userId) {
143
+ const db = await getStateDb();
144
+ const row = db
145
+ .prepare('SELECT * FROM plugin_audience WHERE plugin = ? AND user_id = ?')
146
+ .get(plugin, userId);
147
+ return row ? rowToAudience(row) : null;
148
+ }
149
+
150
+ /**
151
+ * How many of the plugin's rows are active members, disabled members,
152
+ * removed (deleted or unentitled) and erased; and its head.
153
+ *
154
+ * @param {string} plugin
155
+ */
156
+ export async function audienceSummary(plugin) {
157
+ const rows = await listAudience(plugin);
158
+ const summary = { active: 0, disabled: 0, removed: 0, erased: 0, head: 0 };
159
+ for (const row of rows) {
160
+ summary.head = Math.max(summary.head, row.seq);
161
+ const change = row.change;
162
+ if (change?.kind === 'upsert') {
163
+ if (change.member?.status === 'disabled') summary.disabled += 1;
164
+ else summary.active += 1;
165
+ } else if (change?.reason === 'erased') summary.erased += 1;
166
+ else summary.removed += 1;
167
+ }
168
+ return summary;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Computing a plugin's audience
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * The roles each username holds on the plugin: the union over its direct
177
+ * entitlements and those of the Lamaste groups it is in, restricted to the
178
+ * declared roles, sorted. Grants without roles never count.
179
+ *
180
+ * @param {Array<{ principalType: 'user' | 'group', principalId: string, context: Record<string, unknown> }>} grants
181
+ * @param {Map<string, { members: string[] }>} groups by name
182
+ * @param {Set<string>} declared role ids
183
+ * @returns {Map<string, string[]>}
184
+ */
185
+ function rolesByUsername(grants, groups, declared) {
186
+ const held = new Map();
187
+ const add = (username, roles) => {
188
+ let set = held.get(username);
189
+ if (!set) {
190
+ set = new Set();
191
+ held.set(username, set);
192
+ }
193
+ for (const role of roles) if (declared.has(role)) set.add(role);
194
+ };
195
+ for (const grant of grants) {
196
+ const roles = grantRoles(grant);
197
+ if (!roles) continue;
198
+ if (grant.principalType === 'user') add(grant.principalId, roles);
199
+ else for (const member of groups.get(grant.principalId)?.members ?? []) add(member, roles);
200
+ }
201
+ const out = new Map();
202
+ for (const [username, set] of held) if (set.size > 0) out.set(username, [...set].sort());
203
+ return out;
204
+ }
205
+
206
+ /**
207
+ * The declared attributes the directory has for `user` (its fields carry
208
+ * the attributes' names) that pass the feed's checks — an attribute the
209
+ * directory lacks, or that does not pass (a display name with control
210
+ * characters, say), is left out.
211
+ */
212
+ function declaredAttributes(user, attributes) {
213
+ const out = {};
214
+ for (const attribute of IDENTITY_ATTRIBUTES) {
215
+ if (!attributes.includes(attribute)) continue;
216
+ const value = user[attribute];
217
+ if (
218
+ typeof value === 'string' &&
219
+ AudienceMemberSchema.shape[attribute].safeParse(value).success
220
+ ) {
221
+ out[attribute] = value;
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /** The change of a member; `version` is filled in when it is written. */
228
+ function upsertChange(user, roles, attributes) {
229
+ return {
230
+ kind: 'upsert',
231
+ member: {
232
+ userId: user.userId,
233
+ username: user.username,
234
+ ...declaredAttributes(user, attributes),
235
+ roles,
236
+ status: user.status,
237
+ version: 0,
238
+ },
239
+ };
240
+ }
241
+
242
+ /**
243
+ * What the plugin last received about a user, restricted to the attributes
244
+ * it declares now: the member it was sent, or the `last` of the removal it
245
+ * was sent. Null when that was an erasure.
246
+ */
247
+ function lastDelivered(change, attributes) {
248
+ const source = change.kind === 'upsert' ? change.member : change.last;
249
+ if (!source || typeof source.username !== 'string') return null;
250
+ const last = { username: source.username };
251
+ for (const attribute of IDENTITY_ATTRIBUTES) {
252
+ if (attributes.includes(attribute) && typeof source[attribute] === 'string') {
253
+ last[attribute] = source[attribute];
254
+ }
255
+ }
256
+ return last;
257
+ }
258
+
259
+ /** A change as compared: a member's version is the row's, not part of what changed. */
260
+ function comparable(change) {
261
+ if (change?.kind !== 'upsert') return JSON.stringify(change);
262
+ return JSON.stringify({ kind: 'upsert', member: { ...change.member, version: 0 } });
263
+ }
264
+
265
+ /**
266
+ * What the plugin's audience should hold for each user: a member's full
267
+ * state, or the reason a former member left with what the plugin last
268
+ * received about them — a user who left is no longer the plugin's to hear
269
+ * about, so later changes in the directory never reach it, and attributes
270
+ * it stopped declaring are dropped. Nothing is said about users who were
271
+ * never members. A username whose earlier holder's Gatekeeper principal is
272
+ * still to be cleared (`pending`) holds no role: what Gatekeeper has under
273
+ * it may be theirs.
274
+ *
275
+ * @param {object} identity the plugin's normalized identity block
276
+ * @param {import('./directory.js').DirectoryUser[]} users
277
+ * @param {Map<string, string[]>} roles by username
278
+ * @param {Map<string, AudienceRow>} stored by user id
279
+ * @param {Set<string>} [pending] usernames with a pending principal cleanup
280
+ * @returns {{ desired: Map<string, object>, ineligible: number }}
281
+ */
282
+ function desiredAudience(identity, users, roles, stored, pending = new Set()) {
283
+ const attributes = identity.attributes ?? [];
284
+ const desired = new Map();
285
+ let ineligible = 0;
286
+ for (const user of users) {
287
+ const existing = stored.get(user.userId)?.change ?? null;
288
+ /** A former member leaves (or stays gone) for `reason`. */
289
+ const leave = (reason) => {
290
+ const last = lastDelivered(existing, attributes);
291
+ if (last) desired.set(user.userId, { kind: 'remove', userId: user.userId, reason, last });
292
+ };
293
+ if (user.erasedAt !== null) {
294
+ // An erased user is the id alone.
295
+ if (existing) {
296
+ desired.set(user.userId, { kind: 'remove', userId: user.userId, reason: 'erased' });
297
+ }
298
+ continue;
299
+ }
300
+ if (user.status === 'removed') {
301
+ if (existing) leave('deleted');
302
+ continue;
303
+ }
304
+ const held = pending.has(user.username) ? undefined : roles.get(user.username);
305
+ if (held && held.length > 0) {
306
+ // A username the feed cannot carry is never a member (users.yml
307
+ // edited outside Lamaste may hold one); counted, never named.
308
+ if (isLamasteUsername(user.username)) {
309
+ desired.set(user.userId, upsertChange(user, held, attributes));
310
+ } else ineligible += 1;
311
+ continue;
312
+ }
313
+ if (existing) leave('unentitled');
314
+ }
315
+ return { desired, ineligible };
316
+ }
317
+
318
+ /**
319
+ * Write the rows of `desired` that differ from `stored`, in one transaction:
320
+ * version + 1 (1 for a new row), a member's version set to it, each change
321
+ * checked against core's `AudienceChangeSchema` first (one that fails is not
322
+ * written, and logged by user id). Returns how many rows were written.
323
+ */
324
+ function writeAudience(db, plugin, desired, stored, logger) {
325
+ const now = new Date().toISOString();
326
+ const insert = db.prepare(
327
+ 'INSERT INTO plugin_audience (plugin, user_id, change, version, updated_at) VALUES (?, ?, ?, ?, ?)',
328
+ );
329
+ const update = db.prepare(
330
+ 'UPDATE plugin_audience SET change = ?, version = ?, updated_at = ? WHERE plugin = ? AND user_id = ?',
331
+ );
332
+ let written = 0;
333
+ db.exec('BEGIN IMMEDIATE');
334
+ try {
335
+ for (const [userId, change] of desired) {
336
+ const existing = stored.get(userId) ?? null;
337
+ if (existing && comparable(existing.change) === comparable(change)) continue;
338
+ const version = existing ? existing.version + 1 : 1;
339
+ const next =
340
+ change.kind === 'upsert'
341
+ ? { kind: 'upsert', member: { ...change.member, version } }
342
+ : change;
343
+ if (!AudienceChangeSchema.safeParse({ seq: 1, ...next }).success) {
344
+ logger?.error({ plugin, userId }, 'An audience change is not the contract — not written');
345
+ continue;
346
+ }
347
+ const text = JSON.stringify(next);
348
+ if (existing) update.run(text, version, now, plugin, userId);
349
+ else insert.run(plugin, userId, text, version, now);
350
+ written += 1;
351
+ }
352
+ db.exec('COMMIT');
353
+ } catch (err) {
354
+ db.exec('ROLLBACK');
355
+ throw err;
356
+ }
357
+ return written;
358
+ }
359
+
360
+ /**
361
+ * Erasures reach every ledger: each row of an erased user that is not yet
362
+ * their `remove/erased` is rewritten to it (version + 1, a new `seq`), in
363
+ * every plugin that has one — those that no longer take the feed included.
364
+ * Needs nothing from Gatekeeper. Returns how many rows each plugin got.
365
+ *
366
+ * @returns {Map<string, number>}
367
+ */
368
+ function writeErasures(db) {
369
+ const rows = db
370
+ .prepare(
371
+ `SELECT a.plugin, a.user_id, a.version FROM plugin_audience a
372
+ JOIN directory_users u ON u.user_id = a.user_id
373
+ WHERE u.erased_at IS NOT NULL
374
+ AND (json_extract(a.change, '$.kind') <> 'remove'
375
+ OR json_extract(a.change, '$.reason') IS NOT 'erased')`,
376
+ )
377
+ .all();
378
+ const written = new Map();
379
+ if (rows.length === 0) return written;
380
+ const now = new Date().toISOString();
381
+ const update = db.prepare(
382
+ 'UPDATE plugin_audience SET change = ?, version = ?, updated_at = ? WHERE plugin = ? AND user_id = ?',
383
+ );
384
+ db.exec('BEGIN IMMEDIATE');
385
+ try {
386
+ for (const row of rows) {
387
+ const change = JSON.stringify({ kind: 'remove', userId: row.user_id, reason: 'erased' });
388
+ update.run(change, row.version + 1, now, row.plugin, row.user_id);
389
+ written.set(row.plugin, (written.get(row.plugin) ?? 0) + 1);
390
+ }
391
+ db.exec('COMMIT');
392
+ } catch (err) {
393
+ db.exec('ROLLBACK');
394
+ throw err;
395
+ }
396
+ return written;
397
+ }
398
+
399
+ /**
400
+ * Drop plugin `name`'s ledger — its audience rows and its feed's state —
401
+ * once no data of it is left on any agent: it is not installed and no
402
+ * agent retains its data. Called after an uninstall and after retained
403
+ * data is purged or forgotten; a plugin with data somewhere keeps its
404
+ * ledger, and the erasures it owes with it. Returns whether it went.
405
+ *
406
+ * @param {string} name
407
+ * @param {import('pino').Logger} [logger]
408
+ */
409
+ export async function dropIdentityLedgerIfNoData(name, logger) {
410
+ if (await findPlugin(name)) return false;
411
+ const db = await getStateDb();
412
+ const retained = db
413
+ .prepare('SELECT COUNT(*) AS n FROM retained_agent_data WHERE plugin = ?')
414
+ .get(name);
415
+ if (Number(retained.n) > 0) return false;
416
+ db.exec('BEGIN IMMEDIATE');
417
+ let rows = 0;
418
+ try {
419
+ rows = Number(db.prepare('DELETE FROM plugin_audience WHERE plugin = ?').run(name).changes);
420
+ db.prepare('DELETE FROM plugin_identity_sync WHERE plugin = ?').run(name);
421
+ db.exec('COMMIT');
422
+ } catch (err) {
423
+ db.exec('ROLLBACK');
424
+ throw err;
425
+ }
426
+ if (rows > 0) {
427
+ logger?.info(
428
+ { plugin: name, rows },
429
+ 'Dropped the identity ledger of a plugin no agent keeps data of',
430
+ );
431
+ }
432
+ return true;
433
+ }
434
+
435
+ /** Ineligible users last logged per plugin, so a steady count is logged once. */
436
+ const loggedIneligible = new Map();
437
+
438
+ /**
439
+ * One recompute of every audience. Never throws: a plugin whose grants
440
+ * cannot be read keeps its stored audience (logged, without names).
441
+ *
442
+ * @returns {Promise<{ plugins: Array<{ plugin: string, written: number }>, error?: string }>}
443
+ */
444
+ async function recomputeOnce(logger) {
445
+ /** plugin → rows written by this recompute */
446
+ const written = new Map();
447
+ const summary = () => ({
448
+ plugins: [...written].map(([plugin, count]) => ({ plugin, written: count })),
449
+ });
450
+ let declaring = new Set();
451
+ try {
452
+ const db = await getStateDb();
453
+ const plugins = (await readPlugins()).plugins.filter((p) => p.identity && p.route);
454
+ declaring = new Set(plugins.map((p) => p.name));
455
+
456
+ // Erasures first, into every ledger — inactive plugins' too (a plugin
457
+ // that no longer takes the feed keeps its rows: they are what it owes).
458
+ for (const [plugin, count] of writeErasures(db)) {
459
+ written.set(plugin, count);
460
+ logger?.info(
461
+ { plugin, erasures: count, active: declaring.has(plugin) },
462
+ 'Erasures written to a plugin audience',
463
+ );
464
+ }
465
+ if (plugins.length === 0) return summary();
466
+
467
+ const groupsResult = await listGatekeeperGroups();
468
+ if (!groupsResult.ok) {
469
+ logger?.warn(
470
+ { reason: groupsResult.reason, status: groupsResult.statusCode },
471
+ 'Gatekeeper groups could not be read — plugin audiences kept as they are',
472
+ );
473
+ await syncChanged(written, declaring, logger);
474
+ return { ...summary(), error: groupsResult.error };
475
+ }
476
+ const groups = new Map(groupsResult.groups.map((g) => [g.name, g]));
477
+ const users = await listDirectoryUsers();
478
+ const pending = await pendingPrincipalCleanups();
479
+
480
+ for (const plugin of plugins) {
481
+ const grants = await listPluginGrants(plugin.route);
482
+ if (!grants.ok) {
483
+ logger?.warn(
484
+ { plugin: plugin.name, reason: grants.reason, status: grants.statusCode },
485
+ 'Gatekeeper grants could not be read — the plugin audience is kept as it is',
486
+ );
487
+ continue;
488
+ }
489
+ const declared = declaredRoleIds(plugin.identity);
490
+ const stored = new Map((await listAudience(plugin.name)).map((row) => [row.userId, row]));
491
+ const { desired, ineligible } = desiredAudience(
492
+ plugin.identity,
493
+ users,
494
+ rolesByUsername(grants.grants, groups, declared),
495
+ stored,
496
+ pending,
497
+ );
498
+ if ((loggedIneligible.get(plugin.name) ?? 0) !== ineligible) {
499
+ loggedIneligible.set(plugin.name, ineligible);
500
+ if (ineligible > 0) {
501
+ logger?.warn(
502
+ { plugin: plugin.name, ineligible },
503
+ 'Entitled users whose username the identity feed cannot carry are left out of the audience',
504
+ );
505
+ }
506
+ }
507
+ const count = writeAudience(db, plugin.name, desired, stored, logger);
508
+ written.set(plugin.name, (written.get(plugin.name) ?? 0) + count);
509
+ if (count > 0) {
510
+ logger?.info({ plugin: plugin.name, written: count }, 'Plugin audience changed');
511
+ }
512
+ }
513
+ } catch (err) {
514
+ logger?.error({ err: err.message }, 'Recomputing the plugin audiences failed');
515
+ return { ...summary(), error: err.message };
516
+ }
517
+
518
+ await syncChanged(written, declaring, logger);
519
+ return summary();
520
+ }
521
+
522
+ /** Start the sync of every declaring plugin whose rows changed (inactive ones take no feed). */
523
+ async function syncChanged(written, declaring, logger) {
524
+ const changed = [...written].filter(([name, count]) => count > 0 && declaring.has(name));
525
+ if (changed.length === 0) return;
526
+ try {
527
+ const { syncPluginIdentity } = await import('./identity-sync.js');
528
+ for (const [name] of changed) void syncPluginIdentity(name, { logger });
529
+ } catch (err) {
530
+ logger?.error({ err: err.message }, 'Could not start the identity sync of changed audiences');
531
+ }
532
+ }
533
+
534
+ /** The end of the last recompute started (settled, never rejected). */
535
+ let tail = Promise.resolve();
536
+ /** A recompute waiting for the running one to end: every request made meanwhile joins it. */
537
+ let queued = null;
538
+ /** The logger of the latest request — background recomputes log through it. */
539
+ let lastLogger = null;
540
+
541
+ /**
542
+ * Resolves once no recompute runs or waits — tests, and whatever needs the
543
+ * audiences settled.
544
+ */
545
+ export async function audiencesIdle() {
546
+ for (;;) {
547
+ const current = tail;
548
+ await current;
549
+ if (current === tail && queued === null) return;
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Recompute every plugin's audience and write what changed. Serialised:
555
+ * a request made while one runs waits for the next run — which starts
556
+ * after it, so it sees every change made before the request — and every
557
+ * request made meanwhile shares that run. Never rejects; plugins whose rows
558
+ * changed are synced to their agent halves.
559
+ *
560
+ * @param {{ logger?: import('pino').Logger }} [opts]
561
+ * @returns {Promise<{ plugins: Array<{ plugin: string, written: number }>, error?: string }>}
562
+ */
563
+ export function recomputeAudiences({ logger } = {}) {
564
+ if (logger) lastLogger = logger;
565
+ if (queued) return queued;
566
+ const run = tail.then(() => {
567
+ queued = null;
568
+ return recomputeOnce(lastLogger);
569
+ });
570
+ queued = run;
571
+ tail = run.then(
572
+ () => undefined,
573
+ () => undefined,
574
+ );
575
+ return run;
576
+ }