@lamalibre/create-lamaste 4.3.1 → 4.4.1

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 (75) hide show
  1. package/package.json +2 -2
  2. package/src/lib/service-config.js +6 -0
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +38 -1
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +6 -6
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +11 -11
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  19. package/vendor/docs/03-architecture/installer.md +4 -4
  20. package/vendor/docs/03-architecture/management-flow.md +1 -1
  21. package/vendor/docs/03-architecture/overview.md +5 -5
  22. package/vendor/docs/03-architecture/panel-client.md +7 -7
  23. package/vendor/docs/03-architecture/panel-server.md +2 -2
  24. package/vendor/docs/03-architecture/state-management.md +2 -2
  25. package/vendor/docs/03-architecture/system-overview.md +1 -1
  26. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +394 -34
  27. package/vendor/docs/04-api-reference/app-host.md +87 -38
  28. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  29. package/vendor/docs/04-api-reference/certificates.md +22 -19
  30. package/vendor/docs/04-api-reference/lifecycle-hooks.md +34 -12
  31. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  32. package/vendor/docs/04-api-reference/overview.md +137 -135
  33. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -1
  34. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  35. package/vendor/docs/05-operations/backup-and-restore.md +2 -1
  36. package/vendor/docs/05-operations/uninstalling.md +1 -1
  37. package/vendor/docs/05-operations/upgrades.md +6 -6
  38. package/vendor/docs/06-reference/config-files.md +90 -85
  39. package/vendor/docs/06-reference/glossary.md +3 -3
  40. package/vendor/docs/06-reference/installer-flags.md +13 -13
  41. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  42. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  43. package/vendor/docs/gatekeeper-design.md +8 -8
  44. package/vendor/docs/identity-directory-design.md +2 -2
  45. package/vendor/docs/package.json +1 -1
  46. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  47. package/vendor/gatekeeper/package.json +2 -2
  48. package/vendor/server/bin/lamaste-server.js +4 -1
  49. package/vendor/server/package.json +4 -3
  50. package/vendor/server/src/commands/plugin-settings.js +541 -0
  51. package/vendor/server/src/commands/plugins.js +94 -27
  52. package/vendor/server/src/panel-api.js +36 -4
  53. package/vendor/server/src/plugin-approval.js +78 -0
  54. package/vendor/server/src/plugin-settings.js +699 -0
  55. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  56. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +84 -0
  57. package/vendor/server-ui/dist/index.html +2 -2
  58. package/vendor/serverd/package.json +2 -2
  59. package/vendor/serverd/src/index.js +9 -0
  60. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  61. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  62. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  63. package/vendor/serverd/src/lib/mtls.js +44 -4
  64. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  65. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  66. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  67. package/vendor/serverd/src/lib/plugin-packages.js +30 -22
  68. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  69. package/vendor/serverd/src/lib/plugins.js +108 -33
  70. package/vendor/serverd/src/lib/registry-credential.js +37 -0
  71. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  72. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  73. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  74. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +0 -84
  75. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
