@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
@@ -4,8 +4,8 @@
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-B5Wp8lu-.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BqWWFlTU.css">
7
+ <script type="module" crossorigin src="/assets/index-DBp3cmaU.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Bc5NCMSv.css">
9
9
  </head>
10
10
  <body style="background-color: #09090b">
11
11
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/lamaste-serverd",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Lamaste server daemon — REST API and plugin host",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@
29
29
  "@fastify/rate-limit": "^10.3.0",
30
30
  "@fastify/static": "^9.1.1",
31
31
  "@fastify/websocket": "^11.0.0",
32
- "@lamalibre/lamaste": "^4.2.0",
32
+ "@lamalibre/lamaste": "^4.3.0",
33
33
  "bcryptjs": "^3.0.3",
34
34
  "execa": "^9.6.1",
35
35
  "fastify": "^5.8.4",
@@ -17,6 +17,7 @@ import healthRoutes from './routes/health.js';
17
17
  import onboardingRoutes from './routes/onboarding/index.js';
18
18
  import managementRoutes from './routes/management.js';
19
19
  import pluginRouter from './routes/plugin-router.js';
20
+ import agentForwarderRoutes from './routes/agent-forwarder.js';
20
21
  import inviteRoutes from './routes/invite.js';
21
22
  import enrollmentRoutes from './routes/enrollment.js';
