@lamalibre/create-lamaste 4.4.0 → 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.
Files changed (150) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +200 -16
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  28. package/vendor/docs/03-architecture/installer.md +4 -4
  29. package/vendor/docs/03-architecture/management-flow.md +3 -1
  30. package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
  31. package/vendor/docs/03-architecture/overview.md +13 -17
  32. package/vendor/docs/03-architecture/panel-client.md +6 -6
  33. package/vendor/docs/03-architecture/panel-server.md +3 -2
  34. package/vendor/docs/03-architecture/state-management.md +4 -8
  35. package/vendor/docs/03-architecture/system-overview.md +1 -1
  36. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +292 -23
  37. package/vendor/docs/04-api-reference/app-host.md +3 -3
  38. package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
  39. package/vendor/docs/04-api-reference/lifecycle-hooks.md +15 -13
  40. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  41. package/vendor/docs/04-api-reference/registry-and-oidc.md +6 -4
  42. package/vendor/docs/04-api-reference/tunnels.md +67 -29
  43. package/vendor/docs/04-api-reference/users.md +241 -45
  44. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  45. package/vendor/docs/05-operations/monitoring.md +39 -0
  46. package/vendor/docs/05-operations/uninstalling.md +2 -1
  47. package/vendor/docs/05-operations/upgrades.md +15 -6
  48. package/vendor/docs/06-reference/config-files.md +83 -189
  49. package/vendor/docs/06-reference/glossary.md +3 -3
  50. package/vendor/docs/06-reference/installer-flags.md +13 -13
  51. package/vendor/docs/06-reference/ports-and-services.md +25 -26
  52. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  53. package/vendor/docs/gatekeeper-design.md +10 -8
  54. package/vendor/docs/identity-directory-design.md +48 -14
  55. package/vendor/docs/package.json +1 -1
  56. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  57. package/vendor/gatekeeper/dist/index.d.ts +2 -1
  58. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  59. package/vendor/gatekeeper/dist/index.js +2 -0
  60. package/vendor/gatekeeper/dist/index.js.map +1 -1
  61. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  62. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  63. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  64. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  65. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  66. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  68. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  70. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  71. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  72. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  73. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
  74. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  75. package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
  76. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  77. package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
  78. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  79. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  80. package/vendor/gatekeeper/dist/server/index.js +22 -48
  81. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  82. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  83. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  84. package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
  85. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  86. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  88. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  89. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  90. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  91. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  92. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  93. package/vendor/gatekeeper/package.json +2 -2
  94. package/vendor/server/bin/lamaste-server.js +15 -1
  95. package/vendor/server/package.json +2 -2
  96. package/vendor/server/src/commands/plugin-identity.js +335 -0
  97. package/vendor/server/src/commands/plugins.js +99 -11
  98. package/vendor/server/src/commands/registry-credential.js +2 -1
  99. package/vendor/server/src/commands/tunnels.js +102 -2
  100. package/vendor/server/src/commands/uninstall.js +3 -1
  101. package/vendor/server/src/commands/users.js +358 -0
  102. package/vendor/server/src/plugin-approval.js +107 -5
  103. package/vendor/server/src/plugin-identity.js +382 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
  106. package/vendor/server-ui/dist/index.html +2 -2
  107. package/vendor/serverd/package.json +2 -2
  108. package/vendor/serverd/src/index.js +25 -1
  109. package/vendor/serverd/src/lib/audience.js +576 -0
  110. package/vendor/serverd/src/lib/authelia.js +78 -7
  111. package/vendor/serverd/src/lib/config.js +68 -43
  112. package/vendor/serverd/src/lib/directory.js +1113 -0
  113. package/vendor/serverd/src/lib/entitlements.js +517 -0
  114. package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
  115. package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
  116. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  117. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  118. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  119. package/vendor/serverd/src/lib/mtls.js +6 -2
  120. package/vendor/serverd/src/lib/nginx.js +279 -48
  121. package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
  122. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  123. package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
  124. package/vendor/serverd/src/lib/plugin-packages.js +30 -22
  125. package/vendor/serverd/src/lib/plugins.js +94 -15
  126. package/vendor/serverd/src/lib/registry-credential.js +46 -6
  127. package/vendor/serverd/src/lib/request-logging.js +19 -0
  128. package/vendor/serverd/src/lib/retained-data.js +50 -4
  129. package/vendor/serverd/src/lib/state.js +18 -0
  130. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  131. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  132. package/vendor/serverd/src/lib/user-caller.js +67 -0
  133. package/vendor/serverd/src/lib/user-surface.js +733 -0
  134. package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
  135. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  136. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  137. package/vendor/serverd/src/routes/invite.js +53 -12
  138. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  139. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  140. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  141. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  142. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  143. package/vendor/serverd/src/routes/management/tunnels.js +98 -2
  144. package/vendor/serverd/src/routes/management/users.js +290 -208
  145. package/vendor/serverd/src/routes/management.js +4 -0
  146. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  147. package/vendor/serverd/src/routes/user-access.js +17 -4
  148. package/vendor/serverd/src/routes/user-surface.js +69 -0
  149. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  150. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +0 -84
@@ -15,12 +15,12 @@ The helper does only what the relay **signed**. When it is enabled it remembers
15
15
  Once per agent machine (Linux with systemd; macOS agents cannot host an agent half Lamaste installs):
16
16
 
17
17
  ```bash
18
- sudo npm install -g @lamalibre/lamaste-agent@4.4.0 # a root-owned install
18
+ sudo npm install -g @lamalibre/lamaste-agent@4.5.0 # a root-owned install
19
19
  sudo lamaste-agent apphost enable # the agent runs as the user who ran sudo
20
20
  sudo lamaste-agent apphost enable --user root # the agent runs as root: no sudoers rule
21
21
  ```
22
22
 
