@lamalibre/create-lamaste 4.2.0 → 4.3.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 (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -0,0 +1,4 @@
1
+ -- The `agent` block of an agent-hosted plugin's manifest (4.3): present
2
+ -- when the plugin has an agent half Lamaste connects to. JSON text; NULL for
3
+ -- server plugins and identity-only agent-hosted plugins.
4
+ ALTER TABLE plugins ADD COLUMN agent TEXT;
@@ -0,0 +1,29 @@
1
+ -- Lamaste's audit log and panel notifications (4.3, slice 2b).
2
+ --
3
+ -- audit_events: append-only record of administrative decisions and of what
4
+ -- plugins' lifecycle hooks report through the `audit` effect. `details` is
5
+ -- JSON metadata — never a request or response body, a secret or personal
6
+ -- data. Pruned by age and count (lib/audit.js), never edited.
7
+ CREATE TABLE audit_events (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ at TEXT NOT NULL,
10
+ actor TEXT NOT NULL,
11
+ action TEXT NOT NULL,
12
+ plugin TEXT,
13
+ details TEXT NOT NULL DEFAULT '{}'
14
+ );
15
+ CREATE INDEX audit_events_plugin ON audit_events (plugin, id);
16
+ CREATE INDEX audit_events_action ON audit_events (action, id);
17
+
18
+ -- notifications: messages for the panel's administrators — the
19
+ -- `notify-admins` hook effect and Lamaste's own events. Read state is
20
+ -- shared: the panel has one administrator identity.
21
+ CREATE TABLE notifications (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ at TEXT NOT NULL,
24
+ source TEXT NOT NULL,
25
+ level TEXT NOT NULL,
26
+ message TEXT NOT NULL,
27
+ read_at TEXT
28
+ );
29
+ CREATE INDEX notifications_unread ON notifications (read_at, id);
@@ -0,0 +1,70 @@
1
+ -- Lifecycle hooks of agent-hosted plugins (4.3, slice 2b).
2
+
3
+ -- The manifest's `hooks` block (JSON; NULL when the plugin declares none)
4
+ -- and the SHA-256 of its relay entry module, read when the package was
5
+ -- installed. Together they give the relay hook set's digest without
6
+ -- touching the package again.
7
+ ALTER TABLE plugins ADD COLUMN hooks TEXT;
8
+ ALTER TABLE plugins ADD COLUMN hooks_entry_sha256 TEXT;
9
+
10
+ -- Approval of a plugin's relay hook set, and what the hook runner holds.
11
+ -- approved_digest the digest an administrator (or auto-approve) approved
12
+ -- auto_approve approve later hook-set changes automatically (audited)
13
+ -- installed_digest the digest lamaste-priv last installed into the runner
14
+ -- pack_versions JSON {pack: version} of the shared-hook rule packs last
15
+ -- recorded, to audit a rule update once per plugin
16
+ -- pending_update JSON {version, digest, ...} of a staged update waiting
17
+ -- for approval, or NULL
18
+ -- tunnel_fallback JSON fallback response the plugin's tunnel:down hook
19
+ -- returned, served while it is down; NULL otherwise
20
+ -- Kept per plugin name; removed when the plugin is uninstalled.
21
+ CREATE TABLE plugin_hook_sets (
22
+ plugin TEXT PRIMARY KEY,
23
+ approved_digest TEXT,
24
+ approved_version TEXT,
25
+ approved_at TEXT,
26
+ approved_by TEXT,
27
+ auto_approve INTEGER NOT NULL DEFAULT 0,
28
+ installed_digest TEXT,
29
+ pack_versions TEXT NOT NULL DEFAULT '{}',
30
+ pending_update TEXT,
31
+ tunnel_fallback TEXT
32
+ );
33
+
34
+ -- Long-running plugin operations (hosting, unhosting, update, uninstall):
35
+ -- one running per plugin; steps and outcome kept for the panel. `steps` is
36
+ -- JSON [{name, status, startedAt, finishedAt, error?}] — metadata only.
37
+ CREATE TABLE plugin_operations (
38
+ id TEXT PRIMARY KEY,
39
+ plugin TEXT NOT NULL,
40
+ kind TEXT NOT NULL,
41
+ actor TEXT NOT NULL,
42
+ status TEXT NOT NULL,
43
+ steps TEXT NOT NULL DEFAULT '[]',
44
+ error TEXT,
45
+ params TEXT NOT NULL DEFAULT '{}',
46
+ started_at TEXT NOT NULL,
47
+ finished_at TEXT
48
+ );
49
+ CREATE INDEX plugin_operations_plugin ON plugin_operations (plugin, started_at);
50
+
51
+ -- Work for an agent's sync (agent-config `agentJobs`): calls the agent
52
+ -- makes to its agent half on its own loopback (hook stages outside the
53
+ -- tunnel), reported back with a result. `input` is the hook input (JSON
54
+ -- metadata, no secrets); the agent signs the call with the installation
55
+ -- secret it already holds.
56
+ CREATE TABLE agent_jobs (
57
+ id TEXT PRIMARY KEY,
58
+ agent_label TEXT NOT NULL,
59
+ plugin TEXT NOT NULL,
60
+ kind TEXT NOT NULL,
61
+ stage TEXT NOT NULL,
62
+ input TEXT NOT NULL,
63
+ timeout_ms INTEGER NOT NULL,
64
+ status TEXT NOT NULL,
65
+ result TEXT,
66
+ created_at TEXT NOT NULL,
67
+ deadline_at TEXT NOT NULL,
68
+ completed_at TEXT
69
+ );
70
+ CREATE INDEX agent_jobs_agent ON agent_jobs (agent_label, status);
@@ -0,0 +1,53 @@
1
+ -- Agent halves Lamaste installs onto an agent (4.3, slice 3).
2
+
3
+ -- The agent half pinned when the plugin's version was staged (install or
4
+ -- update): JSON {package, version, integrity}, where `integrity` is the npm
5
+ -- Subresource Integrity (sha512) of the agent package's tarball as the
6
+ -- registry published it. The approval digest covers it and the agent
7
+ -- installs only a tarball that matches it. NULL when Lamaste installs no
8
+ -- agent half (no `agent.package` in the manifest).
9
+ ALTER TABLE plugins ADD COLUMN agent_half_pin TEXT;
10
+
11
+ -- Agent jobs gain kinds besides `hook`: `app-install`, `app-activate` and
12
+ -- `app-remove` run the agent's root helper. Their parameters are `payload`
13
+ -- (JSON: {spec} | {version, package} | {purge, package}); `stage` and
14
+ -- `input` belong to hook jobs only, so they become nullable. SQLite cannot
15
+ -- drop a NOT NULL constraint in place: the table is rebuilt with its rows.
16
+ CREATE TABLE agent_jobs_v2 (
17
+ id TEXT PRIMARY KEY,
18
+ agent_label TEXT NOT NULL,
19
+ plugin TEXT NOT NULL,
20
+ kind TEXT NOT NULL,
21
+ stage TEXT,
22
+ input TEXT,
23
+ payload TEXT,
24
+ timeout_ms INTEGER NOT NULL,
25
+ status TEXT NOT NULL,
26
+ result TEXT,
27
+ created_at TEXT NOT NULL,
28
+ deadline_at TEXT NOT NULL,
29
+ completed_at TEXT
30
+ );
31
+ INSERT INTO agent_jobs_v2
32
+ (id, agent_label, plugin, kind, stage, input, payload, timeout_ms, status, result,
33
+ created_at, deadline_at, completed_at)
34
+ SELECT id, agent_label, plugin, kind, stage, input, NULL, timeout_ms, status, result,
35
+ created_at, deadline_at, completed_at
36
+ FROM agent_jobs;
37
+ DROP TABLE agent_jobs;
38
+ ALTER TABLE agent_jobs_v2 RENAME TO agent_jobs;
39
+ CREATE INDEX agent_jobs_agent ON agent_jobs (agent_label, status);
40
+
41
+ -- What an agent reports about its host (JSON HostFacts: OS, distribution,
42
+ -- architecture, Node.js, systemd, the app host helper) and when it last
43
+ -- did — checked against a plugin's `agent.requires` when it is hosted.
44
+ ALTER TABLE agents ADD COLUMN host_facts TEXT;
45
+ ALTER TABLE agents ADD COLUMN host_facts_at TEXT;
46
+
47
+ -- An agent's push-install window and policy. The registry code has set both
48
+ -- fields since push install exists, but the SQLite table (0004) had no
49
+ -- columns for them, so an enabled window was never stored and the
50
+ -- push-install gates could never pass. Hosting, updating and removing an
51
+ -- agent half are gated on them.
52
+ ALTER TABLE agents ADD COLUMN push_install_enabled_until TEXT;
53
+ ALTER TABLE agents ADD COLUMN push_install_policy TEXT;
@@ -0,0 +1,47 @@
1
+ -- Plugin routes are unique; data an agent half leaves on an agent is kept
2
+ -- on record (4.3, slice 3).
3
+
4
+ -- A plugin's route — its name without a trailing "-server" or "-agent"
5
+ -- (`derivePluginRoute`) — is what its API (`/api/<route>`), capabilities
6
+ -- (`plugin:<route>:*`), OIDC client ids, hosting record and, on the agent,
7
+ -- its agent half's account and directories are named after. Two installed
8
+ -- plugins sharing one ("fx" and "fx-server") would act on each other's
9
+ -- hosting and capabilities, so each route belongs to one installed plugin.
10
+ ALTER TABLE plugins ADD COLUMN route TEXT;
11
+ UPDATE plugins SET route = CASE
12
+ WHEN substr(name, -7) = '-server' THEN substr(name, 1, length(name) - 7)
13
+ WHEN substr(name, -6) = '-agent' THEN substr(name, 1, length(name) - 6)
14
+ ELSE name
15
+ END;
16
+ -- Plugins installed before the rule may already share a route: the one
17
+ -- installed first keeps it; the others hold none (NULL) and can only be
18
+ -- uninstalled — never hosted — until they are reinstalled under a route
19
+ -- of their own.
20
+ UPDATE plugins SET route = NULL WHERE EXISTS (
21
+ SELECT 1 FROM plugins AS other
22
+ WHERE other.route = plugins.route
23
+ AND other.name <> plugins.name
24
+ AND (other.installed_at < plugins.installed_at
25
+ OR (other.installed_at = plugins.installed_at AND other.name < plugins.name))
26
+ );
27
+ CREATE UNIQUE INDEX plugins_route ON plugins (route);
28
+
29
+ -- The data an agent half leaves on an agent when it is removed without
30
+ -- purge (unhost, uninstall, a rolled-back install): its data directory and
31
+ -- service account, both named after the route. Kept here — not on the
32
+ -- plugin row — because it outlives the plugin's uninstall and is keyed the
33
+ -- way it exists on the agent: one record per agent and route. `package` is
34
+ -- the agent package that owns it (the agent's helper refuses to install
35
+ -- another package over it); `plugin` the plugin it belonged to. Removed when
36
+ -- it is purged, or when the same agent half is installed there again and
37
+ -- takes the data back.
38
+ CREATE TABLE retained_agent_data (
39
+ agent_label TEXT NOT NULL,
40
+ route TEXT NOT NULL,
41
+ plugin TEXT NOT NULL,
42
+ package TEXT NOT NULL,
43
+ since TEXT NOT NULL,
44
+ operation TEXT,
45
+ PRIMARY KEY (agent_label, route)
46
+ );
47
+ CREATE INDEX retained_agent_data_plugin ON retained_agent_data (plugin);
@@ -0,0 +1,14 @@
1
+ -- App jobs are signed by the relay (4.3, slice 3).
2
+
3
+ -- The agent's root helper runs an app job (`app-install`, `app-activate`,
4
+ -- `app-remove`) only when the relay signed it, with the key the agent's
5
+ -- operator pinned: JSON {envelope, sig} (core `app-job-signature.ts`),
6
+ -- made with the relay's signing key when the job is created and listed with
7
+ -- it in agent-config. NULL for hook jobs, which the helper never sees.
8
+ ALTER TABLE agent_jobs ADD COLUMN signature TEXT;
9
+
10
+ -- An app job left pending from before has no signature, and no agent would
11
+ -- run it: it is cancelled rather than listed.
12
+ UPDATE agent_jobs
13
+ SET status = 'cancelled', completed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
14
+ WHERE status = 'pending' AND kind != 'hook';
@@ -15,10 +15,11 @@ import crypto from 'node:crypto';
15
15
  import { access, constants, mkdir } from 'node:fs/promises';
