@lamalibre/create-lamaste 4.5.0 → 4.6.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 +34 -2
- 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/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +2 -2
- 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/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/02-guides/static-sites.md +4 -0
- 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 +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +65 -7
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +112 -37
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +19 -1
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +21 -19
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +19 -15
- 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 +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/index.d.ts +3 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +29 -10
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +103 -22
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +63 -6
- package/vendor/server/src/commands/sites.js +15 -1
- package/vendor/server/src/plugin-approval.js +80 -1
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.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 +9 -4
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- package/vendor/serverd/src/lib/gatekeeper-client.js +18 -7
- package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
- package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/nginx.js +269 -2
- package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
- package/vendor/serverd/src/lib/plugin-lifecycle.js +128 -5
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +39 -6
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/user-surface.js +48 -27
- package/vendor/serverd/src/lib/vhost-reconcile.js +5 -3
- package/vendor/serverd/src/lib/web-surface.js +766 -0
- package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +2 -0
- package/vendor/serverd/src/routes/management/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +11 -20
- package/vendor/serverd/src/routes/management.js +2 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
|
@@ -29,22 +29,26 @@ Hosting a plugin on an agent does three things at once: it creates the internal
|
|
|
29
29
|
"identity": {
|
|
30
30
|
"roles": [{ "id": "viewer", "label": "Viewer", "description": "Sees the dashboard" }],
|
|
31
31
|
"attributes": ["displayName"]
|
|
32
|
+
},
|
|
33
|
+
"userSurface": {
|
|
34
|
+
"web": { "defaultSubdomain": "piyasa", "accessMode": "authenticated", "root": "web" }
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
| Field | Meaning
|
|
37
|
-
| ---------------- |
|
|
38
|
-
| `name` | For a plugin with an `agent` block: exactly its route — no `-server` or `-agent` suffix (its packages keep their names). The route keys its hosting, its internal tunnel, its forwarded API and, on the agent, its account and directories, while its approvals, operations and audit entries go by name; the two must be one. Refused at install otherwise (`400`).
|
|
39
|
-
| `hosting` | `"agent"`. Lamaste never imports the package.
|
|
40
|
-
| `agent` | The plugin has an agent half Lamaste connects to. Only accepted with `hosting: "agent"`. Without it the plugin is identity only (OIDC clients and capabilities, as Feria), and may not declare a panel.
|
|
41
|
-
| `agent.requires` | Optional. `node` (a semver range), `os` (`linux`, `darwin`), `systemPackages` (Debian names, at most 50). Checked against the agent's [host facts](#host-facts) when Lamaste installs the agent half; for one installed by hand, nothing is checked and the system packages are listed for the operator.
|
|
42
|
-
| `agent.package` | Optional. The agent half's package: an `@lamalibre/` package published at the plugin's version, one bundled tarball (no dependencies, no install scripts — see [App Host](app-host.md#writing-an-agent-half)). With it, Lamaste installs the agent half on the hosting agent; without it, the agent half is installed by hand. Needs a route that starts and ends with a letter or digit.
|
|
43
|
-
| `agent.service` | Required with `agent.package`, and only with it. `entry`: a relative `.js`, `.mjs` or `.cjs` path inside the package, run as the service; `memoryMax` (optional): the unit's `MemoryMax=`, `[1-9][0-9]{0,5}` followed by `K`, `M` or `G`.
|
|
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`).
|
|
45
|
-
| `hooks` | Lifecycle hooks on the relay and in the agent half — see [Lifecycle Hooks](lifecycle-hooks.md).
|
|
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).
|
|
39
|
+
| Field | Meaning |
|
|
40
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| `name` | For a plugin with an `agent` block: exactly its route — no `-server` or `-agent` suffix (its packages keep their names). The route keys its hosting, its internal tunnel, its forwarded API and, on the agent, its account and directories, while its approvals, operations and audit entries go by name; the two must be one. Refused at install otherwise (`400`). |
|
|
42
|
+
| `hosting` | `"agent"`. Lamaste never imports the package. |
|
|
43
|
+
| `agent` | The plugin has an agent half Lamaste connects to. Only accepted with `hosting: "agent"`. Without it the plugin is identity only (OIDC clients and capabilities, as Feria), and may not declare a panel. |
|
|
44
|
+
| `agent.requires` | Optional. `node` (a semver range), `os` (`linux`, `darwin`), `systemPackages` (Debian names, at most 50). Checked against the agent's [host facts](#host-facts) when Lamaste installs the agent half; for one installed by hand, nothing is checked and the system packages are listed for the operator. |
|
|
45
|
+
| `agent.package` | Optional. The agent half's package: an `@lamalibre/` package published at the plugin's version, one bundled tarball (no dependencies, no install scripts — see [App Host](app-host.md#writing-an-agent-half)). With it, Lamaste installs the agent half on the hosting agent; without it, the agent half is installed by hand. Needs a route that starts and ends with a letter or digit. |
|
|
46
|
+
| `agent.service` | Required with `agent.package`, and only with it. `entry`: a relative `.js`, `.mjs` or `.cjs` path inside the package, run as the service; `memoryMax` (optional): the unit's `MemoryMax=`, `[1-9][0-9]{0,5}` followed by `K`, `M` or `G`. |
|
|
47
|
+
| `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`). |
|
|
48
|
+
| `hooks` | Lifecycle hooks on the relay and in the agent half — see [Lifecycle Hooks](lifecycle-hooks.md). |
|
|
49
|
+
| `settings` | What an administrator configures — values and secrets the agent half stores. Only with an `agent` block. See [Settings](#settings). |
|
|
50
|
+
| `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). |
|
|
51
|
+
| `userSurface` | How end users reach the plugin. Only `web`: `{ defaultSubdomain, accessMode?, root? }` — the subdomain Lamaste offers for the plugin's own site (a DNS label, not reserved, not `agent-…`), its access mode (`authenticated`, the default, or `restricted`; never public) and the directory of the server package holding the shell (`root`, default `web`). Only with `identity`. The shell's files enter the approval digest. See [Web surface](#web-surface). |
|
|
48
52
|
|
|
49
53
|
There is no port in the manifest: Lamaste assigns it when the plugin is hosted.
|
|
50
54
|
|
|
@@ -132,13 +136,13 @@ Uninstall (unhost, or uninstalling a hosted plugin): `relay uninstall:pre-teardo
|
|
|
132
136
|
|
|
133
137
|
**Rollback.** Once an install job exists the agent may act on it, whatever it reports later. A failure from then on removes the agent half again (without purge — its data is kept and [recorded](#retained-data)); an install the agent's helper reported as failed was undone by the helper itself, and nothing is recorded for it. The agent never removes or switches an agent half on its own: every recovery is a job the relay signs.
|
|
134
138
|
|
|
135
|
-
**Update** (`POST /api/plugins/:name/update`, [Lifecycle Hooks](lifecycle-hooks.md#updating-a-plugin)) of a hosted plugin installs the new agent half first: `relay install:pre-tunnel` → `agent install:pre-tunnel` (through the tunnel) → `install the new agent half` (the helper switches `current` and keeps the previous version; Lamaste waits until it answers through the tunnel) → `install the new version` (the relay package) → `agent install:post-tunnel` → `relay install:post-tunnel
|
|
139
|
+
**Update** (`POST /api/plugins/:name/update`, [Lifecycle Hooks](lifecycle-hooks.md#updating-a-plugin)) of a hosted plugin installs the new agent half first: `relay install:pre-tunnel` → `agent install:pre-tunnel` (through the tunnel) → `install the new agent half` (the helper switches `current` and keeps the previous version; Lamaste waits until it answers through the tunnel) → `install the new version` (the relay package) → `agent install:post-tunnel` → `relay install:post-tunnel` → `serve the new web tree` (a plugin that owns a site, [Serving the tree](#serving-the-tree)). A failure after the install job was dispatched runs `restore the previous version` and `activate the previous agent half` (an `app-activate` job naming the version the hosting recorded). The hosting must be `active` and its agent enrolled (`409` otherwise) and eligible for the new version, and push install must allow `update`. A version may not switch, under a hosting, between an agent half installed by hand and one Lamaste installs, nor drop the agent half (`400` — clear the hosting first).
|
|
136
140
|
|
|
137
141
|
Every app job is signed by the relay with its signing key when it is created, and the agent's helper runs only what the relay signed ([Signed app jobs](app-host.md#signed-app-jobs)). While the relay's signing key is unusable no app job is created and the step fails.
|
|
138
142
|
|
|
139
143
|
### Host facts
|
|
140
144
|
|
|
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.
|
|
145
|
+
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.6.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.
|
|
142
146
|
|
|
143
147
|
```json
|
|
144
148
|
{
|
|
@@ -197,7 +201,7 @@ To open a window from the panel: the Plugins page's **Push install** section tur
|
|
|
197
201
|
|
|
198
202
|
### Approval
|
|
199
203
|
|
|
200
|
-
What a version brings that Lamaste runs or installs — relay hooks and the agent half — and, when it declares
|
|
204
|
+
What a version brings that Lamaste runs or installs — relay hooks and the agent half — and, when it declares them, its [identity block](#the-identity-block) and its [web tree](#the-web-tree) are 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`:
|
|
201
205
|
|
|
202
206
|
```json
|
|
203
207
|
"agentHalf": {
|
|
@@ -213,7 +217,7 @@ What a version brings that Lamaste runs or installs — relay hooks and the agen
|
|
|
213
217
|
}
|
|
214
218
|
```
|
|
215
219
|
|
|
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
|
|
220
|
+
An update whose agent half differs waits for approval like a changed hook set: `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, identity, web, approvalRequired, autoApproveWithheld }` (`relay` and `agentHalf` are `null` when the version brings none, and `digest` when it brings no relay hooks, no agent half, no identity block and no web surface; `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; `web` its [web tree](#the-web-tree) — `{ before, after, changes }`, the installed tree's digest, the staged one's `{ sha256, files, totalBytes }` and what changes by path — 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`, `web.changed`, `settings.secret-to-plain`, `identity.changed`), and the administrators are notified. It is also withheld when the version's **web tree** changes or appears (`web.changed`): publisher code served to every user of the plugin on the relay's own domain is approved by a person.
|
|
217
221
|
|
|
218
222
|
**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.
|
|
219
223
|
|
|
@@ -342,7 +346,7 @@ What the agent's host offers — see [Host facts](#host-facts).
|
|
|
342
346
|
|
|
343
347
|
`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>`.
|
|
344
348
|
|
|
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.
|
|
349
|
+
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.6.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)):
|
|
346
350
|
|
|
347
351
|
```js
|
|
348
352
|
import {
|
|
@@ -350,7 +354,7 @@ import {
|
|
|
350
354
|
createCallerAssertionVerifier,
|
|
351
355
|
createHealthHandler,
|
|
352
356
|
responderHeaderFor,
|
|
353
|
-
} from '@lamalibre/lamaste@4.
|
|
357
|
+
} from '@lamalibre/lamaste@4.6.0/agent';
|
|
354
358
|
|
|
355
359
|
const verifier = createCallerAssertionVerifier({ configPath, plugin: 'market-indicators' });
|
|
356
360
|
const health = createHealthHandler({ verifier, check: () => db.isOpen() });
|
|
@@ -416,7 +420,7 @@ Answers given before or instead of a verification — a `401` for a bad assertio
|
|
|
416
420
|
|
|
417
421
|
**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)).
|
|
418
422
|
|
|
419
|
-
**Agent halves built against `@lamalibre/lamaste@4.
|
|
423
|
+
**Agent halves built against `@lamalibre/lamaste@4.6.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.
|
|
420
424
|
|
|
421
425
|
## Health
|
|
422
426
|
|
|
@@ -566,13 +570,13 @@ The panel checks the change against the installed schema — without `pattern`
|
|
|
566
570
|
| 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") |
|
|
567
571
|
| 502 | `settings_unsupported` | The agent half does not answer `/admin/settings` (`404`) — it predates settings; update the plugin |
|
|
568
572
|
| 502 | `refused` | Nothing answers on the internal tunnel's port |
|
|
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.
|
|
573
|
+
| 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.6.0` and must be rebuilt. The change may or may not have been stored — read the settings again |
|
|
570
574
|
| 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 |
|
|
571
575
|
| 502 | `agent_half_error` | The agent half answered another status (`status`) |
|
|
572
576
|
| 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) |
|
|
573
577
|
| 504 | `timeout` | No answer within 15 s — the change may or may not have been stored; read the settings again |
|
|
574
578
|
|
|
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.
|
|
579
|
+
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.6.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.
|
|
576
580
|
|
|
577
581
|
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`.
|
|
578
582
|
|
|
@@ -598,7 +602,7 @@ An update's preview — `pendingUpdate.settings`, in `GET /api/plugins/:name/hoo
|
|
|
598
602
|
|
|
599
603
|
### Between the relay and the agent half
|
|
600
604
|
|
|
601
|
-
The relay calls the agent half with `callAgentHalf` (`@lamalibre/lamaste@4.
|
|
605
|
+
The relay calls the agent half with `callAgentHalf` (`@lamalibre/lamaste@4.6.0`, also exported from `@lamalibre/lamaste@4.6.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.
|
|
602
606
|
|
|
603
607
|
| Call | Body | Answer |
|
|
604
608
|
| ----------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -621,7 +625,7 @@ The state, which every answer of the relay's reads and changes is built from:
|
|
|
621
625
|
|
|
622
626
|
`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.
|
|
623
627
|
|
|
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.
|
|
628
|
+
**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.6.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.
|
|
625
629
|
|
|
626
630
|
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.
|
|
627
631
|
|
|
@@ -629,7 +633,7 @@ Only Lamaste: a caller assertion that does not verify is `401`, one naming an ad
|
|
|
629
633
|
|
|
630
634
|
### In the agent half
|
|
631
635
|
|
|
632
|
-
`@lamalibre/lamaste@4.
|
|
636
|
+
`@lamalibre/lamaste@4.6.0/agent` holds the store and the handler, so that no plugin reimplements how a secret is stored:
|
|
633
637
|
|
|
634
638
|
- **`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.
|
|
635
639
|
- **`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.
|
|
@@ -652,7 +656,7 @@ import {
|
|
|
652
656
|
createSettingsHandler,
|
|
653
657
|
createSettingsStore,
|
|
654
658
|
settingsFromManifest,
|
|
655
|
-
} from '@lamalibre/lamaste@4.
|
|
659
|
+
} from '@lamalibre/lamaste@4.6.0/agent';
|
|
656
660
|
import manifest from '../lamaste-plugin.json' with { type: 'json' }; // bundled into the agent half
|
|
657
661
|
|
|
658
662
|
const verifier = createCallerAssertionVerifier({
|
|
@@ -803,7 +807,7 @@ POST /admin/identity/changes { protocol: 1, directoryId, from, to, reset, recon
|
|
|
803
807
|
→ 409 { error: "cursor_mismatch", protocol: 1, directoryId, cursor }
|
|
804
808
|
```
|
|
805
809
|
|
|
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.
|
|
810
|
+
- **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.6.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
811
|
- `seq` in a notification is the plugin's head; `directoryId` the relay directory's id (a UUID v4 made with `state.db`).
|
|
808
812
|
- 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
813
|
- **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`.
|
|
@@ -823,20 +827,21 @@ POST /admin/identity/changes { protocol: 1, directoryId, from, to, reset, recon
|
|
|
823
827
|
|
|
824
828
|
### The user surface
|
|
825
829
|
|
|
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:
|
|
830
|
+
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 — or on the site the plugin owns for its [web surface](#web-surface), which serves the same paths the same way (where the steps below say tunnel, read site; a site's grant is a `site` grant). 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
831
|
|
|
828
832
|
1. **nginx** asks Gatekeeper (`auth_request` to `/authz/user-surface/<fqdn>`, the tunnel named in the path).
|
|
829
833
|
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
834
|
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
835
|
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
836
|
|
|
833
|
-
| Check
|
|
834
|
-
|
|
|
835
|
-
| The request was not started by another site: `Sec-Fetch-Site` absent, `same-origin` or `none` — whatever the method
|
|
836
|
-
| The
|
|
837
|
-
| The
|
|
838
|
-
|
|
|
839
|
-
|
|
|
837
|
+
| Check | Refusal |
|
|
838
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
839
|
+
| 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" }` |
|
|
840
|
+
| The request does not load code: `Sec-Fetch-Dest` is not `script`, `style`, a worker or a worklet (a web surface's policy allows code from its own host — `'self'` takes in the API's paths) | `403 { error: "not a script or style source" }` |
|
|
841
|
+
| 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 |
|
|
842
|
+
| 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`) |
|
|
843
|
+
| A method other than `GET`, `HEAD` and `OPTIONS` carries `Origin: https://<bound host>` (CSRF) | `403 { error: "cross-origin request refused" }` |
|
|
844
|
+
| 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
845
|
|
|
841
846
|
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
847
|
|
|
@@ -844,7 +849,7 @@ A plugin's users reach it at `/api/<route>/app` and everything under it, on the
|
|
|
844
849
|
|
|
845
850
|
**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
851
|
|
|
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
|
|
852
|
+
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 route's host as `tunnels.json` or `sites.json` names it, never the request's `Host`.
|
|
848
853
|
|
|
849
854
|
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
855
|
|
|
@@ -862,7 +867,7 @@ An agent half verifies `/app` requests with its `createCallerAssertionVerifier`,
|
|
|
862
867
|
|
|
863
868
|
### In the agent half
|
|
864
869
|
|
|
865
|
-
`@lamalibre/lamaste@4.
|
|
870
|
+
`@lamalibre/lamaste@4.6.0/agent` keeps the projection and answers the feed:
|
|
866
871
|
|
|
867
872
|
```js
|
|
868
873
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -870,7 +875,7 @@ import {
|
|
|
870
875
|
createCallerAssertionVerifier,
|
|
871
876
|
createIdentityHandlers,
|
|
872
877
|
createIdentityProjection,
|
|
873
|
-
} from '@lamalibre/lamaste@4.
|
|
878
|
+
} from '@lamalibre/lamaste@4.6.0/agent';
|
|
874
879
|
|
|
875
880
|
const db = new DatabaseSync(`${dataDir}/app.db`);
|
|
876
881
|
const projection = createIdentityProjection({
|
|
@@ -975,6 +980,73 @@ lamaste-server plugins identity-sync <name> [--reconcile]
|
|
|
975
980
|
|
|
976
981
|
`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
982
|
|
|
983
|
+
## Web surface
|
|
984
|
+
|
|
985
|
+
A plugin that declares `userSurface.web` ships its users' **shell** — a static web application — in its server package, and the relay serves it on a **site the plugin owns**, beside its [user surface](#the-user-surface)'s `/api/<route>/app/*`: one host, one Authelia session. The shell calls `/api/<route>/app/…` on its own origin, and gets the user's answers through the same path a bound tunnel's host gives them. A plugin needs no tunnel and no web server of its own for its users, and the relay never serves a file of it that was not approved.
|
|
986
|
+
|
|
987
|
+
### The web tree
|
|
988
|
+
|
|
989
|
+
The files under `root` in the server package are the plugin's **web tree**. Every file must be a regular file — a link anywhere fails the read — with a path of segments of letters, digits, `.`, `_` and `-`, none starting with `.`, at most 16 levels and 256 characters, and an extension of `.html .css .js .mjs .json .webmanifest .txt .png .jpg .jpeg .gif .svg .ico .webp .avif .woff .woff2 .ttf .otf` (no `.wasm`: the shell's policy does not let it compile), each served under its own content type (core `WEB_TREE_CONTENT_TYPES`, named in the vhost rather than taken from the host's `mime.types`); at most 2,000 files, 8 MB a file and 32 MB in all, with an `index.html` at the top. The tree's digest is SHA-256 over the JSON `{"v":1,"files":[[path,size,sha256],…]}`, the files sorted by path (core `webTreeManifest`).
|
|
990
|
+
|
|
991
|
+
The digest enters the [approval](#approval) (`web: { treeSha256 }`, only for a plugin that declares a web surface — every other digest is unchanged). It is read from the installed package at install (a tree that breaks a rule is refused, `400`, `code: "invalid_web_tree"`) and from the registry tarball when an update is staged, so the update preview shows the new tree before anything is installed and the installed package must hold the very tree that was reviewed. A changed tree is `digest.unapproved`, and **auto-approve never covers it** (`web.changed`, [above](#approval)). The update preview lists what changes by path — `changes: { added, removed, changed }`, each `{ count, paths }` (at most 200 paths, the count exact), and `review`: the added or changed paths an administrator should look at closely, a page other than `index.html`, an SVG or a manifest — and the panel shows them under the approval; `changes` is `null` when the installed tree cannot be read. `GET /api/plugins/:name/hooks` shows it as `web: { declared, approved, treeSha256, surface }`.
|
|
992
|
+
|
|
993
|
+
### Serving the tree
|
|
994
|
+
|
|
995
|
+
The relay keeps each plugin's trees in `/var/www/lamaste/apps/<route>/`: every tree under its digest, and two links — `current`, what the site serves, and `previous`, the tree before it. While the plugin **owns a site** and its installed version is **approved** — when the site is created or moved, in every update (its last step, `serve the new web tree`, once the new version is installed and every stage passed: a tree that cannot be served reverts the update), after a revert, an approval or a hosting, and at the panel's start — the approved tree is copied out of the installed package (regular files only, read without following links), the copy's own digest checked against the approved one, and `current` switched to it with a rename; `previous` keeps the tree it replaces and every older tree goes. **Going back to the tree `previous` names** — a reverted update, a rollback release — abandons the outgoing tree: `previous` goes and the tree is removed, so none of a version that was backed out of is served any more. The plugin is read again inside the store's lock, so two refreshes never switch to an older tree. A plugin without a site has no store, and a deleted store stays deleted. Audit: `plugin.web-tree.switched`.
|
|
996
|
+
|
|
997
|
+
### The site
|
|
998
|
+
|
|
999
|
+
A plugin owns at most one site, in `sites.json` with `owner: { plugin: <route>, package: <server package> }` and its `accessMode`. The sites API lists it but does not manage it — files, settings, deletion are refused (`409`, `code: "plugin_owned_site"`), and so is `lamaste-agent deploy`. Its vhost ([nginx Configuration](../03-architecture/nginx-configuration.md#web-surface-vhosts)):
|
|
1000
|
+
|
|
1001
|
+
- serves the **shell** — every path but the API — from `current`, `GET` and `HEAD` only (`405` otherwise), asking Gatekeeper first (`/authz/user-surface-shell/<fqdn>`): an Authelia session, membership of the plugin's audience (an entitlement with roles, to the user or a group of theirs) and, on a `restricted` site, a Gatekeeper grant on the site (`resourceType: "site"`, `resourceId: <site id>`). A page without a session goes to the login; a user without access gets the access-request page. A **script, style sheet, font or image** `current` lacks is looked for in `previous` (a page still running the old shell loads its lazy files after an update), else `404`; any other path `current` lacks — a page, an SVG, a manifest — gets the current `index.html`, never the previous tree's. **No service worker** is ever registered: a request for a worker's script (`Service-Worker: script`) gets `404`, which also unregisters one already installed at its next update check — a worker would outlive the tree that installed it, a revert, the site's deletion and the hostname's next owner. Every shell answer carries `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Resource-Policy: same-origin` (no other host learns from a subresource whether the user may use the plugin) and `Cache-Control: no-cache` (every file revalidated: a `304` while the tree is unchanged, the new file after an update). A shell therefore loads everything from its own host — no font, script or style from a CDN — and runs no inline script;
|
|
1002
|
+
- serves the **API** at `/api/<route>/app*` exactly as a [bound tunnel's host](#the-user-surface) does;
|
|
1003
|
+
- answers **`503`** to everything while the surface is not served — no plugin is installed under the route, the installed version does not declare identity or a web surface, the site was created for another package (`not_owner`), the version is not approved, not hosted, or its tree is not current yet. Once its plugin is **uninstalled** the `503` also carries `Clear-Site-Data: "storage"`: whatever takes the hostname next does not inherit the plugin's browser storage, caches or workers. Never while the plugin is only unhosted or waiting for approval — its users' data stays.
|
|
1004
|
+
|
|
1005
|
+
The panel's user-surface context takes a plugin-owned site as the route's host (its `Origin`, its open responses), and Gatekeeper reads `sites.json` beside `tunnels.json`; a route two hosts would serve is served by neither. A plugin that owns a site cannot have its user surface bound to a tunnel, and a tunnel cannot be created on, or enabled for, a hostname a site serves. Uninstalling the plugin keeps the site, answering `503`, until it is deleted — `DELETE /api/plugins/<name>/web-surface` works without the plugin, and the Sites page offers it. A site records the **package** it was created for (`owner: { plugin, package }`): another package installed later under the same route is not served from it (`not_owner`) — it inherits neither the hostname nor the grants nor the old tree; delete the site, then create one for it.
|
|
1006
|
+
|
|
1007
|
+
### Moving from a bound tunnel
|
|
1008
|
+
|
|
1009
|
+
A plugin whose users reach it through a tunnel bound as its user surface — the tunnel's backend serving the shell — moves to its web surface with one call: the site is created on the tunnel's hostname (its certificate kept), the tunnel is disabled and unbound in the same state change, and **one nginx reload** hands the hostname from the tunnel's vhost to the site's; the tunnel's port grant is withdrawn only after that, for new sessions alone — chisel is not restarted, which would drop the plugin's internal tunnel (its API answering `502` until the agent reconnects) and fail what nginx's old workers still send to the tunnel; the agent drops the binding at its next sync, restarting its tunnel client as on any tunnel change. There is no moment without an answer. The tunnel stays in `tunnels.json`, disabled and unbound, and its vhost stays installed, unlinked, for a rollback by hand: delete the web surface, enable the tunnel (its vhost is linked again), bind it again. The site takes the tunnel's access mode — a tunnel without a known one is `restricted`, as everywhere else — and a restricted tunnel's grants are copied to the site. Every step from the copy of the grants to the handover is undone in reverse order on a failure: no grant, site entry or tunnel change outlives a move that did not happen; and a move the panel stopped between its state writes and the handover is finished at the next start (the tunnel's vhost unlinked, the site's linked). The tunnel must be enabled, carried by the plugin's hosting agent and bound to this plugin alone, and the plugin approved; when nginx refuses the new configuration everything is put back. Audit: `plugin.web-surface.moved`.
|
|
1010
|
+
|
|
1011
|
+
### Endpoints
|
|
1012
|
+
|
|
1013
|
+
All admin only.
|
|
1014
|
+
|
|
1015
|
+
#### `GET /api/plugins/:name/web-surface`
|
|
1016
|
+
|
|
1017
|
+
```json
|
|
1018
|
+
{
|
|
1019
|
+
"webSurface": {
|
|
1020
|
+
"declared": { "defaultSubdomain": "piyasa", "accessMode": "authenticated", "root": "web" },
|
|
1021
|
+
"site": {
|
|
1022
|
+
"id": "…",
|
|
1023
|
+
"name": "piyasa",
|
|
1024
|
+
"fqdn": "piyasa.example.com",
|
|
1025
|
+
"accessMode": "authenticated"
|
|
1026
|
+
},
|
|
1027
|
+
"served": true,
|
|
1028
|
+
"reason": null,
|
|
1029
|
+
"tree": { "approved": "5b2a…", "current": "5b2a…", "previous": "85dc…" },
|
|
1030
|
+
"boundTunnel": null,
|
|
1031
|
+
"offer": null
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
`reason` while not served: `not_installed`, `no_identity`, `no_web_surface`, `not_owner`, `not_approved`, `not_hosted`, `tree_not_current`. `site.ownerPackage` is the package the site was created for. `boundTunnel` names a tunnel bound as the plugin's user surface (`{ id, fqdn, enabled, routes }`) — enabled or not, creating a site is refused while it is bound. The status of a site whose plugin is no longer installed stays readable (`reason: "not_installed"`); `404` only when neither a plugin nor a site holds the route. `offer` — while the plugin owns no site, declares a web surface and has no tunnel bound as its user surface (then moving is the way) — is `{ subdomain, accessMode, available }`: what the panel offers to create, and whether the name is free.
|
|
1037
|
+
|
|
1038
|
+
#### `POST /api/plugins/:name/web-surface`
|
|
1039
|
+
|
|
1040
|
+
`{ subdomain?, accessMode? }` (defaults: the declared ones). Issues the certificate, serves the approved tree if there is one, writes the site and its vhost; `201 { webSurface }`. Refused: `no_web_surface` / `no_identity` (`400`), `exists`, `bound_to_tunnel` (move it instead), `hostname_taken` (a tunnel or a site holds the name, or it is reserved), `web_tree` (`409`). Audit: `plugin.web-surface.created`.
|
|
1041
|
+
|
|
1042
|
+
#### `POST /api/plugins/:name/web-surface/move`
|
|
1043
|
+
|
|
1044
|
+
`{ tunnelId }` — see [Moving from a bound tunnel](#moving-from-a-bound-tunnel). `{ webSurface }`. Refused: `exists`, `not_bound`, `shared_tunnel` (other routes are bound there), `tunnel_disabled`, `not_hosted`, `not_approved`, `hostname_taken`, `web_tree` (`409`), `not_found` (`404`), `gatekeeper` (`503`, a restricted tunnel's grants could not be read or copied).
|
|
1045
|
+
|
|
1046
|
+
#### `DELETE /api/plugins/:name/web-surface`
|
|
1047
|
+
|
|
1048
|
+
Removes the vhost, the site, the trees and the grants on the site; the plugin stays installed — or is already gone (`:name` derives the route). `{ ok: true }`, with `grantsNotRevoked` listing the grants Gatekeeper did not revoke (they name a site that no longer exists), or `grantsUnlisted: true` when it could not list them; `404` when nothing owns a site under the route. Audit: `plugin.web-surface.deleted` (with `grantsNotRevoked` or `grantsUnlisted`).
|
|
1049
|
+
|
|
978
1050
|
## Internal tunnels
|
|
979
1051
|
|
|
980
1052
|
`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.
|
|
@@ -1027,6 +1099,9 @@ lamaste-server push-install default-policy <id>
|
|
|
1027
1099
|
| `state.db` `plugins.identity` | The installed version's identity block, normalized |
|
|
1028
1100
|
| `state.db` `plugin_audience` | Each plugin's audience as last delivered — one change per plugin and user, stamped with `seq` |
|
|
1029
1101
|
| `state.db` `plugin_identity_sync` | What the panel knows of each agent half's feed: directory, cursor, ready version, last sync and error |
|
|
1102
|
+
| `state.db` `plugins.user_surface`, `plugins.web_tree_sha256` | The installed version's `userSurface` block; its web tree's digest |
|
|
1103
|
+
| `/etc/lamalibre/lamaste/sites.json` (`owner`, `accessMode`) | The site a plugin owns |
|
|
1104
|
+
| `/var/www/lamaste/apps/<route>/` | The plugin's web trees (`<sha256>/`), `current` and `previous` (lamaste, group www-data; 0750/0640) |
|
|
1030
1105
|
| `<agent half's database>` `lamaste_users`, `lamaste_identity_cursor` (agent) | The agent half's projection of its audience (`createIdentityProjection`) |
|
|
1031
1106
|
| `/etc/lamalibre/lamaste/push-install-config.json`, `push-install-sessions.json` | The switch and policies; the push log (last 500) |
|
|
1032
1107
|
| `<agent>/agents/<label>/app-plugins/<route>.json` | The agent's copy of the port and secret (0600) |
|
|
@@ -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.
|
|
18
|
+
sudo npm install -g @lamalibre/lamaste-agent@4.6.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.
|
|
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.6.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.
|
|
360
|
+
import { agentHalfListenTarget, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.6.0/agent'; // bundled
|
|
361
361
|
|
|
362
362
|
const verifier = createCallerAssertionVerifier({
|
|
363
363
|
configPath: process.env.LAMASTE_APP_CONFIG,
|
|
@@ -626,7 +626,7 @@ GET /authz/user-surface/:fqdn
|
|
|
626
626
|
|
|
627
627
|
**Host:** `127.0.0.1:9294`
|
|
628
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 }`).
|
|
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)) or of the site the plugin owns ([Web surface](agent-hosted-plugins.md#web-surface)). Gatekeeper reads both kinds of host from `tunnels.json` and `sites.json`, re-read when either changes, the way the panel reads them (core `userSurfaceHosts`): a route two hosts would serve is served by neither, and a host whose name another host shares is no host. Where this section says tunnel, read the host; a site's grant is a `site` grant. 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
630
|
|
|
631
631
|
| Status | Meaning |
|
|
632
632
|
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -635,6 +635,24 @@ The `auth_request` target of a plugin's user surface — `/api/<route>/app` and
|
|
|
635
635
|
| 403 | Anything else refused, with an empty body — never the access-request page; the reason is logged (`user-surface authz denied`) |
|
|
636
636
|
| 503 | The relay signing key is unusable |
|
|
637
637
|
|
|
638
|
+
### Web-Surface Shell Authorization (nginx auth_request)
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
GET /authz/user-surface-shell/:fqdn
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
**Host:** `127.0.0.1:9294`
|
|
645
|
+
|
|
646
|
+
The `auth_request` target of every path of a plugin-owned site but its user surface's API: the shell of the plugin's [web surface](agent-hosted-plugins.md#web-surface). The vhost names the site in the path, `X-Original-URL` and `Host`. Gatekeeper checks, in order: the hostnames agree; the host is a site a plugin owns; its route is served (the plugin hosted, no other host serving the route); the Authelia session (one `authelia_session` only); the user is in the plugin's **audience** — an entitlement on the plugin (`resourceType: "plugin"`, `resourceId: <route>`) with a non-empty `context.roles`, to the user or to one of their Lamaste groups (a grant without roles never counts); on a `restricted` site, a grant on the site (`resourceType: "site"`, `resourceId: <site id>`). The panel decides again, from its directory, on every request to the plugin's API — a disabled or removed user, a plugin not ready.
|
|
647
|
+
|
|
648
|
+
| Status | Meaning |
|
|
649
|
+
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
650
|
+
| 200 | Allowed; nothing else is passed on — nginx serves the shell from disk, no backend receives a user |
|
|
651
|
+
| 401 | No valid session (or more than one session cookie) — nginx redirects a page to the login |
|
|
652
|
+
| 403 | No entitlement or no site grant: the access-request page, served inline; anything else refused (not a web surface, not served, names disagree): an empty body |
|
|
653
|
+
|
|
654
|
+
Every answer is `Cache-Control: no-store`. A refusal for want of an entitlement or a grant is logged with the resource and the reason only, and recorded in the access-request log when it is on.
|
|
655
|
+
|
|
638
656
|
### Principals (Gatekeeper's own API)
|
|
639
657
|
|
|
640
658
|
```
|
|
@@ -252,13 +252,13 @@ Relay hooks run in `lamalibre-lamaste-hooks@<route>.service`, started by its soc
|
|
|
252
252
|
|
|
253
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.
|
|
254
254
|
|
|
255
|
-
The protocol between the panel and the runner is one length-prefixed JSON frame each way per connection (`@lamalibre/lamaste@4.
|
|
255
|
+
The protocol between the panel and the runner is one length-prefixed JSON frame each way per connection (`@lamalibre/lamaste@4.6.0/hooks`, `protocol.ts`).
|
|
256
256
|
|
|
257
257
|
## Writing hooks
|
|
258
258
|
|
|
259
259
|
```js
|
|
260
260
|
// hooks/relay.mjs — bundled into one file (esbuild), Node built-ins only
|
|
261
|
-
import { defineRelayHooks } from '@lamalibre/lamaste@4.
|
|
261
|
+
import { defineRelayHooks } from '@lamalibre/lamaste@4.6.0/hooks';
|
|
262
262
|
|
|
263
263
|
export default defineRelayHooks({
|
|
264
264
|
'install:pre-tunnel': async (input) =>
|
|
@@ -287,10 +287,10 @@ Every decision may carry `effects` (at most 10): `{ type: 'audit', event, data?
|
|
|
287
287
|
|
|
288
288
|
### Agent hooks
|
|
289
289
|
|
|
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.
|
|
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.6.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):
|
|
291
291
|
|
|
292
292
|
```js
|
|
293
|
-
import { createAgentHookHandler, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.
|
|
293
|
+
import { createAgentHookHandler, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.6.0/agent';
|
|
294
294
|
|
|
295
295
|
const hooks = createAgentHookHandler({
|
|
296
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.
|
|
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.6.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
|
|
|
@@ -652,6 +652,10 @@ curl -s --cert client.p12:password \
|
|
|
652
652
|
}
|
|
653
653
|
```
|
|
654
654
|
|
|
655
|
+
A site a plugin owns for its [web surface](agent-hosted-plugins.md#web-surface) is listed with two more fields — `"owner": { "plugin": "<route>", "package": "<server package>" }` and `"accessMode": "authenticated" | "restricted"` — and its `rootPath` is the plugin's `current` tree under `/var/www/lamaste/apps/<route>/`; `GET /api/sites` adds `webSurface: { served, reason }` — whether it serves its plugin now, or answers `503` and why (the reasons of [`GET /api/plugins/:name/web-surface`](agent-hosted-plugins.md#get-apipluginsnameweb-surface)). The routes below do not manage it: `PATCH`, `DELETE`, `verify-dns` and the file routes answer `409` with `details.code: "plugin_owned_site"` (and so `lamaste-agent deploy` is refused); it is created, moved to and deleted through `/api/plugins/:name/web-surface`.
|
|
656
|
+
|
|
657
|
+
**Locks.** Every change of `sites.json` runs under one lock; one that gives a site a hostname (creating a site, changing its aliases, verifying its DNS) takes the tunnel lock first, as creating or enabling a tunnel does — a site and a tunnel never take one name at once. The size recorded after an upload or a deletion is written from `sites.json` as it is then, never from a copy read before.
|
|
658
|
+
|
|
655
659
|
### Managed vs Custom Site Flow
|
|
656
660
|
|
|
657
661
|
```
|