@@ -25,6 +25,8 @@ import {
25
25
  import { listRegisteredSubScopeNames } from './tickets.js';
26
26
  import { getStateDb } from './state-db.js';
27
27
  import { revokePluginCapabilitiesFromAgents } from './mtls.js';
28
+ import { withRegistryUserconfig } from './registry-credential.js';
29
+ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
28
30
  import {
29
31
  bareLamalibrePackageName,
30
32
  installedHookEntrySha256,
@@ -69,10 +71,19 @@ async function getStmts() {
69
71
  INSERT INTO plugins
70
72
  (name, route, display_name, package_name, version, description, capabilities,
71
73
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
72
- agent_half_pin, status, installed_at, enabled_at)
73
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74
+ agent_half_pin, settings, status, installed_at, enabled_at)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74
76
  `),
75
77
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
78
+ // The relay's cache of the plugin's settings (lib/plugin-settings.js)
79
+ // goes with its row, and follows its settings schema.
80
+ deleteSettingsByName: db.prepare('DELETE FROM plugin_settings WHERE plugin = ?'),
81
+ selectSettingsByName: db.prepare('SELECT * FROM plugin_settings WHERE plugin = ?'),
82
+ updatePrunedSettings: db.prepare(`
83
+ UPDATE plugin_settings
84
+ SET values_json = ?, secrets_set_json = ?, configured = ?, missing_json = ?
85
+ WHERE plugin = ?
86
+ `),
76
87
  deleteAll: db.prepare('DELETE FROM plugins'),
77
88
  updateStatusEnable: db.prepare(`
78
89
  UPDATE plugins SET status = 'enabled', enabled_at = ? WHERE name = ?
@@ -125,6 +136,7 @@ function rowToPlugin(row) {
125
136
  if (row.hooks != null) entry.hooks = JSON.parse(row.hooks);
126
137
  if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
127
138
  if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
139
+ if (row.settings != null) entry.settings = JSON.parse(row.settings);
128
140
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
129
141
  return entry;
130
142
  }
@@ -183,6 +195,7 @@ export async function writePlugins(data) {
183
195
  nullableJson(p.hooks),
184
196
  nullableString(p.hooksEntrySha256),
185
197
  nullableJson(p.agentHalfPin),
198
+ nullableJson(p.settings),
186
199
  p.status,
187
200
  p.installedAt,
188
201
  nullableString(p.enabledAt),
@@ -315,6 +328,17 @@ export async function manifestPolicyError(manifest) {
315
328
  return null;
316
329
  }
317
330
 
331
+ /**
332
+ * What a failed install of `spec` is reported as. A registry that refused
333
+ * the panel's credential says so (the registry host and the status, from
334
+ * {@link withRegistryUserconfig}); anything else is npm's failure, whose
335
+ * output is in the log.
336
+ */
337
+ function installFailure(spec, err) {
338
+ const detail = err instanceof RegistryCredentialError ? `: ${err.message}` : '';
339
+ return `Failed to install "${spec}"${detail}`;
340
+ }
341
+
318
342
  export function installPlugin(packageName, logger) {
319
343
  return withPluginLock(async () => {
320
344
  // Validate package scope
@@ -347,19 +371,25 @@ export function installPlugin(packageName, logger) {
347
371
  }
348
372
  }
349
373
 
350
- // Install the npm package
374
+ // Install the npm package — from the configured registry, as the
375
+ // panel's own identity, when a registry credential is set.
351
376
  logger.info({ packageName: installSpec }, 'Installing plugin package');
352
377
  try {
353
- await execa('npm', ['install', '--ignore-scripts', installSpec], {
354
- cwd: STATE_DIR,
355
- timeout: 120000,
356
- });
378
+ await withRegistryUserconfig(
379
+ (env) =>
380
+ execa('npm', ['install', '--ignore-scripts', installSpec], {
381
+ cwd: STATE_DIR,
382
+ timeout: 120000,
383
+ env,
384
+ }),
385
+ { log: logger },
386
+ );
357
387
  } catch (err) {
358
388
  logger.error(
359
389
  { packageName: installSpec, stderr: err.stderr, err: err.message },
360
390
  'npm install failed',
361
391
  );
362
- throw Object.assign(new Error(`Failed to install "${installSpec}"`), { statusCode: 500 });
392
+ throw Object.assign(new Error(installFailure(installSpec, err)), { statusCode: 500 });
363
393
  }
364
394
 
365
395
  // Read the plugin manifest from the installed package
@@ -448,12 +478,16 @@ export function installPlugin(packageName, logger) {
448
478
  hooks: manifest.hooks,
449
479
  hooksEntrySha256,
450
480
  agentHalfPin,
481
+ settings: manifest.settings,
451
482
  status: 'disabled',
452
483
  installedAt: new Date().toISOString(),
453
484
  };
454
485
 
455
486
  s.begin.run();
456
487
  try {
488
+ // No settings are cached for a plugin that is not installed (its
489
+ // uninstall dropped them); none from before may meet this schema.
490
+ s.deleteSettingsByName.run(entry.name);
457
491
  s.insert.run(
458
492
  entry.name,
459
493
  entry.route,
@@ -471,6 +505,7 @@ export function installPlugin(packageName, logger) {
471
505
  nullableJson(entry.hooks),
472
506
  nullableString(entry.hooksEntrySha256),
473
507
  nullableJson(entry.agentHalfPin),
508
+ nullableJson(entry.settings),
474
509
  entry.status,
475
510
  entry.installedAt,
476
511
  null,
@@ -543,10 +578,13 @@ export function uninstallPlugin(name, logger) {
543
578
  logger.warn({ err: err.message }, 'Failed to remove plugin state directory');
544
579
  });
545
580
 
546
- // Remove from registry
581
+ // Remove from registry, with the cache of its settings (the agent half
582
+ // holds the settings themselves; a purge removed them, or they stay with
583
+ // the data an agent keeps of it)
547
584
  s.begin.run();
548
585
  try {
549
586
  s.deleteByName.run(name);
587
+ s.deleteSettingsByName.run(name);
550
588
  s.commit.run();
551
589
  } catch (err) {
552
590
  s.rollback.run();
@@ -593,34 +631,66 @@ const updateRowSql = `
593
631
  UPDATE plugins SET
594
632
  display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
595
633
  config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
596
- agent_half_pin = ?
634
+ agent_half_pin = ?, settings = ?
597
635
  WHERE name = ?`;
598
636
 
599
637
  /**
600
638
  * Rewrite an installed plugin's row from a manifest (an update, or its
601
639
  * reversal) with the agent half pinned for that version (null when Lamaste
602
- * installs none). Status and install time stay.
640
+ * installs none). Status and install time stay. When the settings schema
641
+ * changes, the relay's cache of the settings is pruned in the same
642
+ * transaction — values and secrets whose field changed type or went
643
+ * (`prunedSettingsCache`) — so no read pairs the new schema with values
644
+ * stored for the old one: a value whose field became a secret never shows
645
+ * beside it, and a secret whose field became a plain value is no longer
646
+ * reported set.
603
647
  */
604
648
  export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
605
649
  const s = await getStmts();
606
- s.db
607
- .prepare(updateRowSql)
608
- .run(
609
- nullableString(manifest.displayName),
610
- manifest.version,
611
- nullableString(manifest.description),
612
- JSON.stringify(manifest.capabilities ?? []),
613
- nullableJson(manifest.packages),
614
- nullableJson(manifest.panel),
615
- nullableJson(manifest.config),
616
- nullableJson(manifest.modes),
617
- manifest.hosting ?? DEFAULT_PLUGIN_HOSTING,
618
- nullableJson(manifest.agent),
619
- nullableJson(manifest.hooks),
620
- nullableString(hooksEntrySha256),
621
- nullableJson(agentHalfPin),
622
- name,
650
+ const { prunedSettingsCache } = await import('./plugin-settings.js');
651
+ s.begin.run();
652
+ try {
653
+ const before = s.selectByName.get(name);
654
+ s.db
655
+ .prepare(updateRowSql)
656
+ .run(
657
+ nullableString(manifest.displayName),
658
+ manifest.version,
659
+ nullableString(manifest.description),
660
+ JSON.stringify(manifest.capabilities ?? []),
661
+ nullableJson(manifest.packages),
662
+ nullableJson(manifest.panel),
663
+ nullableJson(manifest.config),
664
+ nullableJson(manifest.modes),
665
+ manifest.hosting ?? DEFAULT_PLUGIN_HOSTING,
666
+ nullableJson(manifest.agent),
667
+ nullableJson(manifest.hooks),
668
+ nullableString(hooksEntrySha256),
669
+ nullableJson(agentHalfPin),
670
+ nullableJson(manifest.settings),
671
+ name,
672
+ );
673
+ const pruned = prunedSettingsCache(
674
+ s.selectSettingsByName.get(name),
675
+ before?.settings != null ? JSON.parse(before.settings) : null,
676
+ manifest.settings ?? null,
623
677
  );
678
+ if (pruned?.delete) {
679
+ s.deleteSettingsByName.run(name);
680
+ } else if (pruned) {
681
+ s.updatePrunedSettings.run(
682
+ JSON.stringify(pruned.values),
683
+ JSON.stringify(pruned.secretsSet),
684
+ pruned.configured ? 1 : 0,
685
+ JSON.stringify(pruned.missing),
686
+ name,
687
+ );
688
+ }
689
+ s.commit.run();
690
+ } catch (err) {
691
+ s.rollback.run();
692
+ throw err;
693
+ }
624
694
  }
625
695
 
626
696
  /**
@@ -637,13 +707,18 @@ export function replacePluginPackage(name, packageName, version, logger) {
637
707
  assertSafeLamalibrePackage(spec);
638
708
  logger?.info({ name, spec }, 'Installing plugin package version');
639
709
  try {
640
- await execa('npm', ['install', '--ignore-scripts', spec], {
641
- cwd: STATE_DIR,
642
- timeout: 120000,
643
- });
710
+ await withRegistryUserconfig(
711
+ (env) =>
712
+ execa('npm', ['install', '--ignore-scripts', spec], {
713
+ cwd: STATE_DIR,
714
+ timeout: 120000,
715
+ env,
716
+ }),
717
+ { log: logger },
718
+ );
644
719
  } catch (err) {
645
720
  logger?.error({ spec, stderr: err.stderr, err: err.message }, 'npm install failed');
646
- throw Object.assign(new Error(`Failed to install "${spec}"`), { statusCode: 502 });
721
+ throw Object.assign(new Error(installFailure(spec, err)), { statusCode: 502 });
647
722
  }
648
723
  const require = createRequire(`${STATE_DIR}/`);
649
724
  const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
@@ -12,6 +12,13 @@
12
12
  * install within the hour and limited to ten new tokens per agent per
13
13
  * hour.
14
14
  *
15
+ * The panel's own npm calls — installing a plugin, staging an update with
16
+ * `npm pack`, pinning an agent half with `npm view` — go through
17
+ * {@link withRegistryUserconfig}: with a credential configured, npm is
18
+ * routed to its registry for its scope and authenticated with the service
19
+ * token through a throw-away userconfig; without one, npm runs as it is
20
+ * configured on the machine (the public registry by default).
21
+ *
15
22
  * Also the one piece of policy that belongs here: when a user loses a
16
23
  * `feria-*` group or is deleted, their registry tokens are revoked (best
17
24
  * effort, never blocks the user operation).
@@ -26,6 +33,7 @@ import {
26
33
  revokeRegistryTokensForOwner,
27
34
  lostFeriaGroups,
28
35
  readOidcClientSecret,
36
+ withRegistryUserconfig as withRegistryUserconfigCore,
29
37
  RegistryServiceTokenCache,
30
38
  AgentTokenBroker,
31
39
  RegistryCredentialError,
@@ -102,6 +110,35 @@ async function serviceToken(config) {
102
110
  return (await serviceTokens.resolve(config, await oidcDeps(config))).token;
103
111
  }
104
112
 
113
+ /**
114
+ * Run `fn(env)` — one npm call of the panel's own — against the configured
115
+ * registry: with a credential, `env` names an isolated userconfig (0600,
116
+ * removed afterwards) routing the credential's scope to its registry with
117
+ * the panel's service token; a `401` refreshes the token once and retries.
118
+ * Without a credential `fn({})` runs npm as the machine configures it.
119
+ * A credential file that cannot be read fails the call rather than
120
+ * silently installing from the public registry.
121
+ *
122
+ * @template T
123
+ * @param {(env: Record<string, string>) => Promise<T>} fn
124
+ * @param {{ log?: import('pino').Logger | undefined }} [options]
125
+ * @returns {Promise<T>}
126
+ */
127
+ export async function withRegistryUserconfig(fn, options = {}) {
128
+ const config = await readRegistryCredentialConfig();
129
+ if (!config) return fn({});
130
+ const deps = await oidcDeps(config);
131
+ return withRegistryUserconfigCore(
132
+ config,
133
+ async ({ refresh }) => {
134
+ if (refresh) serviceTokens.invalidate();
135
+ return (await serviceTokens.resolve(config, deps)).token;
136
+ },
137
+ fn,
138
+ { log: options.log },
139
+ );
140
+ }
141
+
105
142
  /**
106
143
  * The calling agent's registry token: the cached one when it is still good
107
144
  * for an install, otherwise a freshly brokered one-hour token — unless the
@@ -9,6 +9,11 @@
9
9
  * up, or after a restart of the panel. The agent side of the pipeline runs
10
10
  * on the agent (its sync follows the relay's view of the tunnel).
11
11
  *
12
+ * Whenever the plugin comes up — after a `down`, or first seen up — the
13
+ * relay also re-reads its settings from the agent half: they may have
14
+ * changed while the relay could not see it (a restore, a retyped field
15
+ * dropped by a new version).
16
+ *
12
17
  * Calls for one plugin are serialised and run beside the monitor, which
13
18
  * does not wait for them.
14
19
  */
@@ -21,6 +26,7 @@ import {
21
26
  } from './plugin-hooks.js';
22
27
  import { runRelayHook } from './relay-hooks.js';
23
28
  import { rememberTunnelFallback } from './request-hooks.js';
29
+ import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
24
30
 
25
31
  const chains = new Map();
26
32
 
@@ -34,6 +40,11 @@ export function onHealthTransition(plugin, before, after, logger) {
34
40
  .then(() => runTunnelHook(plugin, before, after, logger))
35
41
  .catch((err) => {
36
42
  logger?.error({ plugin, err: err?.message }, 'Tunnel-state hook pipeline failed');
43
+ })
44
+ .then(async () => {
45
+ if (after.state === 'up' && before?.state !== 'up') {
46
+ await refreshPluginSettingsBestEffort(plugin, logger);
47
+ }
37
48
  });
38
49
  chains.set(plugin, next);
39
50
  next.finally(() => {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
2
+ import { AGENT_HALF_SETTINGS_MAX_BYTES, AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
3
3
  import {
4
4
  readPlugins,
5
5
  installPlugin,
@@ -35,6 +35,11 @@ import {
35
35
  listPluginOperations,
36
36
  takeOperationReveal,
37
37
  } from '../../lib/plugin-operations.js';
38
+ import {
39
+ attachSettingsSummaries,
40
+ getPluginSettingsView,
41
+ updatePluginSettings,
42
+ } from '../../lib/plugin-settings.js';
38
43
  import { actorFromRequest, certSerialFromRequest } from '../../lib/actor.js';
39
44
  import { recordAudit } from '../../lib/audit.js';
40
45
  import {
@@ -112,13 +117,57 @@ function operationErrorBody(err, fallback) {
112
117
  };
113
118
  }
114
119
 
120
+ /**
121
+ * The error body of a refused settings read or change: the message and what
122
+ * a client acts on — `code` (`no_settings`, `operation_running` with the
123
+ * operation's id, `hosting_offline` with the `hosting`, `revision_mismatch`
124
+ * with the current `revision`, `schema_mismatch`, `settings_unsupported`,
125
+ * `refused`, `timeout`, …) and `errors` per field (400: the panel's check,
126
+ * 422: the agent half's). Built from what the settings library put on the
127
+ * error — never from the request body.
128
+ */
129
+ function settingsErrorBody(err, fallback) {
130
+ // Only the settings library's own refusals carry a status; anything else
131
+ // is answered without its message.
132
+ if (!err.statusCode) return { error: fallback };
133
+ return {
134
+ error: err.message,
135
+ ...(err.code ? { code: err.code } : {}),
136
+ ...(err.errors ? { errors: err.errors } : {}),
137
+ ...(Number.isInteger(err.revision) ? { revision: err.revision } : {}),
138
+ ...(err.operationId ? { operationId: err.operationId } : {}),
139
+ ...(err.hosting !== undefined ? { hosting: err.hosting } : {}),
140
+ // The agent half's own status, when it refused with an unexpected one.
141
+ ...(Number.isInteger(err.status) ? { status: err.status } : {}),
142
+ };
143
+ }
144
+
115
145
  const OperationParamSchema = z.object({
116
146
  id: z.string().uuid(),
117
147
  });
118
148
 
149
+ /**
150
+ * `digest`: the approval digest that was shown — the installed version's or
151
+ * a pending update's. `version`: a pending update's version, which approves
152
+ * that update (with its digest, null when it brings neither relay hooks nor
153
+ * an agent half).
154
+ */
119
155
  const ApproveHooksBodySchema = z
120
- .object({ digest: z.string().regex(/^[a-f0-9]{64}$/, 'digest is a SHA-256 hex string') })
121
- .strict();
156
+ .object({
157
+ digest: z
158
+ .string()
159
+ .regex(/^[a-f0-9]{64}$/, 'digest is a SHA-256 hex string')
160
+ .nullable()
161
+ .optional(),
162
+ version: z
163
+ .string()
164
+ .regex(/^[0-9A-Za-z.+-]{1,64}$/, 'version is a plugin version')
165
+ .optional(),
166
+ })
167
+ .strict()
168
+ .refine((body) => (body.digest ?? null) !== null || body.version !== undefined, {
169
+ message: 'digest or version is required',
170
+ });
122
171
 
123
172
  const AutoApproveBodySchema = z.object({ enabled: z.boolean() }).strict();
124
173
 
@@ -281,7 +330,10 @@ export default async function pluginRoutes(fastify, _opts) {
281
330
  // `retainedData`: what agents keep of its agent half after it was
282
331
  // removed without purge. `hooks`: its lifecycle hooks and their
283
332
  // approval (see GET /plugins/:name/hooks).
284
- const plugins = await attachHostings(registry.plugins);
333
+ // `settings`: for a plugin that declares settings, whether it still
334
+ // needs configuration — { configured, missing, syncedAt }, no values
335
+ // (see GET /plugins/:name/settings).
336
+ const plugins = await attachSettingsSummaries(await attachHostings(registry.plugins));
285
337
  return {
286
338
  plugins: await Promise.all(plugins.map(withHooks)),
287
339
  // Every agent's retained data — of uninstalled plugins too, which
@@ -308,7 +360,7 @@ export default async function pluginRoutes(fastify, _opts) {
308
360
  return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
309
361
  }
310
362
 
311
- const [withHosting] = await attachHostings([plugin]);
363
+ const [withHosting] = await attachSettingsSummaries(await attachHostings([plugin]));
312
364
  return { plugin: await withHooks(withHosting) };
313
365
  },
314
366
  );
@@ -538,6 +590,89 @@ export default async function pluginRoutes(fastify, _opts) {
538
590
  },
539
591
  );
540
592
 
593
+ // ===========================================================================
594
+ // Settings of an agent-hosted plugin (kept by its agent half)
595
+ // ===========================================================================
596
+
597
+ // ------------------------------------------------------------------
598
+ // GET /plugins/:name/settings — { schemaVersion, fields, values, configured,
599
+ // missing, revision, source, syncedAt, hosting }: each non-secret field's
600
+ // effective value, each secret as { set: true, setAt } or { set: false }
601
+ // (never a value). Read from the agent half when its hosting is active
602
+ // and not down (`source: live`), else from the relay's cache (`cache`;
603
+ // `none` when never read). 404 `no_settings` for a plugin without them.
604
+ // ------------------------------------------------------------------
605
+ fastify.get(
606
+ '/plugins/:name/settings',
607
+ {
608
+ preHandler: fastify.requireRole(['admin']),
609
+ },
610
+ async (request, reply) => {
611
+ const params = PluginNameParamSchema.parse(request.params);
612
+ try {
613
+ return await getPluginSettingsView(params.name, { logger: request.log });
614
+ } catch (err) {
615
+ if (!err.statusCode) {
616
+ request.log.error(
617
+ { plugin: params.name, err: err.message },
618
+ 'Failed to read the plugin settings',
619
+ );
620
+ }
621
+ return reply
622
+ .code(err.statusCode || 500)
623
+ .send(settingsErrorBody(err, 'Failed to read the plugin settings'));
624
+ }
625
+ },
626
+ );
627
+
628
+ // ------------------------------------------------------------------
629
+ // PUT /plugins/:name/settings { revision, values?, secrets? } — a partial
630
+ // change (`null` clears an optional field), sent through the tunnel to
631
+ // the agent half, which stores it: 200 with the settings as above. 400
632
+ // `errors` (checked here), 409 (`operation_running`, the agent half's
633
+ // `revision_mismatch` with `revision`, `schema_mismatch`), 422 `errors`
634
+ // (the agent half's check, normalized), 503 `hosting_offline` with
635
+ // `hosting`, 502 `settings_unsupported` / `refused` / `unauthenticated`
636
+ // (an answer without the agent half's responder signature), 504 `timeout`.
637
+ //
638
+ // The body may carry secrets. It is handed to the settings library once
639
+ // and dropped from the request; nothing here logs or repeats it. The
640
+ // library seals each secret to the one call that carries it through the
641
+ // tunnel.
642
+ // ------------------------------------------------------------------
643
+ fastify.put(
644
+ '/plugins/:name/settings',
645
+ {
646
+ preHandler: fastify.requireRole(['admin']),
647
+ bodyLimit: AGENT_HALF_SETTINGS_MAX_BYTES,
648
+ config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
649
+ },
650
+ async (request, reply) => {
651
+ const body = request.body;
652
+ request.body = undefined;
653
+ const params = PluginNameParamSchema.parse(request.params);
654
+ try {
655
+ return await updatePluginSettings(params.name, body, {
656
+ actor: actorFromRequest(request),
657
+ certSerial: certSerialFromRequest(request),
658
+ logger: request.log,
659
+ });
660
+ } catch (err) {
661
+ if (!err.statusCode) {
662
+ // A failure of Lamaste's own (state.db, a malformed hosting record):
663
+ // its message names no value of the change.
664
+ request.log.error(
665
+ { plugin: params.name, err: err.message },
666
+ 'Failed to change the plugin settings',
667
+ );
668
+ }
669
+ return reply
670
+ .code(err.statusCode || 500)
671
+ .send(settingsErrorBody(err, 'Failed to change the plugin settings'));
672
+ }
673
+ },
674
+ );
675
+
541
676
  // ===========================================================================
542
677
  // Lifecycle hooks: review, approval, auto-approve
543
678
  // ===========================================================================
@@ -547,7 +682,8 @@ export default async function pluginRoutes(fastify, _opts) {
547
682
  // fail-open flags, effects, digest), the agent hooks, the agent half
548
683
  // Lamaste installs (package, version, integrity, service, system
549
684
  // packages), the approval (`approval.digest` is what to approve) and a
550
- // pending update's relay hook set and agent half
685
+ // pending update — its relay hook set and agent half, its settings
686
+ // schema diff, why it waits (`approvalRequired`)
551
687
  // ------------------------------------------------------------------
552
688
  fastify.get(
553
689
  '/plugins/:name/hooks',
@@ -563,10 +699,12 @@ export default async function pluginRoutes(fastify, _opts) {
563
699
  );
564
700
 
565
701
  // ------------------------------------------------------------------
566
- // POST /plugins/:name/hooks/approve — approve what the plugin brings
567
- // (relay hooks, an agent half Lamaste installs) with the digest that was
568
- // shown. Approving a pending update's digest applies the update (202
569
- // with its operation).
702
+ // POST /plugins/:name/hooks/approve { digest?, version? } — approve what
703
+ // the plugin brings (relay hooks, an agent half Lamaste installs) with
704
+ // the digest that was shown. Approving a pending update — by its digest,
705
+ // or by its version (and digest, when it has one) — approves everything
706
+ // it waits for (`approvalRequired`) and applies it (202 with its
707
+ // operation).
570
708
  // ------------------------------------------------------------------
571
709
  fastify.post(
572
710
  '/plugins/:name/hooks/approve',
@@ -582,18 +720,23 @@ export default async function pluginRoutes(fastify, _opts) {
582
720
  const actor = actorFromRequest(request);
583
721
  const certSerial = certSerialFromRequest(request);
584
722
  try {
585
- const approved = await approveHookSet(plugin, body.digest, {
723
+ const approved = await approveHookSet(plugin, body.digest ?? null, {
586
724
  actor,
587
725
  certSerial,
588
726
  logger: request.log,
727
+ version: body.version ?? null,
589
728
  });
590
729
  if (approved.target === 'update') {
591
- const state = await readHookState(plugin.name);
592
- const result = await updatePlugin(plugin.name, state.pendingUpdate.version, {
730
+ // The update as it was approved: fetched again, it must still be that.
731
+ const result = await updatePlugin(plugin.name, approved.version, {
593
732
  actor,
594
733
  certSerial,
595
734
  logger: request.log,
596
- approvedDigest: body.digest,
735
+ approvedUpdate: {
736
+ version: approved.version,
737
+ digest: approved.digest,
738
+ secretToPlain: approved.secretToPlain,
739
+ },
597
740
  sourceIp: request.ip,
598
741
  });
599
742
  return reply.code(202).send({ ok: true, ...result });
@@ -603,7 +746,8 @@ export default async function pluginRoutes(fastify, _opts) {
603
746
  const statusCode = err.statusCode || 500;
604
747
  return reply.code(statusCode).send({
605
748
  ...operationErrorBody(err, 'Failed to approve the hook set'),
606
- ...(err.currentDigest ? { currentDigest: err.currentDigest } : {}),
749
+ // The digest to review now: null when what waits brings nothing to approve by one.
750
+ ...(err.currentDigest !== undefined ? { currentDigest: err.currentDigest } : {}),
607
751
  });
608
752
  }
609
753
  },
@@ -638,9 +782,11 @@ export default async function pluginRoutes(fastify, _opts) {
638
782
  // ------------------------------------------------------------------
639
783
  // POST /plugins/:name/update { version? } — fetch the version (default
640
784
  // latest) and read it without installing it; pin its agent half. A
641
- // changed relay hook set or agent half waits for approval
642
- // (202 { pendingUpdate }); otherwise the update runs as an operation
643
- // (202 { operation }). 200 { upToDate } when there is nothing to do.
785
+ // changed relay hook set or agent half (unless auto-approved), and a
786
+ // secret setting turned plain (always), wait for approval
787
+ // (202 { pendingUpdate }, its `approvalRequired` says why); otherwise the
788
+ // update runs as an operation (202 { operation }). 200 { upToDate } when
789
+ // there is nothing to do.
644
790
  // ------------------------------------------------------------------
645
791
  fastify.post(
646
792
  '/plugins/:name/update',
@@ -716,7 +862,8 @@ export default async function pluginRoutes(fastify, _opts) {
716
862
  details: { packageName: plugin.packageName, version: plugin.version },
717
863
  });
718
864
  // `hooks`: the relay hook set to review (and approve) before hosting.
719
- return { ok: true, plugin, oidcClients, hooks: await hookStatus(plugin) };
865
+ const [listed] = await attachSettingsSummaries([plugin]);
866
+ return { ok: true, plugin: listed, oidcClients, hooks: await hookStatus(plugin) };
720
867
  } catch (err) {
721
868
  const statusCode = err.statusCode || 500;
722
869
  // `route_taken`: another installed plugin holds the route (`route`, `owner`).
@@ -24,11 +24,12 @@ import {
24
24
  } from '@lamalibre/lamaste';
25
25
  import { getConfig } from '../../lib/config.js';
26
26
  import { readTunnels, readSites } from '../../lib/state.js';
27
- import { loadAgentRegistry } from '../../lib/mtls.js';
27
+ import { getAgentHostFactsHash, loadAgentRegistry } from '../../lib/mtls.js';
28
28
  import { getChiselCredentialIssuedAt } from '../../lib/chisel-users.js';
29
29
  import { buildChiselArgs } from '../../lib/chisel-args.js';
30
30
  import { listAgentPluginAssignments } from '../../lib/plugin-hosting.js';
31
31
  import { pendingAgentJobs } from '../../lib/agent-jobs.js';
32
+ import { recordAgentContact } from '../../lib/agent-presence.js';
32
33
  import {
33
34
  tunnelNginxDeps,
34
35
  tunnelCertbotDeps,
@@ -236,6 +237,17 @@ export default async function tunnelRoutes(fastify, _opts) {
236
237
  // Parsed outside the try so a malformed ?agent= is a 400 from the
237
238
  // shared Zod error handler, not a 500 from the catch below.
238
239
  const { agent } = AgentConfigQuerySchema.parse(request.query);
240
+ // An agent's own call (its sync, every 30 s) is its contact with the
241
+ // relay — what the panel shows as when a hosting agent last synced.
242
+ // An admin naming an agent with ?agent= is not that agent's contact.
243
+ const caller = confinedLabel(request);
244
+ if (caller !== null && AGENT_LABEL_REGEX.test(caller)) {
245
+ try {
246
+ await recordAgentContact(caller);
247
+ } catch (err) {
248
+ request.log.warn({ err: err.message, agent: caller }, 'Failed to record agent contact');
249
+ }
250
+ }
239
251
  try {
240
252
  const config = getConfig();
241
253
  const tunnels = await readTunnels();
@@ -285,6 +297,14 @@ export default async function tunnelRoutes(fastify, _opts) {
285
297
  // through its app host helper (`app-install`, `app-activate`,
286
298
  // `app-remove`) — see lib/agent-jobs.js for each job's fields.
287
299
  agentJobs: AGENT_LABEL_REGEX.test(label) ? await pendingAgentJobs(label) : [],
300
+ // For the agent's own sync: the hash of the host facts stored on
301
+ // its record (null: none), which it compares with its own and
302
+ // reports them again when they differ — a record replaced since its
303
+ // last report (enrolled again on the label, a restored backup)
304
+ // holds none. Not for an admin's ?agent= call.
305
+ ...(caller !== null && AGENT_LABEL_REGEX.test(caller)
306
+ ? { hostFactsHash: await getAgentHostFactsHash(caller) }
307
+ : {}),
288
308
  };
289
309
  } catch (err) {
290
310
  request.log.error(err, 'Failed to generate agent config');