23
- `enable` refuses to act from an install another user could have changed: the package, the helper it ships and the core library it loads — and every directory above them — must be root-owned and writable by no group or other user. A user-level install (`nvm`, a home-directory prefix) is not; install it root-owned with `sudo npm install -g @lamalibre/lamaste-agent@4.4.0` and run `enable` from that install. It also needs `/usr/bin/node` (the helper's interpreter and the Node.js every agent half runs on — from the distribution or NodeSource), systemd as the system manager (`/run/systemd/system`), and `sudo` and `visudo` unless the agent runs as root.
23
+ `enable` refuses to act from an install another user could have changed: the package, the helper it ships and the core library it loads — and every directory above them — must be root-owned and writable by no group or other user. A user-level install (`nvm`, a home-directory prefix) is not; install it root-owned with `sudo npm install -g @lamalibre/lamaste-agent@4.5.0` and run `enable` from that install. It also needs `/usr/bin/node` (the helper's interpreter and the Node.js every agent half runs on — from the distribution or NodeSource), systemd as the system manager (`/run/systemd/system`), and `sudo` and `visudo` unless the agent runs as root.
24
24
 
25
25
  What `enable` does:
26
26
 
@@ -357,7 +357,7 @@ The contract:
357
357
 
358
358
  ```js
359
359
  import Fastify from 'fastify';
360
- import { agentHalfListenTarget, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.4.0/agent'; // bundled
360
+ import { agentHalfListenTarget, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.5.0/agent'; // bundled
361
361
 
362
362
  const verifier = createCallerAssertionVerifier({
363
363
  configPath: process.env.LAMASTE_APP_CONFIG,
@@ -4,7 +4,9 @@
4
4
 
5
5
  ## Authentication
6
6
 
7
- All Gatekeeper endpoints proxied through the panel require admin mTLS authentication. The internal `authz/tunnel/<fqdn>` endpoint on `127.0.0.1:9294` is called by nginx only and is not exposed externally.
7
+ All Gatekeeper endpoints proxied through the panel require admin mTLS authentication. The internal `authz/tunnel/<fqdn>` and `authz/user-surface/<fqdn>` endpoints on `127.0.0.1:9294` are called by nginx only and are not exposed externally; Gatekeeper's own management API (`127.0.0.1:9294/api/*`, the shared secret in `gatekeeper-secret.json`) is called by the panel only.
8
+
9
+ **Grants and groups decide who uses agent-hosted plugins.** A plugin grant whose `context` names `roles` is an **entitlement** ([Agent-Hosted Plugins](agent-hosted-plugins.md#entitlements-and-the-audience)): every grant or group change that goes through the proxy is followed by a recompute of the plugins' audiences before the answer goes back, and anything given to a username — a user grant, a group membership — first clears what a removed user who held that username left in Gatekeeper ([Users API](users.md#usernames-and-gatekeeper)); when that cannot complete, the proxy answers `503` with `code: "gatekeeper_unavailable"`. Gatekeeper's own CLI writes the same database without telling the panel; the panel's minute-long recompute picks such edits up.
8
10
 
9
11
  If the Gatekeeper service is unavailable, proxied endpoints return `503 Service Unavailable`.
10
12
 
@@ -195,7 +197,7 @@ Add one or more members to a group.
195
197
  }
196
198
  ```
197
199
 
198
- **Errors:** 400 (invalid or empty usernames array), 404 (group not found)
200
+ **Errors:** 400 (invalid or empty usernames array), 404 (group not found), 503 (a removed user's principal under one of the usernames cannot be cleared — `code: "gatekeeper_unavailable"`)
199
201
 
200
202
  ---
201
203
 
@@ -268,7 +270,9 @@ Create a new authorization grant linking a principal (user or group) to a resour
268
270
  }
269
271
  ```
270
272
 
271
- **Errors:** 400 (invalid body), 409 (duplicate grant), 503 (Gatekeeper unavailable)
273
+ **Plugin role grants.** For `resourceType: "plugin"` with `context.roles`, the panel checks the grant before Gatekeeper sees it: `resourceId` must be the route of an installed plugin that declares `identity` (`400`, `code: "no_identity"`) and `roles` a non-empty list of its role ids (`400`, `code: "invalid_roles"`, or `"unknown_roles"` with the `unknown` ones); they go on sorted and without repeats. The plugin's **People** panel and `lamaste-server plugins entitlements` manage these grants one principal at a time (`PUT /api/plugins/:name/entitlements` replaces a principal's role grants) — prefer them to creating one here. A plugin grant without `roles` (the desktop's local-install grants) never makes anyone a plugin user.
274
+
275
+ **Errors:** 400 (invalid body; a role grant as above), 409 (duplicate grant), 503 (Gatekeeper unavailable, or a removed user's principal under `principalId` cannot be cleared — `code: "gatekeeper_unavailable"`)
272
276
 
273
277
  ---
274
278
 
@@ -555,16 +559,31 @@ DELETE /api/gatekeeper/access-log
555
559
 
556
560
  **Auth:** Admin only
557
561
 
558
- Delete all access log entries.
562
+ Delete all access log entries. It takes no query: Gatekeeper refuses one (`400`), so a narrowing query from an older caller never clears the whole log instead.
559
563
 
560
564
  **Response (200):**
561
565
 
562
566
  ```json
563
567
  {
564
- "ok": true
568
+ "ok": true,
569
+ "removed": 142
565
570
  }
566
571
  ```
567
572
 
573
+ ### Purge One User's Access-Log Entries
574
+
575
+ ```
576
+ POST /api/gatekeeper/access-log/purge
577
+ ```
578
+
579
+ **Auth:** Admin only
580
+
581
+ ```json
582
+ { "username": "alice", "before": "2026-10-22T08:00:00.000Z" }
583
+ ```
584
+
585
+ Deletes only that user's entries — logged before `before` (ISO 8601 UTC, optional) when given, since a later user may have the same username — and the entries still queued for the log, so none of them is written afterwards. `{ "ok": true, "removed": 3 }`; `400` for a body that is not `{ username, before? }`. The username travels in the body, never in a URL that request logs keep. The panel calls Gatekeeper's `POST /api/access-log/purge` itself when it [erases](users.md#erasing-a-removed-user) a removed user.
586
+
568
587
  ---
569
588
 
570
589
  ## Internal Endpoints
@@ -581,6 +600,12 @@ GET /authz/tunnel/:fqdn
581
600
 
582
601
  This is the nginx `auth_request` target. nginx calls this endpoint on every request to a Gatekeeper-protected resource. The tunnel vhost names its tunnel in the path (`proxy_pass http://127.0.0.1:9294/authz/tunnel/<fqdn>;`) and sends the same hostname in `X-Original-URL` (with the request's path and query) and `Host`; a request whose `X-Original-URL` or `Host` names another hostname, or has no `Host`, is refused with 401. Gatekeeper validates the user's Authelia session cookie for that URL and checks the corresponding grant.
583
602
 
603
+ **The session cookie.** Of the browser's `Cookie` header Gatekeeper reads the `authelia_session` pair alone (core `autheliaSessionCookies`: a pair whose name, spaces and tabs trimmed, is exactly `authelia_session`) and sends Authelia `Cookie: authelia_session=<value>` — never the browser's other cookies. A request carrying **more than one** `authelia_session` is refused with `401` before Authelia is asked (logged `duplicate_authelia_cookie`): another host under the domain can plant a second one under another path, and which one Authelia would read is not Gatekeeper's to guess. A validated session is cached for `sessionCacheTtlMs` (30 s) under an HMAC of the cookie value **and the tunnel host** — Authelia decides per host, so a session accepted on one host is asked about again on another.
604
+
605
+ **Rate limit.** Every authorization route (`/authz/tunnel/:fqdn`, `/authz/user-surface/:fqdn`, `/authz/user-surface-unauthenticated`, `/authz/check`) allows 6000 requests a minute per client address and tunnel host (`AUTHZ_RATE_LIMIT_PER_CLIENT`), `429` past it. The client address is `X-Forwarded-For`, which nginx sets to `$remote_addr` on every subrequest and Gatekeeper trusts from `127.0.0.1` only (`trustProxy`) — one client exhausting its bucket leaves the others theirs. The management API keeps one shared bucket (1000 a minute): only the panel and local tooling reach it.
606
+
607
+ **Logs.** A refusal is logged with the tunnel, the reason and the request id — never the username (`no_matching_grant` included). The access-request log (`accessLoggingEnabled`) is the one place Gatekeeper records who asked.
608
+
584
609
  `GET /authz/check` — what tunnel vhosts written before 4.3 call, with `X-Original-URL` and `Host` taken from the client's `Host` header — answers `403` (logged as `vhost_predates_host_binding`): such a vhost closes its tunnel until the panel rewrites it, which it does at startup. See [nginx Configuration](../03-architecture/nginx-configuration.md#authorization-subrequests-name-their-own-hostname).
585
610
 
586
611
  **Response codes:**
@@ -592,3 +617,33 @@ This is the nginx `auth_request` target. nginx calls this endpoint on every requ
592
617
  | 403 | Denied | User is authenticated but lacks a grant; returns an HTML body with denial details |
593
618
 
594
619
  This endpoint is bound to `127.0.0.1` only. External traffic never reaches it directly -- nginx forwards the subrequest internally.
620
+
621
+ ### User-Surface Authorization (nginx auth_request)
622
+
623
+ ```
624
+ GET /authz/user-surface/:fqdn
625
+ ```
626
+
627
+ **Host:** `127.0.0.1:9294`
628
+
629
+ The `auth_request` target of a plugin's user surface — `/api/<route>/app` and everything under it on the host of a tunnel bound to the plugin ([Tunnels API](tunnels.md#user-surface-binding)). The vhost names its tunnel in the path, `X-Original-URL` and `Host`, as for `/authz/tunnel/:fqdn`, and sends `X-Original-Method`. Gatekeeper checks, in order: the hostnames agree; the tunnel is known, enabled, not public and has a user surface; the raw request target — what follows `https://<fqdn>` in `X-Original-URL`, never re-encoded by a URL parser — lies on the user surface of a route bound to the tunnel, with no `.` or `..` segment, backslash, percent-encoded `.`, `/`, `\` or `%` (`%25`), or escape that does not decode; the tunnel's agent hosts the plugin — it carries the plugin's internal tunnel, the one `tunnels.json` records for the route (core `tunnelServedUserSurfaceRoutes`; refused `not_hosting_agent` otherwise: the tunnel moved or its agent revoked, the plugin hosted elsewhere or not at all); the Authelia session, read as for `/authz/tunnel/:fqdn` (one `authelia_session` only); on a `restricted` tunnel, the user's grant for the tunnel. Then it signs a **relay assertion for the panel**: Ed25519 with the relay signing key, audience `@panel`, port 3100, caller `{ type: "user", username }` — the username alone; the panel takes everything else about the user from its directory — bound to the method and the raw target (`{ route, path }`).
630
+
631
+ | Status | Meaning |
632
+ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
633
+ | 200 | `X-Lamalibre-Relay-Assertion` for this one request; `Cache-Control: no-store`; no `Remote-*` headers |
634
+ | 401 | No valid session (or more than one session cookie) — nginx answers with `GET /authz/user-surface-unauthenticated`: a JSON `401 { "error": "unauthenticated" }`, never a redirect (the caller is an application's request) |
635
+ | 403 | Anything else refused, with an empty body — never the access-request page; the reason is logged (`user-surface authz denied`) |
636
+ | 503 | The relay signing key is unusable |
637
+
638
+ ### Principals (Gatekeeper's own API)
639
+
640
+ ```
641
+ POST /api/principals/user/remove
642
+ { "username": "alice" }
643
+ ```
644
+
645
+ **Host:** `127.0.0.1:9294`, with the panel's secret. Not proxied.
646
+
647
+ Removes a user principal everywhere — every grant naming the username, of any resource type, and its membership of every group — in one transaction, holding the groups lock and then the grants lock. Idempotent. `{ "ok": true, "grantsRemoved": 2, "groupsLeft": 1 }`; `400` for a body that is not `{ username }` (1–100 characters), never echoing it. The username travels in the body, never in a URL that request logs keep (the `DELETE /api/principals/user/:username` of earlier 4.5 builds is gone). The panel calls it when a user is removed, retries it every minute while it is pending, and calls it before it creates a user under a username a removed user held — always then — and before it gives a grant or a membership to a username whose cleanup is pending ([Usernames and Gatekeeper](users.md#usernames-and-gatekeeper)).
648
+
649
+ **Request logging.** Gatekeeper logs one metadata line per request — method, route pattern (`/authz/tunnel/:fqdn`, never the URL), status, duration — and no client address; Fastify's own request lines are off.
@@ -60,18 +60,19 @@ Only a plugin with `"hosting": "agent"` and an `agent` block may declare hooks.
60
60
 
61
61
  ### Route patterns
62
62
 
63
- `"<METHOD> <path>"` or `"<path>"` (any method). The method is `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` or `*`; `GET` also matches `HEAD`. The path starts with `/admin` (the forwarded admin API) or `/app` (the user surface — accepted, not active until the plugin gateway exists). A segment is a literal or `*`: one segment, except as the last, where it matches one or more.
63
+ `"<METHOD> <path>"` or `"<path>"` (any method). The method is `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` or `*`; `GET` also matches `HEAD`. The path starts with `/admin` (the forwarded admin API) or `/app` (the user surface — requests of the plugin's users, which only a plugin that declares `identity` receives; listed as `userSurfaceRoutes` in the preview). A segment is a literal or `*`: one segment, except as the last, where it matches one or more.
64
64
 
65
65
  Matching errs towards running the hook: literal segments match case-insensitively, segments are compared percent-decoded (`/admin/%69mport` is `/admin/import`), and empty segments are ignored. The forwarder refuses a path with an encoded `/` or `\`.
66
66
 
67
67
  ## Approval
68
68
 
69
- Relay hooks are code Lamaste runs on the relay, and an [agent half Lamaste installs](app-host.md) is code it has an agent's root helper install, so an administrator approves both first. What is approved is one **digest**: SHA-256 over the canonical JSON of the normalised relay declarations (defaults applied, shared-hook settings included) with the SHA-256 of the entry module, and of the agent half pinned for the version — its package, version, tarball integrity, service (`entry`, `memoryMax`) and system packages (`{ v: 2, relay, agentHalf }`, either part `null` when the version brings none). A version whose declarations, module or agent half differ by one byte has another digest. The runner records the relay part's own digest, so a new agent half with an unchanged relay module leaves the runner current.
69
+ Relay hooks are code Lamaste runs on the relay, and an [agent half Lamaste installs](app-host.md) is code it has an agent's root helper install, so an administrator approves both first. What is approved is one **digest**: SHA-256 over the canonical JSON of the normalised relay declarations (defaults applied, shared-hook settings included) with the SHA-256 of the entry module, and of the agent half pinned for the version — its package, version, tarball integrity, service (`entry`, `memoryMax`) and system packages (`{ v: 2, relay, agentHalf }`, either part `null` when the version brings none) — and, only when the plugin declares one, its normalized [identity block](agent-hosted-plugins.md#the-identity-block) (`identity`: the roles its users hold and the attributes it receives about them), so a plugin without one keeps the digest it had before 4.5. A version whose declarations, module, agent half or identity block differ by one byte has another digest. The runner records the relay part's own digest, so a new agent half with an unchanged relay module leaves the runner current.
70
70
 
71
- - Installing a plugin with relay hooks or an agent half Lamaste installs leaves them **pending**: the plugin can be enabled, but not hosted, and its forwarded API answers `503 { code: "hooks_not_approved" }` until the approval.
71
+ - Installing a plugin with relay hooks, an agent half Lamaste installs or an identity block leaves them **pending**: the plugin can be enabled, but not hosted, its forwarded API answers `503 { code: "hooks_not_approved" }`, and its users are neither sent to its agent half nor served on its user surface until the approval.
72
72
  - **Approving** names the digest the administrator saw; if what the version brings changed meanwhile the panel answers `409` with the current digest.
73
73
  - **Updating** a plugin whose new version brings another hook set or agent half waits for approval; the installed version stays live. **Auto-approve**, off by default and per plugin, approves such changes automatically — each use is in the audit log — except an agent half change that runs more as root (an agent half where there was none, another agent package, added system packages, another service entry or memory limit; [withheld](agent-hosted-plugins.md#approval)).
74
74
  - A version that turns a secret setting into a plain one — whose next value the relay would keep in clear — **always waits for an administrator**: settings are outside the digest, so this is a gate of its own, held even when the digest is already approved, when the plugin brings neither relay hooks nor an agent half, and whatever auto-approve says. The pending update says why it waits (`approvalRequired`), and its approval covers both.
75
+ - A version that **adds or removes a role or widens the attributes** of its identity block waits for an administrator too (`identity.changed`) — a new role is a new power over the plugin's users, a new attribute more about them — and auto-approve never covers it. Narrowing the attributes and editing labels only change the digest.
75
76
  - Agent hooks are the plugin's own code on its own agent and need no approval. Shared hooks are part of the approved set by name and settings; their rules ride Lamaste releases (see below).
76
77
 
77
78
  Every approval, auto-approve change and auto-approval is recorded in the [audit log](audit-and-notifications.md) with the administrator certificate's serial.
@@ -98,7 +99,7 @@ Every approval, auto-approve change and auto-approval is recorded in the [audit
98
99
  "use": null,
99
100
  "settings": null,
100
101
  "failOpen": [],
101
- "inactiveRoutes": []
102
+ "userSurfaceRoutes": []
102
103
  }
103
104
  ],
104
105
  "effects": ["audit", "notify-admins"],
@@ -117,6 +118,7 @@ Every approval, auto-approve change and auto-approval is recorded in the [audit
117
118
  "unitName": "lamalibre-app-fx.service",
118
119
  "dataDir": "/var/lib/lamalibre/apps/fx"
119
120
  },
121
+ "identity": { "declared": false, "approved": false, "block": null },
120
122
  "approval": {
121
123
  "required": true,
122
124
  "state": "pending",
@@ -133,7 +135,7 @@ Every approval, auto-approve change and auto-approval is recorded in the [audit
133
135
  }
134
136
  ```
135
137
 
136
- `failOpen` lists a hook's fail-open settings (`onError`, `onOversize`, `onUnscannable`); the panel highlights them. `relay.digest` is the relay hook set's own digest (what the runner holds); `approval.digest` is the one to approve, covering the relay hooks and `agentHalf` — the agent half Lamaste installs, `null` when there is none or it is installed by hand ([App Host](app-host.md)). `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, approvalRequired, autoApproveWithheld }` while an update waits — `digest` is the one to approve it with (`null` when it brings neither relay hooks nor an agent half: it is approved by its version), `settings` lists how the version changes the plugin's [settings schema](agent-hosted-plugins.md#in-the-plugin-list-and-the-update-preview) (`{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key, from, to }], newRequired: [key], secretToPlain: [key] }`, `null` when neither version declares settings; not part of the digest), `approvalRequired` why it waits for an administrator, and `autoApproveWithheld` why auto-approve, when on, did not cover it, in sentences (`null` otherwise):
138
+ `failOpen` lists a hook's fail-open settings (`onError`, `onOversize`, `onUnscannable`); the panel highlights them. `relay.digest` is the relay hook set's own digest (what the runner holds); `approval.digest` is the one to approve, covering the relay hooks, `agentHalf` — the agent half Lamaste installs, `null` when there is none or it is installed by hand ([App Host](app-host.md)) — and the identity block (`identity.block`, normalized; `identity.approved` once the installed version is). `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, identity, approvalRequired, autoApproveWithheld }` while an update waits — `digest` is the one to approve it with (`null` when it brings no relay hooks, no agent half and no identity block: it is approved by its version), `identity` how it changes the identity block (`{ before, after, rolesAdded, rolesRemoved, attributesAdded, attributesRemoved, labelsChanged }`, `null` when neither version declares one), `settings` lists how the version changes the plugin's [settings schema](agent-hosted-plugins.md#in-the-plugin-list-and-the-update-preview) (`{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key, from, to }], newRequired: [key], secretToPlain: [key] }`, `null` when neither version declares settings; not part of the digest), `approvalRequired` why it waits for an administrator, and `autoApproveWithheld` why auto-approve, when on, did not cover it, in sentences (`null` otherwise):
137
139
 
138
140
  ```json
139
141
  "approvalRequired": [
@@ -145,7 +147,7 @@ Every approval, auto-approve change and auto-approval is recorded in the [audit
145
147
  ]
146
148
  ```
147
149
 
148
- `digest.unapproved`: what it brings — relay hooks, an agent half Lamaste installs — is not what was approved (auto-approve may stand in for an administrator on it). `settings.secret-to-plain`: it turns secret settings into plain ones — always an administrator's call, its digest approved or not.
150
+ `digest.unapproved`: what it brings — relay hooks, an agent half Lamaste installs, an identity block — is not what was approved (auto-approve may stand in for an administrator on it). `settings.secret-to-plain`: it turns secret settings into plain ones — always an administrator's call, its digest approved or not. `identity.changed`: it adds or removes roles, or receives more about its users (`it adds roles (editor) and receives more about its users (email)`) — always an administrator's call.
149
151
 
150
152
  ### `POST /api/plugins/:name/hooks/approve`
151
153
 
@@ -163,10 +165,10 @@ Approving a pending update approves everything it waits for (`approvalRequired`)
163
165
 
164
166
  ### `POST /api/plugins/:name/update`
165
167
 
166
- `{ "version"?: "1.2.3" | "latest" }` (default `latest`). For agent-hosted plugins. The panel fetches the version with `npm pack` and reads its manifest and relay module **in memory** — nothing of it is installed yet. Then:
168
+ `{ "version"?: "1.2.3" | "latest" }` (default `latest`). For agent-hosted plugins. The panel fetches the version with `npm pack` (from the registry its [registry credential](registry-and-oidc.md) names, authenticated as the panel, when one is configured) and reads its manifest and relay module **in memory** — nothing of it is installed yet. Then:
167
169
 
168
170
  - `200 { upToDate: true }` — nothing to do;
169
- - `202 { pendingUpdate }` — what it brings (relay hook set, agent half) differs from what was approved and auto-approve is off or withheld, or it turns a secret setting into a plain one (always); `approvalRequired` says which, and the administrators are notified. Approve it with the digest shown, or by its version;
171
+ - `202 { pendingUpdate }` — what it brings (relay hook set, agent half, identity block) differs from what was approved and auto-approve is off or withheld, or it turns a secret setting into a plain one or adds or removes a role or widens the attributes (always); `approvalRequired` says which, and the administrators are notified. Approve it with the digest shown, or by its version;
170
172
  - `202 { operation }` — the update runs: the new hook module goes into the runner, the install pipeline runs with `phase: "upgrade"` around installing the package (agent stages through the tunnel), and a failure restores the previous package, row and module.
171
173
 
172
174
  The agent half of a hosted plugin whose agent half Lamaste installs is pinned when the version is staged and installed on the agent **first**, after the agent's pre stage; a failure after that activates the version that ran before ([Hosting with an agent half](agent-hosted-plugins.md#the-pipelines)). That is a push: the hosting agent must be eligible for the new version and push install must allow `update` — checked when the update starts, not while it waits for approval.
@@ -236,7 +238,7 @@ What a request hook sees: method, path (on the agent half), query, the caller (`
236
238
 
237
239
  ### Tunnel state
238
240
 
239
- When the [health monitor](agent-hosted-plugins.md#health) sees a hosting go `down` (reason `tunnel` or `app`), Lamaste runs the relay's `tunnel:down`; its `fallback` (`{ status, retryAfter?, contentType?, body? }`) answers forwarded requests that fail while the plugin is down. `tunnel:up` runs when it is back after a `down` (not on the first `up`), and clears the fallback.
241
+ When the [health monitor](agent-hosted-plugins.md#health) sees a hosting go `down` (reason `tunnel` or `app`), Lamaste runs the relay's `tunnel:down`; its `fallback` (`{ status, retryAfter?, contentType?, body? }`) answers the plugin's admin API while the plugin is down — no request is forwarded to the port then (without a fallback: `502` with the reason). `tunnel:up` runs when it is back after a `down` (not on the first `up`), and clears the fallback.
240
242
 
241
243
  The agent cannot see its own chisel session; `agent-config` carries the relay's view (`appPlugins[].health`), and the agent's sync calls the agent half's `tunnel:down` / `tunnel:up` once per change. The panel unreachable for 90 s counts as `tunnel` down on the agent.
242
244
 
@@ -250,13 +252,13 @@ Relay hooks run in `lamalibre-lamaste-hooks@<route>.service`, started by its soc
250
252
 
251
253
  `lamaste-priv hooks install <route> <sha256|none>` (module on stdin, checked against the approved digest) writes the module root-owned to `/opt/lamalibre/lamaste/hooks/<route>/<sha256>.mjs` and enables the socket; `hooks activate` points back at the previous module (an update's revert); `hooks restart` stops a stuck runner; `hooks remove` disables it and deletes its modules. The panel reinstalls an approved runner at start-up if it is missing.
252
254
 
253
- The protocol between the panel and the runner is one length-prefixed JSON frame each way per connection (`@lamalibre/lamaste@4.4.0/hooks`, `protocol.ts`).
255
+ The protocol between the panel and the runner is one length-prefixed JSON frame each way per connection (`@lamalibre/lamaste@4.5.0/hooks`, `protocol.ts`).
254
256
 
255
257
  ## Writing hooks
256
258
 
257
259
  ```js
258
260
  // hooks/relay.mjs — bundled into one file (esbuild), Node built-ins only
259
- import { defineRelayHooks } from '@lamalibre/lamaste@4.4.0/hooks';
261
+ import { defineRelayHooks } from '@lamalibre/lamaste@4.5.0/hooks';
260
262
 
261
263
  export default defineRelayHooks({
262
264
  'install:pre-tunnel': async (input) =>
@@ -285,10 +287,10 @@ Every decision may carry `effects` (at most 10): `{ type: 'audit', event, data?
285
287
 
286
288
  ### Agent hooks
287
289
 
288
- The agent half answers `POST /admin/hooks/<stage>` (body `{ stage, input }`, answer `{ ok: true, output? }` or `{ ok: false, reason }`). `createAgentHookHandler` from `@lamalibre/lamaste@4.4.0/agent` does the checking — the caller assertion must name Lamaste itself (`{ type: 'lamaste' }`), which no forwarded caller can, and bind the SHA-256 of the exact body (`wrong_body` otherwise), so it needs the body **as received** (a `Buffer` or a string; a parsed object is refused `400` "The body must be passed as received"); the forwarder never reaches `/admin/hooks*`. Send the `status`, `headers` and `bodyText` it returns, `bodyText` byte for byte: its `X-Lamalibre-Responder` header signs those bytes, and the relay counts a hook answer without a valid one as a failure (`unauthenticated`) — see [Authentication between the relay and the agent half](agent-hosted-plugins.md#authentication-between-the-relay-and-the-agent-half):
290
+ The agent half answers `POST /admin/hooks/<stage>` (body `{ stage, input }`, answer `{ ok: true, output? }` or `{ ok: false, reason }`). `createAgentHookHandler` from `@lamalibre/lamaste@4.5.0/agent` does the checking — the caller assertion must name Lamaste itself (`{ type: 'lamaste' }`), which no forwarded caller can, and bind the SHA-256 of the exact body (`wrong_body` otherwise), so it needs the body **as received** (a `Buffer` or a string; a parsed object is refused `400` "The body must be passed as received"); the forwarder never reaches `/admin/hooks*`. Send the `status`, `headers` and `bodyText` it returns, `bodyText` byte for byte: its `X-Lamalibre-Responder` header signs those bytes, and the relay counts a hook answer without a valid one as a failure (`unauthenticated`) — see [Authentication between the relay and the agent half](agent-hosted-plugins.md#authentication-between-the-relay-and-the-agent-half):
289
291
 
290
292
  ```js
291
- import { createAgentHookHandler, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.4.0/agent';
293
+ import { createAgentHookHandler, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.5.0/agent';
292
294
 
293
295
  const hooks = createAgentHookHandler({
294
296
  verifier: createCallerAssertionVerifier({ configPath, plugin: 'fx' }),
@@ -255,7 +255,7 @@ The wildcard check probes `test-lamaste-check.<domain>`. Wildcard DNS is optiona
255
255
 
256
256
  ### `POST /api/onboarding/provision`
257
257
 
258
- Starts the full stack provisioning process in the background. This configures and starts Chisel and Authelia, issues certbot certificates, and writes nginx vhosts. The Chisel and Authelia binaries and units are installed by `create-lamaste`, not here: if one is missing, the task fails with the error "Chisel is not installed. Run `npx @lamalibre/create-lamaste@4.4.0` on the server to repair the installation." (likewise for Authelia). Task IDs are unchanged from earlier versions; only the titles changed.
258
+ Starts the full stack provisioning process in the background. This configures and starts Chisel and Authelia, issues certbot certificates, and writes nginx vhosts. The Chisel and Authelia binaries and units are installed by `create-lamaste`, not here: if one is missing, the task fails with the error "Chisel is not installed. Run `npx @lamalibre/create-lamaste@4.5.0` on the server to repair the installation." (likewise for Authelia). Task IDs are unchanged from earlier versions; only the titles changed.
259
259
 
260
260
  Provisioning runs asynchronously. This endpoint returns immediately with a 202 status. Use the WebSocket stream endpoint to follow progress in real time.
261
261
 
@@ -6,6 +6,8 @@ Two generic mechanisms a plugin can build on, first used by Feria (the Lamalibre
6
6
 
7
7
  Agents install plugins with `npm install` from the Lamalibre registry. A networked registry accepts nothing anonymously, and an agent must never hold a long-lived registry credential. So the panel holds one credential for the registry and, each time an agent is about to install a plugin, asks the registry for a one-hour read-only token owned by that agent. The agent writes it to a throw-away npm configuration file for that one install and deletes it afterwards.
8
8
 
9
+ The panel's own npm calls use the same credential: installing a plugin, fetching a version to stage an update, and reading an agent package's integrity to pin it all run with npm routed to the credential's registry for its scope and authenticated with the panel's service token — through a throw-away userconfig of the same kind (0600 in a private temporary directory, named to npm as `npm_config_userconfig`, removed once npm has run), never the service account's `~/.npmrc`. A registry that answers `401` has the token obtained again once; a token still refused, or `403`, fails the operation naming the registry and the status. Without a credential the panel's npm runs as the machine configures it (the public registry by default).
10
+
9
11
  The panel itself can authenticate to the registry in two ways: with a long-lived token an administrator pasted in, or — the recommended way — as its own **service principal**: an OpenID Connect client on the relay's Authelia named `lamaste-server`. In that mode nothing long-lived is pasted anywhere; the panel proves its identity to Authelia with a secret it generated itself, Authelia issues a short-lived JWT, and the registry exchanges that JWT for a registry token.
10
12
 
11
13
  Plugins use the same Authelia to let people and programs log in. A plugin's manifest declares its OIDC clients — a CLI that logs in with the device-code flow, a service that authenticates with client credentials — and the panel writes them into Authelia's configuration when the plugin is enabled.
@@ -156,17 +158,17 @@ Admin only. What is configured — never a token.
156
158
 
157
159
  ### `PUT /api/registry-credential`
158
160
 
159
- Admin only, 10 per minute. Verifies the whole chain against the registry, then stores the credential at `/etc/lamalibre/lamaste/registry-credential.json` (0600).
161
+ Admin only, 10 per minute. Verifies the whole chain against the registry, then stores the credential at `/etc/lamalibre/lamaste/registry-credential.json` (0600). From then on the panel's own installs, update staging and agent-half pinning authenticate to `registryUrl` for `scope` with the service token (see In Plain English); a package published only there installs from the panel.
160
162
 
161
163
  ```json
162
164
  { "registryUrl": "https://feria.example.com", "auth": { "kind": "oidc" } }
163
165
  { "registryUrl": "https://feria.example.com", "auth": { "kind": "token", "token": "feria_…" }, "scope": "@lamalibre" }
164
166
  ```
165
167
 
166
- - `auth.kind: "token"` — a long-lived Feria token. The registry's `/-/whoami` must report the `admin` scope (the broker route requires it).
167
- - `auth.kind: "oidc"` — the panel's own service principal. The panel first makes sure Authelia carries the confidential client `lamaste-server` (grant `client_credentials`, `token_endpoint_auth_method: client_secret_basic`, scope `lamaste.registry` — defined under `identity_providers.oidc.scopes` —, audience = the registry URL without a trailing slash, RS256 JWT access tokens; secret in `oidc-clients.json`). It then obtains a JWT from Authelia's token endpoint (`grant_type=client_credentials&scope=lamaste.registry&audience=<registryUrl>`), posts it to `<registryUrl>/-/feria/login`, and checks that `/-/whoami` reports `admin`. If any step fails the client is removed again and the error says what is missing — typically that Feria's `auth.json` needs, on its `groups` issuer for `https://auth.<domain>` (audience `<registryUrl>`), a `workloads` rule granting the `admin` scope when the `client_id` claim (or `sub`, which carries the same value) is `lamaste-server`. Feria keys issuers by `iss` and the relay's users log in through the same Authelia, so the rule cannot be a second `rules` issuer; `workloads` needs Feria 0.3.2 or later.
168
+ - `auth.kind: "token"` — a long-lived Feria token. The registry's `/-/whoami` must report the `read` scope (the panel's own installs, update staging and agent-half pinning) and the `admin` scope (the broker route requires it).
169
+ - `auth.kind: "oidc"` — the panel's own service principal. The panel first makes sure Authelia carries the confidential client `lamaste-server` (grant `client_credentials`, `token_endpoint_auth_method: client_secret_basic`, scope `lamaste.registry` — defined under `identity_providers.oidc.scopes` —, audience = the registry URL without a trailing slash, RS256 JWT access tokens; secret in `oidc-clients.json`). It then obtains a JWT from Authelia's token endpoint (`grant_type=client_credentials&scope=lamaste.registry&audience=<registryUrl>`), posts it to `<registryUrl>/-/feria/login`, and checks that `/-/whoami` reports `read` and `admin`. If any step fails the client is removed again and the error says what is missing — typically that Feria's `auth.json` needs, on its `groups` issuer for `https://auth.<domain>` (audience `<registryUrl>`), a `workloads` rule granting the scopes `read` and `admin` when the `client_id` claim (or `sub`, which carries the same value) is `lamaste-server`. Feria keys issuers by `iss` and the relay's users log in through the same Authelia, so the rule cannot be a second `rules` issuer; `workloads` needs Feria 0.3.2 or later.
168
170
 
169
- Response: as `GET`. Errors: `400` (no admin scope), `502` (registry or Authelia refused or unreachable), `503` (no domain yet).
171
+ Response: as `GET`. Errors: `400` (the `read` or `admin` scope is missing; the message names which), `502` (registry or Authelia refused or unreachable), `503` (no domain yet).
170
172
 
171
173
  The Authelia call goes to `127.0.0.1:9091` with `X-Forwarded-Proto: https` and `X-Forwarded-Host: auth.<domain>`, which is how Authelia derives the issuer — the JWT says `iss: https://auth.<domain>`, exactly as it would through nginx. In the JWT, Authelia sets both `sub` and `client_id` to the client id for the client-credentials flow (there is no end user).
172
174
 
@@ -53,20 +53,22 @@ curl -s --cert client.p12:password \
53
53
  }
54
54
  ```
55
55
 
56
- | Field | Type | Description |
57
- | --------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `id` | `string` | UUID v4 identifier |
59
- | `subdomain` | `string` | The subdomain portion (e.g., `app`) |
60
- | `fqdn` | `string` | Fully qualified domain name (e.g., `app.example.com`) |
61
- | `port` | `number` | Port the carrying agent forwards; bound on `127.0.0.1` on both the server and the agent |
62
- | `description` | `string \| null` | Optional human-readable description |
63
- | `type` | `string` | `'app'` (default), `'panel'` (agent web panel), or `'plugin'` |
64
- | `accessMode` | `string` | `'restricted'` (default), `'authenticated'`, or `'public'` — absent on panel tunnels |
65
- | `agentLabel` | `string` | The agent that carries the tunnel. Absent only on an unassigned legacy tunnel (see below) |
66
- | `exposure` | `string` | `'internal'` on the internal tunnel of a hosted agent-hosted plugin: no `subdomain`, `fqdn` or `accessMode`, only the chisel grant on a relay-loopback port. Absent (public) on every other tunnel. See [Agent-Hosted Plugins](agent-hosted-plugins.md) |
67
- | `maxBodySizeMb` | `number` | Largest request body nginx accepts, in MiB. Absent on panel tunnels and on tunnels created before the setting existed, which keep nginx's built-in 1 MiB until a body limit is set on them |
68
- | `enabled` | `boolean` | Whether the tunnel is active (defaults to `true`) |
69
- | `createdAt` | `string` | ISO 8601 timestamp |
56
+ | Field | Type | Description |
57
+ | ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `id` | `string` | UUID v4 identifier |
59
+ | `subdomain` | `string` | The subdomain portion (e.g., `app`) |
60
+ | `fqdn` | `string` | Fully qualified domain name (e.g., `app.example.com`) |
61
+ | `port` | `number` | Port the carrying agent forwards; bound on `127.0.0.1` on both the server and the agent |
62
+ | `description` | `string \| null` | Optional human-readable description |
63
+ | `type` | `string` | `'app'` (default), `'panel'` (agent web panel), or `'plugin'` |
64
+ | `accessMode` | `string` | `'restricted'` (default), `'authenticated'`, or `'public'` — absent on panel tunnels |
65
+ | `agentLabel` | `string` | The agent that carries the tunnel. Absent only on an unassigned legacy tunnel (see below) |
66
+ | `exposure` | `string` | `'internal'` on the internal tunnel of a hosted agent-hosted plugin: no `subdomain`, `fqdn` or `accessMode`, only the chisel grant on a relay-loopback port. Absent (public) on every other tunnel. See [Agent-Hosted Plugins](agent-hosted-plugins.md) |
67
+ | `maxBodySizeMb` | `number` | Largest request body nginx accepts, in MiB. Absent on panel tunnels and on tunnels created before the setting existed, which keep nginx's built-in 1 MiB until a body limit is set on them |
68
+ | `enabled` | `boolean` | Whether the tunnel is active (defaults to `true`) |
69
+ | `userSurface` | `object` | `{ routes: string[] }` — the agent-hosted plugins whose user surface this host serves ([User Surface Binding](#user-surface-binding)). Absent when none |
70
+ | `userSurfaceStatus` | `object[]` | With `userSurface`: how each bound route is served — `{ route, served, reason }`, `reason` `null` when served, else `not_installed`, `no_identity` or `not_approved` |
71
+ | `createdAt` | `string` | ISO 8601 timestamp |
70
72
 
71
73
  ---
72
74
 
@@ -205,8 +207,8 @@ Return 201 with tunnel object
205
207
  Changes any of: carrying agent, access mode, request body limit, enabled state. All of them are applied in one workflow under the tunnel lock, in that order — ownership first, so a later failure still leaves the tunnel with the agent the caller asked for; access mode and body limit next; the enabled state last, so a tunnel being disabled is never re-enabled by the vhost rewrite before it. Authorization is checked against the tunnel as it is when the lock is taken.
206
208
 
207
209
  - **`enabled`** — when disabled, the tunnel's nginx vhost symlink is removed (the config file is kept) and the owner's Chisel grant for the port is withdrawn (Chisel restarts, which ends sessions still holding it). Re-enabling restores both. Only enabled tunnels appear in an agent's config, and the owner's sync timer drops or adds the remote within about 30 seconds.
208
- - **`agentLabel`** (admin only) — moves the tunnel to another active agent. The previous owner loses the port grant in the same Chisel sync that gives it to the new owner. Both agents' sync timers apply the move within about 30 seconds; `lamaste-agent update` applies it at once. Agent panel tunnels (`type: 'panel'`) belong to the agent they expose and cannot be moved.
209
- - **`accessMode`** and **`maxBodySizeMb`** — both live in the tunnel's nginx vhost, which is re-rendered and tested (nginx keeps the previous vhost if the test fails). Gatekeeper watches the tunnel state and picks up an access-mode change on its own. A disabled tunnel stays disabled. `public` and `authenticated` are admin-only, as on creation. Agent panel tunnels have a fixed mTLS vhost and cannot be reconfigured.
210
+ - **`agentLabel`** (admin only) — moves the tunnel to another active agent. The previous owner loses the port grant in the same Chisel sync that gives it to the new owner. Both agents' sync timers apply the move within about 30 seconds; `lamaste-agent update` applies it at once. Agent panel tunnels (`type: 'panel'`) belong to the agent they expose and cannot be moved. A tunnel bound as a plugin's [user surface](#user-surface-binding) keeps its binding, but serves a route only while the new agent hosts the plugin: when that changes what it serves, its vhost is re-rendered first (and restored if the state cannot be saved).
211
+ - **`accessMode`** and **`maxBodySizeMb`** — both live in the tunnel's nginx vhost, which is re-rendered and tested (nginx keeps the previous vhost if the test fails). Gatekeeper watches the tunnel state and picks up an access-mode change on its own. A disabled tunnel stays disabled. `public` and `authenticated` are admin-only, as on creation. A tunnel bound as a plugin's [user surface](#user-surface-binding) cannot be made public (`409`), and its re-rendered vhost keeps the user-surface locations it serves. Agent panel tunnels have a fixed mTLS vhost and cannot be reconfigured.
210
212
 
211
213
  **Request:**
212
214
 
@@ -252,7 +254,7 @@ curl -s --cert client.p12:password \
252
254
 
253
255
  ### `DELETE /api/tunnels/:id`
254
256
 
255
- Deletes a tunnel by its UUID: removes the nginx vhost, removes the tunnel from the state file, and syncs Chisel so the owner's grant for the port is withdrawn.
257
+ Deletes a tunnel by its UUID: removes the nginx vhost, removes the tunnel from the state file, and syncs Chisel so the owner's grant for the port is withdrawn. Its [user-surface bindings](#user-surface-binding) go with it.
256
258
 
257
259
  The TLS certificate is not deleted (it is harmless to keep and may be reused if the subdomain is recreated).
258
260
 
@@ -316,7 +318,7 @@ curl -s --cert client.p12:password \
316
318
  | `chiselArgs` | `string[]` | Chisel client arguments: `client`, the server URL, and one `R:127.0.0.1:<port>:127.0.0.1:<port>` remote per tunnel. No `--tls-skip-verify` (the agent verifies the relay's certificate) and no credential — the agent supplies its own through the service environment, never argv |
317
319
  | `chiselCredentialIssuedAt` | `string \| null` | When the agent's current Chisel credential was issued — a timestamp, never the credential. An agent holding a credential with a different issue time (an administrator rotated it) fetches the current one from `GET /api/agents/me/chisel-credential` |
318
320
  | `tunnels` | `array` | The agent's enabled tunnels with port and subdomain |
319
- | `hostFactsHash` | `string \| null` | Agent certificates only: the SHA-256 of the [host facts](agent-hosted-plugins.md#host-facts) the relay stores for the agent, computed as the agent computes it over its own (`hostFactsHash` in `@lamalibre/lamaste@4.4.0`); `null` when it stores none. The agent's sync reports its facts again when this is not the hash of its own, so a record replaced since its last report — the agent enrolled again on its label, the relay restored from a backup — has them again after one sync, not a day. An agent behind a relay without the field reports as before |
321
+ | `hostFactsHash` | `string \| null` | Agent certificates only: the SHA-256 of the [host facts](agent-hosted-plugins.md#host-facts) the relay stores for the agent, computed as the agent computes it over its own (`hostFactsHash` in `@lamalibre/lamaste@4.5.0`); `null` when it stores none. The agent's sync reports its facts again when this is not the hash of its own, so a record replaced since its last report — the agent enrolled again on its label, the relay restored from a backup — has them again after one sync, not a day. An agent behind a relay without the field reports as before |
320
322
 
321
323
  **Errors:**
322
324
 
@@ -325,6 +327,40 @@ curl -s --cert client.p12:password \
325
327
  | 400 | `{"error":"Domain not configured"}` | Domain has not been set |
326
328
  | 500 | `{"error":"Failed to generate agent config"}` | Config generation failed |
327
329
 
330
+ ## User Surface Binding
331
+
332
+ An app tunnel can be **bound** as the user surface of an agent-hosted plugin: its host then serves the plugin's users at `/api/<route>/app` and everything under it — Gatekeeper authenticates the Authelia session and vouches for the user to the panel, and the panel forwards the request to the plugin's agent half with the directory's `userId`, username and the user's roles ([Agent-Hosted Plugins](agent-hosted-plugins.md#the-user-surface)). Every other path of the host goes down the tunnel as before. Admin only, 30 a minute.
333
+
334
+ **What can be bound.** An enabled app tunnel (`type` `app`, not internal), with access mode `authenticated` or `restricted` — a public host has no signed-in user to vouch for — **carried by the agent that hosts the plugin** (`400` otherwise: "Plugin '…' is hosted on agent '…', but this tunnel is carried by '…'", or "… is not hosted on an agent" before it is hosted). The host whose sessions reach the plugin's users is never another agent's: whatever answers on its other paths — another agent's application — would share the user surface's origin. On a `restricted` tunnel the user also needs a grant for the tunnel. The route must be held by an installed agent-hosted plugin; a plugin's user surface is on one host at most, and one host serves at most 16 plugins' user surfaces. A bound tunnel cannot be made public (`PATCH` answers `409` "… cannot be made public — unbind it first").
335
+
336
+ **When it is served.** A plugin may be bound before it declares `identity`: its host serves the route — locations that ask Gatekeeper and pass the request to the panel ([nginx Configuration](../03-architecture/nginx-configuration.md#user-surface-locations)) — only while the plugin's installed, approved version declares it **and the tunnel's agent is the plugin's hosting agent**. Until then its requests go down the tunnel as before, so a plugin moves its users' API to the relay with a plain update: bind first, then update and approve. A binding stays when the tunnel moves to another agent, its agent is revoked, or the plugin is unhosted or hosted elsewhere — but the host serves none of it then (`userSurfaceStatus` reason `not_hosting_agent`): its vhost loses the locations, Gatekeeper refuses to vouch for the route, and the panel refuses a request for it even with an assertion. The vhosts of the bound tunnels are re-rendered whenever that can change — the plugin's approval, update or its revert, hosting and unhosting, a move of the tunnel, its agent's revocation — and at the panel's start. Uninstalling the plugin releases its binding everywhere — and any binding left for a route no installed plugin holds, should a bind have raced the uninstall (a bind that finds its plugin gone once the binding is written undoes it and answers `409`); deleting a tunnel releases its bindings. Every change is audited: `tunnel.user-surface.bound`, `tunnel.user-surface.unbound` (with `reason`: `unbound`, `tunnel-deleted`, `plugin-uninstalled`).
337
+
338
+ `userSurfaceStatus` lists each bound route as `{ route, served, reason }`; `reason` is `not_installed`, `no_identity`, `not_approved` or `not_hosting_agent`, and null when served.
339
+
340
+ ### `PUT /api/tunnels/:id/user-surface`
341
+
342
+ ```json
343
+ { "route": "market-indicators" }
344
+ ```
345
+
346
+ Binds the plugin route to the tunnel's host. Idempotent. **Response (200):** `{ "ok": true, "changed": true, "tunnel": { ... } }` — the tunnel with its `userSurface` and `userSurfaceStatus`; `changed: false` when the route was bound there already.
347
+
348
+ ### `DELETE /api/tunnels/:id/user-surface/:route`
349
+
350
+ Unbinds it: requests for the route go down the tunnel again. **Response (200):** `{ "ok": true, "tunnel": { ... } }`.
351
+
352
+ **Errors** (both):
353
+
354
+ | Status | Body | When |
355
+ | ------ | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
356
+ | 400 | `{"error":"…"}` | Not an app tunnel's host, disabled, unassigned, public; no installed agent-hosted plugin holds the route; the plugin not hosted, or hosted by another agent than the tunnel's; 16 routes bound already; domain or email not configured |
357
+ | 404 | `{"error":"Tunnel not found"}` / `{"error":"This tunnel does not serve the user surface of '…'"}` | No such tunnel; unbinding a route that is not bound there |
358
+ | 409 | `{"error":"The user surface of '…' is already served on …","code":"user_surface_conflict"}` | The route is bound to another tunnel |
359
+ | 409 | `{"error":"The plugin holding the route '…' was uninstalled while it was being bound — nothing is bound"}` | The plugin was uninstalled while the binding was written (released again) |
360
+ | 500 | `{"error":"Failed to bind the user surface","details":"…"}` | nginx or state operation failed (the previous vhost is restored) |
361
+
362
+ From the CLI: `lamaste-server tunnels bind-user-surface <tunnel> <plugin>` and `tunnels unbind-user-surface <tunnel> <plugin>` (the tunnel by id, subdomain or FQDN; the plugin by name or route). In the panel: the Tunnels page's **User surface** action on an eligible tunnel, which shows each bound route's status; the desktop app through `AdminClient` `bindTunnelUserSurface(id, route)` and `unbindTunnelUserSurface(id, route)`.
363
+
328
364
  ## Chisel Grants
329
365
 
330
366
  The Chisel server authenticates every client against `/etc/lamalibre/lamaste/chisel-users`. That file is rendered by the panel from two persisted inputs — the per-agent credential store and the tunnel state — and re-rendered whenever either changes:
@@ -355,7 +391,7 @@ Tunnels on a reserved port, or two tunnels on the same port, are granted to no a
355
391
 
356
392
  ### When an agent certificate is revoked
357
393
 
358
- Revoking an agent's certificate releases its tunnels: its app and plugin tunnels become unassigned (their hostnames stay reserved and dark until an administrator assigns them), and its `agent-<label>` panel tunnel is removed with its vhost. A later enrollment that reuses the label therefore inherits nothing.
394
+ Revoking an agent's certificate releases its tunnels: its app and plugin tunnels become unassigned (their hostnames stay reserved and dark until an administrator assigns them), and its `agent-<label>` panel tunnel is removed with its vhost. A later enrollment that reuses the label therefore inherits nothing. A released tunnel bound as a plugin's user surface keeps its binding but serves none of it, and its vhost is re-rendered without those locations.
359
395
 
360
396
  ## Validation Rules
361
397
 
@@ -386,16 +422,18 @@ A tunnel's vhost proxies to `127.0.0.1:<port>` on the relay, so a tunnel on one
386
422
 
387
423
  ## Quick Reference
388
424
 
389
- | Method | Path | Description |
390
- | ------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
391
- | GET | `/api/tunnels` | List tunnels (an agent sees only its own) |
392
- | POST | `/api/tunnels` | Create a tunnel carried by an agent |
393
- | PATCH | `/api/tunnels/:id` | Move (admin), access mode, body limit, enable/disable |
394
- | DELETE | `/api/tunnels/:id` | Delete a tunnel by UUID |
395
- | GET | `/api/tunnels/agent-config` | One agent's Chisel client config (macOS & Linux) |
396
- | GET | `/api/tunnels/agent-panel-status` | Check if agent has an exposed panel tunnel |
397
- | POST | `/api/tunnels/expose-panel` | Create mTLS panel tunnel for requesting agent (refused when a static site or alias serves `agent-<label>.<domain>`) |
398
- | DELETE | `/api/tunnels/retract-panel` | Remove the panel tunnel for requesting agent |
425
+ | Method | Path | Description |
426
+ | ------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
427
+ | GET | `/api/tunnels` | List tunnels (an agent sees only its own) |
428
+ | POST | `/api/tunnels` | Create a tunnel carried by an agent |
429
+ | PATCH | `/api/tunnels/:id` | Move (admin), access mode, body limit, enable/disable |
430
+ | DELETE | `/api/tunnels/:id` | Delete a tunnel by UUID |
431
+ | PUT | `/api/tunnels/:id/user-surface` | Bind a plugin route as the tunnel host's user surface (admin) |
432
+ | DELETE | `/api/tunnels/:id/user-surface/:route` | Unbind it (admin) |
433
+ | GET | `/api/tunnels/agent-config` | One agent's Chisel client config (macOS & Linux) |
434
+ | GET | `/api/tunnels/agent-panel-status` | Check if agent has an exposed panel tunnel |
435
+ | POST | `/api/tunnels/expose-panel` | Create mTLS panel tunnel for requesting agent (refused when a static site or alias serves `agent-<label>.<domain>`) |
436
+ | DELETE | `/api/tunnels/retract-panel` | Remove the panel tunnel for requesting agent |
399
437
 
400
438
  ### curl Cheat Sheet
401
439