@lamalibre/create-lamaste 4.3.0 → 4.4.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 (79) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/src/index.js +4 -0
  4. package/src/lib/panel-pin.js +41 -0
  5. package/src/lib/service-config.js +6 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/certificates.md +3 -3
  10. package/vendor/docs/01-concepts/mtls.md +2 -2
  11. package/vendor/docs/01-concepts/security-model.md +50 -2
  12. package/vendor/docs/01-concepts/tickets.md +2 -2
  13. package/vendor/docs/01-concepts/tunneling.md +4 -4
  14. package/vendor/docs/02-guides/agent-setup.md +6 -6
  15. package/vendor/docs/02-guides/certificate-management.md +2 -2
  16. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  17. package/vendor/docs/02-guides/desktop-app-setup.md +12 -11
  18. package/vendor/docs/02-guides/installation.md +3 -3
  19. package/vendor/docs/02-guides/onboarding.md +2 -2
  20. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  21. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  22. package/vendor/docs/03-architecture/installer.md +4 -4
  23. package/vendor/docs/03-architecture/management-flow.md +1 -1
  24. package/vendor/docs/03-architecture/overview.md +5 -5
  25. package/vendor/docs/03-architecture/panel-client.md +7 -7
  26. package/vendor/docs/03-architecture/panel-server.md +2 -2
  27. package/vendor/docs/03-architecture/state-management.md +2 -2
  28. package/vendor/docs/03-architecture/system-overview.md +1 -1
  29. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  30. package/vendor/docs/04-api-reference/app-host.md +87 -38
  31. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  32. package/vendor/docs/04-api-reference/certificates.md +22 -19
  33. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  34. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  35. package/vendor/docs/04-api-reference/overview.md +137 -135
  36. package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
  37. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  38. package/vendor/docs/05-operations/backup-and-restore.md +4 -1
  39. package/vendor/docs/05-operations/uninstalling.md +1 -1
  40. package/vendor/docs/05-operations/upgrades.md +6 -6
  41. package/vendor/docs/06-reference/config-files.md +94 -84
  42. package/vendor/docs/06-reference/glossary.md +3 -3
  43. package/vendor/docs/06-reference/installer-flags.md +13 -13
  44. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  45. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  46. package/vendor/docs/gatekeeper-design.md +8 -8
  47. package/vendor/docs/identity-directory-design.md +2 -2
  48. package/vendor/docs/package.json +1 -1
  49. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  50. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  51. package/vendor/gatekeeper/package.json +2 -2
  52. package/vendor/server/bin/lamaste-server.js +11 -1
  53. package/vendor/server/package.json +4 -3
  54. package/vendor/server/src/commands/panel-pin.js +40 -0
  55. package/vendor/server/src/commands/plugin-settings.js +541 -0
  56. package/vendor/server/src/commands/plugins.js +94 -27
  57. package/vendor/server/src/panel-api.js +281 -34
  58. package/vendor/server/src/panel-identity.js +165 -0
  59. package/vendor/server/src/plugin-approval.js +78 -0
  60. package/vendor/server/src/plugin-settings.js +699 -0
  61. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  62. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  63. package/vendor/server-ui/dist/index.html +2 -2
  64. package/vendor/serverd/package.json +2 -2
  65. package/vendor/serverd/src/index.js +9 -0
  66. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  67. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  68. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  69. package/vendor/serverd/src/lib/mtls.js +44 -4
  70. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  71. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  72. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  73. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  74. package/vendor/serverd/src/lib/plugins.js +73 -22
  75. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  76. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  77. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  78. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
  79. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
