@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
@@ -11,11 +11,24 @@ import {
11
11
  rotateAgentChiselCredential,
12
12
  getAgentCapabilities,
13
13
  getAgentAllowedSites,
14
+ setAgentHostFacts,
14
15
  } from '../../lib/mtls.js';
15
16
  import { z } from 'zod';
16
- import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
17
+ import {
18
+ AGENT_LABEL_REGEX,
19
+ AgentJobResultSchema,
20
+ HostFactsSchema,
21
+ pluginHostCapability,
22
+ } from '@lamalibre/lamaste';
17
23
  import { RegistryCredentialError } from '@lamalibre/lamaste/server';
18
24
  import { acquireAgentToken } from '../../lib/registry-credential.js';
25
+ import { agentPluginConfig, acknowledgeAgentPluginConfig } from '../../lib/plugin-hosting.js';
26
+ import {
27
+ AGENT_JOB_RESULT_MAX_BYTES,
28
+ completeAgentJob,
29
+ findAgentJob,
30
+ } from '../../lib/agent-jobs.js';
31
+ import { relayVerifyKeys } from '../../lib/relay-signing.js';
19
32
 
20
33
  /**
21
34
  * An agent may rotate its own chisel credential at most this often. Every
@@ -24,6 +37,20 @@ import { acquireAgentToken } from '../../lib/registry-credential.js';
24
37
  */
25
38
  const SELF_ROTATE_MIN_INTERVAL_MS = 10 * 60 * 1000;
26
39
 
