@lamalibre/create-lamaste 4.0.0 → 4.2.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 (66) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +21 -2
  3. package/src/lib/oidc-issuer-key.js +124 -0
  4. package/src/lib/relay-services.js +4 -0
  5. package/src/lib/service-config.js +16 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/authentication.md +45 -1
  10. package/vendor/docs/01-concepts/certificates.md +3 -3
  11. package/vendor/docs/01-concepts/mtls.md +2 -2
  12. package/vendor/docs/01-concepts/security-model.md +1 -1
  13. package/vendor/docs/01-concepts/tickets.md +2 -2
  14. package/vendor/docs/01-concepts/tunneling.md +4 -4
  15. package/vendor/docs/02-guides/agent-setup.md +3 -3
  16. package/vendor/docs/02-guides/certificate-management.md +2 -2
  17. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  18. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  19. package/vendor/docs/02-guides/installation.md +3 -3
  20. package/vendor/docs/02-guides/onboarding.md +2 -2
  21. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  22. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  23. package/vendor/docs/03-architecture/installer.md +6 -5
  24. package/vendor/docs/03-architecture/management-flow.md +1 -1
  25. package/vendor/docs/03-architecture/overview.md +5 -5
  26. package/vendor/docs/03-architecture/panel-client.md +6 -6
  27. package/vendor/docs/03-architecture/panel-server.md +2 -2
  28. package/vendor/docs/03-architecture/state-management.md +2 -2
  29. package/vendor/docs/03-architecture/system-overview.md +1 -1
  30. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  31. package/vendor/docs/04-api-reference/overview.md +111 -105
  32. package/vendor/docs/04-api-reference/registry-and-oidc.md +207 -0
  33. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  34. package/vendor/docs/05-operations/uninstalling.md +1 -1
  35. package/vendor/docs/05-operations/upgrades.md +6 -5
  36. package/vendor/docs/06-reference/config-files.md +103 -45
  37. package/vendor/docs/06-reference/glossary.md +3 -3
  38. package/vendor/docs/06-reference/installer-flags.md +13 -13
  39. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  40. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  41. package/vendor/docs/_index.json +5 -0
  42. package/vendor/docs/gatekeeper-design.md +8 -8
  43. package/vendor/docs/identity-directory-design.md +443 -0
  44. package/vendor/docs/package.json +1 -1
  45. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  46. package/vendor/gatekeeper/package.json +1 -1
  47. package/vendor/server/bin/lamaste-server.js +10 -1
  48. package/vendor/server/package.json +4 -3
  49. package/vendor/server/src/commands/plugins.js +200 -158
  50. package/vendor/server/src/commands/registry-credential.js +142 -0
  51. package/vendor/server/src/panel-api.js +86 -39
  52. package/vendor/server-ui/dist/assets/{index-B0nWuUyL.js → index-B5Wp8lu-.js} +26 -26
  53. package/vendor/server-ui/dist/index.html +1 -1
  54. package/vendor/serverd/package.json +5 -2
  55. package/vendor/serverd/src/index.js +11 -0
  56. package/vendor/serverd/src/lib/authelia.js +11 -0
  57. package/vendor/serverd/src/lib/migrations/state/0005_plugin_hosting.sql +3 -0
  58. package/vendor/serverd/src/lib/plugin-oidc.js +233 -0
  59. package/vendor/serverd/src/lib/plugins.js +37 -5
  60. package/vendor/serverd/src/lib/registry-credential.js +145 -0
  61. package/vendor/serverd/src/routes/management/agents.js +81 -0
  62. package/vendor/serverd/src/routes/management/plugins.js +65 -2
  63. package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
  64. package/vendor/serverd/src/routes/management/storage.js +10 -2
  65. package/vendor/serverd/src/routes/management/users.js +31 -0
  66. package/vendor/serverd/src/routes/management.js +2 -0
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Lamaste</title>
7
- <script type="module" crossorigin src="/assets/index-B0nWuUyL.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-B5Wp8lu-.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-BqWWFlTU.css">
9
9
  </head>
10
10
  <body style="background-color: #09090b">
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-serverd",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "Lamaste server daemon — REST API and plugin host",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
@@ -8,6 +8,9 @@
8
8
  },
9
9
  "type": "module",
10
10
  "main": "src/index.js",
