@lamalibre/create-lamaste 4.4.1 → 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 (149) 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 +291 -22
  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 +14 -12
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  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 +82 -188
  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/plugins.js +59 -4
  125. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  126. package/vendor/serverd/src/lib/request-logging.js +19 -0
  127. package/vendor/serverd/src/lib/retained-data.js +50 -4
  128. package/vendor/serverd/src/lib/state.js +18 -0
  129. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  130. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  131. package/vendor/serverd/src/lib/user-caller.js +67 -0
  132. package/vendor/serverd/src/lib/user-surface.js +733 -0
  133. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  134. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  135. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  136. package/vendor/serverd/src/routes/invite.js +53 -12
  137. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  138. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  139. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  140. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  141. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  142. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  143. package/vendor/serverd/src/routes/management/users.js +290 -208
  144. package/vendor/serverd/src/routes/management.js +4 -0
  145. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  146. package/vendor/serverd/src/routes/user-access.js +17 -4
  147. package/vendor/serverd/src/routes/user-surface.js +69 -0
  148. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  149. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Lamaste</title>
7
- <script type="module" crossorigin src="/assets/index-roDHz3dM.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-25wo_8av.css">
7
+ <script type="module" crossorigin src="/assets/index-DSaDPJaw.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-D-fer5ef.css">
9
9
  </head>
10
10
  <body style="background-color: #09090b">
11
11
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-serverd",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "description": "Lamaste server daemon — REST API and plugin host",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@
29
29
  "@fastify/rate-limit": "^10.3.0",
30
30
  "@fastify/static": "^9.1.1",
31
31
  "@fastify/websocket": "^11.0.0",
32
- "@lamalibre/lamaste": "^4.4.1",
32
+ "@lamalibre/lamaste": "^4.5.0",
33
33
  "bcryptjs": "^3.0.3",
34
34
  "execa": "^9.6.1",
35
35
  "fastify": "^5.8.4",
@@ -8,7 +8,9 @@ import fastifyStatic from '@fastify/static';
8
8
  import websocket from '@fastify/websocket';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { logRequestMetadata } from '@lamalibre/lamaste';
11
12
  import { loadConfig } from './lib/config.js';
13
+ import { panelRequestLogOptions } from './lib/request-logging.js';
12
14
  import mtlsMiddleware from './middleware/mtls.js';
13
15
  import twofaSession from './middleware/twofa-session.js';
14
16
  import roleGuard from './middleware/role-guard.js';
@@ -18,6 +20,7 @@ import onboardingRoutes from './routes/onboarding/index.js';
18
20
  import managementRoutes from './routes/management.js';
19
21
  import pluginRouter from './routes/plugin-router.js';
20
22
  import agentForwarderRoutes from './routes/agent-forwarder.js';
23
+ import userSurfaceContext from './routes/user-surface.js';
21
24
  import inviteRoutes from './routes/invite.js';
22
25
  import enrollmentRoutes from './routes/enrollment.js';
23
26
  import {
@@ -38,6 +41,7 @@ import {
38
41
  stopHostedPluginHealthMonitor,
39
42
  } from './lib/plugin-hosting.js';
40
43
  import { refreshHostedPluginSettings } from './lib/plugin-settings.js';
44
+ import { startIdentityServices, stopIdentityServices } from './lib/identity-sync.js';
41
45
  import { syncOidcClients } from './lib/plugin-oidc.js';
42
46
  import { ensureRelaySigningKey } from './lib/relay-signing.js';
43
47
  import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
@@ -73,12 +77,19 @@ async function start() {
73
77
 
74
78
  const server = Fastify({
75
79
  logger: true,
80
+ // Fastify's own request lines carry every URL with its query and the
81
+ // client address; one metadata line per request replaces them (below).
82
+ disableRequestLogging: true,
76
83
  // Behind the local nginx: trust exactly that one loopback hop, so
77
84
  // request.ip is the client address nginx appended to X-Forwarded-For —
78
85
  // not a value the client sent, and nothing from a non-local peer.
79
86
  trustProxy: trustLocalNginxHop,
80
87
  });
81
88
 
89
+ // One metadata line per request, never a URL or a person's address
90
+ // (lib/request-logging.js).
91
+ logRequestMetadata(server, panelRequestLogOptions);
92
+
82
93
  // Wire ticket module to the Fastify root logger so any load-time warnings
83
94
  // (e.g. legacy tickets.json shape dropped on upgrade) reach journalctl.
84
95
  setTicketModuleLogger(server.log);
@@ -177,10 +188,16 @@ async function start() {
177
188
  app.register(userAccessAdminRoutes, { prefix: '/api' });
178
189
  app.register(pluginRouter, { prefix: '/api' });
179
190
  // Last: agent-hosted plugins with an agent half, forwarded over their
180
- // internal tunnels. Only what no other /api route answers reaches it.
191
+ // internal tunnels. Only what no other /api route answers reaches it —
192
+ // and never /api/<route>/app[/*], the user surface's static routes below.
181
193
  app.register(agentForwarderRoutes, { prefix: '/api' });
182
194
  });
183
195
 
196
+ // --- Plugins' user surfaces: /api/<route>/app[/*] from the host bound to
197
+ // the plugin. No mTLS, 2FA or role: only a relay assertion Gatekeeper
198
+ // signed for the panel admits a request (routes/user-surface.js) ---
199
+ server.register(userSurfaceContext);
200
+
184
201
  // --- User-access session-protected routes (Bearer token, no mTLS) ---
185
202
  server.register(async function userAccessContext(app) {
186
203
  app.register(userAccessSessionMiddleware);
@@ -238,6 +255,12 @@ async function start() {
238
255
  server.log.warn({ err: err.message }, 'Startup refresh of plugin settings failed');
239
256
  });
240
257
 
258
+ // --- Users and identity: the directory reconciled with users.yml (read
259
+ // only), the plugin audiences recomputed and sent to the agent halves that
260
+ // are behind — now, then every minute (Gatekeeper CLI edits, users.yml
261
+ // changed outside Lamaste, retries), with a daily full reconcile per plugin ---
262
+ startIdentityServices(server.log);
263
+
241
264
  // --- Periodic ticket instance liveness check (every 60s) ---
242
265
  const livenessInterval = setInterval(() => {
243
266
  checkInstanceLiveness(server.log).catch((err) => {
@@ -250,6 +273,7 @@ async function start() {
250
273
  const shutdown = async (signal) => {
251
274
  server.log.info({ signal }, 'Received signal, shutting down gracefully');
252
275
  clearInterval(livenessInterval);
276
+ stopIdentityServices();
253
277
  stopHostedPluginHealthMonitor();
254
278
  clearRateLimitInterval();
255
279
  await server.close();
@@ -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
+ }