@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,407 @@
1
+ # Agent-Hosted Plugins
2
+
3
+ An **agent-hosted plugin** is an application that runs on one of your agents — typically a VM — while its admin pages live in the Lamaste panel. Lamaste never runs the plugin's code on the relay. This page covers what Lamaste adds for a plugin whose manifest has an `agent` block: hosting it on an agent, the internal plugin tunnel, the forwarded admin API, the installation secret and the health monitor — and, for a plugin that names its agent package, installing, updating and removing that agent half on the agent from the panel ([Hosting with an agent half](#hosting-with-an-agent-half)). What happens on the agent machine itself — the root helper, the unit, the signed jobs — is in [App Host](app-host.md). The hooks a plugin can run around all of these — and their approval — are in [Lifecycle Hooks](lifecycle-hooks.md). The full design is `plugin-development/design/agent-hosted-app-plugins.md`.
4
+
5
+ ## In Plain English
6
+
7
+ The plugin has two halves. The **agent half** is the application: it runs on the hosting agent, listens on the agent's loopback and holds the data. The **panel** serves the plugin's pages and forwards every call under `/api/<route>/` to the agent half, over a tunnel that exists only on the relay's loopback — no hostname, no certificate, no DNS. Each forwarded call carries a signed **caller assertion** naming who the panel admitted, so the agent half trusts the panel and nothing else that can reach the relay's loopback.
8
+
9
+ Hosting a plugin on an agent does three things at once: it creates the internal tunnel (Lamaste picks the port), issues the installation secret, and grants the agent's certificate the capability that lets it — and only it — fetch that secret. The agent's sync timer fetches the secret within about 30 seconds and hands it to the agent half; from then on the panel's pages work. When the manifest names the agent package, hosting also installs the agent half on the agent — the agent downloads exactly the version the relay pinned and its root helper sets it up as a service — once an administrator approved what it brings and the agent's push-install window is open.
10
+
11
+ ## The manifest
12
+
13
+ ```json
14
+ {
15
+ "name": "market-indicators",
16
+ "version": "1.0.0",
17
+ "hosting": "agent",
18
+ "agent": {
19
+ "requires": { "node": ">=22", "os": ["linux"], "systemPackages": ["sqlite3"] },
20
+ "package": "@lamalibre/market-indicators-agent",
21
+ "service": { "entry": "dist/agentd.mjs", "memoryMax": "256M" }
22
+ },
23
+ "packages": { "server": "@lamalibre/market-indicators-server" },
24
+ "capabilities": ["plugin:market-indicators:use"],
25
+ "panel": {
26
+ "pages": [{ "path": "/indicators", "title": "Indicators" }],
27
+ "apiPrefix": "/api/market-indicators"
28
+ }
29
+ }
30
+ ```
31
+
32
+ | Field | Meaning |
33
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `name` | For a plugin with an `agent` block: exactly its route — no `-server` or `-agent` suffix (its packages keep their names). The route keys its hosting, its internal tunnel, its forwarded API and, on the agent, its account and directories, while its approvals, operations and audit entries go by name; the two must be one. Refused at install otherwise (`400`). |
35
+ | `hosting` | `"agent"`. Lamaste never imports the package. |
36
+ | `agent` | The plugin has an agent half Lamaste connects to. Only accepted with `hosting: "agent"`. Without it the plugin is identity only (OIDC clients and capabilities, as Feria), and may not declare a panel. |
37
+ | `agent.requires` | Optional. `node` (a semver range), `os` (`linux`, `darwin`), `systemPackages` (Debian names, at most 50). Checked against the agent's [host facts](#host-facts) when Lamaste installs the agent half; for one installed by hand, nothing is checked and the system packages are listed for the operator. |
38
+ | `agent.package` | Optional. The agent half's package: an `@lamalibre/` package published at the plugin's version, one bundled tarball (no dependencies, no install scripts — see [App Host](app-host.md#writing-an-agent-half)). With it, Lamaste installs the agent half on the hosting agent; without it, the agent half is installed by hand. Needs a route that starts and ends with a letter or digit. |
39
+ | `agent.service` | Required with `agent.package`, and only with it. `entry`: a relative `.js`, `.mjs` or `.cjs` path inside the package, run as the service; `memoryMax` (optional): the unit's `MemoryMax=`, `[1-9][0-9]{0,5}` followed by `K`, `M` or `G`. |
40
+ | `panel` | Allowed with an `agent` block: the pages are listed in the panel, and the bundle at `/api/<route>/panel.js` comes from the agent half (`/admin/panel.js`). |
41
+ | `hooks` | Lifecycle hooks on the relay and in the agent half — see [Lifecycle Hooks](lifecycle-hooks.md). |
42
+
43
+ There is no port in the manifest: Lamaste assigns it when the plugin is hosted.
44
+
45
+ **Routes are unique.** A plugin's route — its name without a trailing `-server` or `-agent` — belongs to one installed plugin: installing another plugin with the same route is refused (`409`, `code: "route_taken"`, with `route` and `owner`). Plugins installed before the rule that share a route keep it for the one installed first; the others hold no route and can be uninstalled but not hosted (`409`, `code: "route_conflict"`) until they are reinstalled under a route of their own.
46
+
47
+ **Pinned at install.** Installing (or staging an update of) a plugin with `agent.package` reads the agent package's integrity from the registry the panel installs from (`npm view <package>@<version> dist.integrity`) and pins `{ package, version, integrity }` with the plugin: the approval covers it and the agent installs only a tarball that matches. A version that is not semver without build metadata, an agent package not published at the plugin's version (`400`) or a registry without a SHA-512 integrity for it is refused.
48
+
49
+ ## Hosting endpoints
50
+
51
+ All admin only.
52
+
53
+ ### `POST /api/plugins/:name/hosting`
54
+
55
+ ```json
56
+ { "agentLabel": "vm1" }
57
+ ```
58
+
59
+ Hosts the plugin on an enrolled machine agent: starts the plugin's **install pipeline** as an operation and answers `202 { ok, operation }` (follow it at `GET /api/plugins/:name/operations/:id`). Around the plugin's [install hooks](lifecycle-hooks.md#the-pipelines), Lamaste reserves the internal tunnel — the lowest free port of **21000–21999** (the range Lamaste reserves for internal tunnels; a tunnel created by hand may not use it), without its chisel grant yet — a hosting record with a fresh 32-byte installation secret (phase `installing`), and `plugin:<route>:host` on the agent's certificate; the agent fetches its config; for a plugin with `agent.package` the agent installs the agent half ([below](#hosting-with-an-agent-half)); then the tunnel is granted and Lamaste waits until the agent half answers through it, and the hosting turns `active`. A failure rolls back.
60
+
61
+ | Status | Reason |
62
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63
+ | 400 | The plugin is a server plugin or has no `agent` block; the agent is unknown, revoked or a plugin-agent certificate; the relay is not onboarded |
64
+ | 404 | Plugin not installed |
65
+ | 409 | The plugin is already hosted (a plugin has one hosting agent); another operation of the plugin runs; what it brings is not approved (`hooks_not_approved`); the agent cannot host it (`agent_not_eligible`, with `reasons`); the agent keeps another package's data for the route (`retained_data_conflict`, with `retained`); `route_conflict`; `hosting_owner_mismatch` |
66
+ | 400, 403, 404, 500 | `push_install_refused`: a gate of [push install](#push-install-gates) refused installing the agent package — with that gate's status |
67
+
68
+ The hosting record (`GET`, and `agentHosting` on the plugin list):
69
+
70
+ ```json
71
+ {
72
+ "plugin": "market-indicators",
73
+ "agentLabel": "vm1",
74
+ "port": 21000,
75
+ "tunnelId": "…",
76
+ "createdAt": "2026-10-15T10:00:00.000Z",
77
+ "secretIssuedAt": "2026-10-15T10:00:00.000Z",
78
+ "secretAcknowledged": false,
79
+ "rotationPending": false,
80
+ "health": { "state": "unknown", "since": "2026-10-15T10:00:00.000Z" },
81
+ "phase": "active",
82
+ "agentHalf": {
83
+ "package": "@lamalibre/market-indicators-agent",
84
+ "version": "1.0.0",
85
+ "installedAt": "2026-10-15T10:01:30.000Z"
86
+ },
87
+ "pluginName": "market-indicators"
88
+ }
89
+ ```
90
+
91
+ `phase` is `installing` (the install pipeline runs: secret delivered, tunnel not yet granted), `active`, or `removing` (the uninstall pipeline runs: tunnel gone, secret not yet). The forwarder and the health monitor use only active hostings. `agentHalf` is the version of the agent half Lamaste installed that the agent reported running (`null`: installed by hand, or not yet). `pluginName` is the plugin that owns the hosting: no operation of one plugin reads, changes or removes a hosting another plugin owns (`409`, `code: "hosting_owner_mismatch"`).
92
+
93
+ ### `GET /api/plugins/:name/hosting`
94
+
95
+ The record above, with the health the monitor observed last (`checkedAt` and `lastError` while it is not up). `404` when the plugin is not hosted. `GET /api/plugins` and `GET /api/plugins/:name` carry the same record as `agentHosting` (`null` when not hosted).
96
+
97
+ ### `DELETE /api/plugins/:name/hosting`
98
+
99
+ Starts the plugin's **uninstall pipeline** as an operation (`202 { ok, operation }`): its pre-teardown hooks, the tunnel removed (its chisel grant withdrawn), the agent's post-teardown hook while the agent still holds the secret, the agent half Lamaste installed removed, then the secret, the record and the capability (the agent's sync deletes its config file). `?force=true` lets failing pre-teardown hooks pass — for an agent that is gone — and is recorded in the audit log. `?purge=true` also deletes the data directory and service account of an agent half Lamaste installed (kept by default — [retained data](#retained-data)); `400` for an agent half installed by hand. Uninstalling a hosted plugin (`DELETE /api/plugins/:name`, which then answers `202` with its operation) runs the same pipeline first and takes the same `force` and `purge`.
100
+
101
+ ### `POST /api/plugins/:name/hosting/rotate-secret`
102
+
103
+ Issues a new installation secret (10 per minute). The secret in use becomes the _previous_ one and the panel keeps signing with it until the hosting agent acknowledges the new one — so no request is ever signed with a secret the agent half cannot verify. The agent's sync fetches and acknowledges within about 30 seconds — for an agent half Lamaste installed, only once the service's copy is written too; the response shows `rotationPending: true` until then.
104
+
105
+ ## Hosting with an agent half
106
+
107
+ For a plugin whose manifest names `agent.package`, Lamaste installs, updates and removes the agent half on the hosting agent itself, through the agent's root helper `lamaste-apphost`, which the agent's operator enables once (`sudo lamaste-agent apphost enable`). The agent machine's side — the helper, what it creates, the unit, the signed jobs — is in [App Host](app-host.md). A manifest without `agent.package` is hosted as before: its agent half is installed by hand, and nothing below is checked.
108
+
109
+ ### The pipelines
110
+
111
+ The operation's steps, in order:
112
+
113
+ | Install (hosting) | What happens |
114
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `relay install:pre-tunnel` | The plugin's relay hook, if declared |
116
+ | `reserve port, secret and capability` | The internal tunnel without its chisel grant, the record with its secret (`installing`), `plugin:<route>:host` |
117
+ | `install the agent half` | An `app-install` job: the agent's sync fetches exactly the pinned `<package>@<version>` with a registry token the panel brokers, checks its SHA-512 against the pinned integrity, and has its helper install it and start it with the secret reserved above. The agent must report that version with its unit `active` |
118
+ | `agent install:pre-tunnel` | The agent half's hook, called by the agent's sync on its loopback (an agent job) — the service installed above answers it |
119
+ | `grant the tunnel and wait for the agent half` | The chisel grant; Lamaste waits up to 150 s for `/admin/health` through the tunnel |
120
+ | `agent install:post-tunnel` | Through the tunnel |
121
+ | `relay install:post-tunnel` | |
122
+ | `activate` | The hosting turns `active` |
123
+
124
+ Uninstall (unhost, or uninstalling a hosted plugin): `relay uninstall:pre-teardown` → `agent uninstall:pre-teardown` → `remove the tunnel` → `agent uninstall:post-teardown` (agent job, while the agent still holds the secret) → `remove the agent half` (an `app-remove` job: unit, code and config; with `purge` its data directory, account and owner record too) → `revoke the secret and the capability` → `relay uninstall:post-teardown`. An agent that is no longer enrolled cannot be reached: the removal is skipped with a warning naming the command that finishes it on the machine, `sudo lamaste-agent apphost remove <route>`.
125
+
126
+ **Rollback.** Once an install job exists the agent may act on it, whatever it reports later. A failure from then on removes the agent half again (without purge — its data is kept and [recorded](#retained-data)); an install the agent's helper reported as failed was undone by the helper itself, and nothing is recorded for it. The agent never removes or switches an agent half on its own: every recovery is a job the relay signs.
127
+
128
+ **Update** (`POST /api/plugins/:name/update`, [Lifecycle Hooks](lifecycle-hooks.md#updating-a-plugin)) of a hosted plugin installs the new agent half first: `relay install:pre-tunnel` → `agent install:pre-tunnel` (through the tunnel) → `install the new agent half` (the helper switches `current` and keeps the previous version; Lamaste waits until it answers through the tunnel) → `install the new version` (the relay package) → `agent install:post-tunnel` → `relay install:post-tunnel`. A failure after the install job was dispatched runs `restore the previous version` and `activate the previous agent half` (an `app-activate` job naming the version the hosting recorded). The hosting must be `active` and its agent enrolled (`409` otherwise) and eligible for the new version, and push install must allow `update`. A version may not switch, under a hosting, between an agent half installed by hand and one Lamaste installs, nor drop the agent half (`400` — clear the hosting first).
129
+
130
+ Every app job is signed by the relay with its signing key when it is created, and the agent's helper runs only what the relay signed ([Signed app jobs](app-host.md#signed-app-jobs)). While the relay's signing key is unusable no app job is created and the step fails.
131
+
132
+ ### Host facts
133
+
134
+ Each agent reports its host on its sync when the facts change, and once a day: `PUT /api/agents/me/host-facts` (agent certificates only; 10 per minute, 4 KiB).
135
+
136
+ ```json
137
+ {
138
+ "os": "linux",
139
+ "arch": "x64",
140
+ "distro": { "id": "ubuntu", "idLike": ["debian"], "versionId": "24.04" },
141
+ "node": "22.13.1",
142
+ "systemd": true,
143
+ "apphost": { "version": 1, "node": "22.13.1", "relayKid": "…32 hex…" }
144
+ }
145
+ ```
146
+
147
+ | Field | Meaning |
148
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
149
+ | `os` | `linux` or `darwin` |
150
+ | `arch` | Node's `os.arch()` |
151
+ | `distro` | `ID`, `ID_LIKE` and `VERSION_ID` of `/etc/os-release`; `null` on macOS |
152
+ | `node` | The Node.js the agent runs — what `agent.requires.node` of an agent half installed by hand would run on |
153
+ | `systemd` | systemd is the system manager |
154
+ | `apphost` | The app host helper; `null` when it is not enabled or does not answer. `version`: its protocol; `node`: the Node.js it — and every agent half it installs — runs on; `relayKid`: the id of the relay key pinned for this agent (`null`: none pinned) |
155
+
156
+ The relay keeps them on the agent's record (`hostFacts`, `hostFactsAt`; `GET /api/certs/agent`), re-validated on every read. The panel shows them on the Certificates page and in the host dialog. The agent asks its helper only when the helper file changed or a day passed (sudo logs every run); the pinned key is read on every sync, so a re-pin is reported at once. A panel older than 4.3 answers `404`, which the agent logs as a warning.
157
+
158
+ ### `GET /api/plugins/:name/hosting/eligibility`
159
+
160
+ Which enrolled machine agents can host the plugin, and why the others cannot — what the host dialog lists.
161
+
162
+ ```json
163
+ {
164
+ "agents": [
165
+ {
166
+ "label": "mac-mini",
167
+ "eligible": false,
168
+ "reasons": ["Lamaste installs an agent half only on a Linux agent; the agent runs macOS"]
169
+ },
170
+ { "label": "vm1", "eligible": true, "reasons": [] }
171
+ ]
172
+ }
173
+ ```
174
+
175
+ For a plugin with `agent.package`, each agent's host facts are checked: it reported them at all; `agent.requires.os`; `agent.requires.node` against the helper's Node.js; Linux with systemd; the helper enabled at protocol 1 or later; the relay's signing key usable and the one the agent's helper has pinned (`relayKid`); declared system packages only on Debian or Ubuntu. For an agent half installed by hand every agent is eligible — nothing is checked. Hosting and updating check it again (`409`, `code: "agent_not_eligible"`, with the `reasons`). Admin only.
176
+
177
+ ### Push-install gates
178
+
179
+ Installing, updating and removing an agent half Lamaste installs are **pushes**: the relay has the agent install, switch or remove code as root. Each is checked when its operation starts — hosting as `install`, an update as `update` (when it starts, not while it waits for approval), unhosting, uninstalling or purging as `uninstall` — against push install:
180
+
181
+ 1. Push install is on for the relay — else `400`.
182
+ 2. The agent is enrolled — else `404`.
183
+ 3. The agent's window is open — else `403`.
184
+ 4. The request's source address passes the agent's policy (`allowedIps`, `deniedIps`) — else `403`. It is the address nginx accepted the administrator's connection from; `lamaste-server` calls the panel from `127.0.0.1`.
185
+ 5. The policy allows the action (`allowedActions`) and, when `allowedPlugins` is not empty, names the agent package — else `403`.
186
+
187
+ A refusal carries that status and `code: "push_install_refused"`. A push that passes is logged in the push-install session log (`status: "started"`, with its `operationId`). Removing the agent half of an agent that is no longer enrolled is not gated: nothing can be pushed to it.
188
+
189
+ To open a window from the panel: the Plugins page's **Push install** section turns push install on, edits policies and opens an agent's window for a number of minutes (default 30); the host dialog shows each agent's window and links there. From the CLI: `lamaste-server push-install on` and `lamaste-server push-install open <agent> [--minutes <n>] [--policy <id>]`. The API: `PATCH /api/plugins/push-install/config { enabled }`, `POST /api/plugins/push-install/enable/:label { durationMinutes, policyId? }` (5–480 minutes; any agent label enrolment allows, up to 63 characters) and `DELETE` the same path to close it. `GET /api/plugins/push-install/config` reports the bounds as `windowMinutes: { "min": 5, "max": 480 }`. The default policy allows `install`, `update`, `uninstall` and `check-prerequisites`; a stored default policy whose actions were never edited gains `uninstall`.
190
+
191
+ ### Approval
192
+
193
+ What a version brings that Lamaste runs or installs — relay hooks and the agent half — is approved with one digest ([Approval](lifecycle-hooks.md#approval)); for the agent half it covers the package, version, tarball integrity, service and system packages. Hosting waits for it (`409`, `code: "hooks_not_approved"`). `GET /api/plugins/:name/hooks` shows what is to be approved as `agentHalf`:
194
+
195
+ ```json
196
+ "agentHalf": {
197
+ "package": "@lamalibre/market-indicators-agent",
198
+ "version": "1.0.0",
199
+ "integrity": "sha512-…",
200
+ "entry": "dist/agentd.mjs",
201
+ "memoryMax": "256M",
202
+ "systemPackages": ["sqlite3"],
203
+ "serviceUser": "lamaste-app-market-indicators",
204
+ "unitName": "lamalibre-app-market-indicators.service",
205
+ "dataDir": "/var/lib/lamalibre/apps/market-indicators"
206
+ }
207
+ ```
208
+
209
+ An update whose agent half differs waits for approval like a changed hook set: `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, autoApproveWithheld }` (`relay` and `agentHalf` are `null` when the version brings none). **Auto-approve** covers a new version and tarball of the same agent half — what every release brings — and is **withheld** when the version brings an agent half where there was none, changes the agent package, adds system packages, or changes the service entry or memory limit: those run as root on the agent and wait for an administrator. `autoApproveWithheld` says why; the audit log records `hooks.auto-approve.withheld` and the administrators are notified.
210
+
211
+ ### Retained data
212
+
213
+ Removing an agent half without purge — unhosting, uninstalling, a rolled-back install — keeps its data directory and service account on the agent. The relay records it, one record per agent and route, by the name of the plugin it belonged to, so it can be purged after the plugin is uninstalled too. Hosting the same agent package on that agent again takes the data back and drops the record; another package is refused while it is there (`409`, `code: "retained_data_conflict"`, with the `retained` record), because the data is not its own.
214
+
215
+ In the panel, the Plugins page lists the records under **Retained data** on the plugin's card and, for plugins that are no longer installed, in **Retained data of uninstalled plugins** below the list. Each record offers **Purge** — its confirmation names the agent, the route, the data directory and the account it deletes; the purge runs as an operation the page follows like any other — and **Forget**, whose confirmation says the data stays on the machine and how to remove it there. The CLI: `lamaste-server plugins retained-data [<name>]`, `purge-data <name> --agent <label>` and `forget-data <name> --agent <label>` ([below](#cli)).
216
+
217
+ #### `GET /api/plugins/:name/retained-data`
218
+
219
+ ```json
220
+ {
221
+ "retainedData": [
222
+ {
223
+ "agentLabel": "vm1",
224
+ "route": "market-indicators",
225
+ "plugin": "market-indicators",
226
+ "package": "@lamalibre/market-indicators-agent",
227
+ "since": "2026-10-16T09:00:00.000Z",
228
+ "operation": "…uuid…",
229
+ "dataDir": "/var/lib/lamalibre/apps/market-indicators",
230
+ "serviceUser": "lamaste-app-market-indicators"
231
+ }
232
+ ]
233
+ }
234
+ ```
235
+
236
+ `operation` is the operation that removed the agent half (`null` when none did); `dataDir` and `serviceUser` are what a purge deletes on the agent. `GET /api/plugins` carries each plugin's records as `retainedData`, and every agent's — of uninstalled plugins too — as a top-level `retainedData`.
237
+
238
+ #### `DELETE /api/plugins/:name/retained-data?agentLabel=<label>`
239
+
240
+ Purges what agent `<label>` keeps (10 per minute): an `app-remove` job with purge — the helper deletes the data directory, the account and the owner record — gated by push install as `uninstall` of the package; `202 { ok, operation }` (kind `purge-data`). With `&forget=true` only the relay's record goes and the agent is not touched — for an agent that is gone, or data removed on the machine by hand: `200 { ok, forgotten }`. Both are audited.
241
+
242
+ | Status | Reason |
243
+ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
244
+ | 404 | That agent keeps no data of the plugin |
245
+ | 409 | The plugin is hosted on that agent and uses the data (`retained_data_in_use` — remove its hosting with purge instead); the agent is no longer enrolled (`agent_not_enrolled` — purge on the machine with `sudo lamaste-agent apphost remove <route> --purge`, then forget) |
246
+
247
+ `DELETE /api/plugins/:name?purge=true` of a plugin that is not hosted is refused (`400`), naming the agents that keep its data. The refusals that send an administrator to retained data — `retained_data_conflict`, `agent_not_enrolled` and that `400` — name the panel's Retained data and the `lamaste-server plugins purge-data|forget-data` commands.
248
+
249
+ ### Error codes
250
+
251
+ A refused plugin operation answers `{ error, code?, reasons?, retained?, route?, owner?, operationId? }`:
252
+
253
+ | `code` | Status | When |
254
+ | ------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------ |
255
+ | `hooks_not_approved` | 409 | Hosting a plugin whose relay hooks or agent half are not approved (the forwarded API answers `503` with this code) |
256
+ | `agent_not_eligible` | 409 | Hosting or updating on an agent that cannot host the agent half — `reasons` lists why |
257
+ | `push_install_refused` | 400, 403, 404, 500 | A push-install gate refused installing, updating or removing the agent half |
258
+ | `route_taken` | 409 | Installing a plugin whose route another installed plugin holds — `route` and `owner` |
259
+ | `route_conflict` | 409 | The plugin holds no route (it shared one before routes were unique) or is not named by its route |
260
+ | `hosting_owner_mismatch` | 409 | The hosting on the plugin's route belongs to another plugin |
261
+ | `retained_data_conflict` | 409 | The agent keeps another agent package's data for the route — `retained` is its record |
262
+ | `retained_data_in_use` | 409 | Purging retained data the plugin's hosting uses |
263
+ | `agent_not_enrolled` | 409 | Purging retained data of an agent that is no longer enrolled |
264
+
265
+ `409` with `operationId` and no `code`: another operation of the plugin is running.
266
+
267
+ ## The hosting agent's endpoints
268
+
269
+ Identity comes strictly from the agent's mTLS certificate, which must carry `plugin:<route>:host`; the hosting must name that agent.
270
+
271
+ ### `GET /api/agents/me/plugin-config/:route`
272
+
273
+ ```json
274
+ {
275
+ "plugin": "market-indicators",
276
+ "port": 21000,
277
+ "secret": "…64 hex…",
278
+ "issuedAt": "2026-10-15T10:00:00.000Z",
279
+ "previousSecret": "…",
280
+ "previousIssuedAt": "…"
281
+ }
282
+ ```
283
+
284
+ `previousSecret` is present only during a rotation. `403` without the capability (an admin certificate has no agent identity and is refused too), `404` when the plugin is not hosted on this agent.
285
+
286
+ ### `POST /api/agents/me/plugin-config/:route/ack`
287
+
288
+ ```json
289
+ { "issuedAt": "2026-10-15T10:00:00.000Z" }
290
+ ```
291
+
292
+ The agent confirms it holds the secret issued then. Answers `{ "ok": true, "acknowledged": true, "secretIssuedAt": … }`; `acknowledged: false` with the current `secretIssuedAt` when the agent named an older secret, in which case it fetches again.
293
+
294
+ ### `POST /api/agents/me/jobs/:id/result`
295
+
296
+ The agent reports a job `agentJobs` gave it, once: a hook job `{ "ok": true, "decision": … }`, an app job `{ "ok": true, "app": { "version": "1.0.0", "unit": "active" } }` (`version` `null` and `unit` `absent` after a removal), either `{ "ok": false, "error": "…" }` (one line, at most 500 characters, no secret). Only the machine agent the job is for; a hook job's agent must also hold the plugin's host capability, an app job's need not (purging retained data runs after the hosting and its capability are gone). 64 KiB at most; `404` for an unknown job or another agent's, `409` when it is no longer pending, `400` for a result that does not fit the job's kind.
297
+
298
+ ### `PUT /api/agents/me/host-facts`
299
+
300
+ What the agent's host offers — see [Host facts](#host-facts).
301
+
302
+ ### `GET /api/agents/me/relay-keys`
303
+
304
+ `{ "keys": [{ "kid": "…32 hex…", "publicKey": "…base64 DER SPKI…" }], "agentLabel": "vm1" }` — the relay's public key, which signs relay assertions and app jobs, and the label the relay knows the calling agent by (`null` for an admin certificate). `503` while the relay's signing key is unusable. `sudo lamaste-agent apphost enable|pin` pins the first key for the agent's helper ([App Host](app-host.md#pinning-the-relay-key)).
305
+
306
+ ### What the agent does with it
307
+
308
+ `GET /api/tunnels/agent-config` lists the plugins hosted on the calling agent and the jobs it has to run:
309
+
310
+ ```json
311
+ "appPlugins": [
312
+ {
313
+ "route": "market-indicators", "port": 21000, "secretIssuedAt": "…", "secretAcknowledged": true,
314
+ "phase": "active",
315
+ "health": { "state": "up", "since": "…" },
316
+ "agentHooks": [{ "stage": "tunnel:down", "timeoutMs": 60000 }]
317
+ }
318
+ ],
319
+ "agentJobs": [
320
+ {
321
+ "id": "…uuid…", "route": "market-indicators", "kind": "app-install",
322
+ "timeoutMs": 600000, "deadlineAt": "…",
323
+ "spec": {
324
+ "package": "@lamalibre/market-indicators-agent", "version": "1.0.0", "integrity": "sha512-…",
325
+ "service": { "entry": "dist/agentd.mjs", "memoryMax": "256M" }, "systemPackages": ["sqlite3"]
326
+ },
327
+ "signature": { "envelope": "…base64url…", "sig": "…base64url…" }
328
+ }
329
+ ]
330
+ ```
331
+
332
+ `health` is the relay's view of the tunnel (the agent cannot see its own chisel session); the sync calls the agent half's `tunnel:down` / `tunnel:up` when it changes. `agentJobs` are agent hook calls the sync makes on the agent's loopback while there is no tunnel (`kind: "hook"` — see [Lifecycle Hooks](lifecycle-hooks.md#agent-hooks)) and the **app jobs** that install, switch or remove the agent half Lamaste installs: `app-install` (`spec`), `app-activate` (`version`, `package`) and `app-remove` (`purge`, `package`), each with the relay's `signature` over exactly that job ([Signed app jobs](app-host.md#signed-app-jobs)). An app job's timeout is 10 minutes for an install, 2 for an activation and 5 for a removal; its deadline adds 90 s of delivery. Finished jobs are kept a day.
333
+
334
+ `lamaste-agent sync` (the 30-second timer) fetches a plugin's config when it does not hold the secret issued at `secretIssuedAt`, writes `~/.lamalibre/lamaste/agents/<label>/app-plugins/<route>.json` (0600, atomic), acknowledges it, and removes the file of a plugin the relay no longer lists. For an agent half Lamaste installed it first writes the service's own copy, `/etc/lamalibre/apps/<route>/plugin-config.json`, and acknowledges only once both are written. `sync.log` records `plugin config fetched: <route>` and `plugin config delivered to its service: <route>`.
335
+
336
+ The agent half reads that file — `LAMALIBRE_LAMASTE_APP_PLUGIN_CONFIG` by convention — for its port and verifies every request on `/admin/*` with `createCallerAssertionVerifier` from `@lamalibre/lamaste@4.3.0/agent`, which re-reads the file when a signature does not verify, so a rotation needs no restart:
337
+
338
+ ```js
339
+ import { createCallerAssertionVerifier } from '@lamalibre/lamaste@4.3.0/agent';
340
+
341
+ const verifier = createCallerAssertionVerifier({ configPath, plugin: 'market-indicators' });
342
+ const port = await verifier.port();
343
+ // on each /admin request:
344
+ const result = await verifier.verify(req.headers, req.method, req.url);
345
+ if (!result.ok) return reply.code(401).send({ error: result.reason });
346
+ // result.payload.caller is { type: 'admin' }, { type: 'agent', label } or { type: 'lamaste' }
347
+ ```
348
+
349
+ ## The forwarded admin API
350
+
351
+ Once the plugin is enabled and hosted, the panel answers `/api/<route>` and `/api/<route>/*`:
352
+
353
+ - **Authentication** is the panel's: an administrator certificate, or an agent certificate holding one of the plugin's declared capabilities. `plugin:<route>:host` does not admit calls — hosting the application is not using it. `403` otherwise.
354
+ - **Mapping:** `/api/<route>/<rest>?query` → `http://127.0.0.1:<port>/admin/<rest>?query` on the relay's loopback, the relay end of the internal tunnel. Only `/admin/*` of the agent half is reachable; its `/app/*` (the user surface) is not, and neither is `/admin/hooks/*` (Lamaste's lifecycle hook calls; `404` however it is spelled). A path segment with an encoded `/` or `\` is refused (`400`).
355
+ - **Request hooks:** a plugin's approved `request:pre-forward` / `request:post-forward` hooks run on the routes they name; a plugin whose relay hooks are not approved is refused (`503`, `code: "hooks_not_approved"`). See [Lifecycle Hooks](lifecycle-hooks.md#request).
356
+ - **Caller assertion:** `X-Lamalibre-Caller: <base64url(payload)>.<base64url(hmac)>` — payload `{ v: 1, plugin, caller, method, path, iat, nonce }`, HMAC-SHA256 with the installation secret, valid 60 seconds (5 seconds of clock skew), nonces refused on a second sight.
357
+ - **Streaming:** request and response bodies are piped, never buffered; `text/event-stream` responses pass through unbuffered (nginx is told `X-Accel-Buffering: no`). The tunnel's body limit (`maxBodySizeMb`, default 10) is enforced: a larger `Content-Length` is `413` before anything is sent, and a chunked body that grows past it is cut off.
358
+ - **Timeouts:** `504` when the agent half has not sent its headers after 30 seconds; a response body (not an event stream) that stays silent for five minutes is cut off.
359
+ - **Headers:** the caller's `Cookie` and `Authorization`, nginx's `X-SSL-Client-*`, anything under `X-Lamalibre-*` or `Remote-*`, and hop-by-hop headers never reach the agent half; `Set-Cookie` from the application never reaches the client.
360
+ - **Failures:** `502` with `reason: "tunnel"` when nothing listens on the port (the agent is not connected), `502` with `reason: "app"` when the connection drops before the headers (the tunnel is up, the agent half is not), `503` while the plugin is disabled or not yet hosted. While the plugin is down, its `tunnel:down` hook's fallback answers instead of the 502.
361
+ - **Logging:** one line per request — plugin, caller, method, path without its query, status, duration. Never a body.
362
+
363
+ ## Health
364
+
365
+ Every 10 seconds, for every active hosting of an enabled plugin, the panel connects to the relay-loopback port and then calls `GET /admin/health` on the agent half with a caller assertion naming Lamaste itself (`{ "type": "lamaste" }`). A refused connect means the **tunnel** is down; a failed health call means the tunnel is up and the **application** is down. The state changes to `down` after three consecutive failures of the same reason and to `up` after two consecutive successes; each transition is persisted (`health` on the hosting record, with `reason`, `since`, `lastError`) and runs the plugin's `tunnel:down` / `tunnel:up` relay hook. The forwarder still attempts every request; the state decides whether a failure is answered with the hook's fallback.
366
+
367
+ ## Internal tunnels
368
+
369
+ `GET /api/tunnels` lists an internal tunnel with `exposure: "internal"`, `subdomain: null`, `fqdn: null`, no `accessMode`, its `pluginName` and `pluginRoute`, and the hosting agent as `agentLabel`. It cannot be changed or deleted through the tunnels API (`403`) — it belongs to the hosting. Its only effect on the relay is the chisel grant `^R:127\.0\.0\.1:<port>$` for the hosting agent, whose chisel client binds the same port on the agent, where the agent half listens. Gatekeeper and Authelia access control ignore internal tunnels.
370
+
371
+ ## CLI
372
+
373
+ ```
374
+ lamaste-server plugins hosting <name> # agent, port, secret state, health, agent half version
375
+ lamaste-server plugins eligibility <name> # which agents can host it, and why the others cannot
376
+ lamaste-server plugins host <name> --agent <label> # host the plugin (follows the operation)
377
+ lamaste-server plugins unhost <name> [--force] [--purge] # remove the hosting
378
+ lamaste-server plugins uninstall <name> [--force] [--purge]
379
+ lamaste-server plugins rotate-hosting-secret <name> # new installation secret
380
+ lamaste-server plugins retained-data [<name>] # what agents keep of removed agent halves
381
+ lamaste-server plugins purge-data <name> --agent <label> # delete it on the agent (follows the operation)
382
+ lamaste-server plugins forget-data <name> --agent <label> # drop the relay's record only
383
+ lamaste-server plugins hooks <name> # what needs approval: relay hooks, the agent half
384
+ lamaste-server push-install status|on|off # the switch, policies, every agent's window
385
+ lamaste-server push-install open <agent> [--minutes <n>] [--policy <id>]
386
+ lamaste-server push-install close <agent>
387
+ lamaste-server push-install default-policy <id>
388
+ ```
389
+
390
+ `lamaste-server plugins list` shows where each hosted plugin runs, its health and the agent half version. `host` and `unhost` follow the operation step by step and exit non-zero unless it succeeded. A refusal is explained: `agent_not_eligible` lists the reasons (and points at `eligibility`), `push_install_refused` says which gates a push passes — `lamaste-server` calls the panel from `127.0.0.1`, so the agent's policy must allow that address. `--purge` names the data directory it deletes before it starts. `retained-data` lists every agent's records — marking those of uninstalled plugins — or one plugin's, with the data directory and account of each; `purge-data` says which directory and account it deletes, follows the operation and exits non-zero unless it succeeded; `forget-data` says that nothing on the agent changed and how to remove the data there. `hooks` prints the approval digest, the relay hook set and the agent half (package and version, integrity, entry and memory limit, the account and unit it runs as, its data directory, its system packages), and a pending update's. `push-install status` lists the switch, the default policy, each policy's actions, packages and addresses, and each enrolled machine agent's window (`open until …` with its policy, or `closed`); `open` checks the minutes against the bounds the panel reports (default 30).
391
+
392
+ ## State
393
+
394
+ | Path | Contents |
395
+ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
396
+ | `/etc/lamalibre/lamaste/plugin-hostings.json` | Hosting records with their secrets (0600, atomic, one per plugin), owner and agent half version |
397
+ | `/etc/lamalibre/lamaste/tunnels.json` | The internal tunnel, like every tunnel |
398
+ | `state.db` `plugins.route`, `plugins.agent_half_pin` | The plugin's unique route; the pinned agent half `{ package, version, integrity }` |
399
+ | `state.db` `agents.host_facts`, `host_facts_at` | What each agent reported about its host |
400
+ | `state.db` `agents.push_install_enabled_until`, `push_install_policy` | Each agent's push-install window and policy |
401
+ | `state.db` `agent_jobs.payload`, `agent_jobs.signature` | App job parameters and the relay's signature over them |
402
+ | `state.db` `retained_agent_data` | Data agents keep of removed agent halves |
403
+ | `/etc/lamalibre/lamaste/push-install-config.json`, `push-install-sessions.json` | The switch and policies; the push log (last 500) |
404
+ | `<agent>/agents/<label>/app-plugins/<route>.json` | The agent's copy of the port and secret (0600) |
405
+ | `/etc/lamalibre/apps/<route>/plugin-config.json` (agent) | The service's copy, for an agent half Lamaste installed (0600, its account) |
406
+
407
+ Secrets leave the relay only through `GET /api/agents/me/plugin-config/:route`, over TLS, to the hosting agent's certificate. Files on the agent: [App Host](app-host.md#what-it-creates).