@lamalibre/create-lamaste 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -1,14 +1,42 @@
1
1
  import { z } from 'zod';
2
+ import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
2
3
  import {
3
4
  readPlugins,
4
5
  installPlugin,
5
- uninstallPlugin,
6
6
  enablePlugin,
7
7
  disablePlugin,
8
8
  getPluginCapabilities,
9
+ findPlugin,
9
10
  } from '../../lib/plugins.js';
10
11
  import { setPluginCapabilities, loadAgentRegistry } from '../../lib/mtls.js';
11
12
  import { listPluginOidcClients, rotatePluginOidcClientSecret } from '../../lib/plugin-oidc.js';
13
+ import {
14
+ hostPlugin,
15
+ unhostPlugin,
16
+ getHostingView,
17
+ attachHostings,
18
+ rotateHostingSecret,
19
+ hostingEligibility,
20
+ retainedDataOf,
21
+ purgeRetainedData,
22
+ } from '../../lib/plugin-hosting.js';
23
+ import { listRetainedData } from '../../lib/retained-data.js';
24
+ import {
25
+ hookStatus,
26
+ approveHookSet,
27
+ setAutoApprove,
28
+ setPendingUpdate,
29
+ readHookState,
30
+ } from '../../lib/plugin-hooks.js';
31
+ import { uninstallPluginWithHosting, updatePlugin } from '../../lib/plugin-lifecycle.js';
32
+ import {
33
+ getPluginOperation,
34
+ hasOperationReveal,
35
+ listPluginOperations,
36
+ takeOperationReveal,
37
+ } from '../../lib/plugin-operations.js';
38
+ import { actorFromRequest, certSerialFromRequest } from '../../lib/actor.js';
39
+ import { recordAudit } from '../../lib/audit.js';
12
40
  import {
13
41
  readPushInstallConfig,
14
42
  readPushInstallSessions,
@@ -20,6 +48,9 @@ import {
20
48
  disableAgentPushInstall,
21
49
  logPushInstallSession,
22
50
  validatePushInstallAccess,
51
+ pushInstallPolicyRefusal,
52
+ PUSH_INSTALL_ACTIONS,
53
+ PUSH_INSTALL_WINDOW_MINUTES,
23
54
  } from '../../lib/push-install.js';
24
55
 
25
56
  // --- Zod schemas ---
@@ -40,6 +71,66 @@ const OidcClientIdParamSchema = z.object({
40
71
  .regex(/^[a-z0-9][a-z0-9-]*$/, 'Client id must be lowercase letters, numbers and hyphens'),
41
72
  });
42
73
 
74
+ const AgentLabelSchema = z
75
+ .string()
76
+ .min(1)
77
+ .max(63)
78
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Invalid agent label');
79
+
80
+ const HostingBodySchema = z.object({
81
+ agentLabel: AgentLabelSchema,
82
+ });
83
+
84
+ const RetainedDataQuerySchema = z.object({
85
+ agentLabel: AgentLabelSchema,
86
+ // Drop the relay's record only; the agent is not touched.
87
+ forget: z.enum(['true', 'false']).optional(),
88
+ });
89
+
90
+ const ForceQuerySchema = z.object({
91
+ force: z.enum(['true', 'false']).optional(),
92
+ // Remove the data directory and service account of an agent half Lamaste installed.
93
+ purge: z.enum(['true', 'false']).optional(),
94
+ });
95
+
96
+ /**
97
+ * The error body of a refused plugin operation: the message, and what a
98
+ * client acts on — `code` (`hooks_not_approved`, `agent_not_eligible` with
99
+ * its `reasons`, `push_install_refused`, `route_taken`, `route_conflict`,
100
+ * `hosting_owner_mismatch`, `retained_data_conflict` with the `retained`
101
+ * record, `retained_data_in_use`, `agent_not_enrolled`) and a running
102
+ * operation's id.
103
+ */
104
+ function operationErrorBody(err, fallback) {
105
+ return {
106
+ error: err.message || fallback,
107
+ ...(err.code ? { code: err.code } : {}),
108
+ ...(Array.isArray(err.reasons) ? { reasons: err.reasons } : {}),
109
+ ...(err.retained ? { retained: err.retained } : {}),
110
+ ...(err.owner ? { route: err.route, owner: err.owner } : {}),
111
+ ...(err.operationId ? { operationId: err.operationId } : {}),
112
+ };
113
+ }
114
+
115
+ const OperationParamSchema = z.object({
116
+ id: z.string().uuid(),
117
+ });
118
+
119
+ const ApproveHooksBodySchema = z
120
+ .object({ digest: z.string().regex(/^[a-f0-9]{64}$/, 'digest is a SHA-256 hex string') })
121
+ .strict();
122
+
123
+ const AutoApproveBodySchema = z.object({ enabled: z.boolean() }).strict();
124
+
125
+ const UpdateBodySchema = z
126
+ .object({
127
+ version: z
128
+ .string()
129
+ .regex(/^[0-9A-Za-z.+-]{1,64}$/, 'version is an exact version or a dist-tag')
130
+ .optional(),
131
+ })
132
+ .strict();
133
+
43
134
  const InstallBodySchema = z.object({
44
135
  packageName: z
45
136
  .string()
@@ -88,9 +179,9 @@ const CreatePolicySchema = z.object({
88
179
  deniedIps: z.array(IpEntrySchema).optional().default([]),
89
180
  allowedPlugins: z.array(ScopedPackageSchema).optional().default([]),
90
181
  allowedActions: z
91
- .array(z.enum(['install', 'update', 'uninstall', 'check-prerequisites']))
182
+ .array(z.enum(PUSH_INSTALL_ACTIONS))
92
183
  .optional()
93
- .default(['install', 'update', 'check-prerequisites']),
184
+ .default([...PUSH_INSTALL_ACTIONS]),
94
185
  });
95
186
 
96
187
  const UpdatePolicySchema = z.object({
@@ -99,9 +190,7 @@ const UpdatePolicySchema = z.object({
99
190
  allowedIps: z.array(IpEntrySchema).optional(),
100
191
  deniedIps: z.array(IpEntrySchema).optional(),
101
192
  allowedPlugins: z.array(ScopedPackageSchema).optional(),
102
- allowedActions: z
103
- .array(z.enum(['install', 'update', 'uninstall', 'check-prerequisites']))
104
- .optional(),
193
+ allowedActions: z.array(z.enum(PUSH_INSTALL_ACTIONS)).optional(),
105
194
  });
106
195
 
107
196
  const UpdatePushInstallConfigSchema = z.object({
@@ -109,22 +198,30 @@ const UpdatePushInstallConfigSchema = z.object({
109
198
  defaultPolicy: PolicyIdSchema.optional(),
110
199
  });
111
200
 
201
+ const WINDOW_BOUNDS = `A push-install window is open ${PUSH_INSTALL_WINDOW_MINUTES.min} to ${PUSH_INSTALL_WINDOW_MINUTES.max} minutes`;
202
+
112
203
  const EnablePushInstallSchema = z.object({
113
- durationMinutes: z.number().int().min(5).max(480),
204
+ durationMinutes: z
205
+ .number({ invalid_type_error: WINDOW_BOUNDS, required_error: WINDOW_BOUNDS })
206
+ .int(WINDOW_BOUNDS)
207
+ .min(PUSH_INSTALL_WINDOW_MINUTES.min, WINDOW_BOUNDS)
208
+ .max(PUSH_INSTALL_WINDOW_MINUTES.max, WINDOW_BOUNDS),
114
209
  policyId: PolicyIdSchema.optional(),
115
210
  });
116
211
 
212
+ /** A machine agent's label (a DNS label of up to 63 characters, as enrolment allows). */
117
213
  const AgentLabelParamSchema = z.object({
118
- label: z
119
- .string()
120
- .min(1)
121
- .max(50)
122
- .regex(/^[a-z0-9-]+$/, 'Invalid agent label'),
214
+ label: z.string().regex(AGENT_LABEL_REGEX, 'Invalid agent label'),
123
215
  });
124
216
 
217
+ /** The push-install config as the panel shows it, with the window bounds. */
218
+ function pushInstallConfigView(config) {
219
+ return { ...config, windowMinutes: PUSH_INSTALL_WINDOW_MINUTES };
220
+ }
221
+
125
222
  const PushInstallCommandSchema = z
126
223
  .object({
127
- action: z.enum(['install', 'update', 'uninstall', 'check-prerequisites']),
224
+ action: z.enum(PUSH_INSTALL_ACTIONS),
128
225
  packageName: z
129
226
  .string()
130
227
  .min(1)
@@ -145,6 +242,26 @@ const PushInstallCommandSchema = z
145
242
  },
146
243
  );
147
244
 
245
+ /** A plugin entry with a summary of its hooks for list views. */
246
+ async function withHooks(plugin) {
247
+ const status = await hookStatus(plugin);
248
+ return {
249
+ ...plugin,
250
+ hooks: {
251
+ relay: status.relay !== null,
252
+ agent: status.agent !== null,
253
+ // Lamaste installs the agent half (agent.package); false: none, or installed by hand.
254
+ agentHalf: status.agentHalf !== null,
255
+ approval: status.approval.state,
256
+ autoApprove: status.autoApprove,
257
+ failOpenCount: status.relay?.failOpenCount ?? 0,
258
+ pendingUpdate: status.pendingUpdate
259
+ ? { version: status.pendingUpdate.version, digest: status.pendingUpdate.digest }
260
+ : null,
261
+ },
262
+ };
263
+ }
264
+
148
265
  export default async function pluginRoutes(fastify, _opts) {
149
266
  // ===========================================================================
150
267
  // Plugin management routes
@@ -160,7 +277,17 @@ export default async function pluginRoutes(fastify, _opts) {
160
277
  },
161
278
  async (_request, _reply) => {
162
279
  const registry = await readPlugins();
163
- return { plugins: registry.plugins };
280
+ // `agentHosting`: where an agent-hosted plugin's agent half runs, or null.
281
+ // `retainedData`: what agents keep of its agent half after it was
282
+ // removed without purge. `hooks`: its lifecycle hooks and their
283
+ // approval (see GET /plugins/:name/hooks).
284
+ const plugins = await attachHostings(registry.plugins);
285
+ return {
286
+ plugins: await Promise.all(plugins.map(withHooks)),
287
+ // Every agent's retained data — of uninstalled plugins too, which
288
+ // no entry of `plugins` lists any more.
289
+ retainedData: await listRetainedData(),
290
+ };
164
291
  },
165
292
  );
166
293
 
@@ -181,7 +308,387 @@ export default async function pluginRoutes(fastify, _opts) {
181
308
  return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
182
309
  }
183
310
 
184
- return { plugin };
311
+ const [withHosting] = await attachHostings([plugin]);
312
+ return { plugin: await withHooks(withHosting) };
313
+ },
314
+ );
315
+
316
+ // ===========================================================================
317
+ // Hosting an agent-hosted plugin's agent half on an agent
318
+ // ===========================================================================
319
+
320
+ // ------------------------------------------------------------------
321
+ // GET /plugins/:name/hosting — which agent hosts the plugin, port, health
322
+ // ------------------------------------------------------------------
323
+ fastify.get(
324
+ '/plugins/:name/hosting',
325
+ {
326
+ preHandler: fastify.requireRole(['admin']),
327
+ },
328
+ async (request, reply) => {
329
+ const params = PluginNameParamSchema.parse(request.params);
330
+ const registry = await readPlugins();
331
+ if (!registry.plugins.some((p) => p.name === params.name)) {
332
+ return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
333
+ }
334
+ const hosting = await getHostingView(params.name);
335
+ if (!hosting) {
336
+ return reply.code(404).send({ error: `Plugin "${params.name}" is not hosted on an agent` });
337
+ }
338
+ return { hosting };
339
+ },
340
+ );
341
+
342
+ // ------------------------------------------------------------------
343
+ // POST /plugins/:name/hosting — host the plugin on an agent: internal
344
+ // tunnel (port assigned), installation secret, host capability, and the
345
+ // agent half when Lamaste installs it (the agent must be eligible —
346
+ // 409 agent_not_eligible with reasons — and the push-install gates must
347
+ // allow `install` of the agent package — push_install_refused)
348
+ // ------------------------------------------------------------------
349
+ fastify.post(
350
+ '/plugins/:name/hosting',
351
+ {
352
+ preHandler: fastify.requireRole(['admin']),
353
+ },
354
+ async (request, reply) => {
355
+ const params = PluginNameParamSchema.parse(request.params);
356
+ const body = HostingBodySchema.parse(request.body);
357
+ try {
358
+ const operation = await hostPlugin(params.name, body.agentLabel, {
359
+ actor: actorFromRequest(request),
360
+ logger: request.log,
361
+ sourceIp: request.ip,
362
+ });
363
+ return reply.code(202).send({ ok: true, operation });
364
+ } catch (err) {
365
+ const statusCode = err.statusCode || 500;
366
+ return reply.code(statusCode).send(operationErrorBody(err, 'Failed to host plugin'));
367
+ }
368
+ },
369
+ );
370
+
371
+ // ------------------------------------------------------------------
372
+ // GET /plugins/:name/hosting/eligibility — which enrolled agents can host
373
+ // the plugin, and why the others cannot (the host dialog). For a plugin
374
+ // whose agent half Lamaste installs: `agent.requires` and what installing
375
+ // it needs, against the host facts each agent reported.
376
+ // ------------------------------------------------------------------
377
+ fastify.get(
378
+ '/plugins/:name/hosting/eligibility',
379
+ {
380
+ preHandler: fastify.requireRole(['admin']),
381
+ },
382
+ async (request, reply) => {
383
+ const params = PluginNameParamSchema.parse(request.params);
384
+ try {
385
+ return { agents: await hostingEligibility(params.name) };
386
+ } catch (err) {
387
+ const statusCode = err.statusCode || 500;
388
+ return reply
389
+ .code(statusCode)
390
+ .send({ error: err.message || 'Failed to check which agents can host the plugin' });
391
+ }
392
+ },
393
+ );
394
+
395
+ // ------------------------------------------------------------------
396
+ // DELETE /plugins/:name/hosting?force=&purge= — remove tunnel, secret,
397
+ // capability and the agent half Lamaste installed (a push: `uninstall`);
398
+ // `purge=true` removes the agent half's data and service account too
399
+ // ------------------------------------------------------------------
400
+ fastify.delete(
401
+ '/plugins/:name/hosting',
402
+ {
403
+ preHandler: fastify.requireRole(['admin']),
404
+ },
405
+ async (request, reply) => {
406
+ const params = PluginNameParamSchema.parse(request.params);
407
+ const query = ForceQuerySchema.parse(request.query);
408
+ try {
409
+ const operation = await unhostPlugin(params.name, {
410
+ actor: actorFromRequest(request),
411
+ logger: request.log,
412
+ force: query.force === 'true',
413
+ purge: query.purge === 'true',
414
+ sourceIp: request.ip,
415
+ });
416
+ return reply.code(202).send({ ok: true, operation });
417
+ } catch (err) {
418
+ const statusCode = err.statusCode || 500;
419
+ return reply
420
+ .code(statusCode)
421
+ .send(operationErrorBody(err, 'Failed to remove plugin hosting'));
422
+ }
423
+ },
424
+ );
425
+
426
+ // ------------------------------------------------------------------
427
+ // POST /plugins/:name/hosting/rotate-secret — a new installation secret;
428
+ // the previous one stays accepted until the hosting agent acknowledges
429
+ // the new one (its sync does that within about 30 seconds)
430
+ // ------------------------------------------------------------------
431
+ fastify.post(
432
+ '/plugins/:name/hosting/rotate-secret',
433
+ {
434
+ preHandler: fastify.requireRole(['admin']),
435
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
436
+ },
437
+ async (request, reply) => {
438
+ const params = PluginNameParamSchema.parse(request.params);
439
+ try {
440
+ const hosting = await rotateHostingSecret(params.name, request.log);
441
+ return { ok: true, hosting };
442
+ } catch (err) {
443
+ const statusCode = err.statusCode || 500;
444
+ return reply
445
+ .code(statusCode)
446
+ .send({ error: err.message || 'Failed to rotate the installation secret' });
447
+ }
448
+ },
449
+ );
450
+
451
+ // ------------------------------------------------------------------
452
+ // GET /plugins/:name/retained-data — the data agents keep of the plugin's
453
+ // agent half after it was removed without purge (an unhost, an uninstall,
454
+ // a rolled-back install): [{ agentLabel, route, plugin, package, since,
455
+ // operation }]. By the plugin's name, so it answers after the plugin is
456
+ // uninstalled too.
457
+ // ------------------------------------------------------------------
458
+ fastify.get(
459
+ '/plugins/:name/retained-data',
460
+ {
461
+ preHandler: fastify.requireRole(['admin']),
462
+ },
463
+ async (request) => {
464
+ const params = PluginNameParamSchema.parse(request.params);
465
+ return { retainedData: await retainedDataOf(params.name) };
466
+ },
467
+ );
468
+
469
+ // ------------------------------------------------------------------
470
+ // DELETE /plugins/:name/retained-data?agentLabel=<label>[&forget=true] —
471
+ // purge the data agent <label> keeps of the plugin's agent half (its data
472
+ // directory and service account): 202 with the operation (kind
473
+ // `purge-data`), an app-remove job with purge, push-install gated as
474
+ // `uninstall` of the agent package. `forget=true` drops the record only
475
+ // (the agent is not touched — it is gone, or was cleaned by hand): 200.
476
+ // ------------------------------------------------------------------
477
+ fastify.delete(
478
+ '/plugins/:name/retained-data',
479
+ {
480
+ preHandler: fastify.requireRole(['admin']),
481
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
482
+ },
483
+ async (request, reply) => {
484
+ const params = PluginNameParamSchema.parse(request.params);
485
+ const query = RetainedDataQuerySchema.parse(request.query);
486
+ try {
487
+ const result = await purgeRetainedData(params.name, query.agentLabel, {
488
+ actor: actorFromRequest(request),
489
+ logger: request.log,
490
+ sourceIp: request.ip,
491
+ forget: query.forget === 'true',
492
+ });
493
+ if (result.forgotten) return { ok: true, forgotten: result.forgotten };
494
+ return reply.code(202).send({ ok: true, operation: result.operation });
495
+ } catch (err) {
496
+ const statusCode = err.statusCode || 500;
497
+ return reply
498
+ .code(statusCode)
499
+ .send(operationErrorBody(err, 'Failed to purge the retained data'));
500
+ }
501
+ },
502
+ );
503
+
504
+ // ------------------------------------------------------------------
505
+ // GET /plugins/:name/operations — the plugin's recent operations
506
+ // (hosting, unhosting, update, uninstall), newest first; `revealPending`
507
+ // marks one whose secrets wait for that first read below
508
+ // GET /plugins/:name/operations/:id — one of them. Once it finished, the
509
+ // first read also carries `oidcClients`: the secrets of confidential OIDC
510
+ // clients it created (an update adding one), shown this once — as
511
+ // POST /plugins/install returns them — and never stored.
512
+ // ------------------------------------------------------------------
513
+ fastify.get(
514
+ '/plugins/:name/operations',
515
+ {
516
+ preHandler: fastify.requireRole(['admin']),
517
+ },
518
+ async (request) => {
519
+ const params = PluginNameParamSchema.parse(request.params);
520
+ const operations = await listPluginOperations(params.name);
521
+ return {
522
+ operations: operations.map((op) => ({ ...op, revealPending: hasOperationReveal(op.id) })),
523
+ };
524
+ },
525
+ );
526
+
527
+ fastify.get(
528
+ '/plugins/:name/operations/:id',
529
+ {
530
+ preHandler: fastify.requireRole(['admin']),
531
+ },
532
+ async (request, reply) => {
533
+ const params = PluginNameParamSchema.merge(OperationParamSchema).parse(request.params);
534
+ const operation = await getPluginOperation(params.name, params.id);
535
+ if (!operation) return reply.code(404).send({ error: 'Operation not found' });
536
+ const reveal = operation.status === 'running' ? null : takeOperationReveal(operation.id);
537
+ return { operation: reveal ? { ...operation, oidcClients: reveal.oidcClients } : operation };
538
+ },
539
+ );
540
+
541
+ // ===========================================================================
542
+ // Lifecycle hooks: review, approval, auto-approve
543
+ // ===========================================================================
544
+
545
+ // ------------------------------------------------------------------
546
+ // GET /plugins/:name/hooks — the relay hook set (entry, stages, routes,
547
+ // fail-open flags, effects, digest), the agent hooks, the agent half
548
+ // Lamaste installs (package, version, integrity, service, system
549
+ // packages), the approval (`approval.digest` is what to approve) and a
550
+ // pending update's relay hook set and agent half
551
+ // ------------------------------------------------------------------
552
+ fastify.get(
553
+ '/plugins/:name/hooks',
554
+ {
555
+ preHandler: fastify.requireRole(['admin']),
556
+ },
557
+ async (request, reply) => {
558
+ const params = PluginNameParamSchema.parse(request.params);
559
+ const plugin = await findPlugin(params.name);
560
+ if (!plugin) return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
561
+ return { hooks: await hookStatus(plugin) };
562
+ },
563
+ );
564
+
565
+ // ------------------------------------------------------------------
566
+ // POST /plugins/:name/hooks/approve — approve what the plugin brings
567
+ // (relay hooks, an agent half Lamaste installs) with the digest that was
568
+ // shown. Approving a pending update's digest applies the update (202
569
+ // with its operation).
570
+ // ------------------------------------------------------------------
571
+ fastify.post(
572
+ '/plugins/:name/hooks/approve',
573
+ {
574
+ preHandler: fastify.requireRole(['admin']),
575
+ config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
576
+ },
577
+ async (request, reply) => {
578
+ const params = PluginNameParamSchema.parse(request.params);
579
+ const body = ApproveHooksBodySchema.parse(request.body);
580
+ const plugin = await findPlugin(params.name);
581
+ if (!plugin) return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
582
+ const actor = actorFromRequest(request);
583
+ const certSerial = certSerialFromRequest(request);
584
+ try {
585
+ const approved = await approveHookSet(plugin, body.digest, {
586
+ actor,
587
+ certSerial,
588
+ logger: request.log,
589
+ });
590
+ if (approved.target === 'update') {
591
+ const state = await readHookState(plugin.name);
592
+ const result = await updatePlugin(plugin.name, state.pendingUpdate.version, {
593
+ actor,
594
+ certSerial,
595
+ logger: request.log,
596
+ approvedDigest: body.digest,
597
+ sourceIp: request.ip,
598
+ });
599
+ return reply.code(202).send({ ok: true, ...result });
600
+ }
601
+ return { ok: true, hooks: await hookStatus(plugin) };
602
+ } catch (err) {
603
+ const statusCode = err.statusCode || 500;
604
+ return reply.code(statusCode).send({
605
+ ...operationErrorBody(err, 'Failed to approve the hook set'),
606
+ ...(err.currentDigest ? { currentDigest: err.currentDigest } : {}),
607
+ });
608
+ }
609
+ },
610
+ );
611
+
612
+ // ------------------------------------------------------------------
613
+ // PUT /plugins/:name/hooks/auto-approve — approve later hook-set changes
614
+ // of this plugin automatically (off by default; every use is audited)
615
+ // ------------------------------------------------------------------
616
+ fastify.put(
617
+ '/plugins/:name/hooks/auto-approve',
618
+ {
619
+ preHandler: fastify.requireRole(['admin']),
620
+ },
621
+ async (request, reply) => {
622
+ const params = PluginNameParamSchema.parse(request.params);
623
+ const body = AutoApproveBodySchema.parse(request.body);
624
+ const plugin = await findPlugin(params.name);
625
+ if (!plugin) return reply.code(404).send({ error: `Plugin "${params.name}" not found` });
626
+ const result = await setAutoApprove(plugin.name, body.enabled, {
627
+ actor: actorFromRequest(request),
628
+ certSerial: certSerialFromRequest(request),
629
+ });
630
+ return { ok: true, ...result };
631
+ },
632
+ );
633
+
634
+ // ===========================================================================
635
+ // Updating an agent-hosted plugin in place
636
+ // ===========================================================================
637
+
638
+ // ------------------------------------------------------------------
639
+ // POST /plugins/:name/update { version? } — fetch the version (default
640
+ // latest) and read it without installing it; pin its agent half. A
641
+ // changed relay hook set or agent half waits for approval
642
+ // (202 { pendingUpdate }); otherwise the update runs as an operation
643
+ // (202 { operation }). 200 { upToDate } when there is nothing to do.
644
+ // ------------------------------------------------------------------
645
+ fastify.post(
646
+ '/plugins/:name/update',
647
+ {
648
+ preHandler: fastify.requireRole(['admin']),
649
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
650
+ },
651
+ async (request, reply) => {
652
+ const params = PluginNameParamSchema.parse(request.params);
653
+ const body = UpdateBodySchema.parse(request.body ?? {});
654
+ try {
655
+ const result = await updatePlugin(params.name, body.version ?? 'latest', {
656
+ actor: actorFromRequest(request),
657
+ certSerial: certSerialFromRequest(request),
658
+ logger: request.log,
659
+ sourceIp: request.ip,
660
+ });
661
+ if (result.upToDate) return { ok: true, ...result };
662
+ return reply.code(202).send({ ok: true, ...result });
663
+ } catch (err) {
664
+ const statusCode = err.statusCode || 500;
665
+ return reply.code(statusCode).send(operationErrorBody(err, 'Plugin update failed'));
666
+ }
667
+ },
668
+ );
669
+
670
+ // ------------------------------------------------------------------
671
+ // DELETE /plugins/:name/update — discard an update that waits for approval
672
+ // ------------------------------------------------------------------
673
+ fastify.delete(
674
+ '/plugins/:name/update',
675
+ {
676
+ preHandler: fastify.requireRole(['admin']),
677
+ },
678
+ async (request, reply) => {
679
+ const params = PluginNameParamSchema.parse(request.params);
680
+ const state = await readHookState(params.name);
681
+ if (!state?.pendingUpdate) {
682
+ return reply.code(404).send({ error: `Plugin "${params.name}" has no pending update` });
683
+ }
684
+ await setPendingUpdate(params.name, null);
685
+ await recordAudit({
686
+ actor: actorFromRequest(request),
687
+ action: 'plugin.update.discarded',
688
+ plugin: params.name,
689
+ details: { version: state.pendingUpdate.version },
690
+ });
691
+ return { ok: true };
185
692
  },
186
693
  );
187
694
 
@@ -202,12 +709,18 @@ export default async function pluginRoutes(fastify, _opts) {
202
709
  const { oidcClients, ...plugin } = await installPlugin(body.packageName, request.log);
203
710
  // Refresh in-memory capability list so new plugin caps are valid immediately
204
711
  setPluginCapabilities(await getPluginCapabilities());
205
- return { ok: true, plugin, oidcClients };
712
+ await recordAudit({
713
+ actor: actorFromRequest(request),
714
+ action: 'plugin.installed',
715
+ plugin: plugin.name,
716
+ details: { packageName: plugin.packageName, version: plugin.version },
717
+ });
718
+ // `hooks`: the relay hook set to review (and approve) before hosting.
719
+ return { ok: true, plugin, oidcClients, hooks: await hookStatus(plugin) };
206
720
  } catch (err) {
207
721
  const statusCode = err.statusCode || 500;
208
- return reply.code(statusCode).send({
209
- error: err.message || 'Plugin installation failed',
210
- });
722
+ // `route_taken`: another installed plugin holds the route (`route`, `owner`).
723
+ return reply.code(statusCode).send(operationErrorBody(err, 'Plugin installation failed'));
211
724
  }
212
725
  },
213
726
  );
@@ -261,7 +774,9 @@ export default async function pluginRoutes(fastify, _opts) {
261
774
  );
262
775
 
263
776
  // ------------------------------------------------------------------
264
- // DELETE /plugins/:name — uninstall a plugin (must be disabled)
777
+ // DELETE /plugins/:name?force=&purge= — uninstall a plugin (must be
778
+ // disabled); a hosted one's hosting is removed first (see
779
+ // DELETE /plugins/:name/hosting)
265
780
  // ------------------------------------------------------------------
266
781
  fastify.delete(
267
782
  '/plugins/:name',
@@ -270,16 +785,24 @@ export default async function pluginRoutes(fastify, _opts) {
270
785
  },
271
786
  async (request, reply) => {
272
787
  const params = PluginNameParamSchema.parse(request.params);
788
+ const query = ForceQuerySchema.parse(request.query);
273
789
 
274
790
  try {
275
- const result = await uninstallPlugin(params.name, request.log);
276
- setPluginCapabilities(await getPluginCapabilities());
277
- return result;
791
+ // A hosted plugin's hosting is removed first, by an operation
792
+ // (its uninstall pipeline); any other plugin goes at once.
793
+ const result = await uninstallPluginWithHosting(params.name, {
794
+ actor: actorFromRequest(request),
795
+ logger: request.log,
796
+ force: query.force === 'true',
797
+ purge: query.purge === 'true',
798
+ sourceIp: request.ip,
799
+ });
800
+ if (result.operation)
801
+ return reply.code(202).send({ ok: true, operation: result.operation });
802
+ return result.removed;
278
803
  } catch (err) {
279
804
  const statusCode = err.statusCode || 500;
280
- return reply.code(statusCode).send({
281
- error: err.message || 'Plugin uninstall failed',
282
- });
805
+ return reply.code(statusCode).send(operationErrorBody(err, 'Plugin uninstall failed'));
283
806
  }
284
807
  },
285
808
  );
@@ -341,7 +864,9 @@ export default async function pluginRoutes(fastify, _opts) {
341
864
  // ===========================================================================
342
865
 
343
866
  // ------------------------------------------------------------------
344
- // GET /plugins/push-install/config — get push install config
867
+ // GET /plugins/push-install/config — get push install config: the
868
+ // global switch, the policies, the default policy and the bounds of an
869
+ // agent's window (`windowMinutes`)
345
870
  // ------------------------------------------------------------------
346
871
  fastify.get(
347
872
  '/plugins/push-install/config',
@@ -350,7 +875,7 @@ export default async function pluginRoutes(fastify, _opts) {
350
875
  },
351
876
  async (_request, _reply) => {
352
877
  const config = await readPushInstallConfig();
353
- return config;
878
+ return pushInstallConfigView(config);
354
879
  },
355
880
  );
356
881
 
@@ -367,7 +892,7 @@ export default async function pluginRoutes(fastify, _opts) {
367
892
 
368
893
  try {
369
894
  const config = await updatePushInstallConfigFields(body);
370
- return { ok: true, ...config };
895
+ return { ok: true, ...pushInstallConfigView(config) };
371
896
  } catch (err) {
372
897
  const statusCode = err.statusCode || 500;
373
898
  return reply.code(statusCode).send({
@@ -513,7 +1038,12 @@ export default async function pluginRoutes(fastify, _opts) {
513
1038
  );
514
1039
 
515
1040
  // ------------------------------------------------------------------
516
- // POST /plugins/push-install/:label — send push install command
1041
+ // POST /plugins/push-install/:label — record a push-install command
1042
+ // (legacy). It checks the push-install gates and logs the command in the
1043
+ // push-install session log, and that is all: no agent polls for it and
1044
+ // nothing delivers it. What Lamaste really pushes — installing, updating
1045
+ // and removing an agent half — runs as plugin operations (hosting, update,
1046
+ // unhosting, uninstall, purging retained data), gated the same way.
517
1047
  // ------------------------------------------------------------------
518
1048
  fastify.post(
519
1049
  '/plugins/push-install/:label',
@@ -530,27 +1060,14 @@ export default async function pluginRoutes(fastify, _opts) {
530
1060
  return reply.code(access.statusCode).send({ error: access.error });
531
1061
  }
532
1062
 
533
- // Check if the action is allowed by the policy
534
- if (
535
- access.policy.allowedActions &&
536
- access.policy.allowedActions.length > 0 &&
537
- !access.policy.allowedActions.includes(body.action)
538
- ) {
539
- return reply.code(403).send({
540
- error: `Action "${body.action}" is not allowed by the assigned policy`,
541
- });
542
- }
543
-
544
- // Check if the package is allowed by the policy
545
- if (
546
- body.packageName &&
547
- access.policy.allowedPlugins &&
548
- access.policy.allowedPlugins.length > 0 &&
549
- !access.policy.allowedPlugins.includes(body.packageName)
550
- ) {
551
- return reply.code(403).send({
552
- error: `Package "${body.packageName}" is not allowed by the assigned policy`,
553
- });
1063
+ // Check the action and the package against the policy
1064
+ const refusal = pushInstallPolicyRefusal(
1065
+ access.policy,
1066
+ body.action,
1067
+ body.packageName ?? null,
1068
+ );
1069
+ if (refusal) {
1070
+ return reply.code(refusal.statusCode).send({ error: refusal.error });
554
1071
  }
555
1072
 
556
1073
  // Log the session
@@ -563,14 +1080,12 @@ export default async function pluginRoutes(fastify, _opts) {
563
1080
  status: 'sent',
564
1081
  });
565
1082
 
566
- // The actual command execution happens on the agent side.
567
- // This endpoint signals intent; the agent polls or receives via WebSocket.
568
1083
  return {
569
1084
  ok: true,
570
1085
  label: params.label,
571
1086
  action: body.action,
572
1087
  packageName: body.packageName || null,
573
- message: `Push install command "${body.action}" sent to agent "${params.label}"`,
1088
+ message: `Push install command "${body.action}" for agent "${params.label}" recorded in the push-install log; nothing delivers it to the agent`,
574
1089
  };
575
1090
  },
576
1091
  );