11
+ "files": [
12
+ "src"
13
+ ],
11
14
  "scripts": {
12
15
  "dev": "node --watch src/index.js | pino-pretty",
13
16
  "build": "echo 'No build step for lamaste-serverd'",
@@ -26,7 +29,7 @@
26
29
  "@fastify/rate-limit": "^10.3.0",
27
30
  "@fastify/static": "^9.1.1",
28
31
  "@fastify/websocket": "^11.0.0",
29
- "@lamalibre/lamaste": "^4.0.0",
32
+ "@lamalibre/lamaste": "^4.2.0",
30
33
  "bcryptjs": "^3.0.3",
31
34
  "execa": "^9.6.1",
32
35
  "fastify": "^5.8.4",
@@ -28,6 +28,7 @@ import userAccessSessionMiddleware from './middleware/user-access-session.js';
28
28
  import { getPluginCapabilities } from './lib/plugins.js';
29
29
  import { setPluginCapabilities } from './lib/mtls.js';
30
30
  import { startChiselReconciler } from './lib/chisel-reconcile.js';
31
+ import { syncOidcClients } from './lib/plugin-oidc.js';
31
32
  import {
32
33
  loadTicketScopeCapabilities,
33
34
  checkInstanceLiveness,
@@ -76,6 +77,16 @@ async function start() {
76
77
  // it can.
77
78
  startChiselReconciler(server.log);
78
79
 
80
+ // Bring Authelia's OIDC clients (plugin manifests, the panel's own
81
+ // registry client) and the client-secret store in line with persisted
82
+ // state. Best effort: a failure is logged and the next lifecycle change
83
+ // retries. Only once onboarding has written Authelia's configuration.
84
+ if (config.onboarding.status === 'COMPLETED') {
85
+ syncOidcClients(server.log).catch((err) => {
86
+ server.log.warn({ err: err.message }, 'Startup OIDC client sync failed');
87
+ });
88
+ }
89
+
79
90
  // --- Plugins ---
80
91
  const ipOrigin = `https://${config.ip}:9292`;
81
92
  await server.register(cors, {
@@ -18,6 +18,7 @@ import {
18
18
  reloadAuthelia as reloadAutheliaCore,
19
19
  isAutheliaRunning as isAutheliaRunningCore,
20
20
  updateAutheliaAccessControl,
21
+ updateAutheliaOidcProvider,
21
22
  createUserFromInvitation as createUserFromInvitationCore,
22
23
  base32Encode,
23
24
  base32Decode,
@@ -73,6 +74,16 @@ export function updateAccessControl(sites) {
73
74
  return updateAutheliaAccessControl(sites, execa);
74
75
  }
75
76
 
77
+ /**
78
+ * Make Authelia's OIDC provider serve exactly these clients.
79
+ * @param {string} domain
80
+ * @param {import('@lamalibre/lamaste/server').AutheliaOidcClient[]} clients
81
+ * @param {import('@lamalibre/lamaste/server').OidcProviderOptions} [options]
82
+ */
83
+ export function updateOidcProvider(domain, clients, options = {}) {
84
+ return updateAutheliaOidcProvider(domain, clients, execa, options);
85
+ }
86
+
76
87
  export function createUserFromInvitation(username, email, groups, hashedPassword) {
77
88
  return createUserFromInvitationCore(username, email, groups, hashedPassword, execa);
78
89
  }
@@ -0,0 +1,3 @@
1
+ -- Where a plugin's code runs (manifest `hosting`, 4.2). Rows written before
2
+ -- the column existed are server plugins.
3
+ ALTER TABLE plugins ADD COLUMN hosting TEXT NOT NULL DEFAULT 'server';
@@ -0,0 +1,233 @@
1
+ /**
2
+ * OIDC clients → Authelia.
3
+ *
4
+ * Two sources declare clients on the relay's Authelia:
5
+ *
6
+ * - every installed plugin may declare them in its manifest (`oidcClients`,
7
+ * see the core `ManifestSchema`); only the clients of enabled plugins are
8
+ * served. Hosting does not matter: an agent-hosted plugin (`hosting:
9
+ * "agent"`, never imported by the panel) registers its clients exactly as
10
+ * a server plugin does — the manifest is all this reads;
11
+ * - the panel itself declares the confidential `lamaste-server` client
12
+ * when the registry credential uses `auth.kind: 'oidc'`.
13
+ *
14
+ * After any plugin lifecycle change, or a registry-credential change, the
15
+ * set is recomputed, the secrets of confidential clients are reconciled
16
+ * (`oidc-clients.json`: minted on first appearance, deleted when a client is
17
+ * gone — a secret belongs to an installed plugin, enabled or not, so
18
+ * disabling a plugin does not rotate it) and Authelia's
19
+ * `identity_providers.oidc` section is written; Authelia is restarted only
20
+ * when the section actually changed.
21
+ *
22
+ * Best effort by design where a plugin lifecycle calls it: a failure here
23
+ * must not fail the plugin operation — the caller logs it and the next
24
+ * lifecycle change retries.
25
+ */
26
+
27
+ import { readFile } from 'node:fs/promises';
28
+ import { createRequire } from 'node:module';
29
+ import { validateManifest } from '@lamalibre/lamaste';
30
+ import {
31
+ LAMASTE_REGISTRY_OIDC_SCOPES,
32
+ lamasteServerOidcClient,
33
+ listOidcClientSecrets,
34
+ reconcileOidcClientSecrets,
35
+ readOidcClientSecretDigests,
36
+ rotateOidcClientSecret,
37
+ } from '@lamalibre/lamaste/server';
38
+
39
+ import { getConfig } from './config.js';
40
+ import { updateOidcProvider } from './authelia.js';
41
+ import { readPlugins } from './plugins.js';
42
+ import { readRegistryCredentialConfig } from './registry-credential.js';
43
+
44
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
45
+
46
+ /**
47
+ * The OIDC clients every installed plugin declares, tagged with the plugin
48
+ * name and whether the plugin is enabled. Manifests are re-read from the
49
+ * installed packages — the plugin registry does not store them.
50
+ * @returns {Promise<Array<import('@lamalibre/lamaste/server').AutheliaOidcClient & { enabled: boolean }>>}
51
+ */
52
+ export async function collectPluginOidcClients() {
53
+ const require = createRequire(`${STATE_DIR}/`);
54
+ const clients = [];
55
+ for (const plugin of (await readPlugins()).plugins) {
56
+ let manifest;
57
+ try {
58
+ const manifestPath = require.resolve(`${plugin.packageName}/lamaste-plugin.json`);
59
+ manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
60
+ } catch {
61
+ continue; // an unreadable manifest contributes no clients
62
+ }
63
+ for (const client of manifest.oidcClients) {
64
+ clients.push({ ...client, plugin: manifest.name, enabled: plugin.status === 'enabled' });
65
+ }
66
+ }
67
+ return clients;
68
+ }
69
+
70
+ /**
71
+ * The panel's own client, when the registry credential (stored, or the
72
+ * candidate being verified) uses it.
73
+ * @param {{ registryUrl: string } | null | undefined} registryAuth
74
+ */
75
+ function lamasteOwnClient(registryAuth) {
76
+ if (!registryAuth) return [];
77
+ return [{ ...lamasteServerOidcClient(registryAuth.registryUrl), enabled: true }];
78
+ }
79
+
80
+ /**
81
+ * Everything Authelia should know, from the stored state.
82
+ * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides]
83
+ * `registryAuth` replaces the stored registry credential's say in the
84
+ * matter: `{ registryUrl }` while a candidate oidc credential is being
85
+ * verified, `null` to leave the panel's client out.
86
+ */
87
+ async function collectAllClients(overrides = {}) {
88
+ let registryAuth;
89
+ if (overrides.registryAuth !== undefined) {
90
+ registryAuth = overrides.registryAuth;
91
+ } else {
92
+ const stored = await readRegistryCredentialConfig().catch(() => null);
93
+ registryAuth =
94
+ stored && stored.auth.kind === 'oidc' ? { registryUrl: stored.registryUrl } : null;
95
+ }
96
+ return [...(await collectPluginOidcClients()), ...lamasteOwnClient(registryAuth)];
97
+ }
98
+
99
+ /**
100
+ * What Authelia should serve and which secrets the store should hold, from
101
+ * the stored state: every installed plugin's clients (an installed plugin
102
+ * keeps its confidential-client secret while disabled), the clients of
103
+ * enabled plugins served, and the panel's own scopes when its client is.
104
+ * Pure with respect to Authelia — nothing is written.
105
+ *
106
+ * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
107
+ * @returns {Promise<{
108
+ * confidential: Array<{ id: string, plugin: string }>,
109
+ * served: Array<import('@lamalibre/lamaste/server').AutheliaOidcClient>,
110
+ * scopes: Record<string, { claims: readonly string[] }>,
111
+ * }>}
112
+ */
113
+ export async function planOidcClients(overrides = {}) {
114
+ const all = await collectAllClients(overrides);
115
+ const served = all.filter((c) => c.enabled).map(({ enabled: _enabled, ...client }) => client);
116
+ return {
117
+ confidential: all.filter((c) => c.confidential).map((c) => ({ id: c.id, plugin: c.plugin })),
118
+ served,
119
+ scopes: served.some((c) => c.plugin === 'lamaste') ? LAMASTE_REGISTRY_OIDC_SCOPES : {},
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Sync Authelia's OIDC clients and the secret store with the plugins and
125
+ * the registry credential. Returns what was done, or `null` when the panel
126
+ * has no domain yet (onboarding).
127
+ *
128
+ * @param {import('pino').Logger} logger
129
+ * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
130
+ * @returns {Promise<{ changed: boolean, clients: string[], createdSecrets: Array<{ id: string, plugin: string, secret: string }> } | null>}
131
+ */
132
+ export async function syncOidcClients(logger, overrides = {}) {
133
+ let domain = null;
134
+ try {
135
+ domain = getConfig().domain;
136
+ } catch {
137
+ domain = null;
138
+ }
139
+ if (!domain) {
140
+ logger.debug('OIDC client sync skipped — no domain configured yet');
141
+ return null;
142
+ }
143
+
144
+ const { confidential, served, scopes } = await planOidcClients(overrides);
145
+ const reconciled = await reconcileOidcClientSecrets(STATE_DIR, confidential);
146
+ if (reconciled.created.length > 0 || reconciled.removed.length > 0) {
147
+ logger.info(
148
+ { created: reconciled.created.map((c) => c.id), removed: reconciled.removed },
149
+ 'OIDC client secrets reconciled',
150
+ );
151
+ }
152
+
153
+ const result = await updateOidcProvider(domain, served, {
154
+ secretDigests: reconciled.digests,
155
+ scopes,
156
+ });
157
+ if (result.changed) {
158
+ logger.info({ clients: served.map((c) => c.id) }, 'Authelia OIDC provider updated');
159
+ }
160
+ return {
161
+ changed: result.changed,
162
+ clients: served.map((c) => c.id),
163
+ createdSecrets: reconciled.created,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Metadata of a plugin's OIDC clients: what the manifest declares, whether
169
+ * it is served, and — for confidential clients — when the secret was
170
+ * created and last rotated. Never the secret.
171
+ * @param {string} pluginName
172
+ */
173
+ export async function listPluginOidcClients(pluginName) {
174
+ const declared = (await collectPluginOidcClients()).filter((c) => c.plugin === pluginName);
175
+ const secrets = new Map((await listOidcClientSecrets(STATE_DIR)).map((m) => [m.id, m]));
176
+ return declared.map((client) => {
177
+ const secret = client.confidential ? (secrets.get(client.id) ?? null) : null;
178
+ return {
179
+ id: client.id,
180
+ name: client.name ?? null,
181
+ confidential: client.confidential,
182
+ grantTypes: client.grantTypes,
183
+ scopes: client.scopes,
184
+ audience: client.audience,
185
+ accessTokenClaims: client.accessTokenClaims,
186
+ enabled: client.enabled,
187
+ secretCreatedAt: secret?.createdAt ?? null,
188
+ secretRotatedAt: secret?.rotatedAt ?? null,
189
+ };
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Replace the secret of one of a plugin's confidential clients and write
195
+ * the new digest to Authelia. Returns the new secret — the only time it is
196
+ * shown. Throws with `statusCode` 404 for an unknown client and 400 for a
197
+ * public one.
198
+ * @param {string} pluginName
199
+ * @param {string} clientId
200
+ * @param {import('pino').Logger} logger
201
+ */
202
+ export async function rotatePluginOidcClientSecret(pluginName, clientId, logger) {
203
+ const client = (await collectPluginOidcClients()).find(
204
+ (c) => c.plugin === pluginName && c.id === clientId,
205
+ );
206
+ if (!client) {
207
+ throw Object.assign(new Error(`Plugin "${pluginName}" declares no OIDC client "${clientId}"`), {
208
+ statusCode: 404,
209
+ });
210
+ }
211
+ if (!client.confidential) {
212
+ throw Object.assign(new Error(`OIDC client "${clientId}" is public and has no secret`), {
213
+ statusCode: 400,
214
+ });
215
+ }
216
+ // The store is reconciled first so a client that was never synced (an
217
+ // earlier Authelia failure) gets its record now.
218
+ await syncOidcClients(logger);
219
+ const rotated = await rotateOidcClientSecret(STATE_DIR, clientId);
220
+ const digests = await readOidcClientSecretDigests(STATE_DIR);
221
+ if (client.enabled) {
222
+ const domain = getConfig().domain;
223
+ const { served, scopes } = await planOidcClients();
224
+ await updateOidcProvider(domain, served, { secretDigests: digests, scopes });
225
+ }
226
+ logger.info({ plugin: pluginName, clientId }, 'OIDC client secret rotated');
227
+ return {
228
+ id: rotated.id,
229
+ plugin: rotated.plugin,
230
+ secret: rotated.secret,
231
+ rotatedAt: rotated.rotatedAt,
232
+ };
233
+ }
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import { z } from 'zod';
15
15
  import { execa } from 'execa';
16
16
  import {
17
+ DEFAULT_PLUGIN_HOSTING,
17
18
  RESERVED_API_PREFIXES,
18
19
  RESERVED_NAV_LABELS,
19
20
  derivePluginRoute,
@@ -60,8 +61,8 @@ async function getStmts() {
60
61
  insert: db.prepare(`
61
62
  INSERT INTO plugins
62
63
  (name, display_name, package_name, version, description, capabilities,
63
- packages, panel, config, modes, status, installed_at, enabled_at)
64
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
64
+ packages, panel, config, modes, hosting, status, installed_at, enabled_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
65
66
  `),
66
67
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
67
68
  deleteAll: db.prepare('DELETE FROM plugins'),
@@ -86,7 +87,10 @@ async function getStmts() {
86
87
  // JSON-encoded TEXT in storage. They round-trip through JSON.parse/stringify
87
88
  // so callers see the same shape the JSON-backed module returned — including
88
89
  // `undefined` for optional fields (mirroring the JSON object layout where
89
- // missing keys are absent rather than null).
90
+ // missing keys are absent rather than null). `hosting` is plain TEXT
91
+ // ('server' | 'agent'; rows from before 4.2 default to 'server'): an
92
+ // agent-hosted plugin is installed (npm, manifest) like any other, but the
93
+ // plugin host never imports it — see `pluginHostPlugin`.
90
94
 
91
95
  function rowToPlugin(row) {
92
96
  const entry = {
@@ -94,6 +98,7 @@ function rowToPlugin(row) {
94
98
  packageName: row.package_name,
95
99
  version: row.version,
96
100
  capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
101
+ hosting: row.hosting,
97
102
  status: row.status,
98
103
  installedAt: row.installed_at,
99
104
  };
@@ -155,6 +160,7 @@ export async function writePlugins(data) {
155
160
  nullableJson(p.panel),
156
161
  nullableJson(p.config),
157
162
  nullableJson(p.modes),
163
+ p.hosting ?? DEFAULT_PLUGIN_HOSTING,
158
164
  p.status,
159
165
  p.installedAt,
160
166
  nullableString(p.enabledAt),
@@ -210,6 +216,26 @@ function assertSafeLamalibrePackage(packageName) {
210
216
  }
211
217
  }
212
218
 
219
+ /**
220
+ * After a lifecycle change, bring Authelia's OIDC clients and the secret
221
+ * store in line with the installed plugins. Best effort — logged, never
222
+ * thrown — so a plugin operation does not fail because Authelia could not
223
+ * be restarted. Returns the secrets of confidential clients this change
224
+ * created (`[{ id, secret }]`), shown to the admin exactly once.
225
+ * @param {import('pino').Logger} logger
226
+ * @returns {Promise<Array<{ id: string, secret: string }>>}
227
+ */
228
+ async function syncOidcClientsBestEffort(logger) {
229
+ try {
230
+ const { syncOidcClients } = await import('./plugin-oidc.js');
231
+ const result = await syncOidcClients(logger);
232
+ return (result?.createdSecrets ?? []).map(({ id, secret }) => ({ id, secret }));
233
+ } catch (err) {
234
+ logger.warn({ err: err.message }, 'Failed to sync plugin OIDC clients to Authelia');
235
+ return [];
236
+ }
237
+ }
238
+
213
239
  export function installPlugin(packageName, logger) {
214
240
  return withPluginLock(async () => {
215
241
  // Validate package scope
@@ -339,6 +365,7 @@ export function installPlugin(packageName, logger) {
339
365
  panel: manifest.panel,
340
366
  config: manifest.config,
341
367
  modes: manifest.modes,
368
+ hosting: manifest.hosting,
342
369
  status: 'disabled',
343
370
  installedAt: new Date().toISOString(),
344
371
  };
@@ -356,6 +383,7 @@ export function installPlugin(packageName, logger) {
356
383
  nullableJson(entry.panel),
357
384
  nullableJson(entry.config),
358
385
  nullableJson(entry.modes),
386
+ entry.hosting,
359
387
  entry.status,
360
388
  entry.installedAt,
361
389
  null,
@@ -367,7 +395,8 @@ export function installPlugin(packageName, logger) {
367
395
  }
368
396
 
369
397
  logger.info({ name: manifest.name, packageName }, 'Plugin installed');
370
- return entry;
398
+ const oidcClients = await syncOidcClientsBestEffort(logger);
399
+ return { ...entry, oidcClients };
371
400
  });
372
401
  }
373
402
 
@@ -436,6 +465,7 @@ export function uninstallPlugin(name, logger) {
436
465
  }
437
466
 
438
467
  logger.info({ name }, 'Plugin uninstalled');
468
+ await syncOidcClientsBestEffort(logger);
439
469
  return { ok: true, name };
440
470
  });
441
471
  }
@@ -470,7 +500,8 @@ export function enablePlugin(name, logger) {
470
500
  }
471
501
 
472
502
  logger?.info({ name }, 'Plugin enabled');
473
- return { ok: true, name, status: 'enabled' };
503
+ const oidcClients = await syncOidcClientsBestEffort(logger);
504
+ return { ok: true, name, status: 'enabled', oidcClients };
474
505
  });
475
506
  }
476
507
 
@@ -504,6 +535,7 @@ export function disablePlugin(name, logger) {
504
535
  }
505
536
 
506
537
  logger?.info({ name }, 'Plugin disabled');
538
+ await syncOidcClientsBestEffort(logger);
507
539
  return { ok: true, name, status: 'disabled' };
508
540
  });
509
541
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Registry credential (Feria) — daemon shim over `@lamalibre/lamaste/server`.
3
+ *
4
+ * Binds the core module to this daemon's state directory and holds the
5
+ * per-process state the core keeps pure:
6
+ *
7
+ * - the panel's own registry token (`RegistryServiceTokenCache`): a pasted
8
+ * token as is, or — with `auth.kind: 'oidc'` — a Feria token obtained
9
+ * through Authelia's client-credentials grant and reused until five
10
+ * minutes before it expires;
11
+ * - one brokered token per agent (`AgentTokenBroker`), reused for every
12
+ * install within the hour and limited to ten new tokens per agent per
13
+ * hour.
14
+ *
15
+ * Also the one piece of policy that belongs here: when a user loses a
16
+ * `feria-*` group or is deleted, their registry tokens are revoked (best
17
+ * effort, never blocks the user operation).
18
+ */
19
+
20
+ import {
21
+ readRegistryCredentialConfig as readCore,
22
+ writeRegistryCredentialConfig as writeCore,
23
+ clearRegistryCredentialConfig as clearCore,
24
+ verifyRegistryCredential as verifyCore,
25
+ brokerAgentRegistryToken,
26
+ revokeRegistryTokensForOwner,
27
+ lostFeriaGroups,
28
+ readOidcClientSecret,
29
+ RegistryServiceTokenCache,
30
+ AgentTokenBroker,
31
+ RegistryCredentialError,
32
+ LAMASTE_SERVER_OIDC_CLIENT_ID,
33
+ } from '@lamalibre/lamaste/server';
34
+
35
+ import { getConfig } from './config.js';
36
+
37
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
38
+
39
+ const serviceTokens = new RegistryServiceTokenCache();
40
+ const agentTokens = new AgentTokenBroker();
41
+
42
+ export function readRegistryCredentialConfig() {
43
+ return readCore(STATE_DIR);
44
+ }
45
+
46
+ /** Store a credential and forget every token obtained with the previous one. */
47
+ export async function writeRegistryCredentialConfig(config) {
48
+ await writeCore(STATE_DIR, config);
49
+ resetRegistryTokenState();
50
+ }
51
+
52
+ export async function clearRegistryCredentialConfig() {
53
+ const removed = await clearCore(STATE_DIR);
54
+ resetRegistryTokenState();
55
+ return removed;
56
+ }
57
+
58
+ /** Drop the cached service token and every agent's cached token and issuance record. */
59
+ export function resetRegistryTokenState() {
60
+ serviceTokens.invalidate();
61
+ agentTokens.reset();
62
+ }
63
+
64
+ /**
65
+ * What the core needs to run the OIDC chain for a credential: the relay
66
+ * domain and the `lamaste-server` client secret. Pasted-token credentials
67
+ * need nothing.
68
+ * @param {{ auth: { kind: string } }} config
69
+ */
70
+ async function oidcDeps(config) {
71
+ if (config.auth.kind !== 'oidc') return {};
72
+ const domain = getConfig().domain;
73
+ if (!domain) {
74
+ throw new RegistryCredentialError(
75
+ 'the panel has no domain yet — complete onboarding first',
76
+ 503,
77
+ );
78
+ }
79
+ const clientSecret = await readOidcClientSecret(STATE_DIR, LAMASTE_SERVER_OIDC_CLIENT_ID);
80
+ if (!clientSecret) {
81
+ throw new RegistryCredentialError(
82
+ `the ${LAMASTE_SERVER_OIDC_CLIENT_ID} OIDC client has no secret yet — set the registry credential again`,
83
+ 500,
84
+ );
85
+ }
86
+ return { oidc: { domain, clientSecret } };
87
+ }
88
+
89
+ /**
90
+ * Verify a candidate credential end to end (for `oidc`: Authelia token
91
+ * endpoint → Feria login → whoami with the admin scope). The
92
+ * `lamaste-server` client must already be in Authelia — the route syncs it
93
+ * first.
94
+ * @param {{ registryUrl: string, auth: { kind: 'token', token: string } | { kind: 'oidc', clientId: string } }} candidate
95
+ */
96
+ export async function verifyRegistryCredential(candidate) {
97
+ return verifyCore(candidate, await oidcDeps(candidate));
98
+ }
99
+
100
+ /** The panel's registry token for the stored credential (cached per process). */
101
+ async function serviceToken(config) {
102
+ return (await serviceTokens.resolve(config, await oidcDeps(config))).token;
103
+ }
104
+
105
+ /**
106
+ * The calling agent's registry token: the cached one when it is still good
107
+ * for an install, otherwise a freshly brokered one-hour token — unless the
108
+ * agent already had ten new tokens this hour. `null` when no credential is
109
+ * configured. Throws RegistryCredentialError (with `statusCode`) when the
110
+ * registry refuses.
111
+ * @param {string} agentLabel
112
+ * @returns {Promise<import('@lamalibre/lamaste/server').AgentTokenAcquisition | null>}
113
+ */
114
+ export async function acquireAgentToken(agentLabel) {
115
+ const config = await readRegistryCredentialConfig();
116
+ if (!config) return null;
117
+ return agentTokens.acquire(agentLabel, async () =>
118
+ brokerAgentRegistryToken(config, agentLabel, { serviceToken: await serviceToken(config) }),
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Revoke a user's registry tokens after their `feria-*` groups changed or
124
+ * they were deleted. Returns the number revoked, or `null` when nothing
125
+ * needed doing (no credential configured, or no feria group lost).
126
+ *
127
+ * @param {string} username
128
+ * @param {{ before: string[], after: string[] } | { deleted: true }} change
129
+ * @param {import('pino').Logger} log
130
+ */
131
+ export async function revokeUserRegistryTokens(username, change, log) {
132
+ const config = await readRegistryCredentialConfig();
133
+ if (!config) return null;
134
+
135
+ const lost =
136
+ 'deleted' in change ? ['(deleted)'] : lostFeriaGroups(change.before ?? [], change.after ?? []);
137
+ if (lost.length === 0) return null;
138
+
139
+ const owner = `user:${username}`;
140
+ const revoked = await revokeRegistryTokensForOwner(config, owner, {
141
+ serviceToken: await serviceToken(config),
142
+ });
143
+ log.info({ username, lost, revoked }, 'Revoked registry tokens after group change');
144
+ return revoked;
145
+ }
@@ -14,6 +14,8 @@ import {
14
14
  } from '../../lib/mtls.js';
15
15
  import { z } from 'zod';
16
16
  import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
17
+ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
18
+ import { acquireAgentToken } from '../../lib/registry-credential.js';
17
19
 
18
20
  /**
19
21
  * An agent may rotate its own chisel credential at most this often. Every
@@ -113,6 +115,85 @@ export default async function agentsRoutes(fastify, _opts) {
113
115
  },
114
116
  );
115
117
 
118
+ // ------------------------------------------------------------------
119
+ // GET /agents/me/registry-credential
120
+ //
121
+ // Brokers a one-hour, read-only npm registry token for the calling agent
122
+ // so it can `npm install` a plugin from the Lamalibre registry (Feria)
123
+ // without ever holding a long-lived credential. Identity comes strictly
124
+ // from the mTLS cert; the token is owned by `agent:<label>` on the
125
+ // registry, so its audit log says which agent installed what.
126
+ //
127
+ // The token is cached per agent until five minutes before it expires, so
128
+ // repeated installs within the hour get the same one; an agent may be
129
+ // issued at most ten new tokens per hour (429 + Retry-After beyond that).
130
+ // The per-IP limit stays as a backstop.
131
+ //
132
+ // 404 when the panel has no registry credential configured — the agent
133
+ // then installs from the public registry as before.
134
+ // ------------------------------------------------------------------
135
+ fastify.get(
136
+ '/agents/me/registry-credential',
137
+ {
138
+ preHandler: fastify.requireRole(['agent']),
139
+ // One token per install is the expected rate; this only stops a loop.
140
+ config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
141
+ },
142
+ async (request, reply) => {
143
+ // requireRole lets the admin master role through every guard; an admin
144
+ // certificate has no agent identity to broker for. Plugin-agents pass
145
+ // here and are answered 400 below.
146
+ if (request.certRole !== 'agent' && request.certRole !== 'plugin-agent') {
147
+ return reply
148
+ .code(403)
149
+ .send({ error: 'Only an agent certificate can obtain a registry credential' });
150
+ }
151
+ const label = request.certLabel;
152
+ if (!label) {
153
+ return reply.code(400).send({
154
+ error: 'Agent label could not be determined from client certificate',
155
+ });
156
+ }
157
+ if (!AGENT_LABEL_REGEX.test(label)) {
158
+ return reply.code(400).send({ error: 'Plugin-agents do not install plugins' });
159
+ }
160
+ try {
161
+ const acquired = await acquireAgentToken(label);
162
+ if (!acquired) {
163
+ return reply.code(404).send({
164
+ error: 'No registry credential is configured on this panel',
165
+ hint: 'lamaste-server registry-credential set --url <registry> --oidc',
166
+ });
167
+ }
168
+ if (!acquired.ok) {
169
+ request.log.warn(
170
+ { label, retryAfterSeconds: acquired.retryAfterSeconds },
171
+ 'Agent exceeded its registry token issuance limit',
172
+ );
173
+ return reply.code(429).header('Retry-After', String(acquired.retryAfterSeconds)).send({
174
+ error: 'This agent has been issued its hourly share of registry tokens',
175
+ retryAfterSeconds: acquired.retryAfterSeconds,
176
+ });
177
+ }
178
+ const { credential } = acquired;
179
+ request.log.info(
180
+ {
181
+ label,
182
+ tokenId: credential.tokenId,
183
+ expiresAt: credential.expiresAt,
184
+ cached: acquired.cached,
185
+ },
186
+ acquired.cached ? 'Reused registry token for agent' : 'Brokered registry token for agent',
187
+ );
188
+ return credential;
189
+ } catch (err) {
190
+ const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
191
+ request.log.warn({ label, err: err.message }, 'Failed to broker registry token');
192
+ return reply.code(status).send({ error: err.message || 'Failed to broker registry token' });
193
+ }
194
+ },
195
+ );
196
+
116
197
  // ------------------------------------------------------------------
117
198
  // GET /agents/me/capabilities
118
199
  //