22
23
  import {
@@ -25,10 +26,20 @@ import {
25
26
  userAccessProtectedRoutes,
26
27
  } from './routes/user-access.js';
27
28
  import userAccessSessionMiddleware from './middleware/user-access-session.js';
28
- import { getPluginCapabilities } from './lib/plugins.js';
29
+ import { getPluginCapabilities, readPlugins } from './lib/plugins.js';
30
+ import { markInterruptedOperations } from './lib/plugin-operations.js';
31
+ import { trustLocalNginxHop } from './lib/trust-proxy.js';
32
+ import { reconcileRelayHooks } from './lib/plugin-hooks.js';
33
+ import { loadTunnelFallbacks } from './lib/request-hooks.js';
29
34
  import { setPluginCapabilities } from './lib/mtls.js';
30
35
  import { startChiselReconciler } from './lib/chisel-reconcile.js';
36
+ import {
37
+ startHostedPluginHealthMonitor,
38
+ stopHostedPluginHealthMonitor,
39
+ } from './lib/plugin-hosting.js';
31
40
  import { syncOidcClients } from './lib/plugin-oidc.js';
41
+ import { ensureRelaySigningKey } from './lib/relay-signing.js';
42
+ import { reconcileSiteVhosts, reconcileTunnelVhosts } from './lib/vhost-reconcile.js';
32
43
  import {
33
44
  loadTicketScopeCapabilities,
34
45
  checkInstanceLiveness,
@@ -61,9 +72,10 @@ async function start() {
61
72
 
62
73
  const server = Fastify({
63
74
  logger: true,
64
- // Behind nginx: trust exactly one proxy hop so request.ip reflects the real client IP
65
- // from nginx's $remote_addr, not a spoofed X-Forwarded-For value from upstream.
66
- trustProxy: 1,
75
+ // Behind the local nginx: trust exactly that one loopback hop, so
76
+ // request.ip is the client address nginx appended to X-Forwarded-For —
77
+ // not a value the client sent, and nothing from a non-local peer.
78
+ trustProxy: trustLocalNginxHop,
67
79
  });
68
80
 
69
81
  // Wire ticket module to the Fastify root logger so any load-time warnings
@@ -87,6 +99,11 @@ async function start() {
87
99
  });
88
100
  }
89
101
 
102
+ // The key relay assertions to agents' lamaste-agentd are signed with —
103
+ // created here when missing, before Gatekeeper (which signs with it too)
104
+ // needs it.
105
+ await ensureRelaySigningKey(server.log);
106
+
90
107
  // --- Plugins ---
91
108
  const ipOrigin = `https://${config.ip}:9292`;
92
109
  await server.register(cors, {
@@ -158,6 +175,9 @@ async function start() {
158
175
  app.register(managementRoutes, { prefix: '/api' });
159
176
  app.register(userAccessAdminRoutes, { prefix: '/api' });
160
177
  app.register(pluginRouter, { prefix: '/api' });
178
+ // Last: agent-hosted plugins with an agent half, forwarded over their
179
+ // internal tunnels. Only what no other /api route answers reaches it.
180
+ app.register(agentForwarderRoutes, { prefix: '/api' });
161
181
  });
162
182
 
163
183
  // --- User-access session-protected routes (Bearer token, no mTLS) ---
@@ -176,8 +196,39 @@ async function start() {
176
196
  });
177
197
 
178
198
  // --- Start ---
199
+
200
+ // The auth portal and static site vhosts an older version wrote name the
201
+ // client's Host header, not their own hostname, to Authelia. Before listen:
202
+ // site changes take no lock, and none can arrive until the panel listens.
203
+ if (config.onboarding.status === 'COMPLETED' && config.domain) {
204
+ await reconcileSiteVhosts(server.log, config.domain).catch((err) => {
205
+ server.log.error({ err: err.message }, 'Startup site vhost reconcile failed');
206
+ });
207
+ }
208
+
179
209
  await server.listen({ host: '127.0.0.1', port: 3100 });
180
210
 
211
+ // --- Lifecycle hooks: operations a restart cut short, the runners of
212
+ // approved relay hook sets (a redeploy may have reset them), fallbacks ---
213
+ await markInterruptedOperations(server.log);
214
+ await loadTunnelFallbacks();
215
+ readPlugins()
216
+ .then(({ plugins }) => reconcileRelayHooks(plugins, server.log))
217
+ .catch((err) => server.log.error({ err: err.message }, 'Relay hook reconciliation failed'));
218
+
219
+ // Tunnel vhosts written by an older version: agent-panel and plugin vhosts
220
+ // do not forward relay assertions, without which lamaste-agentd refuses the
221
+ // relay, and Gatekeeper vhosts name the client's Host header to Gatekeeper.
222
+ // After listen: a rewritten agent-panel vhost asks this server at once.
223
+ if (config.onboarding.status === 'COMPLETED') {
224
+ reconcileTunnelVhosts(server.log).catch((err) => {
225
+ server.log.error({ err: err.message }, 'Startup tunnel vhost reconcile failed');
226
+ });
227
+ }
228
+
229
+ // --- Health of hosted agent-hosted plugins (every 10s; idle without hostings) ---
230
+ startHostedPluginHealthMonitor(server.log);
231
+
181
232
  // --- Periodic ticket instance liveness check (every 60s) ---
182
233
  const livenessInterval = setInterval(() => {
183
234
  checkInstanceLiveness(server.log).catch((err) => {
@@ -190,6 +241,7 @@ async function start() {
190
241
  const shutdown = async (signal) => {
191
242
  server.log.info({ signal }, 'Received signal, shutting down gracefully');
192
243
  clearInterval(livenessInterval);
244
+ stopHostedPluginHealthMonitor();
193
245
  clearRateLimitInterval();
194
246
  await server.close();
195
247
  process.exit(0);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Derive a stable audit identifier for the calling principal.
3
+ *
4
+ * Returns the certificate CN form (`admin`, `agent:<label>`, `plugin-agent:...`)
5
+ * so a reader of the journal or the audit log can correlate events with the
6
+ * principal that initiated them. Returns `'unknown'` when middleware did not
7
+ * populate cert metadata (e.g. dev mode shortcut on /api/health).
8
+ */
9
+ export function actorFromRequest(request) {
10
+ if (request.certRole === 'admin') return 'admin';
11
+ if (request.certRole === 'agent' && request.certLabel) return `agent:${request.certLabel}`;
12
+ if (request.certRole === 'plugin-agent' && request.certLabel) return request.certLabel;
13
+ return 'unknown';
14
+ }
15
+
16
+ /**
17
+ * The client certificate serial nginx reported, for audit entries: every
18
+ * administrator shares the `admin` identity, so the serial is what tells two
19
+ * admin certificates apart. Not secret. Null when absent (development mode).
20
+ */
21
+ export function certSerialFromRequest(request) {
22
+ const serial = request.headers?.['x-ssl-client-serial'];
23
+ return typeof serial === 'string' && /^[0-9A-Fa-f]{1,64}$/.test(serial) ? serial : null;
24
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Agent jobs — the panel's way to have an agent do something on its own
3
+ * machine and report back. The panel cannot reach an agent (agents connect
4
+ * out, behind NAT); an agent's sync timer fetches `agent-config` every 30 s,
5
+ * which lists the agent's pending jobs, runs them, and posts each result to
6
+ * `POST /api/agents/me/jobs/:id/result`.
7
+ *
8
+ * The kinds (core `agent-jobs.ts` has the result contract both ends check):
9
+ *
10
+ * - `hook` — call the agent half's `POST /admin/hooks/<stage>` on the
11
+ * agent's loopback, for the lifecycle stages that run while the internal
12
+ * tunnel is not there (the agent's `install:pre-tunnel` before it is
13
+ * granted, `uninstall:post-teardown` after it is removed). The agent
14
+ * signs that call with the installation secret it already holds.
15
+ * - `app-install` (`spec`: the pinned agent half), `app-activate`
16
+ * (`version`, `package`) and `app-remove` (`purge`, `package`) — install,
17
+ * switch or remove the agent half through the agent's root helper
18
+ * `lamaste-apphost`. The agent fetches the package itself and installs it
19
+ * only when its tarball matches the pinned integrity.
20
+ *
21
+ * The helper runs as root and does only what the relay signed: every app
22
+ * job is signed with the relay's signing key when it is created (core
23
+ * `app-job-signature.ts`; the envelope names the agent, route, job id, kind
24
+ * and payload, and expires at the job's deadline) and listed with its
25
+ * signature. No app job is created while the key is unusable.
26
+ *
27
+ * A job carries no secret. It has a deadline (its timeout plus delivery
28
+ * slack); an unfinished job past it expires. Waiting is event-driven within
29
+ * this process, with the database as the record.
30
+ */
31
+
32
+ import crypto from 'node:crypto';
33
+ import { EventEmitter } from 'node:events';
34
+ import {
35
+ AGENT_APP_JOB_TIMEOUTS,
36
+ AgentAppJobResultSchema,
37
+ AgentHookJobResultSchema,
38
+ appJobPayload,
39
+ signAppJob,
40
+ } from '@lamalibre/lamaste';
41
+ import { relaySigner } from './relay-signing.js';
42
+ import { getStateDb } from './state-db.js';
43
+
44
+ /** Two sync intervals and a margin: how long delivery may take on top of the hook's own timeout. */
45
+ export const AGENT_JOB_DELIVERY_SLACK_MS = 90_000;
46
+
47
+ /** Largest result an agent may post. */
48
+ export const AGENT_JOB_RESULT_MAX_BYTES = 64 * 1024;
49
+
50
+ /** The app job kinds and how long the agent has for each (the helper's work, not delivery). */
51
+ export const AGENT_APP_JOB_KINDS = Object.freeze({
52
+ 'app-install': AGENT_APP_JOB_TIMEOUTS.install,
53
+ 'app-activate': AGENT_APP_JOB_TIMEOUTS.activate,
54
+ 'app-remove': AGENT_APP_JOB_TIMEOUTS.remove,
55
+ });
56
+
57
+ const events = new EventEmitter();
58
+ events.setMaxListeners(0);
59
+
60
+ /** An app job could not be signed: the relay's signing key is missing or unusable. */
61
+ export class AppJobSigningError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = 'AppJobSigningError';
65
+ }
66
+ }
67
+
68
+ function rowToJob(row) {
69
+ return {
70
+ id: row.id,
71
+ agentLabel: row.agent_label,
72
+ route: row.plugin,
73
+ kind: row.kind,
74
+ stage: row.stage,
75
+ input: row.input ? JSON.parse(row.input) : null,
76
+ payload: row.payload ? JSON.parse(row.payload) : null,
77
+ signature: row.signature ? JSON.parse(row.signature) : null,
78
+ timeoutMs: row.timeout_ms,
79
+ status: row.status,
80
+ result: row.result ? JSON.parse(row.result) : null,
81
+ createdAt: row.created_at,
82
+ deadlineAt: row.deadline_at,
83
+ completedAt: row.completed_at,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Insert a pending job: `stage` and `input` for a hook job, `payload` (and
89
+ * `sign`, which makes its signature) for an app job; null otherwise. Its
90
+ * deadline leaves delivery {@link AGENT_JOB_DELIVERY_SLACK_MS} on top of
91
+ * `timeoutMs`.
92
+ *
93
+ * @param {object} job
94
+ * @param {((timing: { id: string, issuedAt: string, expiresAt: string }) => object) | null} [job.sign]
95
+ * @returns {Promise<string>} the job id
96
+ */
97
+ async function insertJob({ agentLabel, plugin, kind, stage, input, payload, timeoutMs, sign }) {
98
+ const db = await getStateDb();
99
+ const id = crypto.randomUUID();
100
+ const now = Date.now();
101
+ const createdAt = new Date(now).toISOString();
102
+ const deadlineAt = new Date(now + timeoutMs + AGENT_JOB_DELIVERY_SLACK_MS).toISOString();
103
+ const signature = sign ? sign({ id, issuedAt: createdAt, expiresAt: deadlineAt }) : null;
104
+ db.prepare(
105
+ `INSERT INTO agent_jobs (id, agent_label, plugin, kind, stage, input, payload, signature, timeout_ms, status, created_at, deadline_at)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
107
+ ).run(
108
+ id,
109
+ agentLabel,
110
+ plugin,
111
+ kind,
112
+ stage,
113
+ input,
114
+ payload,
115
+ signature === null ? null : JSON.stringify(signature),
116
+ timeoutMs,
117
+ createdAt,
118
+ deadlineAt,
119
+ );
120
+ return id;
121
+ }
122
+
123
+ /**
124
+ * A `hook` job: call the agent half's `stage` hook on the agent's loopback.
125
+ *
126
+ * @param {{ agentLabel: string, plugin: string, stage: string, input: object, timeoutMs: number }} job
127
+ * @returns {Promise<string>} the job id
128
+ */
129
+ export async function createAgentJob({ agentLabel, plugin, stage, input, timeoutMs }) {
130
+ return insertJob({
131
+ agentLabel,
132
+ plugin,
133
+ kind: 'hook',
134
+ stage,
135
+ input: JSON.stringify(input),
136
+ payload: null,
137
+ timeoutMs,
138
+ sign: null,
139
+ });
140
+ }
141
+
142
+ /**
143
+ * The relay's signing key for an app job; throws {@link AppJobSigningError}
144
+ * when there is none to use — the agent's helper would refuse the job.
145
+ */
146
+ async function appJobSigningKey() {
147
+ let key;
148
+ try {
149
+ key = await relaySigner.current();
150
+ } catch (err) {
151
+ throw new AppJobSigningError(
152
+ `The relay's signing key is unusable (${err instanceof Error ? err.message : String(err)}), ` +
153
+ "so it cannot sign the job the agent's app host helper would run",
154
+ );
155
+ }
156
+ if (!key) {
157
+ throw new AppJobSigningError(
158
+ "The relay has no signing key, so it cannot sign the job the agent's app host helper would run",
159
+ );
160
+ }
161
+ return key;
162
+ }
163
+
164
+ /**
165
+ * An app job: install (`spec`), activate (`version`, `package`) or remove
166
+ * (`purge`, `package`) the plugin's agent half through the agent's root
167
+ * helper — signed with the relay's key for the agent and the job's
168
+ * deadline. Throws {@link AppJobSigningError} (nothing is created) when the
169
+ * key is unusable, a TypeError for parameters that are not the kind's.
170
+ *
171
+ * @param {{ agentLabel: string, plugin: string, kind: 'app-install' | 'app-activate' | 'app-remove', spec?: object, version?: string, purge?: boolean, package?: string }} job
172
+ * @returns {Promise<string>} the job id
173
+ */
174
+ export async function createAppJob({ agentLabel, plugin, kind, ...params }) {
175
+ if (!Object.hasOwn(AGENT_APP_JOB_KINDS, kind)) {
176
+ throw new TypeError(`Unknown app job kind "${kind}"`);
177
+ }
178
+ const payload = appJobPayload(kind, params);
179
+ const key = await appJobSigningKey();
180
+ return insertJob({
181
+ agentLabel,
182
+ plugin,
183
+ kind,
184
+ stage: null,
185
+ input: null,
186
+ payload: JSON.stringify(payload),
187
+ timeoutMs: AGENT_APP_JOB_KINDS[kind],
188
+ sign: ({ id, issuedAt, expiresAt }) =>
189
+ signAppJob(
190
+ { agent: agentLabel, route: plugin, jobId: id, kind, payload, issuedAt, expiresAt },
191
+ key,
192
+ ),
193
+ });
194
+ }
195
+
196
+ /** Finish the pending jobs `rows` as `status` (expired, cancelled) and wake their waiters. */
197
+ function finishPending(db, rows, status, now) {
198
+ const finish = db.prepare(
199
+ "UPDATE agent_jobs SET status = ?, completed_at = ? WHERE id = ? AND status = 'pending'",
200
+ );
201
+ for (const { id } of rows) {
202
+ finish.run(status, now, id);
203
+ events.emit(id);
204
+ }
205
+ }
206
+
207
+ /** Expire jobs past their deadline. */
208
+ async function expireOverdue(db) {
209
+ const now = new Date().toISOString();
210
+ const overdue = db
211
+ .prepare("SELECT id FROM agent_jobs WHERE status = 'pending' AND deadline_at <= ?")
212
+ .all(now);
213
+ finishPending(db, overdue, 'expired', now);
214
+ // Finished jobs are kept a day for the record, then dropped.
215
+ const cutoff = new Date(Date.now() - 86_400_000).toISOString();
216
+ db.prepare("DELETE FROM agent_jobs WHERE status != 'pending' AND created_at < ?").run(cutoff);
217
+ }
218
+
219
+ /**
220
+ * The agent-config `agentJobs` of agent `label`: pending, not yet overdue.
221
+ * A `hook` job carries `stage` and `input`; an app job its parameters
222
+ * (`spec`; `version` and `package`; `purge` and `package`) and the relay's
223
+ * `signature` over them.
224
+ */
225
+ export async function pendingAgentJobs(label) {
226
+ const db = await getStateDb();
227
+ await expireOverdue(db);
228
+ return db
229
+ .prepare(
230
+ "SELECT * FROM agent_jobs WHERE agent_label = ? AND status = 'pending' ORDER BY created_at",
231
+ )
232
+ .all(label)
233
+ .map((row) => {
234
+ const job = rowToJob(row);
235
+ const base = { id: job.id, route: job.route, kind: job.kind };
236
+ const timing = { timeoutMs: job.timeoutMs, deadlineAt: job.deadlineAt };
237
+ return job.kind === 'hook'
238
+ ? { ...base, stage: job.stage, input: job.input, ...timing }
239
+ : { ...base, ...timing, ...job.payload, signature: job.signature };
240
+ });
241
+ }
242
+
243
+ /** One job by id, or null. */
244
+ export async function findAgentJob(id) {
245
+ const db = await getStateDb();
246
+ const row = db.prepare('SELECT * FROM agent_jobs WHERE id = ?').get(id);
247
+ return row ? rowToJob(row) : null;
248
+ }
249
+
250
+ /**
251
+ * The agent reports a job's result. Only the agent the job is for may, and
252
+ * only once. The result must fit the job's kind (core `AgentJobResultSchema`):
253
+ * a hook job's `{ ok: true, decision }`, an app job's
254
+ * `{ ok: true, app: { version, unit } }`, or `{ ok: false, error }` (one
255
+ * line). What is stored is the validated result.
256
+ *
257
+ * @returns {Promise<'recorded' | 'not_found' | 'not_pending' | 'invalid'>}
258
+ */
259
+ export async function completeAgentJob(label, id, result) {
260
+ const db = await getStateDb();
261
+ const row = db
262
+ .prepare('SELECT * FROM agent_jobs WHERE id = ? AND agent_label = ?')
263
+ .get(id, label);
264
+ if (!row) return 'not_found';
265
+ if (row.status !== 'pending') return 'not_pending';
266
+ const schema = row.kind === 'hook' ? AgentHookJobResultSchema : AgentAppJobResultSchema;
267
+ const parsed = schema.safeParse(result);
268
+ if (!parsed.success) return 'invalid';
269
+ const changes = db
270
+ .prepare(
271
+ "UPDATE agent_jobs SET status = 'done', result = ?, completed_at = ? WHERE id = ? AND status = 'pending'",
272
+ )
273
+ .run(JSON.stringify(parsed.data), new Date().toISOString(), id).changes;
274
+ if (Number(changes) === 0) return 'not_pending';
275
+ events.emit(id);
276
+ return 'recorded';
277
+ }
278
+
279
+ /**
280
+ * Wait for a job to finish. Resolves the job's result — `{ ok: true, decision }`
281
+ * (hook) or `{ ok: true, app }` (app job) — `{ ok: false, error }` (the
282
+ * agent reported a failure), or `{ ok: false, error, expired: true }` when
283
+ * the deadline passed (or the job was cancelled): the outcome is unknown.
284
+ */
285
+ export async function waitForAgentJob(id) {
286
+ const db = await getStateDb();
287
+ for (;;) {
288
+ const row = db.prepare('SELECT * FROM agent_jobs WHERE id = ?').get(id);
289
+ if (!row) return { ok: false, error: 'The job no longer exists' };
290
+ if (row.status === 'done') {
291
+ const result = JSON.parse(row.result);
292
+ return result && typeof result === 'object'
293
+ ? result
294
+ : { ok: false, error: 'Malformed result' };
295
+ }
296
+ if (row.status === 'expired' || row.status === 'cancelled') {
297
+ return {
298
+ ok: false,
299
+ expired: true,
300
+ error:
301
+ row.status === 'expired'
302
+ ? 'The agent did not report the job before its deadline (offline, or its sync is not running)'
303
+ : 'The job was cancelled',
304
+ };
305
+ }
306
+ const untilDeadline = Date.parse(row.deadline_at) - Date.now();
307
+ if (untilDeadline <= 0) {
308
+ await expireOverdue(db);
309
+ continue;
310
+ }
311
+ await new Promise((resolve) => {
312
+ const timer = setTimeout(done, Math.min(untilDeadline + 10, 5_000));
313
+ function done() {
314
+ clearTimeout(timer);
315
+ events.off(id, done);
316
+ resolve();
317
+ }
318
+ events.once(id, done);
319
+ });
320
+ }
321
+ }
322
+
323
+ /** Cancel a plugin's pending jobs (its operation ended). */
324
+ export async function cancelAgentJobs(plugin) {
325
+ const db = await getStateDb();
326
+ const rows = db
327
+ .prepare("SELECT id FROM agent_jobs WHERE plugin = ? AND status = 'pending'")
328
+ .all(plugin);
329
+ finishPending(db, rows, 'cancelled', new Date().toISOString());
330
+ }
@@ -12,3 +12,13 @@ export class AppError extends Error {
12
12
  this.isOperational = true;
13
13
  }
14
14
  }
15
+
16
+ /**
17
+ * A plain Error carrying the HTTP `statusCode` a route answers with, and
18
+ * whatever `extra` names beyond the message (a refusal's `code`,
19
+ * `reasons`, …). Not an {@link AppError}: the error middleware treats those
20
+ * as operational.
21
+ */
22
+ export function httpError(message, statusCode, extra = {}) {
23
+ return Object.assign(new Error(message), { statusCode, ...extra });
24
+ }
@@ -0,0 +1,145 @@
1
+ // Lamaste's audit log — an append-only record in state.db of administrative
2
+ // decisions (hook approvals, auto-approve changes, hostings, plugin updates)
3
+ // and of what plugins' lifecycle hooks report through the `audit` effect.
4
+ //
5
+ // Entries hold metadata only. `details` is a flat-ish JSON object capped at
6
+ // AUDIT_DETAILS_MAX_BYTES; strings in it are truncated. Nothing here is ever
7
+ // a request or response body or a secret — callers pass identifiers, counts
8
+ // and reasons. The log is pruned by age and count, never edited.
9
+
10
+ import { getStateDb } from './state-db.js';
11
+
12
+ export const AUDIT_RETENTION_DAYS = 180;
13
+ export const AUDIT_MAX_ROWS = 50_000;
14
+ export const AUDIT_DETAILS_MAX_BYTES = 4096;
15
+ const AUDIT_STRING_MAX = 300;
16
+ const PRUNE_EVERY = 500;
17
+
18
+ const ACTION_RE = /^[a-z][a-z0-9.-]{0,99}$/;
19
+ const ACTOR_RE = /^[A-Za-z0-9:._@-]{1,200}$/;
20
+ const PLUGIN_RE = /^[a-z0-9-]{1,100}$/;
21
+
22
+ let insertsSincePrune = 0;
23
+
24
+ function sanitize(value, depth = 0) {
25
+ if (value === null || typeof value === 'boolean') return value;
26
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
27
+ if (typeof value === 'string') {
28
+ return value.length > AUDIT_STRING_MAX ? `${value.slice(0, AUDIT_STRING_MAX)}…` : value;
29
+ }
30
+ if (depth >= 3) return null;
31
+ if (Array.isArray(value)) return value.slice(0, 50).map((v) => sanitize(v, depth + 1));
32
+ if (typeof value === 'object') {
33
+ const out = {};
34
+ for (const [key, v] of Object.entries(value).slice(0, 50)) {
35
+ if (v !== undefined) out[String(key).slice(0, 64)] = sanitize(v, depth + 1);
36
+ }
37
+ return out;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** Serialise `details`, dropping everything when it would exceed the cap. */
43
+ function encodeDetails(details) {
44
+ const json = JSON.stringify(sanitize(details ?? {}) ?? {});
45
+ if (Buffer.byteLength(json) <= AUDIT_DETAILS_MAX_BYTES) return json;
46
+ return JSON.stringify({ truncated: true });
47
+ }
48
+
49
+ /**
50
+ * Append one entry.
51
+ *
52
+ * @param {{ actor: string, action: string, plugin?: string | null, details?: object }} entry
53
+ * `actor` — `admin`, `agent:<label>`, `plugin:<route>` (a hook's effect),
54
+ * `lamaste` (the panel itself), `auto-approve`; `action` — dotted lower case
55
+ * (`hooks.approved`, `plugin.updated`, `hook.secret-scan.redacted`).
56
+ */
57
+ export async function recordAudit({ actor, action, plugin = null, details = {} }) {
58
+ if (!ACTOR_RE.test(actor)) throw new Error(`Invalid audit actor "${actor}"`);
59
+ if (!ACTION_RE.test(action)) throw new Error(`Invalid audit action "${action}"`);
60
+ if (plugin !== null && !PLUGIN_RE.test(plugin)) throw new Error('Invalid audit plugin');
61
+ const db = await getStateDb();
62
+ const at = new Date().toISOString();
63
+ const result = db
64
+ .prepare('INSERT INTO audit_events (at, actor, action, plugin, details) VALUES (?, ?, ?, ?, ?)')
65
+ .run(at, actor, action, plugin, encodeDetails(details));
66
+ insertsSincePrune++;
67
+ if (insertsSincePrune >= PRUNE_EVERY) {
68
+ insertsSincePrune = 0;
69
+ pruneAudit(db);
70
+ }
71
+ return { id: Number(result.lastInsertRowid), at };
72
+ }
73
+
74
+ /**
75
+ * Append, logging instead of throwing — for paths where the audited action
76
+ * already happened and a full disk must not turn it into an error.
77
+ */
78
+ export async function recordAuditBestEffort(entry, logger) {
79
+ try {
80
+ return await recordAudit(entry);
81
+ } catch (err) {
82
+ logger?.error?.({ err: err.message, action: entry.action }, 'Failed to write audit entry');
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function pruneAudit(db) {
88
+ const cutoff = new Date(Date.now() - AUDIT_RETENTION_DAYS * 86_400_000).toISOString();
89
+ db.prepare('DELETE FROM audit_events WHERE at < ?').run(cutoff);
90
+ db.prepare(
91
+ 'DELETE FROM audit_events WHERE id <= (SELECT id FROM audit_events ORDER BY id DESC LIMIT 1 OFFSET ?)',
92
+ ).run(AUDIT_MAX_ROWS);
93
+ }
94
+
95
+ function rowToEntry(row) {
96
+ let details;
97
+ try {
98
+ details = JSON.parse(row.details);
99
+ } catch {
100
+ details = {};
101
+ }
102
+ return {
103
+ id: row.id,
104
+ at: row.at,
105
+ actor: row.actor,
106
+ action: row.action,
107
+ plugin: row.plugin,
108
+ details,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Newest first. `before` pages backwards by id; `action` matches exactly or,
114
+ * ending in `.`, as a prefix (`hooks.`).
115
+ */
116
+ export async function listAudit({ plugin, action, before, limit = 50 } = {}) {
117
+ const db = await getStateDb();
118
+ const where = [];
119
+ const args = [];
120
+ if (plugin) {
121
+ where.push('plugin = ?');
122
+ args.push(plugin);
123
+ }
124
+ if (action) {
125
+ if (action.endsWith('.')) {
126
+ where.push('substr(action, 1, ?) = ?');
127
+ args.push(action.length, action);
128
+ } else {
129
+ where.push('action = ?');
130
+ args.push(action);
131
+ }
132
+ }
133
+ if (before) {
134
+ where.push('id < ?');
135
+ args.push(before);
136
+ }
137
+ const sql = `SELECT id, at, actor, action, plugin, details FROM audit_events${
138
+ where.length ? ` WHERE ${where.join(' AND ')}` : ''
139
+ } ORDER BY id DESC LIMIT ?`;
140
+ args.push(Math.min(Math.max(1, limit), 200));
141
+ return db
142
+ .prepare(sql)
143
+ .all(...args)
144
+ .map(rowToEntry);
145
+ }