16
16
  import {
17
17
  BASE_CAPABILITIES as CORE_BASE_CAPABILITIES,
18
+ HostFactsSchema,
18
19
  PLUGIN_AGENT_CN_PREFIX as CORE_PLUGIN_AGENT_CN_PREFIX,
19
20
  } from '@lamalibre/lamaste';
20
21
  import { addToRevocationList } from './revocation.js';
21
- import { releaseAgentTunnels } from '@lamalibre/lamaste/server';
22
+ import { releaseAgentTunnels, stripPluginCapabilities } from '@lamalibre/lamaste/server';
22
23
  import { tunnelNginxDeps, tunnelChiselDeps, tunnelStateDeps } from './tunnel-deps.js';
23
24
  import {
24
25
  addChiselCredential,
@@ -241,8 +242,9 @@ async function getStmts() {
241
242
  insert: db.prepare(`
242
243
  INSERT INTO agents
243
244
  (label, serial, capabilities, allowed_sites, enrollment_method,
244
- delegated_by, created_at, expires_at, revoked, revoked_at)
245
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
245
+ delegated_by, created_at, expires_at, revoked, revoked_at,
246
+ host_facts, host_facts_at, push_install_enabled_until, push_install_policy)
247
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
246
248
  `),
247
249
 
248
250
  deleteAll: db.prepare('DELETE FROM agents'),
@@ -276,13 +278,51 @@ function rowToAgent(row) {
276
278
  };
277
279
  if (row.revoked_at != null) agent.revokedAt = row.revoked_at;
278
280
  if (row.delegated_by != null) agent.delegatedBy = row.delegated_by;
281
+ if (row.host_facts != null) agent.hostFacts = parseHostFacts(row.host_facts);
282
+ if (row.host_facts_at != null) agent.hostFactsAt = row.host_facts_at;
283
+ if (row.push_install_enabled_until != null) {
284
+ agent.pushInstallEnabledUntil = row.push_install_enabled_until;
285
+ }
286
+ if (row.push_install_policy != null) agent.pushInstallPolicy = row.push_install_policy;
279
287
  return agent;
280
288
  }
281
289
 
290
+ /**
291
+ * Stored host facts, re-validated on the way out: a row that no longer
292
+ * parses as HostFacts (written by another version) reads as none, so
293
+ * eligibility asks the agent to report again instead of trusting it.
294
+ */
295
+ function parseHostFacts(text) {
296
+ try {
297
+ const parsed = HostFactsSchema.safeParse(JSON.parse(text));
298
+ return parsed.success ? parsed.data : null;
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
282
304
  function nullableString(value) {
283
305
  return value === undefined || value === null ? null : value;
284
306
  }
285
307
 
308
+ /**
309
+ * Remove every `plugin:<route>:*` capability from every agent's stored
310
+ * record, under the registry lock — run when a plugin is uninstalled, so
311
+ * installing it again later does not silently re-grant what an admin gave
312
+ * the old installation. Returns the per-agent diff for the audit log.
313
+ *
314
+ * @param {string} route
315
+ * @returns {Promise<Array<{ label: string, removed: string[] }>>}
316
+ */
317
+ export function revokePluginCapabilitiesFromAgents(route) {
318
+ return withRegistryLock(async () => {
319
+ const registry = await loadAgentRegistry();
320
+ const diffs = stripPluginCapabilities(registry.agents, route);
321
+ if (diffs.length > 0) await saveAgentRegistry(registry);
322
+ return diffs;
323
+ });
324
+ }
325
+
286
326
  /**
287
327
  * Load the agent registry.
288
328
  * Returns `{ agents: [] }` if the table is empty.
@@ -340,6 +380,10 @@ export async function saveAgentRegistry(data) {
340
380
  a.expiresAt,
341
381
  a.revoked ? 1 : 0,
342
382
  nullableString(a.revokedAt),
383
+ a.hostFacts ? JSON.stringify(a.hostFacts) : null,
384
+ nullableString(a.hostFactsAt),
385
+ nullableString(a.pushInstallEnabledUntil),
386
+ nullableString(a.pushInstallPolicy),
343
387
  );
344
388
  }
345
389
  s.commit.run();
@@ -533,20 +577,24 @@ export async function generateAgentCert(label, logger, capabilities, allowedSite
533
577
  }
534
578
 
535
579
  /**
536
- * List all agent certificates with expiry status.
580
+ * List all agent certificates with expiry status and the host facts each
581
+ * agent last reported. `status` sums a certificate up for the panel:
582
+ * `revoked`, `expired`, `expiring-soon` (within 30 days) or `active`.
537
583
  *
538
- * @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean }>>}
584
+ * @returns {Promise<Array<{ label: string, serial: string, createdAt: string, expiresAt: string, revoked: boolean, expiringSoon: boolean, status: 'active' | 'expiring-soon' | 'expired' | 'revoked', hostFacts: object | null, hostFactsAt: string | null }>>}
539
585
  */
540
586
  export async function listAgentCerts() {
541
587
  const registry = await loadAgentRegistry();
588
+ const now = Date.now();
542
589
 
543
590
  return registry.agents.map((agent) => {
544
591
  let expiringSoon = false;
592
+ let expired = false;
545
593
  if (!agent.revoked && agent.expiresAt) {
546
- const daysUntilExpiry = Math.floor(
547
- (new Date(agent.expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24),
548
- );
594
+ const expiresAt = new Date(agent.expiresAt).getTime();
595
+ const daysUntilExpiry = Math.floor((expiresAt - now) / (1000 * 60 * 60 * 24));
549
596
  expiringSoon = daysUntilExpiry <= 30;
597
+ expired = expiresAt <= now;
550
598
  }
551
599
  const isPluginAgent = agent.label.startsWith(PLUGIN_AGENT_CN_PREFIX);
552
600
  const entry = {
@@ -554,6 +602,16 @@ export async function listAgentCerts() {
554
602
  capabilities: agent.capabilities || (isPluginAgent ? [] : ['tunnels:read']),
555
603
  enrollmentMethod: agent.enrollmentMethod || 'p12',
556
604
  expiringSoon,
605
+ status: agent.revoked
606
+ ? 'revoked'
607
+ : expired
608
+ ? 'expired'
609
+ : expiringSoon
610
+ ? 'expiring-soon'
611
+ : 'active',
612
+ // What the agent last reported about its host (null until it has).
613
+ hostFacts: agent.hostFacts ?? null,
614
+ hostFactsAt: agent.hostFactsAt ?? null,
557
615
  };
558
616
  if (isPluginAgent) {
559
617
  entry.certType = 'plugin-agent';
@@ -657,6 +715,29 @@ export async function updateAgentAllowedSites(label, allowedSites) {
657
715
  });
658
716
  }
659
717
 
718
+ /**
719
+ * Record what agent `label` reports about its host (validated
720
+ * `HostFacts`), under the registry lock. Only an enrolled, unrevoked
721
+ * agent has a record to keep them on.
722
+ *
723
+ * @param {string} label
724
+ * @param {import('@lamalibre/lamaste').HostFacts} hostFacts
725
+ * @returns {Promise<{ hostFactsAt: string }>}
726
+ */
727
+ export function setAgentHostFacts(label, hostFacts) {
728
+ return withRegistryLock(async () => {
729
+ const registry = await loadAgentRegistry();
730
+ const agent = registry.agents.find((a) => a.label === label && !a.revoked);
731
+ if (!agent) {
732
+ throw Object.assign(new Error(`Agent certificate "${label}" not found`), { statusCode: 404 });
733
+ }
734
+ agent.hostFacts = hostFacts;
735
+ agent.hostFactsAt = new Date().toISOString();
736
+ await saveAgentRegistry(registry);
737
+ return { hostFactsAt: agent.hostFactsAt };
738
+ });
739
+ }
740
+
660
741
  /**
661
742
  * Revoke an agent certificate by label.
662
743
  *