40
+ const PluginRouteParamSchema = z.object({
41
+ route: z
42
+ .string()
43
+ .min(1)
44
+ .max(100)
45
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Invalid plugin route'),
46
+ });
47
+
48
+ const AckBodySchema = z.object({
49
+ issuedAt: z.string().min(1).max(64),
50
+ });
51
+
52
+ const JobIdParamSchema = z.object({ id: z.string().uuid() });
53
+
27
54
  const LabelParamSchema = z.object({
28
55
  label: z
29
56
  .string()
@@ -32,6 +59,34 @@ const LabelParamSchema = z.object({
32
59
  .regex(/^[a-z0-9-]+$/, 'Invalid agent label'),
33
60
  });
34
61
 
62
+ /**
63
+ * The plugin-config routes admit only a machine agent whose certificate
64
+ * carries the plugin's host capability. The admin master role passes every
65
+ * `requireRole` guard but has no agent identity to serve, so it is refused
66
+ * here too. Returns the reply when refused, undefined when admitted.
67
+ */
68
+ function refuseUnlessHostingAgent(request, reply, route) {
69
+ if (request.certRole !== 'agent') {
70
+ return reply
71
+ .code(403)
72
+ .send({ error: 'Only the hosting agent certificate can read a plugin config' });
73
+ }
74
+ const label = request.certLabel;
75
+ if (!label || !AGENT_LABEL_REGEX.test(label)) {
76
+ return reply
77
+ .code(400)
78
+ .send({ error: 'Agent label could not be determined from client certificate' });
79
+ }
80
+ const required = pluginHostCapability(route);
81
+ if (!(request.certCapabilities || []).includes(required)) {
82
+ return reply.code(403).send({
83
+ error: 'Insufficient certificate capability',
84
+ details: { required, granted: request.certCapabilities || [] },
85
+ });
86
+ }
87
+ return undefined;
88
+ }
89
+
35
90
  export default async function agentsRoutes(fastify, _opts) {
36
91
  // ------------------------------------------------------------------
37
92
  // GET /agents/me/chisel-credential
@@ -194,6 +249,175 @@ export default async function agentsRoutes(fastify, _opts) {
194
249
  },
195
250
  );
196
251
 
252
+ // ------------------------------------------------------------------
253
+ // GET /agents/me/plugin-config/:route
254
+ //
255
+ // The config of an agent-hosted plugin hosted on the calling agent: the
256
+ // port its agent half listens on and the installation secret (plus the
257
+ // previous secret during a rotation). Only the hosting agent can read it:
258
+ // its certificate must carry `plugin:<route>:host`, which hosting granted,
259
+ // and the hosting must name it. Identity comes strictly from the mTLS
260
+ // certificate.
261
+ // ------------------------------------------------------------------
262
+ fastify.get(
263
+ '/agents/me/plugin-config/:route',
264
+ {
265
+ preHandler: fastify.requireRole(['agent']),
266
+ config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
267
+ },
268
+ async (request, reply) => {
269
+ const { route } = PluginRouteParamSchema.parse(request.params);
270
+ const refused = refuseUnlessHostingAgent(request, reply, route);
271
+ if (refused) return refused;
272
+ const config = await agentPluginConfig(request.certLabel, route);
273
+ if (!config) {
274
+ return reply.code(404).send({ error: `Plugin "${route}" is not hosted on this agent` });
275
+ }
276
+ return config;
277
+ },
278
+ );
279
+
280
+ // ------------------------------------------------------------------
281
+ // POST /agents/me/plugin-config/:route/ack
282
+ //
283
+ // The hosting agent confirms it holds the secret issued at `issuedAt`.
284
+ // From then on the panel's forwarder signs with it and drops the previous
285
+ // one. An `issuedAt` that is not the current secret's is answered with
286
+ // `acknowledged: false` and the current `secretIssuedAt`, so the agent
287
+ // fetches again.
288
+ // ------------------------------------------------------------------
289
+ fastify.post(
290
+ '/agents/me/plugin-config/:route/ack',
291
+ {
292
+ preHandler: fastify.requireRole(['agent']),
293
+ config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
294
+ },
295
+ async (request, reply) => {
296
+ const { route } = PluginRouteParamSchema.parse(request.params);
297
+ const body = AckBodySchema.parse(request.body);
298
+ const refused = refuseUnlessHostingAgent(request, reply, route);
299
+ if (refused) return refused;
300
+ const result = await acknowledgeAgentPluginConfig(request.certLabel, route, body.issuedAt);
301
+ if (!result) {
302
+ return reply.code(404).send({ error: `Plugin "${route}" is not hosted on this agent` });
303
+ }
304
+ request.log.info(
305
+ { label: request.certLabel, plugin: route, acknowledged: result.acknowledged },
306
+ 'Plugin config acknowledgement',
307
+ );
308
+ return { ok: true, ...result };
309
+ },
310
+ );
311
+
312
+ // ------------------------------------------------------------------
313
+ // POST /agents/me/jobs/:id/result
314
+ //
315
+ // The calling agent reports a job agent-config gave it. A hook job (a
316
+ // lifecycle hook call to its agent half): `{ ok: true, decision }`; an
317
+ // app job (install, activate or remove its agent half through the app
318
+ // host helper): `{ ok: true, app: { version, unit } }`; either:
319
+ // `{ ok: false, error }` (one line). The result must fit the job's kind
320
+ // (core `AgentJobResultSchema`). Only the machine agent the job is for,
321
+ // and only once. A hook job's agent must also hold the host capability of
322
+ // the job's plugin (it calls the hosted agent half); an app job's need
323
+ // not — purging the data an agent half left runs after the hosting, and
324
+ // the capability, are gone.
325
+ // ------------------------------------------------------------------
326
+ fastify.post(
327
+ '/agents/me/jobs/:id/result',
328
+ {
329
+ preHandler: fastify.requireRole(['agent']),
330
+ config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
331
+ bodyLimit: AGENT_JOB_RESULT_MAX_BYTES,
332
+ },
333
+ async (request, reply) => {
334
+ const { id } = JobIdParamSchema.parse(request.params);
335
+ const result = AgentJobResultSchema.parse(request.body);
336
+ const job = await findAgentJob(id);
337
+ if (!job || job.agentLabel !== request.certLabel) {
338
+ return reply.code(404).send({ error: 'Job not found' });
339
+ }
340
+ if (job.kind === 'hook') {
341
+ const refused = refuseUnlessHostingAgent(request, reply, job.route);
342
+ if (refused) return refused;
343
+ } else if (request.certRole !== 'agent') {
344
+ return reply
345
+ .code(403)
346
+ .send({ error: 'Only the agent certificate the job is for can report it' });
347
+ }
348
+ const outcome = await completeAgentJob(request.certLabel, id, result);
349
+ if (outcome === 'not_found') return reply.code(404).send({ error: 'Job not found' });
350
+ if (outcome === 'not_pending') {
351
+ return reply.code(409).send({ error: 'The job is no longer pending' });
352
+ }
353
+ if (outcome === 'invalid') {
354
+ return reply.code(400).send({
355
+ error:
356
+ job.kind === 'hook'
357
+ ? 'A hook job reports { ok: true, decision } or { ok: false, error }'
358
+ : 'An app job reports { ok: true, app: { version, unit } } or { ok: false, error }',
359
+ });
360
+ }
361
+ request.log.info(
362
+ {
363
+ label: request.certLabel,
364
+ plugin: job.route,
365
+ kind: job.kind,
366
+ ...(job.stage ? { stage: job.stage } : {}),
367
+ ok: result.ok,
368
+ },
369
+ 'Agent job reported',
370
+ );
371
+ return { ok: true };
372
+ },
373
+ );
374
+
375
+ // ------------------------------------------------------------------
376
+ // PUT /agents/me/host-facts
377
+ //
378
+ // The calling agent reports what the relay needs to know about its host
379
+ // to tell which plugins it can host (`agent.requires`, and whether
380
+ // Lamaste can install an agent half there): OS, distribution,
381
+ // architecture, Node.js, systemd and its app host helper — core
382
+ // `HostFactsSchema`. Its sync sends them when they change. Kept on the
383
+ // agent's registry record (`hostFacts`, `hostFactsAt`); identity comes
384
+ // strictly from the mTLS certificate.
385
+ // ------------------------------------------------------------------
386
+ fastify.put(
387
+ '/agents/me/host-facts',
388
+ {
389
+ preHandler: fastify.requireRole(['agent']),
390
+ // Sent on change (and once a day); this only stops a loop.
391
+ config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
392
+ bodyLimit: 4 * 1024,
393
+ },
394
+ async (request, reply) => {
395
+ // requireRole lets the admin master role through every guard; an admin
396
+ // certificate has no host to report. A plugin-agent hosts nothing.
397
+ if (request.certRole !== 'agent') {
398
+ return reply.code(403).send({ error: 'Only an agent certificate can report host facts' });
399
+ }
400
+ const label = request.certLabel;
401
+ if (!label || !AGENT_LABEL_REGEX.test(label)) {
402
+ return reply
403
+ .code(400)
404
+ .send({ error: 'Agent label could not be determined from client certificate' });
405
+ }
406
+ const facts = HostFactsSchema.parse(request.body);
407
+ try {
408
+ await setAgentHostFacts(label, facts);
409
+ } catch (err) {
410
+ const status = err.statusCode || 500;
411
+ return reply.code(status).send({ error: err.message || 'Failed to store host facts' });
412
+ }
413
+ request.log.info(
414
+ { label, os: facts.os, arch: facts.arch, apphost: facts.apphost?.version ?? null },
415
+ 'Agent reported host facts',
416
+ );
417
+ return { ok: true };
418
+ },
419
+ );
420
+
197
421
  // ------------------------------------------------------------------
198
422
  // GET /agents/me/capabilities
199
423
  //
@@ -234,6 +458,43 @@ export default async function agentsRoutes(fastify, _opts) {
234
458
  },
235
459
  );
236
460
 
461
+ // ------------------------------------------------------------------
462
+ // GET /agents/me/relay-keys
463
+ //
464
+ // The public keys relay assertions are signed with
465
+ // (`X-Lamalibre-Relay-Assertion`, see relay-assertion.ts), and the label
466
+ // this relay knows the calling agent by — the audience of the assertions
467
+ // made for it, which the agent's local registry name need not equal.
468
+ // lamaste-agentd fetches both over its pinned mTLS channel and accepts a
469
+ // request from the relay only with an assertion one of the keys verifies,
470
+ // made for that label. `sudo lamaste-agent apphost enable|pin` pins the
471
+ // first key, and the label, for the agent's root helper, which runs only
472
+ // the app jobs this relay signed with it (app-job-signature.ts). The keys
473
+ // are public material: any agent or admin certificate may read them
474
+ // (`agentLabel` is null for non-agent callers).
475
+ // ------------------------------------------------------------------
476
+ fastify.get(
477
+ '/agents/me/relay-keys',
478
+ {
479
+ preHandler: fastify.requireRole(['admin', 'agent']),
480
+ config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
481
+ },
482
+ async (request, reply) => {
483
+ try {
484
+ return {
485
+ keys: await relayVerifyKeys(),
486
+ agentLabel: request.certRole === 'agent' ? request.certLabel : null,
487
+ };
488
+ } catch (err) {
489
+ request.log.error(
490
+ { err: err instanceof Error ? err.message : String(err) },
491
+ 'Relay signing key unusable',
492
+ );
493
+ return reply.code(503).send({ error: 'Relay signing key unavailable' });
494
+ }
495
+ },
496
+ );
497
+
237
498
  // ------------------------------------------------------------------
238
499
  // POST /agents/:label/chisel-credential/rotate (admin only)
239
500
  //
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ import { listAudit } from '../../lib/audit.js';
3
+ import { listNotifications, markNotificationsRead } from '../../lib/notifications.js';
4
+
5
+ const AuditQuerySchema = z.object({
6
+ plugin: z
7
+ .string()
8
+ .regex(/^[a-z0-9-]{1,100}$/)
9
+ .optional(),
10
+ action: z
11
+ .string()
12
+ .regex(/^[a-z][a-z0-9.-]{0,99}$/)
13
+ .optional(),
14
+ before: z.coerce.number().int().positive().optional(),
15
+ limit: z.coerce.number().int().min(1).max(200).optional(),
16
+ });
17
+
18
+ const NotificationsQuerySchema = z.object({
19
+ unread: z.enum(['true', 'false']).optional(),
20
+ before: z.coerce.number().int().positive().optional(),
21
+ limit: z.coerce.number().int().min(1).max(200).optional(),
22
+ });
23
+
24
+ const MarkReadBodySchema = z.union([
25
+ z.object({ all: z.literal(true) }).strict(),
26
+ z.object({ ids: z.array(z.number().int().positive()).min(1).max(200) }).strict(),
27
+ ]);
28
+
29
+ export default async function auditRoutes(fastify, _opts) {
30
+ // ------------------------------------------------------------------
31
+ // GET /audit — the audit log, newest first (?plugin, ?action — exact or
32
+ // a prefix ending in ".", ?before=<id>, ?limit)
33
+ // ------------------------------------------------------------------
34
+ fastify.get(
35
+ '/audit',
36
+ {
37
+ preHandler: fastify.requireRole(['admin']),
38
+ },
39
+ async (request) => {
40
+ const query = AuditQuerySchema.parse(request.query);
41
+ const entries = await listAudit(query);
42
+ return { entries };
43
+ },
44
+ );
45
+
46
+ // ------------------------------------------------------------------
47
+ // GET /notifications — panel notifications, newest first, and the
48
+ // unread count
49
+ // ------------------------------------------------------------------
50
+ fastify.get(
51
+ '/notifications',
52
+ {
53
+ preHandler: fastify.requireRole(['admin']),
54
+ },
55
+ async (request) => {
56
+ const query = NotificationsQuerySchema.parse(request.query);
57
+ return listNotifications({
58
+ unreadOnly: query.unread === 'true',
59
+ before: query.before,
60
+ limit: query.limit,
61
+ });
62
+ },
63
+ );
64
+
65
+ // ------------------------------------------------------------------
66
+ // POST /notifications/read — { ids: [...] } or { all: true }
67
+ // ------------------------------------------------------------------
68
+ fastify.post(
69
+ '/notifications/read',
70
+ {
71
+ preHandler: fastify.requireRole(['admin']),
72
+ },
73
+ async (request) => {
74
+ const body = MarkReadBodySchema.parse(request.body);
75
+ const changed = await markNotificationsRead(
76
+ 'all' in body ? { all: true } : { ids: body.ids },
77
+ );
78
+ return { ok: true, changed };
79
+ },
80
+ );
81
+ }
@@ -25,6 +25,7 @@ import { getConfig, updateConfig } from '../../lib/config.js';
25
25
  import { addToRevocationList } from '../../lib/revocation.js';
26
26
  import { agentOwnsInstanceForScope } from '../../lib/tickets.js';
27
27
  import * as nginx from '../../lib/nginx.js';
28
+ import { actorFromRequest } from '../../lib/actor.js';
28
29
 
29
30
  const DomainParamSchema = z.object({
30
31
  domain: z
@@ -142,21 +143,6 @@ const DelegatedEnrollBodySchema = z.object({
142
143
  ),
143
144
  });
144
145
 
145
- /**
146
- * Derive a stable audit identifier for the calling principal.
147
- *
148
- * Returns the certificate CN form (`admin`, `agent:<label>`, `plugin-agent:...`)
149
- * so a journalctl reader can correlate cert lifecycle events with the
150
- * principal that initiated them. Returns `'unknown'` when middleware did not
151
- * populate cert metadata (e.g. dev mode shortcut on /api/health).
152
- */
153
- function actorFromRequest(request) {
154
- if (request.certRole === 'admin') return 'admin';
155
- if (request.certRole === 'agent' && request.certLabel) return `agent:${request.certLabel}`;
156
- if (request.certRole === 'plugin-agent' && request.certLabel) return request.certLabel;
157
- return 'unknown';
158
- }
159
-
160
146
  /**
161
147
  * Parse certbot listing results into the unified cert response format.
162
148
  */