@lamalibre/create-lamaste 4.4.1 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/lamaste-priv +200 -16
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +291 -22
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
- package/vendor/docs/04-api-reference/tunnels.md +67 -29
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +82 -188
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +22 -48
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugins.js +99 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +107 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +25 -1
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +279 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
- package/vendor/serverd/src/lib/plugins.js +59 -4
- package/vendor/serverd/src/lib/registry-credential.js +9 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +733 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/tunnels.js +98 -2
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agent-Hosted Plugins
|
|
2
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, the health monitor and what the panel shows while the hosting agent is offline, the plugin's [settings and secrets](#settings) — 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`.
|
|
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, the health monitor and what the panel shows while the hosting agent is offline, the plugin's [settings and secrets](#settings), its [users and the user surface](#users-and-identity) where they reach it — 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
4
|
|
|
5
5
|
## In Plain English
|
|
6
6
|
|
|
@@ -25,6 +25,10 @@ Hosting a plugin on an agent does three things at once: it creates the internal
|
|
|
25
25
|
"panel": {
|
|
26
26
|
"pages": [{ "path": "/indicators", "title": "Indicators" }],
|
|
27
27
|
"apiPrefix": "/api/market-indicators"
|
|
28
|
+
},
|
|
29
|
+
"identity": {
|
|
30
|
+
"roles": [{ "id": "viewer", "label": "Viewer", "description": "Sees the dashboard" }],
|
|
31
|
+
"attributes": ["displayName"]
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
```
|
|
@@ -40,6 +44,7 @@ Hosting a plugin on an agent does three things at once: it creates the internal
|
|
|
40
44
|
| `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
45
|
| `hooks` | Lifecycle hooks on the relay and in the agent half — see [Lifecycle Hooks](lifecycle-hooks.md). |
|
|
42
46
|
| `settings` | What an administrator configures — values and secrets the agent half stores. Only with an `agent` block. See [Settings](#settings). |
|
|
47
|
+
| `identity` | The plugin's users: its roles (1–32, `{ id, label, description? }`) and the user attributes it may receive (`attributes`, a subset of `displayName` and `email`, default `["displayName"]`). Only with `hosting: "agent"` and an `agent` block. Part of the approval digest when present. See [Users and identity](#users-and-identity). |
|
|
43
48
|
|
|
44
49
|
There is no port in the manifest: Lamaste assigns it when the plugin is hosted.
|
|
45
50
|
|
|
@@ -133,7 +138,7 @@ Every app job is signed by the relay with its signing key when it is created, an
|
|
|
133
138
|
|
|
134
139
|
### Host facts
|
|
135
140
|
|
|
136
|
-
Each agent reports its host on its sync when the facts change, when the relay does not hold them, and once a day: `PUT /api/agents/me/host-facts` (agent certificates only; 10 per minute, 4 KiB). What the relay holds it tells the agent in every [`GET /api/tunnels/agent-config`](tunnels.md#get-api-tunnels-agent-config) answer: `hostFactsHash`, the SHA-256 of the facts stored on the agent's record (`hostFactsHash` in `@lamalibre/lamaste@4.
|
|
141
|
+
Each agent reports its host on its sync when the facts change, when the relay does not hold them, and once a day: `PUT /api/agents/me/host-facts` (agent certificates only; 10 per minute, 4 KiB). What the relay holds it tells the agent in every [`GET /api/tunnels/agent-config`](tunnels.md#get-api-tunnels-agent-config) answer: `hostFactsHash`, the SHA-256 of the facts stored on the agent's record (`hostFactsHash` in `@lamalibre/lamaste@4.5.0`, the same function the agent hashes its own with), `null` when it stores none. When that is not the hash of the agent's facts, the sync reports them whatever it last reported — so an agent enrolled again on its label (a new record, without host facts) or a relay restored from a backup can host again after one sync, not a day later. Behind a relay that does not send the field, the agent reports as before.
|
|
137
142
|
|
|
138
143
|
```json
|
|
139
144
|
{
|
|
@@ -192,7 +197,7 @@ To open a window from the panel: the Plugins page's **Push install** section tur
|
|
|
192
197
|
|
|
193
198
|
### Approval
|
|
194
199
|
|
|
195
|
-
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`:
|
|
200
|
+
What a version brings that Lamaste runs or installs — relay hooks and the agent half — and, when it declares one, its [identity block](#the-identity-block) 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"`), and so do the plugin's users: its audience is sent to the agent half, and its user surface served, only while the installed version is approved. `GET /api/plugins/:name/hooks` shows what is to be approved as `agentHalf`:
|
|
196
201
|
|
|
197
202
|
```json
|
|
198
203
|
"agentHalf": {
|
|
@@ -208,7 +213,7 @@ What a version brings that Lamaste runs or installs — relay hooks and the agen
|
|
|
208
213
|
}
|
|
209
214
|
```
|
|
210
215
|
|
|
211
|
-
An update whose agent half differs waits for approval like a changed hook set: `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, approvalRequired, autoApproveWithheld }` (`relay` and `agentHalf` are `null` when the version brings none, and `digest` when it brings
|
|
216
|
+
An update whose agent half differs waits for approval like a changed hook set: `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, identity, approvalRequired, autoApproveWithheld }` (`relay` and `agentHalf` are `null` when the version brings none, and `digest` when it brings no relay hooks, no agent half and no identity block; `settings` is how it changes the [settings schema](#in-the-plugin-list-and-the-update-preview), outside the digest; `identity` how it changes the [identity block](#the-identity-block), inside it). `approvalRequired` says why it waits, never empty: `[{ code, reason }]` with `digest.unapproved` (what it brings is not what was approved), `settings.secret-to-plain` (below) and `identity.changed` ([below](#the-identity-block)), each with a sentence. **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, in sentences; the audit log records `hooks.auto-approve.withheld` with the sentences (`reasons`) and a stable code for each (`codes`: `agent-half.added`, `agent-half.package`, `agent-half.system-packages`, `agent-half.entry`, `agent-half.memory-max`, `settings.secret-to-plain`, `identity.changed`), and the administrators are notified.
|
|
212
217
|
|
|
213
218
|
**A secret turned plain always waits for an administrator.** Settings stay outside the digest, but an update that turns a secret setting into a plain one (`secretToPlain`) waits for approval — whatever is typed into the field next would be kept on the relay and shown in clear, which a release must not decide alone — even when its digest is already approved, when the plugin brings neither relay hooks nor an agent half Lamaste installs, and whether or not auto-approve is on (when it is, `autoApproveWithheld` and `hooks.auto-approve.withheld` name it as well). It is the same pending update, shown and approved the same way, and one approval covers everything it waits for: `POST /api/plugins/:name/hooks/approve` with the digest it shows, or with `{ digest, version }` — `{ version }` alone when it has no digest ([Approval](lifecycle-hooks.md#post-api-plugins-name-hooks-approve)). Approved, the version is fetched again and must still have that digest and turn no other secret plain than those shown (`409` otherwise). `plugin.update.awaiting-approval` and the update's `hooks.approved` carry the codes of what it waited for (`codes`), and the administrators are notified with the reasons. Any other update that needs no approval — no relay hooks and an agent half installed by hand, or a version whose digest is already approved — runs at once, whatever else it does to the settings.
|
|
214
219
|
|
|
@@ -337,7 +342,7 @@ What the agent's host offers — see [Host facts](#host-facts).
|
|
|
337
342
|
|
|
338
343
|
`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>`.
|
|
339
344
|
|
|
340
|
-
The agent half reads its plugin config — `LAMASTE_APP_CONFIG` for an agent half Lamaste installs, the file above for one installed by hand (`LAMALIBRE_LAMASTE_APP_PLUGIN_CONFIG` by convention) — and verifies every request on `/admin/*` with `createCallerAssertionVerifier` from `@lamalibre/lamaste@4.
|
|
345
|
+
The agent half reads its plugin config — `LAMASTE_APP_CONFIG` for an agent half Lamaste installs, the file above for one installed by hand (`LAMALIBRE_LAMASTE_APP_PLUGIN_CONFIG` by convention) — and verifies every request on `/admin/*` with `createCallerAssertionVerifier` from `@lamalibre/lamaste@4.5.0/agent`, which re-reads the file when a signature does not verify, so a rotation needs no restart. It listens where `agentHalfListenTarget` says: for an agent half Lamaste installs, systemd holds the port in a socket unit and passes the listening socket (descriptor 3), so the port stays bound while the agent half restarts or is updated and no other local process can take it in between — such an agent half must listen this way, and one that binds its port itself fails with `EADDRINUSE`; for one installed by hand, the config's port on `127.0.0.1` ([App Host](app-host.md#writing-an-agent-half)). Every answer it gives a verified request carries the responder signature ([below](#authentication-between-the-relay-and-the-agent-half)):
|
|
341
346
|
|
|
342
347
|
```js
|
|
343
348
|
import {
|
|
@@ -345,7 +350,7 @@ import {
|
|
|
345
350
|
createCallerAssertionVerifier,
|
|
346
351
|
createHealthHandler,
|
|
347
352
|
responderHeaderFor,
|
|
348
|
-
} from '@lamalibre/lamaste@4.
|
|
353
|
+
} from '@lamalibre/lamaste@4.5.0/agent';
|
|
349
354
|
|
|
350
355
|
const verifier = createCallerAssertionVerifier({ configPath, plugin: 'market-indicators' });
|
|
351
356
|
const health = createHealthHandler({ verifier, check: () => db.isOpen() });
|
|
@@ -368,22 +373,22 @@ reply.headers(responderHeaderFor(result, 200)).code(200).send(page);
|
|
|
368
373
|
Once the plugin is enabled and hosted, the panel answers `/api/<route>` and `/api/<route>/*`:
|
|
369
374
|
|
|
370
375
|
- **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.
|
|
371
|
-
- **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 anything Lamaste keeps for itself: a path whose segment after `/admin` — percent-decoded until it no longer changes (at most three rounds; one that still decodes, or does not decode, counts as reserved), lower-cased and stripped of everything but `a-z` and `0-9` — **starts with** `hooks` (Lamaste's lifecycle hook calls)
|
|
376
|
+
- **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 anything Lamaste keeps for itself: a path whose segment after `/admin` — percent-decoded until it no longer changes (at most three rounds; one that still decodes, or does not decode, counts as reserved), lower-cased and stripped of everything but `a-z` and `0-9` — **starts with** `hooks` (Lamaste's lifecycle hook calls), `settings` (where Lamaste writes the plugin's [settings](#between-the-relay-and-the-agent-half)) or `identity` (the identity feed Lamaste sends). `/api/<route>/Settings`, `/api/<route>/%2573ettings`, `/api/<route>/settings.json` and `/api/<route>/hooks-v2/x` all answer `404` and reach nothing, whatever the agent half's router would make of them — so an agent half's own routes cannot be named so. Nor does the forwarder reach the agent half's `/admin/app` or anything under it: on the panel `/api/<route>/app` and `/api/<route>/app/*` are the plugin's user surface (for its users, admitted by a relay assertion Gatekeeper signs — never by a certificate), whose routes win over the forwarder's as static segments win over a wildcard; an agent half keeps no admin routes there. A path segment with an encoded `/` or `\` is refused (`400`).
|
|
372
377
|
- **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).
|
|
373
378
|
- **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. A forwarded request streams, so its assertion carries no body hash ([below](#authentication-between-the-relay-and-the-agent-half)).
|
|
374
379
|
- **The answer must authenticate itself:** before the client sees a status, a header or a byte, the forwarder checks the answer's `X-Lamalibre-Responder` (the `-` form, over the request's nonce and the answer's status). An answer without a valid one — from whatever took the port while the hosting agent was away, or from an agent half built against a core older than 4.4 — is `502` with `reason: "app"` (or the `tunnel:down` fallback while the plugin is down), so nothing but the agent half can serve the plugin's pages or API.
|
|
375
|
-
- **Streaming:** request and response bodies are piped, never buffered; `text/event-stream` responses pass through unbuffered (
|
|
380
|
+
- **Streaming:** request and response bodies are piped, never buffered; `text/event-stream` responses pass through unbuffered (the forwarder tells nginx `X-Accel-Buffering: no` itself — the only `X-Accel-*` header the panel ever sends). 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.
|
|
376
381
|
- **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.
|
|
377
|
-
- **Headers:** the caller's `Cookie` and `Authorization`, nginx's `X-SSL-Client-*`, anything under `X-Lamalibre-*` or `Remote-*`,
|
|
382
|
+
- **Headers:** the caller's `Cookie` and `Authorization`, nginx's `X-SSL-Client-*`, anything under `X-Lamalibre-*` or `Remote-*`, hop-by-hop headers, and the headers that make some frameworks take a request for another method or path than the one the caller assertion signs — `X-HTTP-Method-Override`, `X-HTTP-Method`, `X-Method-Override`, `X-Original-URL`, `X-Rewrite-URL`, `X-Forwarded-Prefix`, `X-Forwarded-Uri`, `X-Original-Method` (core `METHOD_URL_OVERRIDE_HEADERS`; no request hook may set them either) — never reach the agent half. From the application, `Set-Cookie`, `Service-Worker-Allowed` and `Clear-Site-Data` (an answer must not widen a service worker's scope over the panel or wipe the panel's storage), anything under `X-Lamalibre-*` (the responder signature is the relay's to check) and anything under `X-Accel-*` (nginx obeys `X-Accel-Redirect` and its kin from the panel's port — an application must never have nginx serve a file or an internal location) never reach the client.
|
|
378
383
|
- **No service workers:** a request that fetches a service worker's script (`Service-Worker: script`, any value) is refused with `403` before it enters the tunnel — no plugin installs a service worker on the panel's origin. A plugin's admin bundle runs in the panel's own document; that is a boundary of its own until plugin UIs are isolated in sandboxed frames ([Security Model](../01-concepts/security-model.md), 5e).
|
|
379
|
-
- **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 or the answer does not authenticate itself (the tunnel is up, the agent half is not — or something else holds the port), `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.
|
|
384
|
+
- **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 or the answer does not authenticate itself (the tunnel is up, the agent half is not — or something else holds the port), `503` while the plugin is disabled or not yet hosted. **Once the health monitor has marked the hosting down, nothing is forwarded**: an authorized request is answered as a request into a dead tunnel — `502` with the health's reason (`{ "error": "The hosting agent is unreachable", "reason": "tunnel" }`, or `"The agent half is not responding"`, `"app"`) — without a connection to the port, whatever holds it. While the plugin is down, its `tunnel:down` hook's fallback answers instead of the 502.
|
|
380
385
|
- **Logging:** one line per request — plugin, caller, method, path without its query, status, duration. Never a body.
|
|
381
386
|
|
|
382
387
|
## Authentication between the relay and the agent half
|
|
383
388
|
|
|
384
|
-
The internal tunnel ends on loopback ports: on the relay, the port chisel listens on while the hosting agent is connected; on the agent, the port the agent half listens on. Every process on either machine can reach them — and **take** them: on the relay while the agent is offline, on the agent while an agent half installed by hand restarts (for one Lamaste installs, systemd holds the port in a socket unit, so it stays bound across restarts and updates — [App Host](app-host.md)). So neither end trusts the network position, and **anything that takes a tunnel's loopback port
|
|
389
|
+
The internal tunnel ends on loopback ports: on the relay, the port chisel listens on while the hosting agent is connected; on the agent, the port the agent half listens on. Every process on either machine can reach them — and **take** them: on the relay while the agent is offline, on the agent while an agent half installed by hand restarts (for one Lamaste installs, systemd holds the port in a socket unit, so it stays bound across restarts and updates — [App Host](app-host.md)). So neither end trusts the network position, and **anything that takes a tunnel's loopback port can serve nothing and open no secret**. All three mechanisms below are keyed with the hosting's installation secret, which only the relay's panel, the hosting agent's account and the agent half's own account hold. They do not keep the relay from _sending_ to such a port — what reaches it, and for how long, is under [When the hosting agent is offline](#when-the-hosting-agent-is-offline).
|
|
385
390
|
|
|
386
|
-
**Requests carry a caller assertion bound to their body.** Lamaste's own calls with a body — settings changes, hook calls — carry `bodySha256` in the assertion: the SHA-256 of the exact body bytes, 64 lower-case hex characters. `createSettingsHandler` (`PUT`) and `
|
|
391
|
+
**Requests carry a caller assertion bound to their body.** Lamaste's own calls with a body — settings changes, hook calls, the [identity feed](#the-identity-feed) — carry `bodySha256` in the assertion: the SHA-256 of the exact body bytes, 64 lower-case hex characters. `createSettingsHandler` (`PUT`), `createAgentHookHandler` and `createIdentityHandlers` require it and compare it with the bytes received; an assertion lifted onto another body is refused (`401`, reason `wrong_body`) without burning its nonce, so the true request still verifies. The handlers therefore need the body **exactly as received** — a `Buffer` (or `Uint8Array`) or a string; an object a framework already parsed is refused with `400` "The body must be passed as received". With Fastify, register a `parseAs: 'buffer'` content-type parser for these routes; with `node:http`, collect the chunks. Forwarded requests stream, so theirs carries no hash. Verifiers built before the field existed ignore it.
|
|
387
392
|
|
|
388
393
|
**Answers carry a responder signature.** Every answer an agent half gives to a request whose assertion verified carries
|
|
389
394
|
|
|
@@ -396,7 +401,7 @@ keyed with the secret that verified the request (the previous one during a rotat
|
|
|
396
401
|
|
|
397
402
|
| Who checks | Form | Without a valid signature |
|
|
398
403
|
| ---------------------------------------------------- | ---- | -------------------------------------------------------------------------- |
|
|
399
|
-
| `callAgentHalf` (settings
|
|
404
|
+
| `callAgentHalf` (settings, the identity feed) | body | failure `unauthenticated`; a settings change answers `502 unauthenticated` |
|
|
400
405
|
| `callAgentHalfHook` (agent hooks through the tunnel) | body | failure `unauthenticated`, a failed hook |
|
|
401
406
|
| The health monitor (`GET /admin/health`) | body | a failure with reason `app`, however the port answered |
|
|
402
407
|
| The forwarder (every forwarded `/admin/*` answer) | `-` | `502` with `reason: "app"` before anything reaches the client |
|
|
@@ -411,11 +416,11 @@ Answers given before or instead of a verification — a `401` for a bad assertio
|
|
|
411
416
|
|
|
412
417
|
**Secret settings travel sealed** to the one request that carries them ([Between the relay and the agent half](#between-the-relay-and-the-agent-half)).
|
|
413
418
|
|
|
414
|
-
**Agent halves built against `@lamalibre/lamaste@4.
|
|
419
|
+
**Agent halves built against `@lamalibre/lamaste@4.5.0` 4.3 must be rebuilt** against this version: an agent half that does not sign its answers is down (`app`) to the health monitor, its pages and API answer `502`, and its settings and hooks fail as `unauthenticated`; one that expects secrets in clear refuses sealed ones.
|
|
415
420
|
|
|
416
421
|
## Health
|
|
417
422
|
|
|
418
|
-
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" }`). Only a `2xx` answer authenticated with the responder signature (`createHealthHandler`) counts: whatever squats the port without the installation secret is not the plugin's application, however it answers. A refused connect means the **tunnel** is down; a failed or unauthenticated 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.
|
|
423
|
+
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" }`). Only a `2xx` answer authenticated with the responder signature (`createHealthHandler`) counts: whatever squats the port without the installation secret is not the plugin's application, however it answers. A refused connect means the **tunnel** is down; a failed or unauthenticated 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. While the state is `down` neither forwarder sends anything to the port (the admin API answers `502` with the reason, or the hook's fallback; the user surface `503`); the probes go on, so the hosting comes up again as soon as the agent half answers them.
|
|
419
424
|
|
|
420
425
|
## When the hosting agent is offline
|
|
421
426
|
|
|
@@ -434,6 +439,12 @@ What the panel shows while a hosting is down:
|
|
|
434
439
|
|
|
435
440
|
`lamaste-server plugins settings <name>` prints the same sentence, and `plugins configure` refuses with it.
|
|
436
441
|
|
|
442
|
+
**What reaches the port meanwhile.** While the hosting agent is offline, its internal tunnel's port on the relay's loopback is free for any local process to take. Whatever takes it serves nothing — every answer must carry the responder signature — and opens no sealed secret, but the relay still _sends_ to it what it forwards:
|
|
443
|
+
|
|
444
|
+
- **The user surface forwards nothing while the hosting is `down`**: a user's request is answered `503` by the panel, so it never reaches the port. The health monitor marks the hosting down after three failed probes in a row, 10 seconds apart — a squatter cannot pass one — so a user's request (its path, query and body, and the `userId`, username and roles in its caller assertion) can reach a squatter only in that first window of about 30 seconds after the agent went away.
|
|
445
|
+
- **The admin API forwards nothing while the hosting is `down` either**: an administrator's or agent's request is answered `502` with the reason (or the plugin's `tunnel:down` fallback) without a connection to the port. Its request — method, path, query, body and caller assertion — can reach a squatter only in the same first window of about 30 seconds; nothing the squatter answers reaches anyone.
|
|
446
|
+
- **Lamaste's own calls** (the health probe, settings reads, hook calls, the identity feed) do not go through the forwarders and keep reaching the port — the health probe has to, to see the agent half come back — with their bodies bound to their assertions and settings secrets (and the feed's changes) sealed; a squatter can replay none of them.
|
|
447
|
+
|
|
437
448
|
## Settings
|
|
438
449
|
|
|
439
450
|
A plugin with an agent half may declare **settings**: what an administrator configures for it — an API key, a schedule, a region. The **agent half is the system of record**: it stores the values in its own data directory and answers `GET` and `PUT /admin/settings` for Lamaste alone. The panel shows them as a form, checks a change and carries it to the agent half through the internal tunnel; it keeps a cache of the non-secret values and of _when_ each secret was set — never a secret's value. A secret exists on the relay in clear only in the memory of the request that carries it, and crosses the tunnel sealed to that request, so not even whatever takes the tunnel's port can read it ([Security Model](../01-concepts/security-model.md), 5e).
|
|
@@ -555,13 +566,13 @@ The panel checks the change against the installed schema — without `pattern`
|
|
|
555
566
|
| 422 | — | The agent half refused it: `errors` per field — its `pattern`, its own schema, or a sealed secret that did not open ("Could not be decrypted") |
|
|
556
567
|
| 502 | `settings_unsupported` | The agent half does not answer `/admin/settings` (`404`) — it predates settings; update the plugin |
|
|
557
568
|
| 502 | `refused` | Nothing answers on the internal tunnel's port |
|
|
558
|
-
| 502 | `unauthenticated` | The answer did not carry a valid responder signature: something else may hold the tunnel's port, or the agent half was built against an older `@lamalibre/lamaste@4.
|
|
569
|
+
| 502 | `unauthenticated` | The answer did not carry a valid responder signature: something else may hold the tunnel's port, or the agent half was built against an older `@lamalibre/lamaste@4.5.0` and must be rebuilt. The change may or may not have been stored — read the settings again |
|
|
559
570
|
| 502 | `invalid`, `other` | The agent half answered too large a body or in another shape (reload the settings to see what it stored), or could not be reached |
|
|
560
571
|
| 502 | `agent_half_error` | The agent half answered another status (`status`) |
|
|
561
572
|
| 503 | `hosting_offline` | The plugin is not hosted, its hosting is not `active`, or its health is `down` — with `hosting` as in `GET` (`null` when not hosted) |
|
|
562
573
|
| 504 | `timeout` | No answer within 15 s — the change may or may not have been stored; read the settings again |
|
|
563
574
|
|
|
564
|
-
Once the plugin and its settings block are found, the checks run in this order: the body (`400`), a running operation (`409`), the hosting (`503`), then the agent half. A refusal answers `{ error, code?, errors?, revision?, operationId?, hosting?, status? }`, built from what Lamaste found and never from the request body. The agent half's per-field messages are passed on only under the keys of the installed schema (or `_`), and only when they are messages Lamaste's own validation produces — the fixed ones (`Is required`, `Does not match the required format`, …) and the templated ones (`Must be at most 64 characters`, `Must be at least 0`, `2 unknown settings`), matched exactly; anything else reads `Invalid value` (`normalizeSettingsErrorMessage` in `@lamalibre/lamaste@4.
|
|
575
|
+
Once the plugin and its settings block are found, the checks run in this order: the body (`400`), a running operation (`409`), the hosting (`503`), then the agent half. A refusal answers `{ error, code?, errors?, revision?, operationId?, hosting?, status? }`, built from what Lamaste found and never from the request body. The agent half's per-field messages are passed on only under the keys of the installed schema (or `_`), and only when they are messages Lamaste's own validation produces — the fixed ones (`Is required`, `Does not match the required format`, …) and the templated ones (`Must be at most 64 characters`, `Must be at least 0`, `2 unknown settings`), matched exactly; anything else reads `Invalid value` (`normalizeSettingsErrorMessage` in `@lamalibre/lamaste@4.5.0`). The agent half is Lamaste code that never repeats a value, but the relay does not answer an administrator with text it cannot vouch for.
|
|
565
576
|
|
|
566
577
|
A stored change is recorded in the [audit log](audit-and-notifications.md) as `plugin.settings.updated` with key names only — `changed`, `cleared`, `secretsSet`, `secretsCleared` — the new `revision` and the certificate serial. A change whose answer was lost (`504 timeout`, `502 unauthenticated` or `invalid`) is remembered by its key names; when a later read finds the agent half at exactly its revision, changed in those keys only, it is recorded then, with `confirmedLater: true`.
|
|
567
578
|
|
|
@@ -587,7 +598,7 @@ An update's preview — `pendingUpdate.settings`, in `GET /api/plugins/:name/hoo
|
|
|
587
598
|
|
|
588
599
|
### Between the relay and the agent half
|
|
589
600
|
|
|
590
|
-
The relay calls the agent half with `callAgentHalf` (`@lamalibre/lamaste@4.
|
|
601
|
+
The relay calls the agent half with `callAgentHalf` (`@lamalibre/lamaste@4.5.0`, also exported from `@lamalibre/lamaste@4.5.0/server`): one JSON request to `http://127.0.0.1:<port>/admin/…` — the relay end of the internal tunnel — with a caller assertion naming Lamaste itself (`{ "type": "lamaste" }`) that binds the body's SHA-256, answered with whatever status the agent half gives once the answer carries a valid responder signature over that status and its exact body ([Authentication](#authentication-between-the-relay-and-the-agent-half)). It calls only paths under `/admin/` without `.` or `..` segments, with `GET`, `PUT`, `POST` or `DELETE`, and throws before sending anything otherwise; it sends a body as `application/json` — `body` may be a function of the assertion's nonce, chosen before anything is signed, which is how a change seals its secrets to the one call — bounds the time and the response size, and resolves `{ ok: true, status, body }` (the parsed JSON, or `null`) or `{ ok: false, code, error }` — `refused` (nothing listens), `timeout`, `invalid` (a response over the size limit), `unauthenticated` (no valid responder signature: not the agent half, or one built against an older core) or `other` — with an error that never contains the request body. The hook call `callAgentHalfHook` is built on the same exchange.
|
|
591
602
|
|
|
592
603
|
| Call | Body | Answer |
|
|
593
604
|
| ----------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -610,7 +621,7 @@ The state, which every answer of the relay's reads and changes is built from:
|
|
|
610
621
|
|
|
611
622
|
`values` are the explicitly stored non-secret values — no defaults; `secrets` names the secrets that are set, and when — never a value; `setAt` must be an ISO 8601 UTC timestamp as `Date.prototype.toISOString` writes it (`2026-10-16T10:00:00.000Z`), so not even that field can carry free text. In a change, `schemaVersion` is the relay's installed manifest's, `baseRevision` the revision the administrator edited, `values` the non-secret change the panel validated and `secrets` each secret **sealed** (`null` clears). Bodies and responses are 2 MiB at most — the largest valid change, 50 fields of 4096 characters that JSON escapes to six bytes apiece, is about 1.23 MB. The relay parses every state with `parseAgentHalfSettingsState`, which builds a fresh object of the contract's fields: nothing else the agent half sent — a secret's value in particular — survives into the cache.
|
|
612
623
|
|
|
613
|
-
**Sealed secrets.** Each secret of a change crosses the tunnel as `{ "v": 1, "iv": "<base64url, 12 bytes>", "ct": "<base64url ciphertext and 16-byte tag>" }` — AES-256-GCM under a key derived from the installation secret (HKDF-SHA256 over its 32 bytes, salt `lamaste/settings-secrets/v1`, info the plugin's route), a random IV per value, and as additional data the JSON array `[plugin, nonce, key, baseRevision]`: the plugin, the call's assertion nonce, the field and the revision the change was made on. `encryptSettingsSecret` and `decryptSettingsSecret` in `@lamalibre/lamaste@4.
|
|
624
|
+
**Sealed secrets.** Each secret of a change crosses the tunnel as `{ "v": 1, "iv": "<base64url, 12 bytes>", "ct": "<base64url ciphertext and 16-byte tag>" }` — AES-256-GCM under a key derived from the installation secret (HKDF-SHA256 over its 32 bytes, salt `lamaste/settings-secrets/v1`, info the plugin's route), a random IV per value, and as additional data the JSON array `[plugin, nonce, key, baseRevision]`: the plugin, the call's assertion nonce, the field and the revision the change was made on. `encryptSettingsSecret` and `decryptSettingsSecret` in `@lamalibre/lamaste@4.5.0` are the two halves; the relay seals with the secret it signs the call with, and the agent half opens with the secret that verified it (`decryptSettingsSecret` on the verify result — the secret never leaves the verifier). A sealed secret opens for no other plugin, call, field or revision; one that does not open is a `422` for its key, and an error never repeats it. Whatever takes the tunnel's port receives no secret in clear — the non-secret values of a change, which the relay stores in clear anyway, are not sealed.
|
|
614
625
|
|
|
615
626
|
Only Lamaste: a caller assertion that does not verify is `401`, one naming an administrator or an agent `403`; another path under `/admin/settings` is `404`, a method a path does not take `405`. The **forwarder reserves `/admin/settings`** and everything under it as it reserves `/admin/hooks/*`: `/api/<route>/settings`, `/api/<route>/Settings` and `/api/<route>/%73ettings/x` all answer `404` and reach nothing. No forwarded caller — an administrator, or an agent holding one of the plugin's capabilities — reads or changes the settings behind the panel's validation, and the relay's cache cannot drift from the agent half. An agent half's own admin routes cannot live there.
|
|
616
627
|
|
|
@@ -618,7 +629,7 @@ Only Lamaste: a caller assertion that does not verify is `401`, one naming an ad
|
|
|
618
629
|
|
|
619
630
|
### In the agent half
|
|
620
631
|
|
|
621
|
-
`@lamalibre/lamaste@4.
|
|
632
|
+
`@lamalibre/lamaste@4.5.0/agent` holds the store and the handler, so that no plugin reimplements how a secret is stored:
|
|
622
633
|
|
|
623
634
|
- **`settingsFromManifest(manifest)`** — the `settings` block of the agent half's own copy of its manifest (bundled with it), validated — here each string default is also tested against its field's `pattern`, which the relay never runs; throws `ManifestValidationError` when it is missing or invalid. The agent half checks values against this schema, not the relay's.
|
|
624
635
|
- **`createSettingsStore({ dataDir, settings })`** — the store over two files in `dataDir`, which must exist: for an agent half Lamaste installs, `LAMASTE_APP_DATA_DIR`, the only directory its unit may write ([App Host](app-host.md)). One store per process. `load()` reads and reconciles the files and must run before anything else; then `state()` is what `GET` answers, `values()` the values the plugin runs with (defaults overlaid by the stored values, never a secret), `secret(key)` a secret's value for the agent half's own use (`undefined` when it is not set), `configured()`, `update(change)`, `reload()`, and `onChange(listener)` — called with the new state after every update and reload, returning its unsubscribe function; a listener that throws does not fail the update, which is stored.
|
|
@@ -641,7 +652,7 @@ import {
|
|
|
641
652
|
createSettingsHandler,
|
|
642
653
|
createSettingsStore,
|
|
643
654
|
settingsFromManifest,
|
|
644
|
-
} from '@lamalibre/lamaste@4.
|
|
655
|
+
} from '@lamalibre/lamaste@4.5.0/agent';
|
|
645
656
|
import manifest from '../lamaste-plugin.json' with { type: 'json' }; // bundled into the agent half
|
|
646
657
|
|
|
647
658
|
const verifier = createCallerAssertionVerifier({
|
|
@@ -716,6 +727,254 @@ Before sending, `configure` refuses what the panel would: the settings of a plug
|
|
|
716
727
|
|
|
717
728
|
Both commands exit `0` when they did what was asked — for `configure`, saved the change, found nothing to change, or the administrator declined to save — and `1` for a usage error, a refusal, or a prompt cancelled with Ctrl-C (nothing is saved). With `--json`, `configure` prints `{ "event": "complete", "name", "changed", "cleared", "secretsSet", "secretsCleared", "settings" }` — key names, and the settings as `GET` answers them — and every failure of either command `{ "event": "error", "message", "recoverable": false, "code"?, "errors"?, "revision"?, "operationId"?, "hosting"? }`.
|
|
718
729
|
|
|
730
|
+
## Users and identity
|
|
731
|
+
|
|
732
|
+
A plugin with an agent half may have **users**: people who sign in to the relay's Authelia and use the application itself, not its admin pages. Lamaste is their directory. The plugin declares its roles; an administrator gives users (or Lamaste groups) those roles; Lamaste keeps the plugin's **audience** — who holds which role — and pushes it to the agent half through the internal tunnel, which keeps a local copy to join its data to and to show names from. On every request a user makes to the plugin, the agent half learns who it is from a signed caller assertion: the user's stable `userId`, username and roles. Nothing about users reaches a plugin that does not declare `identity`.
|
|
733
|
+
|
|
734
|
+
| Piece | Where |
|
|
735
|
+
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
736
|
+
| The plugin's roles and attributes | The manifest's [`identity` block](#the-identity-block), approved with the plugin |
|
|
737
|
+
| Who the users are | The relay's user directory — a `userId` per user, `active`, `disabled` or `removed` ([Users API](users.md)) |
|
|
738
|
+
| Who holds which role | [Entitlements](#entitlements-and-the-audience): Gatekeeper plugin grants with `context.roles` |
|
|
739
|
+
| What the agent half knows | [The identity feed](#the-identity-feed) into its own SQLite projection |
|
|
740
|
+
| Where users reach the plugin | [The user surface](#the-user-surface): `/api/<route>/app/*` on a tunnel host bound to the plugin ([Tunnels API](tunnels.md#user-surface-binding)) |
|
|
741
|
+
| Who is calling, on each request | [The user caller](#the-user-caller) in the caller assertion |
|
|
742
|
+
|
|
743
|
+
### The identity block
|
|
744
|
+
|
|
745
|
+
```json
|
|
746
|
+
"identity": {
|
|
747
|
+
"roles": [
|
|
748
|
+
{ "id": "viewer", "label": "Viewer", "description": "Sees the dashboard" },
|
|
749
|
+
{ "id": "editor", "label": "Editor" }
|
|
750
|
+
],
|
|
751
|
+
"attributes": ["displayName", "email"]
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
- **`roles`** — 1 to 32, ids unique. `id`: a lower-case letter, then lower-case letters, digits and `-`, at most 32 characters; `label`: 1–64 characters; `description`: optional, at most 200. Labels and descriptions refuse control and bidirectional formatting characters.
|
|
756
|
+
- **`attributes`** — what Lamaste tells the agent half about each user besides the `userId`, username, roles and status: a subset of `displayName` and `email`, no repeats; `["displayName"]` when omitted. The agent half receives nothing it did not declare.
|
|
757
|
+
- Only a plugin with `hosting: "agent"` and an `agent` block may declare it (`400` at install otherwise): its users reach the agent half.
|
|
758
|
+
|
|
759
|
+
The panel stores the block **normalized** — roles sorted by id, attributes sorted, the default applied — as `identity` on the plugin's entry in `GET /api/plugins` and in `plugins.identity` (`state.db`). It enters the [approval digest](lifecycle-hooks.md#approval) **only when present**, so the digest of a plugin without one is the digest it had before 4.5, byte for byte. A plugin that declares one waits for approval even when it brings no relay hooks and no agent half Lamaste installs; until its installed version is approved, it has no audience on its agent half and no user surface. `GET /api/plugins/:name/hooks` shows it as `identity: { declared, approved, block }`.
|
|
760
|
+
|
|
761
|
+
An update that **adds or removes a role**, or **widens the attributes**, waits for an administrator with the reason `identity.changed` — the update gives the plugin a new power over its users, or more about them — and **auto-approve never covers it**. Narrowing the attributes and editing a role's label or description only change the digest (`digest.unapproved`, which auto-approve may cover). The update's preview carries the change:
|
|
762
|
+
|
|
763
|
+
```json
|
|
764
|
+
"identity": {
|
|
765
|
+
"before": { "roles": [{ "id": "viewer", "label": "Viewer" }], "attributes": ["displayName"] },
|
|
766
|
+
"after": { "roles": [{ "id": "editor", "label": "Editor" }, { "id": "viewer", "label": "Viewer" }], "attributes": ["displayName", "email"] },
|
|
767
|
+
"rolesAdded": ["editor"],
|
|
768
|
+
"rolesRemoved": [],
|
|
769
|
+
"attributesAdded": ["email"],
|
|
770
|
+
"attributesRemoved": [],
|
|
771
|
+
"labelsChanged": []
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
`null` when neither version declares a block. When the update removes roles (or the whole block), the preview also says how many role grants approving it changes — `"grantsAffected": { "rewritten": 1, "revoked": 3 }` (`null` when Gatekeeper could not be read). **A removed role stays removed**: once the version that drops it is installed and approved, the role is taken out of every grant on the plugin — a grant left with other roles is replaced by one with those, a grant left with none is revoked — and audited as `plugin.entitlement.roles-removed` (the roles, how many grants were rewritten and revoked); a later version that declares the role again finds nobody holding it. Members left with no role leave the audience (`unentitled`). Uninstalling a plugin **with purge** revokes every role grant on it; without purge they stay with the data its agent keeps.
|
|
776
|
+
|
|
777
|
+
### Entitlements and the audience
|
|
778
|
+
|
|
779
|
+
An **entitlement** is a Gatekeeper grant on the plugin — `resourceType: "plugin"`, `resourceId: <route>` — to a user (by username) or a Lamaste group, whose `context.roles` is a non-empty list of role ids the installed version declares. Nothing else makes anyone a user of the plugin: a grant without roles — the desktop's local-install grants, grants migrated from `user-plugin-access.json` — never counts. The panel checks roles against the manifest wherever a grant is created, here and in the [Gatekeeper proxy](gatekeeper.md#create-grant) (`400`, `code: "unknown_roles"` with the `unknown` ones), and stores them sorted, without repeats. One principal has one role grant per plugin: setting its roles creates the new grant first, then revokes the others.
|
|
780
|
+
|
|
781
|
+
The **audience** is the directory's users who hold at least one of the plugin's roles — the union of their own entitlements and those of the Lamaste groups they are in, restricted to the declared roles. Users whose username an earlier, removed user held hold no role until Gatekeeper has dropped what that user left there ([Users API](users.md#usernames-and-gatekeeper)). A member is carried in full; a user who leaves is a removal with its reason:
|
|
782
|
+
|
|
783
|
+
| Change | Carries |
|
|
784
|
+
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
785
|
+
| `upsert` | `member: { userId, username, displayName?, email?, roles, status: "active" \| "disabled", version }` — only declared attributes |
|
|
786
|
+
| `remove`, reason `unentitled` (lost every role) | `userId`, `last: { username, displayName?, email? }` — what the plugin last received |
|
|
787
|
+
| `remove`, reason `deleted` (removed from the directory) | `userId`, `last` as above — history stays readable |
|
|
788
|
+
| `remove`, reason `erased` (an administrator erased the user) | `userId` alone |
|
|
789
|
+
|
|
790
|
+
The panel materializes each plugin's audience in `state.db` (`plugin_audience`): one row per plugin and user who was ever a member, written only when the change differs, each write stamped with the next value of one store-wide sequence (`seq`, by triggers) — the feed's cursor. It recomputes the audiences after every change of users, the directory or entitlements made through the panel (the Gatekeeper proxy included), on a plugin's install, update and approval, at start and every minute (Gatekeeper's own CLI edits); while Gatekeeper cannot be read, the stored audience stays as it is (erasures still go in). A disabled user stays a member with `status: "disabled"`.
|
|
791
|
+
|
|
792
|
+
**The audience is the plugin's erasure ledger.** Its rows record everyone the plugin's agent halves were ever sent, so they outlive the feed: when the plugin is uninstalled while an agent keeps its data ([retained data](app-host.md)), or stops declaring identity, its rows stay — recomputed for **erasures alone** (erasing a user rewrites their row, with a new `seq`, in every plugin that has one) — and its erasures stay pending, marked as such on the directory (`pendingErasuresInactive`). A plugin that comes back — reinstalled with its half reusing the data an agent kept, or declaring identity again — replays them: a new hosting's half is always sent a full replay. The rows go only when no data of the plugin is left anywhere: an uninstall with purge, or its retained data purged or forgotten once it is uninstalled. When data is retained, the relay records with it how far the half's projection had applied the feed (`retained_agent_data.identity_cursor`, migration `0014`): the erasures after that are pending on that agent.
|
|
793
|
+
|
|
794
|
+
### The identity feed
|
|
795
|
+
|
|
796
|
+
The agent half cannot reach the panel, so the panel pushes: two calls through the internal tunnel, Lamaste's own (`callAgentHalf`: caller `lamaste`, the body hash bound, the answer authenticated by its responder signature over the body). The forwarder reserves `/admin/identity*`, so no administrator or agent reaches them.
|
|
797
|
+
|
|
798
|
+
```
|
|
799
|
+
POST /admin/identity/notify { protocol: 1, directoryId, seq }
|
|
800
|
+
→ 200 { protocol: 1, directoryId, cursor } what the half holds (directoryId null before its first batch)
|
|
801
|
+
POST /admin/identity/changes { protocol: 1, directoryId, from, to, reset, reconcile, pass, final, changes: { v: 1, iv, ct } }
|
|
802
|
+
→ 200 { protocol: 1, directoryId, cursor, mismatches? }
|
|
803
|
+
→ 409 { error: "cursor_mismatch", protocol: 1, directoryId, cursor }
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
- **The changes travel sealed.** The internal tunnel's ends are loopback ports — the chisel server's on the relay, the agent's on the agent — and whatever holds one sees what crosses it. A batch's envelope (directory, cursors, flags, pass) is no one's personal data; its `changes` array is encrypted with AES-256-GCM under `HKDF-SHA256(installation secret, salt "lamaste/identity-changes/v1", info route)` — a key of its own, never the settings secrets' — with 12 random IV bytes and, as additional data, the route, the caller assertion's nonce and the whole envelope (`JSON.stringify([plugin, nonce, directoryId, from, to, reset, reconcile, pass, final])`): sealed changes open only in the one request that carries them, under that envelope (`sealIdentityChanges`, `openIdentityChanges` in `@lamalibre/lamaste@4.5.0`; the verified request's `openIdentityChanges` in an agent half). Notifications and answers carry no member and stay as they are. The 1 MiB limit is the sealed request's.
|
|
807
|
+
- `seq` in a notification is the plugin's head; `directoryId` the relay directory's id (a UUID v4 made with `state.db`).
|
|
808
|
+
- A batch carries the changes of `(from, to]` in strictly increasing `seq`, each one of the [changes above](#entitlements-and-the-audience) with its `seq` — at most 500 changes and 1 MiB. The half applies it and moves its cursor to `to` in one transaction; one that does not continue from its cursor (another `from`, or another directory without `reset`) is answered `409` with what it holds, and the panel continues from there (or replays) — up to three times per sync.
|
|
809
|
+
- **A replay from 0 is the snapshot.** The panel replays when the half follows another directory or none, its cursor is ahead of the head (a restored relay), it never completed a replay since the plugin was installed or hosted, a replay was cut short — and once a day as a **reconcile**. Every batch of a replay carries the same `pass`; the first has `reset: true` (the half adopts `directoryId` and restarts its cursor), the last `final: true`, when the half marks every row the pass did not mention as removed. Batches with `reconcile: true` — every batch of the daily reconcile, and every batch after the first of any replay — have the half count every change that differs from its row (and, at the final batch, every row the pass did not mention) and answer `mismatches`. For the daily reconcile a non-zero count on a healthy system is a feed bug: it is recorded on the plugin's sync state and audited as `identity.reconcile.mismatches`.
|
|
810
|
+
- A half that speaks another protocol answers `400 { error, protocol: 1 }`; one built before 4.5 has no such route. Either way the sync fails and says so.
|
|
811
|
+
|
|
812
|
+
**When it runs.** After a recompute that changed the plugin's rows, when its agent half comes up (a health transition), after a hosting, an update or its revert succeeded, on approval, at the panel's start, every minute for a plugin that is behind or whose last sync failed, daily (the reconcile), and on demand (`POST /api/plugins/:name/identity/sync`). One sync per plugin at a time; requests made while one runs share the next. A sync runs only for a plugin that declares `identity`, whose installed version is approved, that is hosted, and whose hosting is active and not down; otherwise it records why (`lastError`). When a hosting is removed, what the panel knew of its half goes with it: the half of a later hosting must answer again and receives a replay. The audience itself stays — it is the plugin's, not the hosting's.
|
|
813
|
+
|
|
814
|
+
**Readiness.** Users reach the plugin only once its agent half has **applied every change up to the head after a complete replay** — a sync that ended with the half's cursor at the head, a full replay done since the half was hosted — **since the installed version was installed** (`readyVersion`). Answering the notification is not enough: a half still behind would serve stale roles, names and erasures. So a half built on core 4.4 never receives a user caller — it would refuse one as malformed — and an update that brings a new half waits until that half has caught up. `readiness.reason` says why a plugin is not ready:
|
|
815
|
+
|
|
816
|
+
| `reason` | Meaning |
|
|
817
|
+
| --------------- | --------------------------------------------------------------------------------------------- |
|
|
818
|
+
| `not_installed` | No installed plugin holds the route |
|
|
819
|
+
| `no_identity` | The installed version declares no `identity` |
|
|
820
|
+
| `not_approved` | The installed version is not approved |
|
|
821
|
+
| `not_hosted` | The plugin is not hosted, or its hosting is not active |
|
|
822
|
+
| `not_answered` | Its agent half has not applied the feed up to the head (protocol 1) for the installed version |
|
|
823
|
+
|
|
824
|
+
### The user surface
|
|
825
|
+
|
|
826
|
+
A plugin's users reach it at `/api/<route>/app` and everything under it, on the host of an app tunnel **bound** as the plugin's user surface ([Tunnels API](tunnels.md#user-surface-binding)) — a tunnel its hosting agent carries. The bound host serves those paths only while the plugin's installed, approved version declares `identity` and the tunnel's agent hosts the plugin; before that, its requests go down the tunnel as every other path does — which is how a plugin moves its users' API to the relay with a plain update. Each request:
|
|
827
|
+
|
|
828
|
+
1. **nginx** asks Gatekeeper (`auth_request` to `/authz/user-surface/<fqdn>`, the tunnel named in the path).
|
|
829
|
+
2. **Gatekeeper** checks the tunnel (enabled, not public, the route bound to it), that the raw request target lies on that route's user surface — no `.` or `..` segment, no backslash, no percent-encoded `.`, `/`, `\` or `%`, no escape that does not decode, so the path it approves is the path every server after it resolves, even one that decodes twice — that the tunnel's agent hosts the plugin (it carries the plugin's internal tunnel), the Authelia session (one `authelia_session` cookie, never two), and on a `restricted` tunnel the user's grant for the tunnel. It answers with a **relay assertion for the panel**: Ed25519 with the relay signing key, audience `@panel`, port 3100, caller `{ type: "user", username }` — the username alone — bound to the method and the raw request target, single use, never cached. Without a session it answers a JSON `401 { error: "unauthenticated" }` (never a login redirect: the caller is an application's request); anything else refused is a bare `403`.
|
|
830
|
+
3. **nginx** passes the request to the panel on `127.0.0.1:3100` with the assertion and the request target exactly as the browser sent it, and nothing else of the browser's credentials: `Cookie`, `Authorization`, `Remote-*`, `X-SSL-Client-*` and `X-Forwarded-Host` are cleared ([nginx Configuration](../03-architecture/nginx-configuration.md#user-surface-locations)).
|
|
831
|
+
4. **The panel** serves `/api/:route/app` and `/api/:route/app/*` in a context of its own — no mTLS, no 2FA session, no role guard; the assertion is the only thing it trusts — and answers, in order:
|
|
832
|
+
|
|
833
|
+
| Check | Refusal |
|
|
834
|
+
| -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
835
|
+
| The request was not started by another site: `Sec-Fetch-Site` absent, `same-origin` or `none` — whatever the method | `403 { error: "cross-site request refused" }` |
|
|
836
|
+
| The assertion verifies (audience `@panel`, port 3100, method, raw target, 60 s + 5 s skew, nonce once) for the URL's route | `401 { error: "unauthenticated" }` — JSON, no redirect |
|
|
837
|
+
| The route is bound to an enabled tunnel its plugin's hosting agent carries, and the plugin is enabled, hosted and not down | `404` (no user surface here) or `503` (not available right now — nothing is forwarded while the hosting is `down`) |
|
|
838
|
+
| A method other than `GET`, `HEAD` and `OPTIONS` carries `Origin: https://<bound host>` (CSRF) | `403 { error: "cross-origin request refused" }` |
|
|
839
|
+
| The directory's user for the attested username, for this plugin | `404` (no identity), `503` (not [ready](#the-identity-feed)), `403` (disabled, removed, not entitled, an earlier holder's principal not yet cleared) |
|
|
840
|
+
|
|
841
|
+
5. Then the request goes to the agent half's `/app/<rest>?query` with the [user caller](#the-user-caller), through the admin forwarder's pipeline: the same stripped headers (method and URL overrides among them), the internal tunnel's body limit (`413`), the plugin's approved [request hooks](lifecycle-hooks.md#request) on its `/app` routes, the responder check (`502` without it), streaming, timeouts, and no `Set-Cookie`, `Service-Worker-Allowed`, `Clear-Site-Data`, `X-Lamalibre-*` or `X-Accel-*` back.
|
|
842
|
+
|
|
843
|
+
**Every answer** of the user surface — the agent half's, a hook's, the panel's own refusals and errors, the rate limit's — carries `Cross-Origin-Resource-Policy: same-origin`, `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox; default-src 'none'` (an application's own `Content-Security-Policy` is kept as a further policy, which can only restrict more; its resource policy and `nosniff` are replaced), and never `Access-Control-*`: the panel's CORS is off for these routes and the application's are removed, so no other origin reads a user's answers and no answer runs as a page of the bound host. The relay's own errors are **fixed texts** per status (`userSurfaceErrorText`: `"The application is unavailable"` for a `502`, `"The application did not answer in time"` for a `504`, `"Request body too large"` for a `413`, …) — never an internal message, a socket error or which side failed; those go to the log line.
|
|
844
|
+
|
|
845
|
+
**Open responses.** While a response is being sent — an event stream, a slow download — the panel keeps it with its user's `userId` and roles, and every 5 seconds resolves each user again through the directory: a response whose user is disabled, removed or no longer entitled (`access_revoked`), now holds other roles (`roles_changed`), or whose route the host no longer serves (`surface_withdrawn` — unbound, the tunnel disabled or moved, the plugin unhosted) is ended at once, the browser's connection and the request to the agent half alike. No response lives longer than **one hour** (`USER_SURFACE_MAX_RESPONSE_SECONDS`); nginx's timeouts on the user-surface locations are the same hour. An application that streams longer reconnects; its next request is checked anew. The request's log line says why the relay ended it (`ended by the relay: <reason>`).
|
|
846
|
+
|
|
847
|
+
The user surface has its own rate limit: 1200 requests a minute per client address and route (the panel's root limit is for its administrators). The panel logs no URL and no client address for these requests: Fastify's own request lines are off, its one line per request has the route pattern (`/api/:route/app/*`), the status and the duration; the forwarder's line names the user as `user:<userId>` with the path asked of the agent half without its query — never a username, a query string or a body. nginx's access log still records each request's client address and URL ([Monitoring](../05-operations/monitoring.md#logs-and-personal-data)). The origin a request must come from is the bound host as `tunnels.json` names it, never the request's `Host`.
|
|
848
|
+
|
|
849
|
+
On the panel's router `/api/<route>/app*` are static routes that win over the admin forwarder's wildcard, so **the admin forwarder no longer reaches an agent half's `/admin/app` or anything under it**; an agent half keeps none of its admin routes there.
|
|
850
|
+
|
|
851
|
+
### The user caller
|
|
852
|
+
|
|
853
|
+
A forwarded user request carries the caller assertion (`X-Lamalibre-Caller`, still version 1, HMAC with the installation secret, no body hash — it streams) with
|
|
854
|
+
|
|
855
|
+
```json
|
|
856
|
+
{ "type": "user", "userId": "6f1c…-4…", "username": "alice", "roles": ["viewer"] }
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
— the directory's `userId` (a lower-case UUID v4: key everything on it, a username can come back as another person), the username to show, and the user's roles in this plugin (1–32 of its declared role ids, from the audience). Paths are a **boundary between callers**: a `user` caller is valid only for `/app`, `/app/…` and `/app?…` read literally with no unsafe segment (an encoded `%` — `%25` — is one), and every other caller — an administrator, an agent, Lamaste — never for a path a router could take for `/app` (`/App`, `/%61pp`, `/app.json`, `/apps`). Whoever the caller, the path must be in **origin form**: exactly one leading `/` — never `//` or `/\`, no scheme, no host (`http://x/app/me`, `//app/x`). Signing refuses each mistake and the verifier answers `wrong_request`, so an administrator's assertion cannot act on a user's routes, a user's never reaches `/admin`, and no assertion names a path a router could read as another host's. A verifier built before 4.5 refuses a user caller as `malformed`; the panel never sends one before the agent half is [ready](#the-identity-feed).
|
|
860
|
+
|
|
861
|
+
An agent half verifies `/app` requests with its `createCallerAssertionVerifier`, checks `verified.payload.caller.type === "user"` and the roles it needs, and signs **every** answer to a verified request — a `404` and a `405` too — with `responderHeaderFor(verified, status)`; the relay passes on nothing that is not signed. An administrator certificate holds every role in the plugin's admin pages, which ignore roles; those pages stay on `/admin`.
|
|
862
|
+
|
|
863
|
+
### In the agent half
|
|
864
|
+
|
|
865
|
+
`@lamalibre/lamaste@4.5.0/agent` keeps the projection and answers the feed:
|
|
866
|
+
|
|
867
|
+
```js
|
|
868
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
869
|
+
import {
|
|
870
|
+
createCallerAssertionVerifier,
|
|
871
|
+
createIdentityHandlers,
|
|
872
|
+
createIdentityProjection,
|
|
873
|
+
} from '@lamalibre/lamaste@4.5.0/agent';
|
|
874
|
+
|
|
875
|
+
const db = new DatabaseSync(`${dataDir}/app.db`);
|
|
876
|
+
const projection = createIdentityProjection({
|
|
877
|
+
db,
|
|
878
|
+
// Inside the batch's transaction, synchronous: anonymise what the plugin keeps.
|
|
879
|
+
onErase: (userId, tx) =>
|
|
880
|
+
tx.prepare('UPDATE notes SET author = NULL WHERE author_id = ?').run(userId),
|
|
881
|
+
});
|
|
882
|
+
const identity = createIdentityHandlers({ verifier, projection, logger });
|
|
883
|
+
|
|
884
|
+
// POST /admin/identity/notify and /admin/identity/changes — the body exactly as received
|
|
885
|
+
const answer = await identity.handle({ method, url, headers, body: rawBody });
|
|
886
|
+
// send answer.status, answer.headers and answer.bodyText, byte for byte
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
- **`createIdentityProjection({ db, onUpsert?, onRemove?, onErase? })`** keeps two tables in the half's own database, created when missing — `lamaste_users` (one row per user the feed ever named: the declared attributes it last carried, roles, `active` / `disabled` / `removed` with reason and time, `erased_at`, version) and `lamaste_identity_cursor` (the directory and the cursor) — and never touches the database's `user_version`, which stays the plugin's. A batch and its cursor are written in one `BEGIN IMMEDIATE` transaction. Removal keeps history readable: a deleted or unentitled user keeps the row with the last known attributes, no roles, `removed`; an **erased** user is scrubbed to the bare `userId`, and `onErase` anonymises the plugin's own data in the same transaction — the cursor passing the erasure is the acknowledgement the panel waits for. **An erasure is sticky**: once a row is erased, no later `upsert` or `remove` (`deleted`, `unentitled` — what a relay restored from an older backup would send) writes a username, name or address into it again; the row stays the bare id, no hook but `onErase` runs for it (and `onErase` only when the change is the erasure itself, or the row had to be scrubbed again), and in a reconcile such a change counts as a mismatch. Hooks run inside the transaction and must be synchronous; one that throws rolls the batch back and the panel sends it again. Replays repeat erasures (the daily reconcile replays every one), so `onErase` must be idempotent — which is also how an erasure reaches data restored from an older backup. It offers `state()`, `applyBatch(request)`, `getUser(userId)` and `listUsers({ includeRemoved })`.
|
|
890
|
+
- **`createIdentityHandlers({ verifier, projection, logger? })`** answers `POST /admin/identity/notify` and `POST /admin/identity/changes`: `404` for another path, `405` for another method, `400` for a body already parsed, `413` above 1 MiB — unsigned, before verification; `401` for an assertion that does not verify or does not sign the body; then, signed: `403` for any caller but Lamaste, `400` for a body that is not the contract (or of another protocol, with `protocol: 1`) or whose sealed changes do not open for this request (`The identity changes could not be opened`), the projection's state, a batch applied, `409 cursor_mismatch`, `500` when the projection failed (rolled back). It opens a batch's sealed changes with the verified request's `openIdentityChanges` before parsing the batch; the projection sees the plain batch. A plugin with its own person model may pass any `projection` with `state()` and `applyBatch()` that throws `IdentityCursorConflictError`.
|
|
891
|
+
|
|
892
|
+
Neither logs a member's attributes: log lines carry the directory, the cursor and counts.
|
|
893
|
+
|
|
894
|
+
### Erasure
|
|
895
|
+
|
|
896
|
+
Deleting a user tombstones them; **erasing** scrubs the tombstone ([Users API](users.md#erasing-a-removed-user)). Every plugin that ever had the user as a member has their row rewritten to `remove` with reason `erased` and no attributes — inactive plugins included ([the ledger](#entitlements-and-the-audience)); a plugin that takes the feed is sent it, and its agent half scrubs its row and runs `onErase` in the same transaction — for good: an erasure is sticky in the projection. The panel shows the erasures a plugin has not acknowledged as `pendingErasures` on the plugin's identity view and, per user, on the directory: those stamped after its hosting's half's cursor (all of them while no half has answered), and those stamped after the position recorded with the data an agent retains of it. Delivery takes seconds while the hosting agent is online and happens when it reconnects otherwise — or, for an inactive plugin, when it comes back.
|
|
897
|
+
|
|
898
|
+
### Endpoints
|
|
899
|
+
|
|
900
|
+
All admin only. `404` when the plugin is not installed; `400 { code: "no_identity" }` from the audience, entitlement and sync routes for a plugin that declares no identity.
|
|
901
|
+
|
|
902
|
+
#### `GET /api/plugins/:name/identity`
|
|
903
|
+
|
|
904
|
+
```json
|
|
905
|
+
{
|
|
906
|
+
"identity": {
|
|
907
|
+
"declared": { "roles": [{ "id": "viewer", "label": "Viewer" }], "attributes": ["displayName"] },
|
|
908
|
+
"readiness": {
|
|
909
|
+
"ready": true,
|
|
910
|
+
"declared": true,
|
|
911
|
+
"approved": true,
|
|
912
|
+
"hostingActive": true,
|
|
913
|
+
"version": "1.1.0",
|
|
914
|
+
"readyVersion": "1.1.0",
|
|
915
|
+
"reason": null
|
|
916
|
+
},
|
|
917
|
+
"approval": { "approved": true, "state": "approved" },
|
|
918
|
+
"sync": {
|
|
919
|
+
"directoryId": "3b0e…",
|
|
920
|
+
"halfDirectoryId": "3b0e…",
|
|
921
|
+
"cursor": 42,
|
|
922
|
+
"head": 42,
|
|
923
|
+
"behind": 0,
|
|
924
|
+
"readyVersion": "1.1.0",
|
|
925
|
+
"lastSyncAt": "2026-10-22T09:14:03.120Z",
|
|
926
|
+
"lastReconcileAt": "2026-10-22T03:00:11.002Z",
|
|
927
|
+
"lastMismatches": 0,
|
|
928
|
+
"lastError": null
|
|
929
|
+
},
|
|
930
|
+
"audience": { "active": 12, "disabled": 1, "removed": 3, "erased": 1 },
|
|
931
|
+
"pendingErasures": []
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
For a plugin without identity: `declared`, `sync` and `audience` are `null`, `readiness.reason` `no_identity`, and `pendingErasures` the erasures it still owes from when it declared identity (empty for a plugin that never did). `sync.cursor` and `halfDirectoryId` are what the half last answered; `behind` is how many rows it has not applied; `lastError` why the last sync did not finish or was not tried.
|
|
937
|
+
|
|
938
|
+
#### `GET /api/plugins/:name/audience`
|
|
939
|
+
|
|
940
|
+
`{ head, audience: [{ userId, seq, version, updatedAt, change }] }` in `seq` order — members and tombstones, each `change` as the feed delivers it (without `seq`).
|
|
941
|
+
|
|
942
|
+
#### `GET /api/plugins/:name/entitlements`
|
|
943
|
+
|
|
944
|
+
`{ entitlements: [{ grantId, principalType, principalId, userId, roles, unknownRoles, createdAt }], roleless }` — the role grants (a user principal with its directory `userId`, `null` for a group; `unknownRoles` those the installed version no longer declares), and how many grants on the plugin carry no roles.
|
|
945
|
+
|
|
946
|
+
#### `PUT /api/plugins/:name/entitlements`
|
|
947
|
+
|
|
948
|
+
```json
|
|
949
|
+
{ "principalType": "user", "principalId": "alice", "roles": ["viewer"] }
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
Gives the principal exactly these roles (`principalType` `user` with a username, or `group` with a Lamaste group name). `{ ok, entitlement, changed, warnings? }`. `400` `invalid_roles` or `unknown_roles` (with `unknown`); `404` `user_not_found` (the user must be in the directory and not removed) or `group_not_found`; `503` `gatekeeper_unavailable` (also when an earlier holder's principal under the username cannot be cleared first); Gatekeeper's own refusals with its status, `gatekeeper_refused`. Audited as `plugin.entitlement.set` (a user by `userId`, never by name). 30 a minute.
|
|
953
|
+
|
|
954
|
+
#### `DELETE /api/plugins/:name/entitlements?principalType=<user|group>&principalId=<id>`
|
|
955
|
+
|
|
956
|
+
Revokes every role grant of the principal on the plugin (grants without roles stay). `{ ok, revoked, warnings? }`; `404 entitlement_not_found`, `502 gatekeeper_refused` when Gatekeeper revoked none. Audited as `plugin.entitlement.removed`. 30 a minute.
|
|
957
|
+
|
|
958
|
+
#### `POST /api/plugins/:name/identity/sync`
|
|
959
|
+
|
|
960
|
+
`{ "reconcile"?: true }` — sync now; `reconcile` replays everything and counts mismatches. Answers when the sync ended: `{ ok, result, sync }` — `result.status` is `synced`, `up-to-date`, `skipped` (with `reason`) or `failed` (with `error`), with `cursor`, `replay` and, for a reconcile, `mismatches`; `sync` is the state above. 10 a minute.
|
|
961
|
+
|
|
962
|
+
### In the panel and the CLI
|
|
963
|
+
|
|
964
|
+
On the Plugins page, a plugin that declares identity gets a **People** section on its card: the declared roles and attributes with the approval state and readiness (with its reason), the entitlements by user or group with a checkbox per role (add, change, remove), the audience — members with their roles and status, and tombstones — the sync state (directory, head, the half's cursor, last sync and reconcile, mismatches, last error) with **Sync now** and **Reconcile**, and pending erasures. **Lifecycle hooks** shows the identity block in the approval preview and an update's identity diff, with `identity.changed` among the reasons it waits. The desktop app shows the same through `AdminClient` (`getPluginIdentity`, `getPluginAudience`, `getPluginEntitlements`, `setPluginEntitlement`, `removePluginEntitlement`, `syncPluginIdentity`; Tauri commands `admin_get_plugin_identity`, `admin_get_plugin_audience`, `admin_get_plugin_entitlements`, `admin_set_plugin_entitlement`, `admin_remove_plugin_entitlement`, `admin_sync_plugin_identity`).
|
|
965
|
+
|
|
966
|
+
```
|
|
967
|
+
lamaste-server plugins identity <name> # roles, attributes, approval, readiness, sync state, pending erasures
|
|
968
|
+
lamaste-server plugins audience <name> # members and tombstones
|
|
969
|
+
lamaste-server plugins entitlements <name> # role grants
|
|
970
|
+
lamaste-server plugins entitlements <name> --user <u>|--group <g> --roles a,b
|
|
971
|
+
# exactly these roles
|
|
972
|
+
lamaste-server plugins entitlements <name> --revoke --user <u>|--group <g>
|
|
973
|
+
lamaste-server plugins identity-sync <name> [--reconcile]
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
`plugins hooks` and `plugins update` print the identity block, an update's identity diff and the `identity.changed` reason. Binding the user surface: [Tunnels API](tunnels.md#user-surface-binding).
|
|
977
|
+
|
|
719
978
|
## Internal tunnels
|
|
720
979
|
|
|
721
980
|
`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.
|
|
@@ -737,13 +996,19 @@ lamaste-server plugins approve-hooks <name> --digest <sha> [--version <v>]
|
|
|
737
996
|
# approve it; a pending update by its version too
|
|
738
997
|
lamaste-server plugins settings <name> # its settings (secrets only as set), the hosting
|
|
739
998
|
lamaste-server plugins configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]
|
|
999
|
+
lamaste-server plugins identity <name> # its users: roles, readiness, the feed's state
|
|
1000
|
+
lamaste-server plugins audience <name> # members and tombstones
|
|
1001
|
+
lamaste-server plugins entitlements <name> [--user <u>|--group <g> --roles a,b] [--revoke --user <u>|--group <g>]
|
|
1002
|
+
lamaste-server plugins identity-sync <name> [--reconcile] # send its audience to the agent half now
|
|
1003
|
+
lamaste-server tunnels bind-user-surface <tunnel> <plugin> # serve the plugin's user surface on the tunnel's host
|
|
1004
|
+
lamaste-server tunnels unbind-user-surface <tunnel> <plugin>
|
|
740
1005
|
lamaste-server push-install status|on|off # the switch, policies, every agent's window
|
|
741
1006
|
lamaste-server push-install open <agent> [--minutes <n>] [--policy <id>]
|
|
742
1007
|
lamaste-server push-install close <agent>
|
|
743
1008
|
lamaste-server push-install default-policy <id>
|
|
744
1009
|
```
|
|
745
1010
|
|
|
746
|
-
`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 — with why it waits (`approvalRequired`; a secret turned plain in red) and the `approve-hooks` command that approves it, naming its version (`--digest <sha256> --version <v>`, or `--version <v>` alone when it has no digest); `update` prints the same for an update that waits. `settings` and `configure` — interactive in a terminal, a secret only from a masked prompt or stdin, never an argument — are described under [Settings](#from-the-cli). `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).
|
|
1011
|
+
`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 — with why it waits (`approvalRequired`; a secret turned plain in red) and the `approve-hooks` command that approves it, naming its version (`--digest <sha256> --version <v>`, or `--version <v>` alone when it has no digest) — and the identity block, an update's identity diff and the `identity.changed` reason; `update` prints the same for an update that waits. The identity commands are described under [Users and identity](#in-the-panel-and-the-cli). `settings` and `configure` — interactive in a terminal, a secret only from a masked prompt or stdin, never an argument — are described under [Settings](#from-the-cli). `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).
|
|
747
1012
|
|
|
748
1013
|
## State
|
|
749
1014
|
|
|
@@ -759,6 +1024,10 @@ lamaste-server push-install default-policy <id>
|
|
|
759
1024
|
| `state.db` `plugins.settings` | The installed manifest's settings block — the schema the panel checks a change against |
|
|
760
1025
|
| `state.db` `plugin_settings` | The cache of each plugin's settings: non-secret values, `{ key: setAt }` for secrets, status, provenance |
|
|
761
1026
|
| `state.db` `agent_presence` | When each agent last contacted the relay (written at most once a minute per agent) |
|
|
1027
|
+
| `state.db` `plugins.identity` | The installed version's identity block, normalized |
|
|
1028
|
+
| `state.db` `plugin_audience` | Each plugin's audience as last delivered — one change per plugin and user, stamped with `seq` |
|
|
1029
|
+
| `state.db` `plugin_identity_sync` | What the panel knows of each agent half's feed: directory, cursor, ready version, last sync and error |
|
|
1030
|
+
| `<agent half's database>` `lamaste_users`, `lamaste_identity_cursor` (agent) | The agent half's projection of its audience (`createIdentityProjection`) |
|
|
762
1031
|
| `/etc/lamalibre/lamaste/push-install-config.json`, `push-install-sessions.json` | The switch and policies; the push log (last 500) |
|
|
763
1032
|
| `<agent>/agents/<label>/app-plugins/<route>.json` | The agent's copy of the port and secret (0600) |
|
|
764
1033
|
| `/etc/lamalibre/apps/<route>/plugin-config.json` (agent) | The service's copy, for an agent half Lamaste installed (0600, its account) |
|