@@ -0,0 +1,1101 @@
1
+ /**
2
+ * Plugin settings on the relay — what an administrator configures for an
3
+ * agent-hosted plugin (the manifest's `settings` block; the contract and its
4
+ * validation are core's `plugin-settings.ts`).
5
+ *
6
+ * The agent half is the system of record: it keeps the values in its own
7
+ * data directory and answers `GET` / `PUT /admin/settings` for Lamaste
8
+ * alone. The relay keeps a cache per plugin in state.db (`plugin_settings`)
9
+ * — the non-secret values the agent half stored, which secrets are set and
10
+ * when, whether the plugin still needs configuration — so the panel can show
11
+ * them while the agent is offline. Never a secret's value: the table has no
12
+ * column for one.
13
+ *
14
+ * A change is checked by the panel and stored by the agent half: the panel
15
+ * validates it against the installed schema (type, required, length, range,
16
+ * options — never `pattern`, which only the form and the agent half
17
+ * evaluate, so a manifest's regular expression cannot stall the panel) and
18
+ * sends it through the internal tunnel (`PUT /admin/settings`, caller
19
+ * `lamaste`), each secret sealed to that one request (core's
20
+ * `settings-secrets`), and answers with the agent half's verdict — once the
21
+ * answer has authenticated itself as the agent half's. A secret exists on
22
+ * the relay in clear only in the memory of that request: what the panel
23
+ * logs, stores, audits and answers is built from key names and the agent
24
+ * half's state, never from the request body, and no reference to it
25
+ * outlives the request.
26
+ *
27
+ * The cache is refreshed after a hosting succeeds, after an update, when the
28
+ * health monitor sees the plugin come up, once when the panel starts, on
29
+ * every live read and from the answer to every change; it goes when the
30
+ * hosting is removed and when the plugin is uninstalled. Reads and writes of
31
+ * one plugin's settings are serialized, so a slow read cannot put back what
32
+ * a change replaced. When the installed schema changes, the values and
33
+ * secrets whose field changed type leave the cache in the transaction that
34
+ * writes the plugin's row ({@link prunedSettingsCache}).
35
+ *
36
+ * Provenance: only Lamaste writes the settings (the forwarder reserves
37
+ * `/admin/settings*`), so every revision of the agent half's should be one
38
+ * Lamaste wrote. The cache remembers the last revision it wrote or adopted
39
+ * (`lamaste_revision`); a read that finds another found a change made
40
+ * outside Lamaste — by whatever holds the installation secret on the agent,
41
+ * or files put back there — and records it (audit
42
+ * `plugin.settings.changed-outside-lamaste` and a notification, key names
43
+ * only) before adopting it. A change whose answer never authenticated (a
44
+ * timeout, say) is remembered by its key names, so finding it stored later
45
+ * is recorded as the administrator's change, not as one made outside.
46
+ */
47
+
48
+ import {
49
+ AGENT_HALF_SETTINGS_MAX_BYTES,
50
+ AGENT_HALF_SETTINGS_PATH,
51
+ SETTINGS_KEY_RE,
52
+ SETTINGS_MAX_FIELDS,
53
+ callAgentHalf,
54
+ effectiveSettingsValues,
55
+ encryptSettingsSecret,
56
+ normalizeSettingsErrorMessage,
57
+ parseAgentHalfSettingsState,
58
+ settingsStatus,
59
+ validateSettingsUpdate,
60
+ } from '@lamalibre/lamaste';
61
+ import { pluginHostingPhase, signingSecretFor } from '@lamalibre/lamaste/server';
62
+ import { httpError } from './app-error.js';
63
+ import { agentLastSeenAt } from './agent-presence.js';
64
+ import { recordAuditBestEffort } from './audit.js';
65
+ import { notifyAdminsBestEffort } from './notifications.js';
66
+ import { findPlugin, readPlugins } from './plugins.js';
67
+ import { getPluginOperation, isPluginOperationRunning } from './plugin-operations.js';
68
+ import { getStateDb } from './state-db.js';
69
+
70
+ /** How long a read of the agent half's settings may take (the panel waits on it). */
71
+ export const SETTINGS_READ_TIMEOUT_MS = 5_000;
72
+
73
+ /** How long the agent half may take to store a change. */
74
+ export const SETTINGS_WRITE_TIMEOUT_MS = 15_000;
75
+
76
+ /** The audit action of a change of the agent half's settings that Lamaste did not make. */
77
+ export const SETTINGS_CHANGED_OUTSIDE_ACTION = 'plugin.settings.changed-outside-lamaste';
78
+
79
+ const UPDATE_KEYS = new Set(['revision', 'values', 'secrets']);
80
+
81
+ /** The key-name lists of a change, as its audit entry names them. */
82
+ const CHANGE_LISTS = ['changed', 'cleared', 'secretsSet', 'secretsCleared'];
83
+
84
+ function isRecord(value) {
85
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ function parseJson(text, fallback) {
89
+ try {
90
+ return JSON.parse(text);
91
+ } catch {
92
+ return fallback;
93
+ }
94
+ }
95
+
96
+ function isRevision(value) {
97
+ return Number.isSafeInteger(value) && value >= 0;
98
+ }
99
+
100
+ /**
101
+ * The settings block of a registry entry — its installed manifest's,
102
+ * validated when the version was installed or staged — or null when the
103
+ * plugin declares none.
104
+ *
105
+ * @param {{ settings?: unknown } | null | undefined} plugin
106
+ * @returns {import('@lamalibre/lamaste').SettingsBlock | null}
107
+ */
108
+ export function settingsBlockOf(plugin) {
109
+ const block = plugin?.settings;
110
+ if (!isRecord(block)) return null;
111
+ if (!Number.isSafeInteger(block.schemaVersion) || !Array.isArray(block.fields)) return null;
112
+ return block;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // The cache
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * A change Lamaste sent whose answer never authenticated — key names only.
121
+ *
122
+ * @typedef {object} UnconfirmedChange
123
+ * @property {number} revision the revision the agent half would be at had it stored it
124
+ * @property {string} actor
125
+ * @property {string} [certSerial]
126
+ * @property {string[]} changed
127
+ * @property {string[]} cleared
128
+ * @property {string[]} secretsSet
129
+ * @property {string[]} secretsCleared
130
+ */
131
+
132
+ /**
133
+ * @typedef {object} SettingsCache
134
+ * @property {number} schemaVersion the agent half's settings schema version
135
+ * @property {number} revision the agent half's revision
136
+ * @property {Record<string, string | number | boolean>} values stored non-secret values (no defaults)
137
+ * @property {Record<string, string>} secretsSet secret key → when it was set
138
+ * @property {boolean} configured
139
+ * @property {string[]} missing
140
+ * @property {string} syncedAt
141
+ * @property {number | null} lamasteRevision the revision Lamaste last wrote or adopted
142
+ * @property {UnconfirmedChange | null} unconfirmed
143
+ */
144
+
145
+ function isKeyList(value) {
146
+ return Array.isArray(value) && value.every((key) => typeof key === 'string');
147
+ }
148
+
149
+ /** A stored {@link UnconfirmedChange}, or null for anything else. */
150
+ function parseUnconfirmed(text) {
151
+ if (typeof text !== 'string') return null;
152
+ const raw = parseJson(text, null);
153
+ if (!isRecord(raw) || !isRevision(raw.revision) || typeof raw.actor !== 'string') return null;
154
+ if (!CHANGE_LISTS.every((list) => isKeyList(raw[list]))) return null;
155
+ return {
156
+ revision: raw.revision,
157
+ actor: raw.actor,
158
+ ...(typeof raw.certSerial === 'string' ? { certSerial: raw.certSerial } : {}),
159
+ ...Object.fromEntries(CHANGE_LISTS.map((list) => [list, [...raw[list]]])),
160
+ };
161
+ }
162
+
163
+ /** @returns {SettingsCache | null} */
164
+ function rowToCache(row) {
165
+ if (!row) return null;
166
+ const values = parseJson(row.values_json, {});
167
+ const secretsSet = parseJson(row.secrets_set_json, {});
168
+ const missing = parseJson(row.missing_json, []);
169
+ return {
170
+ schemaVersion: row.schema_version,
171
+ revision: row.revision,
172
+ values: isRecord(values) ? values : {},
173
+ secretsSet: isRecord(secretsSet) ? secretsSet : {},
174
+ configured: row.configured === 1,
175
+ missing: Array.isArray(missing) ? missing.filter((key) => typeof key === 'string') : [],
176
+ syncedAt: row.synced_at,
177
+ lamasteRevision: isRevision(row.lamaste_revision) ? row.lamaste_revision : null,
178
+ unconfirmed: parseUnconfirmed(row.unconfirmed_change_json),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * The cached settings of plugin `name`, or null when the relay never read
184
+ * them (or the hosting they belonged to was removed).
185
+ *
186
+ * @param {string} name
187
+ * @returns {Promise<SettingsCache | null>}
188
+ */
189
+ export async function readSettingsCache(name) {
190
+ const db = await getStateDb();
191
+ return rowToCache(db.prepare('SELECT * FROM plugin_settings WHERE plugin = ?').get(name));
192
+ }
193
+
194
+ /** @returns {Promise<Map<string, SettingsCache>>} */
195
+ async function readAllSettingsCaches() {
196
+ const db = await getStateDb();
197
+ return new Map(
198
+ db
199
+ .prepare('SELECT * FROM plugin_settings')
200
+ .all()
201
+ .map((row) => [row.plugin, rowToCache(row)]),
202
+ );
203
+ }
204
+
205
+ /** Drop the cached settings of plugin `name` (its hosting is gone). */
206
+ export async function deleteSettingsCache(name) {
207
+ const db = await getStateDb();
208
+ db.prepare('DELETE FROM plugin_settings WHERE plugin = ?').run(name);
209
+ }
210
+
211
+ /**
212
+ * What a cache row becomes when the installed settings schema changes from
213
+ * `prev` to `next` — an update, its revert, an install: each stored value
214
+ * and each set secret stays only while its field keeps its type (a value
215
+ * whose field became a secret goes; a secret whose field became a plain
216
+ * value loses its `setAt`; a field the new schema lacks goes), and whether
217
+ * the plugin is configured is worked out again against `next` until the
218
+ * agent half reports its own view. Null when there is no row or the schema
219
+ * did not change; `{ delete: true }` when `next` declares no settings.
220
+ * Pure: `lib/plugins.js` applies it in the transaction that writes the
221
+ * plugin's row, so no read ever pairs the new schema with values of the
222
+ * old one.
223
+ *
224
+ * @param {Record<string, unknown> | null | undefined} row the `plugin_settings` row
225
+ * @param {import('@lamalibre/lamaste').SettingsBlock | null} prev
226
+ * @param {import('@lamalibre/lamaste').SettingsBlock | null} next
227
+ * @returns {null | { delete: true } | { values: Record<string, unknown>, secretsSet: Record<string, string>, configured: boolean, missing: string[] }}
228
+ */
229
+ export function prunedSettingsCache(row, prev, next) {
230
+ if (!row) return null;
231
+ const nextBlock = settingsBlockOf({ settings: next });
232
+ if (!nextBlock) return { delete: true };
233
+ const prevBlock = settingsBlockOf({ settings: prev });
234
+ if (prevBlock && JSON.stringify(prevBlock) === JSON.stringify(nextBlock)) return null;
235
+ const before = new Map((prevBlock?.fields ?? []).map((field) => [field.key, field.type]));
236
+ const after = new Map(nextBlock.fields.map((field) => [field.key, field.type]));
237
+ const keeps = (key, secret) =>
238
+ before.has(key) &&
239
+ before.get(key) === after.get(key) &&
240
+ (after.get(key) === 'secret') === secret;
241
+
242
+ const storedValues = parseJson(row.values_json, {});
243
+ const storedSecrets = parseJson(row.secrets_set_json, {});
244
+ const values = Object.fromEntries(
245
+ Object.entries(isRecord(storedValues) ? storedValues : {}).filter(([key]) => keeps(key, false)),
246
+ );
247
+ const secretsSet = Object.fromEntries(
248
+ Object.entries(isRecord(storedSecrets) ? storedSecrets : {}).filter(
249
+ ([key, setAt]) => typeof setAt === 'string' && keeps(key, true),
250
+ ),
251
+ );
252
+ const status = settingsStatus(nextBlock, { values, secretsSet: Object.keys(secretsSet) });
253
+ return { values, secretsSet, configured: status.configured, missing: status.missing };
254
+ }
255
+
256
+ /**
257
+ * The stored non-secret values the installed schema knows and that still
258
+ * suit their field (type, length, range, option) — the agent half's own
259
+ * schema may differ from the relay's while an update is under way.
260
+ */
261
+ function knownStoredValues(block, values) {
262
+ const withoutDefaults = {
263
+ schemaVersion: block.schemaVersion,
264
+ fields: block.fields
265
+ .filter((field) => field.type !== 'secret')
266
+ .map((field) => ({ ...field, default: undefined })),
267
+ };
268
+ return effectiveSettingsValues(withoutDefaults, values);
269
+ }
270
+
271
+ /**
272
+ * The agent half's state of a plugin as the cache keeps it: its stored
273
+ * non-secret values and set secrets for the keys the installed schema
274
+ * knows, and its own `configured` / `missing`. The state never carries a
275
+ * secret's value (`parseAgentHalfSettingsState` keeps `setAt` alone).
276
+ *
277
+ * @param {import('@lamalibre/lamaste').SettingsBlock} block the installed schema
278
+ * @param {import('@lamalibre/lamaste').AgentHalfSettingsState} state
279
+ */
280
+ function cacheOfState(block, state) {
281
+ return {
282
+ schemaVersion: state.schemaVersion,
283
+ revision: state.revision,
284
+ values: knownStoredValues(block, state.values),
285
+ secretsSet: Object.fromEntries(
286
+ block.fields
287
+ .filter((field) => field.type === 'secret' && Object.hasOwn(state.secrets, field.key))
288
+ .map((field) => [field.key, state.secrets[field.key].setAt]),
289
+ ),
290
+ configured: state.configured,
291
+ missing: [...state.missing],
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Store the cache of plugin `name` with its provenance — the revision
297
+ * Lamaste wrote or adopted, and a change whose answer it is still waiting
298
+ * to find stored.
299
+ *
300
+ * @returns {Promise<SettingsCache>}
301
+ */
302
+ async function storeSettingsCache(name, cache, { lamasteRevision, unconfirmed }) {
303
+ const syncedAt = new Date().toISOString();
304
+ const db = await getStateDb();
305
+ db.prepare(
306
+ `INSERT INTO plugin_settings
307
+ (plugin, schema_version, revision, values_json, secrets_set_json, configured,
308
+ missing_json, synced_at, lamaste_revision, unconfirmed_change_json)
309
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
310
+ ON CONFLICT(plugin) DO UPDATE SET
311
+ schema_version = excluded.schema_version,
312
+ revision = excluded.revision,
313
+ values_json = excluded.values_json,
314
+ secrets_set_json = excluded.secrets_set_json,
315
+ configured = excluded.configured,
316
+ missing_json = excluded.missing_json,
317
+ synced_at = excluded.synced_at,
318
+ lamaste_revision = excluded.lamaste_revision,
319
+ unconfirmed_change_json = excluded.unconfirmed_change_json`,
320
+ ).run(
321
+ name,
322
+ cache.schemaVersion,
323
+ cache.revision,
324
+ JSON.stringify(cache.values),
325
+ JSON.stringify(cache.secretsSet),
326
+ cache.configured ? 1 : 0,
327
+ JSON.stringify(cache.missing),
328
+ syncedAt,
329
+ lamasteRevision,
330
+ unconfirmed ? JSON.stringify(unconfirmed) : null,
331
+ );
332
+ return { ...cache, syncedAt, lamasteRevision, unconfirmed };
333
+ }
334
+
335
+ /** Remember a change whose answer never authenticated, on the plugin's cache row (if it has one). */
336
+ async function recordUnconfirmedChange(name, change) {
337
+ const db = await getStateDb();
338
+ db.prepare('UPDATE plugin_settings SET unconfirmed_change_json = ? WHERE plugin = ?').run(
339
+ JSON.stringify(change),
340
+ name,
341
+ );
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Provenance
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /** The keys of `a` and `b` whose entry differs (present in one only, or another value). */
349
+ function differingKeys(a, b) {
350
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
351
+ return [...keys]
352
+ .filter((key) => Object.hasOwn(a, key) !== Object.hasOwn(b, key) || a[key] !== b[key])
353
+ .sort();
354
+ }
355
+
356
+ /**
357
+ * Which settings differ between two caches — names only: the non-secret
358
+ * values (`changed`) and the secrets set, replaced or cleared
359
+ * (`secretsChanged`, by their `setAt`).
360
+ */
361
+ function changedSettings(before, after) {
362
+ return {
363
+ changed: differingKeys(before.values, after.values),
364
+ secretsChanged: differingKeys(before.secretsSet, after.secretsSet),
365
+ };
366
+ }
367
+
368
+ /** `keys` without the keys an administrator's change named. */
369
+ function withoutChange(keys, change) {
370
+ const own = new Set(CHANGE_LISTS.flatMap((list) => change[list]));
371
+ return {
372
+ changed: keys.changed.filter((key) => !own.has(key)),
373
+ secretsChanged: keys.secretsChanged.filter((key) => !own.has(key)),
374
+ };
375
+ }
376
+
377
+ /** Whether every key in `keys` is one the unconfirmed change named. */
378
+ function withinChange(keys, change) {
379
+ const own = new Set(CHANGE_LISTS.flatMap((list) => change[list]));
380
+ return [...keys.changed, ...keys.secretsChanged].every((key) => own.has(key));
381
+ }
382
+
383
+ /**
384
+ * What finding the agent half at `revision`, changed in `keys` since the
385
+ * cache `previous`, says about who changed it:
386
+ *
387
+ * - `baseline` — the relay never knew a revision of Lamaste's to compare
388
+ * with (no cache, or one from before provenance): adopted as it is;
389
+ * - `confirmed` — exactly the revision and keys of a change whose answer
390
+ * never authenticated: the administrator's change, stored after all;
391
+ * - `unchanged` — the revision Lamaste last wrote or adopted;
392
+ * - `outside` — any other revision, higher or lower: a change Lamaste did
393
+ * not make (only Lamaste can reach `/admin/settings` through the panel).
394
+ *
395
+ * @param {SettingsCache | null} previous
396
+ * @param {number} revision
397
+ * @param {{ changed: string[], secretsChanged: string[] }} keys
398
+ * @returns {'baseline' | 'confirmed' | 'unchanged' | 'outside'}
399
+ */
400
+ function judgeProvenance(previous, revision, keys) {
401
+ if (!previous) return 'baseline';
402
+ const { unconfirmed } = previous;
403
+ if (unconfirmed && revision === unconfirmed.revision && withinChange(keys, unconfirmed)) {
404
+ return 'confirmed';
405
+ }
406
+ if (previous.lamasteRevision === null) return 'baseline';
407
+ return revision === previous.lamasteRevision ? 'unchanged' : 'outside';
408
+ }
409
+
410
+ /** Record an administrator's change that turned out stored after its answer failed. */
411
+ async function recordConfirmedChange(name, change, logger) {
412
+ await recordAuditBestEffort(
413
+ {
414
+ actor: change.actor,
415
+ action: 'plugin.settings.updated',
416
+ plugin: name,
417
+ details: {
418
+ ...Object.fromEntries(CHANGE_LISTS.map((list) => [list, change[list]])),
419
+ revision: change.revision,
420
+ confirmedLater: true,
421
+ ...(change.certSerial ? { certSerial: change.certSerial } : {}),
422
+ },
423
+ },
424
+ logger,
425
+ );
426
+ logger?.info(
427
+ { plugin: name, revision: change.revision },
428
+ 'A plugin settings change whose answer was lost turned out stored',
429
+ );
430
+ }
431
+
432
+ /**
433
+ * Record a change of the agent half's settings that Lamaste did not make:
434
+ * an audit entry and a warning for the administrators, with key names and
435
+ * revisions — never a value.
436
+ */
437
+ async function recordOutsideChange(name, record, { fromRevision, revision, keys }, logger) {
438
+ await recordAuditBestEffort(
439
+ {
440
+ actor: 'lamaste',
441
+ action: SETTINGS_CHANGED_OUTSIDE_ACTION,
442
+ plugin: name,
443
+ details: {
444
+ changed: keys.changed,
445
+ secretsChanged: keys.secretsChanged,
446
+ fromRevision,
447
+ revision,
448
+ agentLabel: record.agentLabel,
449
+ },
450
+ },
451
+ logger,
452
+ );
453
+ const named = [...keys.changed, ...keys.secretsChanged];
454
+ await notifyAdminsBestEffort(
455
+ {
456
+ source: 'lamaste',
457
+ level: 'warning',
458
+ message:
459
+ `The settings of ${name} were changed outside Lamaste on agent "${record.agentLabel}" ` +
460
+ `(revision ${fromRevision} → ${revision}${named.length > 0 ? `: ${named.join(', ')}` : ''}). ` +
461
+ 'Lamaste changes them only from the panel — check who holds the installation secret there, or whether files were restored.',
462
+ },
463
+ logger,
464
+ );
465
+ logger?.warn({ plugin: name, fromRevision, revision }, 'Plugin settings changed outside Lamaste');
466
+ }
467
+
468
+ /**
469
+ * Cache a state the agent half answered a read with, judging its
470
+ * provenance against the previous cache: adopted as it is, recorded as an
471
+ * administrator's change stored late, or recorded as a change made outside
472
+ * Lamaste — then adopted, so it is reported once.
473
+ */
474
+ async function adoptReadState(name, record, block, state, logger) {
475
+ const previous = await readSettingsCache(name);
476
+ const next = cacheOfState(block, state);
477
+ const keys = previous ? changedSettings(previous, next) : { changed: [], secretsChanged: [] };
478
+ const verdict = judgeProvenance(previous, state.revision, keys);
479
+ const cache = await storeSettingsCache(name, next, {
480
+ lamasteRevision: verdict === 'unchanged' ? previous.lamasteRevision : state.revision,
481
+ // A change still unconfirmed may yet land while the revision is unchanged.
482
+ unconfirmed: verdict === 'unchanged' ? previous.unconfirmed : null,
483
+ });
484
+ if (verdict === 'confirmed') await recordConfirmedChange(name, previous.unconfirmed, logger);
485
+ if (verdict === 'outside') {
486
+ await recordOutsideChange(
487
+ name,
488
+ record,
489
+ { fromRevision: previous.lamasteRevision, revision: state.revision, keys },
490
+ logger,
491
+ );
492
+ }
493
+ return cache;
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // One plugin at a time
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /** plugin name → the tail of its chain of settings reads and writes */
501
+ const chains = new Map();
502
+
503
+ /** Run `fn` after every settings read or write of plugin `name` before it. */
504
+ function serialized(name, fn) {
505
+ const previous = chains.get(name) ?? Promise.resolve();
506
+ const next = previous.then(fn);
507
+ const tail = next.catch(() => undefined);
508
+ chains.set(name, tail);
509
+ void tail.finally(() => {
510
+ if (chains.get(name) === tail) chains.delete(name);
511
+ });
512
+ return next;
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // The hosting and the agent half
517
+ // ---------------------------------------------------------------------------
518
+
519
+ /** The hosting installed plugin `plugin` owns, or null (see `ownHosting`). */
520
+ async function ownHostingOf(plugin) {
521
+ const { ownHosting } = await import('./plugin-hosting.js');
522
+ return ownHosting(plugin);
523
+ }
524
+
525
+ /** Whether Lamaste calls the agent half of hosting `record`: active, and not known to be down. */
526
+ function isReachable(record) {
527
+ return pluginHostingPhase(record) === 'active' && record.health?.state !== 'down';
528
+ }
529
+
530
+ /**
531
+ * The hosting as a settings view shows it — where the agent half runs, the
532
+ * health monitor's view of it, and when its agent last synced — or null
533
+ * when the plugin is not hosted.
534
+ */
535
+ async function hostingBlock(record) {
536
+ if (!record) return null;
537
+ return {
538
+ agentLabel: record.agentLabel,
539
+ phase: pluginHostingPhase(record),
540
+ state: record.health?.state ?? 'unknown',
541
+ reason: record.health?.reason ?? null,
542
+ since: record.health?.since ?? null,
543
+ agentLastSeenAt: await agentLastSeenAt(record.agentLabel),
544
+ };
545
+ }
546
+
547
+ /** Why plugin `name`'s settings cannot be changed now (its hosting is not active, or down). */
548
+ function offlineMessage(name, record) {
549
+ if (!record) {
550
+ return `Plugin "${name}" is not hosted on an agent — its settings are kept by its agent half`;
551
+ }
552
+ const phase = pluginHostingPhase(record);
553
+ if (phase !== 'active') {
554
+ return `Plugin "${name}" is ${phase} on agent "${record.agentLabel}" — its settings can be changed once it is active`;
555
+ }
556
+ if (record.health?.reason === 'app') {
557
+ return `The agent half of plugin "${name}" is not responding since ${record.health.since} — its settings can be changed when it answers again`;
558
+ }
559
+ return `Hosting agent "${record.agentLabel}" of plugin "${name}" is offline since ${record.health?.since} — its settings can be changed when it is back`;
560
+ }
561
+
562
+ /**
563
+ * Read the agent half's settings through the tunnel; null (logged, with
564
+ * metadata only) when it cannot be read — including an answer that does
565
+ * not authenticate itself as the agent half's.
566
+ */
567
+ async function readAgentHalfState(name, record, logger) {
568
+ const result = await callAgentHalf({
569
+ plugin: record.plugin,
570
+ port: record.port,
571
+ secret: signingSecretFor(record),
572
+ method: 'GET',
573
+ path: AGENT_HALF_SETTINGS_PATH,
574
+ timeoutMs: SETTINGS_READ_TIMEOUT_MS,
575
+ maxResponseBytes: AGENT_HALF_SETTINGS_MAX_BYTES,
576
+ });
577
+ if (!result.ok) {
578
+ logger?.warn(
579
+ { plugin: name, code: result.code, err: result.error },
580
+ 'Could not read the plugin settings from its agent half',
581
+ );
582
+ return null;
583
+ }
584
+ if (result.status !== 200) {
585
+ logger?.warn(
586
+ { plugin: name, status: result.status },
587
+ 'The agent half did not answer its settings',
588
+ );
589
+ return null;
590
+ }
591
+ const state = parseAgentHalfSettingsState(result.body);
592
+ if (!state) {
593
+ logger?.warn({ plugin: name }, 'The agent half answered its settings in an unexpected shape');
594
+ }
595
+ return state;
596
+ }
597
+
598
+ /** {@link refreshPluginSettings} inside the plugin's chain. */
599
+ async function refreshUnserialized(name, logger) {
600
+ const plugin = await findPlugin(name);
601
+ const block = plugin ? settingsBlockOf(plugin) : null;
602
+ if (!block) return null;
603
+ const record = await ownHostingOf(plugin);
604
+ if (!record || pluginHostingPhase(record) !== 'active') return null;
605
+ const state = await readAgentHalfState(name, record, logger);
606
+ return state ? adoptReadState(name, record, block, state, logger) : null;
607
+ }
608
+
609
+ /**
610
+ * Read plugin `name`'s settings from its agent half and cache them. Null
611
+ * when the plugin declares none, is not actively hosted, or its agent half
612
+ * could not be read (logged; the cache is kept).
613
+ *
614
+ * @param {string} name
615
+ * @param {{ logger?: import('pino').Logger }} [opts]
616
+ * @returns {Promise<SettingsCache | null>}
617
+ */
618
+ export function refreshPluginSettings(name, { logger } = {}) {
619
+ return serialized(name, () => refreshUnserialized(name, logger));
620
+ }
621
+
622
+ /**
623
+ * {@link refreshPluginSettings} for the triggers beside other work — a
624
+ * hosting or an update that succeeded, the health monitor seeing the plugin
625
+ * come up, the panel starting. Never throws.
626
+ */
627
+ export async function refreshPluginSettingsBestEffort(name, logger) {
628
+ try {
629
+ return await refreshPluginSettings(name, { logger });
630
+ } catch (err) {
631
+ logger?.warn({ plugin: name, err: err?.message }, 'Failed to refresh the plugin settings');
632
+ return null;
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Refresh the cache of every plugin that declares settings and whose
638
+ * hosting is active and not known to be down — one plugin after another,
639
+ * each in its own chain, best effort. The panel runs it once when it
640
+ * starts: the health monitor resumes from the persisted health, so a
641
+ * plugin that was up before the restart sees no transition that would
642
+ * refresh it, and changes made while the panel was down would go unseen
643
+ * (and unreported) until the next one. A hosting that is down is refreshed
644
+ * by its transition to up. Returns how many plugins were read.
645
+ *
646
+ * @param {import('pino').Logger} [logger]
647
+ * @returns {Promise<number>}
648
+ */
649
+ export async function refreshHostedPluginSettings(logger) {
650
+ let read = 0;
651
+ let plugins = [];
652
+ try {
653
+ plugins = (await readPlugins()).plugins.filter((plugin) => settingsBlockOf(plugin) !== null);
654
+ } catch (err) {
655
+ logger?.warn({ err: err?.message }, 'Could not list the plugins to refresh their settings');
656
+ return 0;
657
+ }
658
+ for (const plugin of plugins) {
659
+ let record;
660
+ try {
661
+ record = await ownHostingOf(plugin);
662
+ } catch (err) {
663
+ logger?.warn(
664
+ { plugin: plugin.name, err: err?.message },
665
+ 'Could not read the hosting to refresh the plugin settings',
666
+ );
667
+ continue;
668
+ }
669
+ if (!record || !isReachable(record)) continue;
670
+ if (await refreshPluginSettingsBestEffort(plugin.name, logger)) read += 1;
671
+ }
672
+ return read;
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Views
677
+ // ---------------------------------------------------------------------------
678
+
679
+ /**
680
+ * What the panel shows: the fields, each non-secret field's effective value
681
+ * (stored, or its default) and each secret as `{ set: true, setAt }` or
682
+ * `{ set: false }`, whether the plugin still needs configuration, the
683
+ * revision to base a change on, where it came from (`live`: the agent half
684
+ * just answered; `cache`: the relay's last read; `none`: never read — the
685
+ * defaults) and the hosting.
686
+ */
687
+ function settingsView(block, cache, source, hosting) {
688
+ const effective = effectiveSettingsValues(block, cache?.values ?? {});
689
+ const values = {};
690
+ for (const field of block.fields) {
691
+ if (field.type === 'secret') {
692
+ const setAt = cache?.secretsSet[field.key];
693
+ values[field.key] = typeof setAt === 'string' ? { set: true, setAt } : { set: false };
694
+ } else if (Object.hasOwn(effective, field.key)) {
695
+ values[field.key] = effective[field.key];
696
+ }
697
+ }
698
+ const status = cache
699
+ ? { configured: cache.configured, missing: cache.missing }
700
+ : settingsStatus(block, { values: {}, secretsSet: [] });
701
+ return {
702
+ schemaVersion: block.schemaVersion,
703
+ fields: block.fields,
704
+ values,
705
+ configured: status.configured,
706
+ missing: status.missing,
707
+ revision: cache?.revision ?? null,
708
+ source,
709
+ syncedAt: cache?.syncedAt ?? null,
710
+ hosting,
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Plugin `name`'s settings: read from its agent half when its hosting is
716
+ * active and not down (and cached), otherwise from the cache. 404 when the
717
+ * plugin is not installed or declares no settings (`no_settings`).
718
+ *
719
+ * @param {string} name
720
+ * @param {{ logger?: import('pino').Logger }} [opts]
721
+ */
722
+ export async function getPluginSettingsView(name, { logger } = {}) {
723
+ const plugin = await findPlugin(name);
724
+ if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
725
+ const block = settingsBlockOf(plugin);
726
+ if (!block) {
727
+ throw httpError(`Plugin "${name}" declares no settings`, 404, { code: 'no_settings' });
728
+ }
729
+ const record = await ownHostingOf(plugin);
730
+ let cache = null;
731
+ let source = 'none';
732
+ if (record && isReachable(record)) {
733
+ cache = await refreshPluginSettingsBestEffort(name, logger);
734
+ if (cache) source = 'live';
735
+ }
736
+ if (!cache) {
737
+ cache = await readSettingsCache(name);
738
+ if (cache) source = 'cache';
739
+ }
740
+ return settingsView(block, cache, source, await hostingBlock(record));
741
+ }
742
+
743
+ /**
744
+ * The plugins of a list, each that declares settings with `settings` as a
745
+ * summary — `{ configured, missing, syncedAt }` from the cache (from the
746
+ * schema's defaults while the relay never read them: `syncedAt` null) — in
747
+ * place of its schema. Never a value.
748
+ *
749
+ * @param {Array<object>} plugins registry entries
750
+ */
751
+ export async function attachSettingsSummaries(plugins) {
752
+ const caches = await readAllSettingsCaches();
753
+ return plugins.map((plugin) => {
754
+ const block = settingsBlockOf(plugin);
755
+ if (!block) {
756
+ const rest = { ...plugin };
757
+ delete rest.settings;
758
+ return rest;
759
+ }
760
+ const cache = caches.get(plugin.name) ?? null;
761
+ const status = cache
762
+ ? { configured: cache.configured, missing: cache.missing }
763
+ : settingsStatus(block, { values: {}, secretsSet: [] });
764
+ return {
765
+ ...plugin,
766
+ settings: {
767
+ configured: status.configured,
768
+ missing: status.missing,
769
+ syncedAt: cache?.syncedAt ?? null,
770
+ },
771
+ };
772
+ });
773
+ }
774
+
775
+ /**
776
+ * The `settings` of a relay hook's lifecycle input: the plugin's effective
777
+ * non-secret values, from the cache — `{}` when the plugin declares no
778
+ * settings or the relay never read them. Never a secret. (Agent hooks get
779
+ * `{}`: the agent half holds the settings itself.)
780
+ *
781
+ * @param {object} plugin registry entry (or a staged version in that shape)
782
+ */
783
+ export async function relayHookSettings(plugin) {
784
+ const block = settingsBlockOf(plugin);
785
+ if (!block) return {};
786
+ const cache = await readSettingsCache(plugin.name);
787
+ return cache ? effectiveSettingsValues(block, cache.values) : {};
788
+ }
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // Changing settings
792
+ // ---------------------------------------------------------------------------
793
+
794
+ /**
795
+ * The request body's envelope `{ revision, values?, secrets? }`, checked
796
+ * without repeating anything it carries.
797
+ */
798
+ function parseUpdateEnvelope(body) {
799
+ if (!isRecord(body)) {
800
+ return { ok: false, error: 'The body must be a JSON object: { revision, values?, secrets? }' };
801
+ }
802
+ if (Object.keys(body).some((key) => !UPDATE_KEYS.has(key))) {
803
+ return { ok: false, error: 'The body carries only revision, values and secrets' };
804
+ }
805
+ const { revision } = body;
806
+ if (!isRevision(revision)) {
807
+ return {
808
+ ok: false,
809
+ error: '"revision" must be the revision the settings were read at (an integer of at least 0)',
810
+ };
811
+ }
812
+ return { ok: true, revision, values: body.values, secrets: body.secrets };
813
+ }
814
+
815
+ /** 400 (the panel's check) or 422 (the agent half's), with a message per field. */
816
+ function invalidSettings(errors, statusCode = 400) {
817
+ return httpError('Invalid settings', statusCode, { errors });
818
+ }
819
+
820
+ /**
821
+ * The agent half's per-field messages as the relay passes them on: only
822
+ * under the keys of the installed schema (or `_`), and only messages
823
+ * Lamaste's own validation produces — anything else reads "Invalid value"
824
+ * (core's `normalizeSettingsErrorMessage`). The agent half is Lamaste code
825
+ * that never repeats a value, but the relay does not answer an
826
+ * administrator with text it cannot vouch for.
827
+ */
828
+ function agentHalfErrors(block, body) {
829
+ const known = new Set(block.fields.map((field) => field.key));
830
+ const raw = isRecord(body) && isRecord(body.errors) ? body.errors : {};
831
+ const out = [];
832
+ for (const [key, message] of Object.entries(raw).slice(0, SETTINGS_MAX_FIELDS + 1)) {
833
+ if (key !== '_' && !(SETTINGS_KEY_RE.test(key) && known.has(key))) continue;
834
+ out.push([key, normalizeSettingsErrorMessage(message)]);
835
+ }
836
+ if (out.length === 0) out.push(['_', 'The agent half refused the values']);
837
+ return Object.fromEntries(out);
838
+ }
839
+
840
+ /** The agent half's 409, as the panel answers it: a message of its own, the code, the revision. */
841
+ function agentHalfConflict(body) {
842
+ const code = isRecord(body) ? body.code : undefined;
843
+ if (code === 'revision_mismatch') {
844
+ const revision = isRevision(body.revision) ? body.revision : null;
845
+ return httpError(
846
+ 'The settings changed since they were read — reload them and make the change again',
847
+ 409,
848
+ { code, ...(revision !== null ? { revision } : {}) },
849
+ );
850
+ }
851
+ if (code === 'schema_mismatch') {
852
+ return httpError(
853
+ 'The agent half runs another version of the settings schema — let the update of the plugin finish, then make the change again',
854
+ 409,
855
+ { code },
856
+ );
857
+ }
858
+ return httpError('The agent half refused the change as conflicting', 409, { code: 'conflict' });
859
+ }
860
+
861
+ /**
862
+ * A call through the tunnel that got no answer Lamaste can believe: 504
863
+ * when it timed out, 502 otherwise — `unauthenticated` when something
864
+ * answered without the agent half's responder signature.
865
+ */
866
+ function agentHalfUnreachable(name, result) {
867
+ switch (result.code) {
868
+ case 'refused':
869
+ return httpError(
870
+ `Nothing answers on the internal tunnel of plugin "${name}" — its hosting agent or agent half is down`,
871
+ 502,
872
+ { code: 'refused' },
873
+ );
874
+ case 'timeout':
875
+ return httpError(
876
+ `The agent half of plugin "${name}" did not answer within ${SETTINGS_WRITE_TIMEOUT_MS / 1000} s — the change may or may not have been stored; reload the settings`,
877
+ 504,
878
+ { code: 'timeout' },
879
+ );
880
+ case 'unauthenticated':
881
+ return httpError(
882
+ `The agent half of plugin "${name}" did not authenticate its answer — something else may hold its internal tunnel's port, or the agent half was built against an older @lamalibre/lamaste and must be rebuilt. The change may or may not have been stored; reload the settings`,
883
+ 502,
884
+ { code: 'unauthenticated' },
885
+ );
886
+ case 'invalid':
887
+ return httpError(`The agent half of plugin "${name}" answered with too large a body`, 502, {
888
+ code: 'invalid',
889
+ });
890
+ default:
891
+ return httpError(`The agent half of plugin "${name}" could not be reached`, 502, {
892
+ code: 'other',
893
+ });
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Change plugin `name`'s settings: `body` is the request's
899
+ * `{ revision, values?, secrets? }` — partial; `null` puts a non-secret field
900
+ * back to its default and clears a secret (refused for a required field).
901
+ * Checked here without `pattern` (400 `{ errors }`), refused while a plugin
902
+ * operation runs (409 `operation_running`) and while the hosting is not
903
+ * active or its health is down (503 `hosting_offline`, with the hosting),
904
+ * then sent to the agent half — each secret sealed to the request — whose
905
+ * authenticated answer decides: its new state (cached, audited with key
906
+ * names only, returned as the settings view), its 409 (`revision_mismatch`
907
+ * with the current revision, `schema_mismatch`), its 422 (`errors`,
908
+ * normalized), or 502 `settings_unsupported` when it does not answer
909
+ * `/admin/settings`. An answer that does not authenticate itself is 502
910
+ * `unauthenticated`. An empty change stores nothing and returns the
911
+ * settings as they are.
912
+ *
913
+ * Nothing derived from `body` is logged, stored or thrown, and nothing keeps
914
+ * it once this returns.
915
+ *
916
+ * @param {string} name
917
+ * @param {unknown} body
918
+ * @param {{ actor: string, certSerial?: string | null, logger?: import('pino').Logger }} opts
919
+ */
920
+ export function updatePluginSettings(name, body, { actor, certSerial = null, logger }) {
921
+ return serialized(name, async () => {
922
+ const plugin = await findPlugin(name);
923
+ if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
924
+ const block = settingsBlockOf(plugin);
925
+ if (!block) {
926
+ throw httpError(`Plugin "${name}" declares no settings`, 404, { code: 'no_settings' });
927
+ }
928
+
929
+ const envelope = parseUpdateEnvelope(body);
930
+ if (!envelope.ok) throw invalidSettings({ _: envelope.error });
931
+ const checked = validateSettingsUpdate(
932
+ block,
933
+ { values: envelope.values, secrets: envelope.secrets },
934
+ { checkPattern: false },
935
+ );
936
+ if (!checked.ok) throw invalidSettings(checked.errors);
937
+
938
+ if (isPluginOperationRunning(name)) {
939
+ const running = await getPluginOperation(name);
940
+ throw httpError(
941
+ `Plugin "${name}" has an operation in progress — change its settings once it finished`,
942
+ 409,
943
+ {
944
+ code: 'operation_running',
945
+ ...(running?.status === 'running' ? { operationId: running.id } : {}),
946
+ },
947
+ );
948
+ }
949
+ const record = await ownHostingOf(plugin);
950
+ if (!record || !isReachable(record)) {
951
+ throw httpError(offlineMessage(name, record), 503, {
952
+ code: 'hosting_offline',
953
+ hosting: await hostingBlock(record),
954
+ });
955
+ }
956
+
957
+ const change = {
958
+ changed: Object.keys(checked.values).filter((key) => checked.values[key] !== null),
959
+ cleared: Object.keys(checked.values).filter((key) => checked.values[key] === null),
960
+ secretsSet: Object.keys(checked.secrets).filter((key) => checked.secrets[key] !== null),
961
+ secretsCleared: Object.keys(checked.secrets).filter((key) => checked.secrets[key] === null),
962
+ };
963
+ if (CHANGE_LISTS.every((list) => change[list].length === 0)) {
964
+ // Nothing to store: the settings as they are.
965
+ let cache = await refreshUnserialized(name, logger);
966
+ let source = cache ? 'live' : 'none';
967
+ if (!cache) {
968
+ cache = await readSettingsCache(name);
969
+ if (cache) source = 'cache';
970
+ }
971
+ return settingsView(block, cache, source, await hostingBlock(record));
972
+ }
973
+
974
+ // Each secret is sealed to this one request: under the secret the call
975
+ // is signed with, bound to the plugin, the assertion's nonce, the key
976
+ // and the revision (core's settings-secrets), so nothing that takes the
977
+ // tunnel's port can read it.
978
+ const signingSecret = signingSecretFor(record);
979
+ const result = await callAgentHalf({
980
+ plugin: record.plugin,
981
+ port: record.port,
982
+ secret: signingSecret,
983
+ method: 'PUT',
984
+ path: AGENT_HALF_SETTINGS_PATH,
985
+ body: (nonce) => ({
986
+ schemaVersion: block.schemaVersion,
987
+ baseRevision: envelope.revision,
988
+ values: checked.values,
989
+ secrets: Object.fromEntries(
990
+ Object.entries(checked.secrets).map(([key, value]) => [
991
+ key,
992
+ value === null
993
+ ? null
994
+ : encryptSettingsSecret({
995
+ secret: signingSecret,
996
+ plugin: record.plugin,
997
+ nonce,
998
+ key,
999
+ baseRevision: envelope.revision,
1000
+ value,
1001
+ }),
1002
+ ]),
1003
+ ),
1004
+ }),
1005
+ timeoutMs: SETTINGS_WRITE_TIMEOUT_MS,
1006
+ maxResponseBytes: AGENT_HALF_SETTINGS_MAX_BYTES,
1007
+ });
1008
+
1009
+ // The agent half may have stored a change whose answer Lamaste cannot
1010
+ // believe: remembered by key names, so that finding it stored later is
1011
+ // the administrator's change and not one made outside Lamaste.
1012
+ const unconfirmed = {
1013
+ revision: envelope.revision + 1,
1014
+ actor,
1015
+ ...(certSerial ? { certSerial } : {}),
1016
+ ...change,
1017
+ };
1018
+ if (!result.ok) {
1019
+ logger?.warn(
1020
+ { plugin: name, code: result.code, err: result.error },
1021
+ 'Could not send the settings change to the agent half',
1022
+ );
1023
+ if (result.code !== 'refused') await recordUnconfirmedChange(name, unconfirmed);
1024
+ throw agentHalfUnreachable(name, result);
1025
+ }
1026
+
1027
+ switch (result.status) {
1028
+ case 200: {
1029
+ const state = parseAgentHalfSettingsState(result.body);
1030
+ if (!state) {
1031
+ logger?.warn({ plugin: name }, 'The agent half answered a settings change unexpectedly');
1032
+ await recordUnconfirmedChange(name, unconfirmed);
1033
+ throw httpError(
1034
+ `The agent half of plugin "${name}" answered the change in an unexpected shape — reload the settings to see what it stored`,
1035
+ 502,
1036
+ { code: 'invalid' },
1037
+ );
1038
+ }
1039
+ const previous = await readSettingsCache(name);
1040
+ const next = cacheOfState(block, state);
1041
+ // The change was made on `envelope.revision`: one Lamaste wrote or
1042
+ // adopted, unless the administrator named a revision the relay
1043
+ // never saw — then what changed before it is reported too.
1044
+ const before = withoutChange(
1045
+ previous ? changedSettings(previous, next) : { changed: [], secretsChanged: [] },
1046
+ change,
1047
+ );
1048
+ const verdict = judgeProvenance(previous, envelope.revision, before);
1049
+ const cache = await storeSettingsCache(name, next, {
1050
+ lamasteRevision: state.revision,
1051
+ unconfirmed: null,
1052
+ });
1053
+ if (verdict === 'confirmed')
1054
+ await recordConfirmedChange(name, previous.unconfirmed, logger);
1055
+ if (verdict === 'outside') {
1056
+ await recordOutsideChange(
1057
+ name,
1058
+ record,
1059
+ { fromRevision: previous.lamasteRevision, revision: envelope.revision, keys: before },
1060
+ logger,
1061
+ );
1062
+ }
1063
+ await recordAuditBestEffort(
1064
+ {
1065
+ actor,
1066
+ action: 'plugin.settings.updated',
1067
+ plugin: name,
1068
+ details: {
1069
+ ...change,
1070
+ revision: state.revision,
1071
+ ...(certSerial ? { certSerial } : {}),
1072
+ },
1073
+ },
1074
+ logger,
1075
+ );
1076
+ logger?.info({ plugin: name, revision: state.revision }, 'Plugin settings changed');
1077
+ return settingsView(block, cache, 'live', await hostingBlock(record));
1078
+ }
1079
+ case 409:
1080
+ throw agentHalfConflict(result.body);
1081
+ case 422:
1082
+ throw invalidSettings(agentHalfErrors(block, result.body), 422);
1083
+ case 404:
1084
+ throw httpError(
1085
+ `The agent half of plugin "${name}" does not answer ${AGENT_HALF_SETTINGS_PATH} — it predates plugin settings`,
1086
+ 502,
1087
+ { code: 'settings_unsupported' },
1088
+ );
1089
+ default:
1090
+ logger?.warn(
1091
+ { plugin: name, status: result.status },
1092
+ 'The agent half refused a settings change',
1093
+ );
1094
+ throw httpError(
1095
+ `The agent half of plugin "${name}" answered ${result.status} to the settings change`,
1096
+ 502,
1097
+ { code: 'agent_half_error', status: result.status },
1098
+ );
1099
+ }
1100
+ });
1101
+ }