@lamalibre/create-lamaste 4.4.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +234 -18
  3. package/src/index.js +1 -0
  4. package/src/lib/backend-cookie.js +65 -0
  5. package/src/lib/ip-panel-vhost.js +208 -0
  6. package/src/lib/service-config.js +5 -3
  7. package/src/tasks/nginx.js +18 -135
  8. package/src/tasks/redeploy.js +39 -1
  9. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  10. package/vendor/docs/00-introduction/quickstart.md +5 -5
  11. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  12. package/vendor/docs/01-concepts/authentication.md +1 -1
  13. package/vendor/docs/01-concepts/authorization.md +66 -48
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/mtls.md +2 -2
  16. package/vendor/docs/01-concepts/security-model.md +70 -33
  17. package/vendor/docs/01-concepts/tickets.md +2 -2
  18. package/vendor/docs/01-concepts/tunneling.md +4 -4
  19. package/vendor/docs/02-guides/agent-setup.md +5 -5
  20. package/vendor/docs/02-guides/certificate-management.md +2 -2
  21. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  22. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  23. package/vendor/docs/02-guides/installation.md +3 -3
  24. package/vendor/docs/02-guides/managing-users.md +113 -25
  25. package/vendor/docs/02-guides/onboarding.md +2 -2
  26. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  27. package/vendor/docs/02-guides/static-sites.md +4 -0
  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 +3 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +229 -24
  32. package/vendor/docs/03-architecture/overview.md +13 -17
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +3 -2
  35. package/vendor/docs/03-architecture/state-management.md +4 -8
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +378 -34
  38. package/vendor/docs/04-api-reference/app-host.md +3 -3
  39. package/vendor/docs/04-api-reference/gatekeeper.md +78 -5
  40. package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
  41. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  42. package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
  43. package/vendor/docs/04-api-reference/sites.md +4 -0
  44. package/vendor/docs/04-api-reference/tunnels.md +87 -47
  45. package/vendor/docs/04-api-reference/users.md +241 -45
  46. package/vendor/docs/05-operations/backup-and-restore.md +35 -26
  47. package/vendor/docs/05-operations/monitoring.md +39 -0
  48. package/vendor/docs/05-operations/uninstalling.md +2 -1
  49. package/vendor/docs/05-operations/upgrades.md +24 -6
  50. package/vendor/docs/06-reference/config-files.md +100 -202
  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 +25 -26
  54. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  55. package/vendor/docs/gatekeeper-design.md +10 -8
  56. package/vendor/docs/identity-directory-design.md +48 -14
  57. package/vendor/docs/package.json +1 -1
  58. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  59. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  60. package/vendor/gatekeeper/dist/index.d.ts +4 -3
  61. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/index.js +4 -2
  63. package/vendor/gatekeeper/dist/index.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  65. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  67. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  68. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  69. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  71. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  72. package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
  73. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  74. package/vendor/gatekeeper/dist/lib/grants.js +6 -1
  75. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  76. package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
  77. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/groups.js +5 -1
  79. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
  81. package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
  82. package/vendor/gatekeeper/dist/lib/principals.js +67 -0
  83. package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
  84. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +10 -0
  85. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
  86. package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
  87. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
  88. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  89. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  90. package/vendor/gatekeeper/dist/server/index.js +49 -56
  91. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  92. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
  93. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  94. package/vendor/gatekeeper/dist/server/routes/authz.js +332 -104
  95. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  96. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
  98. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
  100. package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
  101. package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
  102. package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
  103. package/vendor/gatekeeper/package.json +2 -2
  104. package/vendor/server/bin/lamaste-server.js +15 -1
  105. package/vendor/server/package.json +2 -2
  106. package/vendor/server/src/commands/plugin-identity.js +335 -0
  107. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  108. package/vendor/server/src/commands/plugins.js +156 -11
  109. package/vendor/server/src/commands/registry-credential.js +2 -1
  110. package/vendor/server/src/commands/sites.js +15 -1
  111. package/vendor/server/src/commands/tunnels.js +102 -2
  112. package/vendor/server/src/commands/uninstall.js +3 -1
  113. package/vendor/server/src/commands/users.js +358 -0
  114. package/vendor/server/src/plugin-approval.js +186 -5
  115. package/vendor/server/src/plugin-identity.js +382 -0
  116. package/vendor/server/src/plugin-web-surface.js +307 -0
  117. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  118. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  119. package/vendor/server-ui/dist/index.html +2 -2
  120. package/vendor/serverd/package.json +2 -2
  121. package/vendor/serverd/src/index.js +34 -5
  122. package/vendor/serverd/src/lib/audience.js +576 -0
  123. package/vendor/serverd/src/lib/authelia.js +78 -7
  124. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  125. package/vendor/serverd/src/lib/chisel.js +10 -2
  126. package/vendor/serverd/src/lib/config.js +68 -43
  127. package/vendor/serverd/src/lib/directory.js +1113 -0
  128. package/vendor/serverd/src/lib/entitlements.js +517 -0
  129. package/vendor/serverd/src/lib/gatekeeper-client.js +226 -10
  130. package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
  131. package/vendor/serverd/src/lib/identity-sync.js +905 -0
  132. package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
  133. package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
  134. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  135. package/vendor/serverd/src/lib/mtls.js +6 -2
  136. package/vendor/serverd/src/lib/nginx.js +546 -48
  137. package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
  138. package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
  139. package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
  140. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  141. package/vendor/serverd/src/lib/plugins.js +95 -7
  142. package/vendor/serverd/src/lib/registry-credential.js +9 -6
  143. package/vendor/serverd/src/lib/request-logging.js +19 -0
  144. package/vendor/serverd/src/lib/retained-data.js +50 -4
  145. package/vendor/serverd/src/lib/state.js +36 -0
  146. package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
  147. package/vendor/serverd/src/lib/user-access-session.js +21 -11
  148. package/vendor/serverd/src/lib/user-caller.js +67 -0
  149. package/vendor/serverd/src/lib/user-surface.js +754 -0
  150. package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
  151. package/vendor/serverd/src/lib/web-surface.js +766 -0
  152. package/vendor/serverd/src/middleware/user-access-session.js +9 -0
  153. package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
  154. package/vendor/serverd/src/routes/invite.js +53 -12
  155. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
  156. package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
  157. package/vendor/serverd/src/routes/management/invitations.js +78 -68
  158. package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
  159. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  160. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  161. package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
  162. package/vendor/serverd/src/routes/management/sites.js +143 -65
  163. package/vendor/serverd/src/routes/management/tunnels.js +109 -22
  164. package/vendor/serverd/src/routes/management/users.js +290 -208
  165. package/vendor/serverd/src/routes/management.js +6 -0
  166. package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
  167. package/vendor/serverd/src/routes/user-access.js +17 -4
  168. package/vendor/serverd/src/routes/user-surface.js +69 -0
  169. package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
  170. package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
@@ -11,9 +11,12 @@ import {
11
11
  updateSite,
12
12
  verifyDns,
13
13
  SiteError,
14
+ withTunnelLock,
14
15
  } from '@lamalibre/lamaste/server';
16
+ import { AppError } from '../../lib/app-error.js';
17
+ import { webSurfaceServing } from '../../lib/web-surface.js';
15
18
  import { getConfig } from '../../lib/config.js';
16
- import { readSites, writeSites, readTunnels } from '../../lib/state.js';
19
+ import { readSites, writeSites, readTunnels, withSitesLock } from '../../lib/state.js';
17
20
  import { writeStaticSiteVhost, removeStaticSiteVhost } from '../../lib/nginx.js';
18
21
  import { updateAccessControl } from '../../lib/authelia.js';
19
22
  import { issueTunnelCert, getCertPath, issueSiteCert } from '../../lib/certbot.js';
@@ -134,6 +137,79 @@ function siteErrorStatus(code) {
134
137
  }
135
138
  }
136
139
 
140
+ /**
141
+ * Run a site workflow that may give a site a hostname: under core's tunnel
142
+ * lock, then the sites lock — the order every such workflow takes them in
143
+ * (`withSitesLock`) — so no tunnel takes the name meanwhile.
144
+ *
145
+ * @template T
146
+ * @param {() => Promise<T>} fn
147
+ * @returns {Promise<T>}
148
+ */
149
+ function withHostnameLocks(fn) {
150
+ return withTunnelLock(() => withSitesLock(fn));
151
+ }
152
+
153
+ /**
154
+ * Refuse (409) a site a plugin owns: its files are the plugin's approved
155
+ * web tree and its settings follow the plugin — it is managed through the
156
+ * plugin's web surface, never here (and never by `lamaste-agent deploy`).
157
+ */
158
+ function assertNotPluginOwned(site) {
159
+ if (site?.owner) {
160
+ throw new AppError(
161
+ `Site '${site.name}' is the web surface of plugin '${site.owner.plugin}' — manage it through the plugin`,
162
+ 409,
163
+ { code: 'plugin_owned_site' },
164
+ );
165
+ }
166
+ }
167
+
168
+ /** The site `id`, refused when a plugin owns it; null when there is none. */
169
+ async function siteForManagement(id) {
170
+ const site = (await readSites()).find((s) => s.id === id) ?? null;
171
+ assertNotPluginOwned(site);
172
+ return site;
173
+ }
174
+
175
+ /**
176
+ * Record the size of site `id` on disk in sites.json, re-reading the file
177
+ * under the sites lock — never a copy read before an upload, which would
178
+ * undo every change made meanwhile.
179
+ *
180
+ * @returns {Promise<number>} the size
181
+ */
182
+ async function recordSiteSize(id) {
183
+ const totalSize = await getSiteSize(id);
184
+ await withSitesLock(async () => {
185
+ const sites = await readSites();
186
+ const site = sites.find((s) => s.id === id);
187
+ if (!site) return;
188
+ site.totalSize = totalSize;
189
+ await writeSites(sites);
190
+ });
191
+ return totalSize;
192
+ }
193
+
194
+ /**
195
+ * Sites as listed: a plugin-owned one with `webSurface: { served, reason }`
196
+ * — whether it serves its plugin's shell and API now, or answers 503 and why.
197
+ */
198
+ async function withWebSurfaceState(sites) {
199
+ if (!sites.some((site) => site.owner)) return sites;
200
+ const tunnels = await readTunnels();
201
+ return Promise.all(
202
+ sites.map(async (site) => {
203
+ if (!site.owner) return site;
204
+ const serving = await webSurfaceServing(site.owner.plugin, tunnels);
205
+ return {
206
+ ...site,
207
+ webSurface: { served: serving.served, reason: serving.served ? null : serving.reason },
208
+ };
209
+ }),
210
+ );
211
+ }
212
+
137
213
  function assertAgentSiteAccess(request, site) {
138
214
  if (request.certRole === 'agent') {
139
215
  const allowed = request.certAllowedSites || [];
@@ -191,7 +267,7 @@ export default async function sitesRoutes(fastify, _opts) {
191
267
  }
192
268
 
193
269
  const total = sites.length;
194
- const windowed = sites.slice(offset, offset + limit);
270
+ const windowed = await withWebSurfaceState(sites.slice(offset, offset + limit));
195
271
 
196
272
  return { sites: windowed, total, limit, offset };
197
273
  },
@@ -214,22 +290,24 @@ export default async function sitesRoutes(fastify, _opts) {
214
290
  }
215
291
 
216
292
  try {
217
- const result = await createSite({
218
- name: body.name,
219
- type: body.type,
220
- customDomain: body.customDomain,
221
- aliases: body.aliases,
222
- spaMode: body.spaMode,
223
- autheliaProtected: body.autheliaProtected,
224
- domain: config.domain,
225
- email: config.email,
226
- nginx: nginxDeps,
227
- certbot: certbotDeps,
228
- files: filesDeps,
229
- siteState: siteStateDeps,
230
- tunnelState: tunnelReadDeps,
231
- logger: request.log,
232
- });
293
+ const result = await withHostnameLocks(() =>
294
+ createSite({
295
+ name: body.name,
296
+ type: body.type,
297
+ customDomain: body.customDomain,
298
+ aliases: body.aliases,
299
+ spaMode: body.spaMode,
300
+ autheliaProtected: body.autheliaProtected,
301
+ domain: config.domain,
302
+ email: config.email,
303
+ nginx: nginxDeps,
304
+ certbot: certbotDeps,
305
+ files: filesDeps,
306
+ siteState: siteStateDeps,
307
+ tunnelState: tunnelReadDeps,
308
+ logger: request.log,
309
+ }),
310
+ );
233
311
 
234
312
  const response = { ok: true, site: result.site };
235
313
  if (result.message) response.message = result.message;
@@ -261,12 +339,15 @@ export default async function sitesRoutes(fastify, _opts) {
261
339
  const { id } = IdParamSchema.parse(request.params);
262
340
 
263
341
  try {
264
- await deleteSite({
265
- id,
266
- nginx: nginxDeps,
267
- files: filesDeps,
268
- siteState: siteStateDeps,
269
- logger: request.log,
342
+ await withSitesLock(async () => {
343
+ await siteForManagement(id);
344
+ await deleteSite({
345
+ id,
346
+ nginx: nginxDeps,
347
+ files: filesDeps,
348
+ siteState: siteStateDeps,
349
+ logger: request.log,
350
+ });
270
351
  });
271
352
  return { ok: true };
272
353
  } catch (err) {
@@ -277,6 +358,7 @@ export default async function sitesRoutes(fastify, _opts) {
277
358
  details: err.message,
278
359
  });
279
360
  }
361
+ if (err instanceof AppError) throw err;
280
362
  request.log.error(err, 'Failed to delete site');
281
363
  return reply.code(500).send({
282
364
  error: 'Failed to delete site',
@@ -298,21 +380,24 @@ export default async function sitesRoutes(fastify, _opts) {
298
380
  const config = getConfig();
299
381
 
300
382
  try {
301
- const result = await updateSite({
302
- id,
303
- spaMode: body.spaMode,
304
- autheliaProtected: body.autheliaProtected,
305
- allowedUsers: body.allowedUsers,
306
- aliases: body.aliases,
307
- domain: config.domain,
308
- email: config.email,
309
- serverIp: config.ip,
310
- nginx: nginxDeps,
311
- certbot: certbotDeps,
312
- siteState: siteStateDeps,
313
- tunnelState: tunnelReadDeps,
314
- authelia: autheliaDeps,
315
- logger: request.log,
383
+ const result = await withHostnameLocks(async () => {
384
+ await siteForManagement(id);
385
+ return updateSite({
386
+ id,
387
+ spaMode: body.spaMode,
388
+ autheliaProtected: body.autheliaProtected,
389
+ allowedUsers: body.allowedUsers,
390
+ aliases: body.aliases,
391
+ domain: config.domain,
392
+ email: config.email,
393
+ serverIp: config.ip,
394
+ nginx: nginxDeps,
395
+ certbot: certbotDeps,
396
+ siteState: siteStateDeps,
397
+ tunnelState: tunnelReadDeps,
398
+ authelia: autheliaDeps,
399
+ logger: request.log,
400
+ });
316
401
  });
317
402
  return result;
318
403
  } catch (err) {
@@ -330,6 +415,7 @@ export default async function sitesRoutes(fastify, _opts) {
330
415
  details: err.message,
331
416
  });
332
417
  }
418
+ if (err instanceof AppError) throw err;
333
419
  request.log.error(err, 'Failed to update site');
334
420
  return reply.code(500).send({
335
421
  error: 'Failed to update site configuration',
@@ -353,15 +439,18 @@ export default async function sitesRoutes(fastify, _opts) {
353
439
  const config = getConfig();
354
440
 
355
441
  try {
356
- const result = await verifyDns({
357
- id,
358
- serverIp: config.ip,
359
- domain: config.domain,
360
- email: config.email,
361
- nginx: nginxDeps,
362
- certbot: certbotDeps,
363
- siteState: siteStateDeps,
364
- logger: request.log,
442
+ const result = await withHostnameLocks(async () => {
443
+ await siteForManagement(id);
444
+ return verifyDns({
445
+ id,
446
+ serverIp: config.ip,
447
+ domain: config.domain,
448
+ email: config.email,
449
+ nginx: nginxDeps,
450
+ certbot: certbotDeps,
451
+ siteState: siteStateDeps,
452
+ logger: request.log,
453
+ });
365
454
  });
366
455
  return result;
367
456
  } catch (err) {
@@ -371,6 +460,7 @@ export default async function sitesRoutes(fastify, _opts) {
371
460
  error: err.message,
372
461
  });
373
462
  }
463
+ if (err instanceof AppError) throw err;
374
464
  request.log.error(err, 'Failed to verify DNS');
375
465
  return reply.code(500).send({
376
466
  error: 'DNS verification failed',
@@ -390,8 +480,7 @@ export default async function sitesRoutes(fastify, _opts) {
390
480
  const { id } = IdParamSchema.parse(request.params);
391
481
  const { path: relativePath } = PathQuerySchema.parse(request.query);
392
482
 
393
- const sites = await readSites();
394
- const site = sites.find((s) => s.id === id);
483
+ const site = await siteForManagement(id);
395
484
  if (!site) {
396
485
  return reply.code(404).send({ error: 'Site not found' });
397
486
  }
@@ -417,8 +506,7 @@ export default async function sitesRoutes(fastify, _opts) {
417
506
  const { id } = IdParamSchema.parse(request.params);
418
507
  const config = getConfig();
419
508
 
420
- const sites = await readSites();
421
- const site = sites.find((s) => s.id === id);
509
+ const site = await siteForManagement(id);
422
510
  if (!site) {
423
511
  return reply.code(404).send({ error: 'Site not found' });
424
512
  }
@@ -450,13 +538,9 @@ export default async function sitesRoutes(fastify, _opts) {
450
538
 
451
539
  // Update site size
452
540
  try {
453
- const totalSize = await getSiteSize(id);
454
- const siteIndex = sites.findIndex((s) => s.id === id);
455
- sites[siteIndex].totalSize = totalSize;
456
-
541
+ const totalSize = await recordSiteSize(id);
457
542
  if (totalSize > config.maxSiteSize) {
458
543
  // Don't block but warn
459
- await writeSites(sites);
460
544
  return reply.code(200).send({
461
545
  ok: true,
462
546
  files: uploadedFiles,
@@ -464,8 +548,6 @@ export default async function sitesRoutes(fastify, _opts) {
464
548
  totalSize,
465
549
  });
466
550
  }
467
-
468
- await writeSites(sites);
469
551
  } catch {
470
552
  // Non-critical — don't fail the upload
471
553
  }
@@ -483,8 +565,7 @@ export default async function sitesRoutes(fastify, _opts) {
483
565
  async (request, reply) => {
484
566
  const { id } = IdParamSchema.parse(request.params);
485
567
 
486
- const sites = await readSites();
487
- const site = sites.find((s) => s.id === id);
568
+ const site = await siteForManagement(id);
488
569
  if (!site) {
489
570
  return reply.code(404).send({ error: 'Site not found' });
490
571
  }
@@ -501,10 +582,7 @@ export default async function sitesRoutes(fastify, _opts) {
501
582
 
502
583
  // Update site size
503
584
  try {
504
- const totalSize = await getSiteSize(id);
505
- const siteIndex = sites.findIndex((s) => s.id === id);
506
- sites[siteIndex].totalSize = totalSize;
507
- await writeSites(sites);
585
+ await recordSiteSize(id);
508
586
  } catch {
509
587
  // Non-critical
510
588
  }
@@ -5,6 +5,15 @@
5
5
  * under the tunnel-state lock. Read-only endpoints and HTTP-specific auth
6
6
  * checks remain here; checks that depend on the tunnel's current state run
7
7
  * inside the lock through the workflows' `authorize` hook.
8
+ *
9
+ * A tunnel may serve agent-hosted plugins' user surfaces (slice 6, admin
10
+ * only; `lib/user-surface.js`):
11
+ *
12
+ * PUT /tunnels/:id/user-surface { route } — bind
13
+ * DELETE /tunnels/:id/user-surface/:route unbind
14
+ *
15
+ * Listings carry the binding (`userSurface: { routes }`) and how each bound
16
+ * route is served (`userSurfaceStatus`).
8
17
  */
9
18
  import { z } from 'zod';
10
19
  import {
@@ -30,6 +39,14 @@ import { buildChiselArgs } from '../../lib/chisel-args.js';
30
39
  import { listAgentPluginAssignments } from '../../lib/plugin-hosting.js';
31
40
  import { pendingAgentJobs } from '../../lib/agent-jobs.js';
32
41
  import { recordAgentContact } from '../../lib/agent-presence.js';
42
+ import { actorFromRequest } from '../../lib/actor.js';
43
+ import {
44
+ auditDeletedTunnelUserSurface,
45
+ bindTunnelUserSurface,
46
+ unbindTunnelUserSurface,
47
+ userSurfaceDeps,
48
+ withUserSurfaceStatus,
49
+ } from '../../lib/user-surface.js';
33
50
  import {
34
51
  tunnelNginxDeps,
35
52
  tunnelCertbotDeps,
@@ -39,6 +56,20 @@ import {
39
56
 
40
57
  const IdParamSchema = z.object({ id: z.string().uuid() });
41
58
 
59
+ /** A plugin route, as a user surface is bound for it (core `isUserSurfaceRoute` decides the rest). */
60
+ const UserSurfaceRouteSchema = z
61
+ .string()
62
+ .min(1)
63
+ .max(64)
64
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'route must be a plugin route');
65
+
66
+ const UserSurfaceBodySchema = z.object({ route: UserSurfaceRouteSchema }).strict();
67
+
68
+ const UserSurfaceParamsSchema = z.object({ id: z.string().uuid(), route: UserSurfaceRouteSchema });
69
+
70
+ /** Binding and unbinding rewrite a vhost and reload nginx. */
71
+ const USER_SURFACE_WRITE_RATE_LIMIT = { rateLimit: { max: 30, timeWindow: '1 minute' } };
72
+
42
73
  const TunnelPortSchema = z
43
74
  .number()
44
75
  .int('Port must be an integer')
@@ -189,10 +220,16 @@ function maxBodyCeiling(request) {
189
220
  return request.certRole === 'admin' ? TUNNEL_MAX_BODY_MB.max : TUNNEL_MAX_BODY_MB.agentMax;
190
221
  }
191
222
 
192
- /** A static site (or one of its aliases) that already answers on `fqdn`. */
223
+ /**
224
+ * Core `createTunnel`'s `hostTaken`: the static site (or one of its
225
+ * aliases) that already answers on `fqdn` — two vhosts with one
226
+ * server_name make nginx pick silently. Asked under the tunnel lock, which
227
+ * every workflow giving a site a hostname holds too.
228
+ */
193
229
  async function siteServing(fqdn) {
194
230
  const sites = await readSites();
195
- return sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn)) ?? null;
231
+ const site = sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn));
232
+ return site ? `by a static site ('${site.name}')` : null;
196
233
  }
197
234
 
198
235
  /**
@@ -215,11 +252,30 @@ function tunnelErrorStatus(code) {
215
252
  return 403;
216
253
  case 'NOT_FOUND':
217
254
  return 404;
255
+ case 'USER_SURFACE_CONFLICT':
256
+ return 409;
218
257
  default:
219
258
  return 500;
220
259
  }
221
260
  }
222
261
 
262
+ /** A refused or failed user-surface change: the workflow's status, or a 500. */
263
+ function sendUserSurfaceFailure(request, reply, err, fallback) {
264
+ if (err instanceof TunnelError) {
265
+ const status = tunnelErrorStatus(err.code);
266
+ return reply.code(status).send({
267
+ error: status === 500 ? fallback : err.message,
268
+ ...(status === 500 ? { details: err.message } : {}),
269
+ ...(err.code === 'USER_SURFACE_CONFLICT' ? { code: 'user_surface_conflict' } : {}),
270
+ });
271
+ }
272
+ if (Number.isInteger(err.statusCode)) {
273
+ return reply.code(err.statusCode).send({ error: err.message });
274
+ }
275
+ request.log.error(err, fallback);
276
+ return reply.code(500).send({ error: fallback, details: err.message });
277
+ }
278
+
223
279
  export default async function tunnelRoutes(fastify, _opts) {
224
280
  const nginxDeps = tunnelNginxDeps;
225
281
  const certbotDeps = tunnelCertbotDeps;
@@ -348,7 +404,9 @@ export default async function tunnelRoutes(fastify, _opts) {
348
404
  });
349
405
 
350
406
  const total = tunnels.length;
351
- const windowed = tunnels.slice(offset, offset + limit);
407
+ const windowed = await Promise.all(
408
+ tunnels.slice(offset, offset + limit).map((t) => withUserSurfaceStatus(t)),
409
+ );
352
410
 
353
411
  return { tunnels: windowed, total, limit, offset };
354
412
  },
@@ -444,16 +502,6 @@ export default async function tunnelRoutes(fastify, _opts) {
444
502
  });
445
503
  }
446
504
 
447
- // A static site (or one of its aliases) may already answer on this
448
- // hostname; two vhosts with one server_name make nginx pick silently.
449
- const fqdn = `${subdomain}.${config.domain}`;
450
- if (await siteServing(fqdn)) {
451
- return reply.code(400).send({
452
- error: 'Failed to create tunnel',
453
- details: `Domain '${fqdn}' is already served by a static site`,
454
- });
455
- }
456
-
457
505
  try {
458
506
  const tunnel = await createTunnel({
459
507
  subdomain,
@@ -471,6 +519,7 @@ export default async function tunnelRoutes(fastify, _opts) {
471
519
  chisel: chiselDeps,
472
520
  state: stateDeps,
473
521
  agents: agentDeps,
522
+ hostTaken: siteServing,
474
523
  logger: request.log,
475
524
  });
476
525
  return reply.code(201).send({ ok: true, tunnel });
@@ -547,6 +596,8 @@ export default async function tunnelRoutes(fastify, _opts) {
547
596
  chisel: chiselDeps,
548
597
  state: stateDeps,
549
598
  agents: agentDeps,
599
+ userSurface: userSurfaceDeps,
600
+ hostTaken: siteServing,
550
601
  logger: request.log,
551
602
  authorize: authorizeFor(request, 'change'),
552
603
  });
@@ -577,7 +628,7 @@ export default async function tunnelRoutes(fastify, _opts) {
577
628
  const { id } = IdParamSchema.parse(request.params);
578
629
 
579
630
  try {
580
- await deleteTunnel({
631
+ const { tunnel } = await deleteTunnel({
581
632
  id,
582
633
  nginx: nginxDeps,
583
634
  chisel: chiselDeps,
@@ -585,6 +636,11 @@ export default async function tunnelRoutes(fastify, _opts) {
585
636
  logger: request.log,
586
637
  authorize: authorizeFor(request, 'delete'),
587
638
  });
639
+ // Its user-surface bindings went with it.
640
+ await auditDeletedTunnelUserSurface(tunnel, {
641
+ actor: actorFromRequest(request),
642
+ logger: request.log,
643
+ });
588
644
  return { ok: true };
589
645
  } catch (err) {
590
646
  if (err instanceof TunnelError) {
@@ -603,6 +659,44 @@ export default async function tunnelRoutes(fastify, _opts) {
603
659
  },
604
660
  );
605
661
 
662
+ // PUT /api/tunnels/:id/user-surface — serve a plugin's user surface on this
663
+ // tunnel's host (from when its installed, approved version declares identity)
664
+ fastify.put(
665
+ '/tunnels/:id/user-surface',
666
+ { preHandler: fastify.requireRole(['admin']), config: USER_SURFACE_WRITE_RATE_LIMIT },
667
+ async (request, reply) => {
668
+ const { id } = IdParamSchema.parse(request.params);
669
+ const { route } = UserSurfaceBodySchema.parse(request.body);
670
+ try {
671
+ const { changed, tunnel } = await bindTunnelUserSurface(id, route, {
672
+ actor: actorFromRequest(request),
673
+ logger: request.log,
674
+ });
675
+ return { ok: true, changed, tunnel };
676
+ } catch (err) {
677
+ return sendUserSurfaceFailure(request, reply, err, 'Failed to bind the user surface');
678
+ }
679
+ },
680
+ );
681
+
682
+ // DELETE /api/tunnels/:id/user-surface/:route — stop serving it there
683
+ fastify.delete(
684
+ '/tunnels/:id/user-surface/:route',
685
+ { preHandler: fastify.requireRole(['admin']), config: USER_SURFACE_WRITE_RATE_LIMIT },
686
+ async (request, reply) => {
687
+ const { id, route } = UserSurfaceParamsSchema.parse(request.params);
688
+ try {
689
+ const { tunnel } = await unbindTunnelUserSurface(id, route, {
690
+ actor: actorFromRequest(request),
691
+ logger: request.log,
692
+ });
693
+ return { ok: true, tunnel };
694
+ } catch (err) {
695
+ return sendUserSurfaceFailure(request, reply, err, 'Failed to unbind the user surface');
696
+ }
697
+ },
698
+ );
699
+
606
700
  // GET /api/tunnels/agent-panel-status — check if agent has a panel tunnel
607
701
  fastify.get(
608
702
  '/tunnels/agent-panel-status',
@@ -652,14 +746,6 @@ export default async function tunnelRoutes(fastify, _opts) {
652
746
  });
653
747
  }
654
748
 
655
- const fqdn = `${subdomain}.${config.domain}`;
656
- if (await siteServing(fqdn)) {
657
- return reply.code(400).send({
658
- error: 'Failed to expose agent panel',
659
- details: `Domain '${fqdn}' is already served by a static site`,
660
- });
661
- }
662
-
663
749
  // Check if a panel tunnel already exists for this agent
664
750
  const existing = await readTunnels();
665
751
  const existingPanel = existing.find((t) => t.type === 'panel' && t.subdomain === subdomain);
@@ -684,6 +770,7 @@ export default async function tunnelRoutes(fastify, _opts) {
684
770
  chisel: chiselDeps,
685
771
  state: stateDeps,
686
772
  agents: agentDeps,
773
+ hostTaken: siteServing,
687
774
  logger: request.log,
688
775
  });
689
776
  return reply.code(201).send({ ok: true, tunnel });