@lamalibre/create-lamaste 4.5.0 → 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 (105) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +34 -2
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +2 -2
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +5 -5
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/02-guides/static-sites.md +4 -0
  19. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  20. package/vendor/docs/03-architecture/installer.md +4 -4
  21. package/vendor/docs/03-architecture/management-flow.md +1 -1
  22. package/vendor/docs/03-architecture/nginx-configuration.md +65 -7
  23. package/vendor/docs/03-architecture/overview.md +5 -5
  24. package/vendor/docs/03-architecture/panel-client.md +6 -6
  25. package/vendor/docs/03-architecture/panel-server.md +2 -2
  26. package/vendor/docs/03-architecture/state-management.md +2 -2
  27. package/vendor/docs/03-architecture/system-overview.md +1 -1
  28. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +112 -37
  29. package/vendor/docs/04-api-reference/app-host.md +3 -3
  30. package/vendor/docs/04-api-reference/gatekeeper.md +19 -1
  31. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  32. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  33. package/vendor/docs/04-api-reference/sites.md +4 -0
  34. package/vendor/docs/04-api-reference/tunnels.md +21 -19
  35. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  36. package/vendor/docs/05-operations/uninstalling.md +1 -1
  37. package/vendor/docs/05-operations/upgrades.md +15 -6
  38. package/vendor/docs/06-reference/config-files.md +19 -15
  39. package/vendor/docs/06-reference/glossary.md +3 -3
  40. package/vendor/docs/06-reference/installer-flags.md +13 -13
  41. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  42. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  43. package/vendor/docs/gatekeeper-design.md +8 -8
  44. package/vendor/docs/identity-directory-design.md +3 -3
  45. package/vendor/docs/package.json +1 -1
  46. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  47. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  48. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  49. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  50. package/vendor/gatekeeper/dist/index.js +2 -2
  51. package/vendor/gatekeeper/dist/index.js.map +1 -1
  52. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  53. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  54. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  55. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  56. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  57. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  58. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  59. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  60. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  61. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  63. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  64. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  65. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  66. package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
  67. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  68. package/vendor/gatekeeper/dist/server/index.js +29 -10
  69. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/server/routes/authz.js +103 -22
  72. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  73. package/vendor/gatekeeper/package.json +2 -2
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  76. package/vendor/server/src/commands/plugins.js +63 -6
  77. package/vendor/server/src/commands/sites.js +15 -1
  78. package/vendor/server/src/plugin-approval.js +80 -1
  79. package/vendor/server/src/plugin-web-surface.js +307 -0
  80. package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
  81. package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +84 -0
  82. package/vendor/server-ui/dist/index.html +2 -2
  83. package/vendor/serverd/package.json +2 -2
  84. package/vendor/serverd/src/index.js +9 -4
  85. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  86. package/vendor/serverd/src/lib/chisel.js +10 -2
  87. package/vendor/serverd/src/lib/gatekeeper-client.js +18 -7
  88. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  89. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  90. package/vendor/serverd/src/lib/nginx.js +269 -2
  91. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  92. package/vendor/serverd/src/lib/plugin-lifecycle.js +128 -5
  93. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  94. package/vendor/serverd/src/lib/plugins.js +39 -6
  95. package/vendor/serverd/src/lib/state.js +18 -0
  96. package/vendor/serverd/src/lib/user-surface.js +48 -27
  97. package/vendor/serverd/src/lib/vhost-reconcile.js +5 -3
  98. package/vendor/serverd/src/lib/web-surface.js +766 -0
  99. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  100. package/vendor/serverd/src/routes/management/plugins.js +2 -0
  101. package/vendor/serverd/src/routes/management/sites.js +143 -65
  102. package/vendor/serverd/src/routes/management/tunnels.js +11 -20
  103. package/vendor/serverd/src/routes/management.js +2 -0
  104. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  105. package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +0 -84
@@ -39,7 +39,7 @@ import {
39
39
  settingsSchemaDiff,
40
40
  validateManifest,
41
41
  } from '@lamalibre/lamaste';
42
- import { pluginHostingPhase } from '@lamalibre/lamaste/server';
42
+ import { pluginHostingPhase, webTreeManifestOfDirectory } from '@lamalibre/lamaste/server';
43
43
  import { httpError } from './app-error.js';
44
44
  import { setPluginCapabilities } from './mtls.js';
45
45
  import {
@@ -51,7 +51,7 @@ import {
51
51
  uninstallPlugin,
52
52
  writePluginRow,
53
53
  } from './plugins.js';
54
- import { pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
54
+ import { installedWebTreeDir, pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
55
55
  import {
56
56
  DIGEST_UNAPPROVED,
57
57
  IDENTITY_CHANGED,
@@ -314,7 +314,8 @@ function identityApprovalRequired(identity) {
314
314
  * in for an administrator on the first reason alone:
315
315
  *
316
316
  * - `digest.unapproved` — what it brings (relay hooks, the agent half
317
- * Lamaste installs, its identity block) is not what was approved;
317
+ * Lamaste installs, its identity block, its web tree) is not what was
318
+ * approved;
318
319
  * - `settings.secret-to-plain` — {@link settingsApprovalRequired};
319
320
  * - `identity.changed` — {@link identityApprovalRequired}.
320
321
  *
@@ -334,6 +335,7 @@ function approvalRequiredFor(staged, state, settings, identity) {
334
335
  relay: staged.relaySet,
335
336
  agentHalf: staged.agentHalf,
336
337
  identity: staged.identity,
338
+ web: staged.webTree,
337
339
  }),
338
340
  ]),
339
341
  ...settingsApprovalRequired(settings),
@@ -446,6 +448,10 @@ async function stageVersion(plugin, requestedVersion, record) {
446
448
  }
447
449
  entrySha256 = hookEntrySha256(moduleBytes);
448
450
  }
451
+ // A web surface's tree, read from the tarball: every rule checked now,
452
+ // its digest approved with the rest.
453
+ const webRoot = manifest.userSurface?.web?.root;
454
+ const webTree = webRoot ? await staged.readWebTree(webRoot) : null;
449
455
  // The staged version as a registry entry, so it is judged exactly as the
450
456
  // installed one will be.
451
457
  const entry = {
@@ -457,6 +463,8 @@ async function stageVersion(plugin, requestedVersion, record) {
457
463
  agentHalfPin,
458
464
  settings: manifest.settings,
459
465
  identity: identityOf(manifest),
466
+ userSurface: manifest.userSurface,
467
+ webTreeSha256: webTree?.sha256 ?? null,
460
468
  };
461
469
  const approval = installedApproval(entry);
462
470
  return {
@@ -470,6 +478,8 @@ async function stageVersion(plugin, requestedVersion, record) {
470
478
  agentHalf: approval.agentHalf,
471
479
  // The staged identity block, normalized (null: it declares none).
472
480
  identity: approval.identity,
481
+ // The staged web tree's manifest (null: it declares no web surface).
482
+ webTree,
473
483
  // The relay hook set's own digest — what the runner holds.
474
484
  relayDigest: approval.relay?.digest ?? null,
475
485
  approvalRequired: approval.required,
@@ -514,6 +524,104 @@ function identityPreview(plugin, manifest) {
514
524
  return { before, after, ...identityDiff(before, after) };
515
525
  }
516
526
 
527
+ /**
528
+ * How a staged version changes the web tree, for the update's preview: the
529
+ * installed version's tree digest (`before`), the staged tree (`after`:
530
+ * its digest, file count and size) and what changes by path (`changes`,
531
+ * {@link webTreeChanges}) — null when neither version declares a web
532
+ * surface.
533
+ *
534
+ * @param {object} plugin the installed registry entry
535
+ * @param {{ webTree: import('@lamalibre/lamaste').WebTreeManifest | null }} staged
536
+ */
537
+ async function webPreview(plugin, staged) {
538
+ const before = installedTreeSha256(plugin);
539
+ const after = staged.webTree
540
+ ? {
541
+ sha256: staged.webTree.sha256,
542
+ files: staged.webTree.files.length,
543
+ totalBytes: staged.webTree.totalBytes,
544
+ }
545
+ : null;
546
+ if (before === null && after === null) return null;
547
+ return { before, after, changes: await webTreeChanges(plugin, staged.webTree) };
548
+ }
549
+
550
+ /** The approved web tree of the installed version, or null when it declares none. */
551
+ function installedTreeSha256(plugin) {
552
+ return plugin.userSurface?.web ? (plugin.webTreeSha256 ?? null) : null;
553
+ }
554
+
555
+ /** At most this many paths per list of a tree change (the counts are exact). */
556
+ const WEB_CHANGE_LIST_MAX = 200;
557
+
558
+ /**
559
+ * Files of a web tree an administrator should look at before approving: a
560
+ * document other than the shell's `index.html` (another page served on
561
+ * the relay's domain), an SVG (it runs script when opened), a manifest.
562
+ */
563
+ function webFileNeedsReview(file) {
564
+ return (file.endsWith('.html') && file !== 'index.html') || /\.(?:svg|webmanifest)$/.test(file);
565
+ }
566
+
567
+ /**
568
+ * What a staged version's web tree changes against the installed one's,
569
+ * by path: added, removed, changed (each `{ count, paths }`, the paths
570
+ * capped), and `review` — the added or changed paths
571
+ * {@link webFileNeedsReview} names. Null when the staged version declares
572
+ * no web tree, or when the installed tree cannot be read (the digests
573
+ * still tell them apart).
574
+ *
575
+ * @param {object} plugin the installed registry entry
576
+ * @param {import('@lamalibre/lamaste').WebTreeManifest | null} stagedTree
577
+ */
578
+ async function webTreeChanges(plugin, stagedTree) {
579
+ if (!stagedTree) return null;
580
+ let installed = new Map();
581
+ if (installedTreeSha256(plugin) !== null) {
582
+ try {
583
+ const dir = await installedWebTreeDir(plugin.packageName, plugin);
584
+ const tree = await webTreeManifestOfDirectory(dir);
585
+ installed = new Map(tree.files.map((f) => [f.path, f.sha256]));
586
+ } catch {
587
+ return null;
588
+ }
589
+ }
590
+ const added = [];
591
+ const changed = [];
592
+ for (const file of stagedTree.files) {
593
+ const was = installed.get(file.path);
594
+ if (was === undefined) added.push(file.path);
595
+ else if (was !== file.sha256) changed.push(file.path);
596
+ }
597
+ const staged = new Set(stagedTree.files.map((f) => f.path));
598
+ const removed = [...installed.keys()].filter((p) => !staged.has(p));
599
+ const list = (paths) => ({ count: paths.length, paths: paths.slice(0, WEB_CHANGE_LIST_MAX) });
600
+ return {
601
+ added: list(added),
602
+ removed: list(removed),
603
+ changed: list(changed),
604
+ review: [...added, ...changed].filter(webFileNeedsReview).slice(0, WEB_CHANGE_LIST_MAX),
605
+ };
606
+ }
607
+
608
+ /**
609
+ * Why auto-approve may not stand in for an administrator on a staged
610
+ * version's web tree: it changes (or appears) — publisher code served to
611
+ * every user of the plugin on the relay's own domain is approved by a
612
+ * person, as an identity change is.
613
+ */
614
+ function webTreeWithheld(plugin, staged) {
615
+ const after = staged.webTree?.sha256 ?? null;
616
+ if (after === null || after === installedTreeSha256(plugin)) return [];
617
+ return [
618
+ {
619
+ code: 'web.changed',
620
+ message: "the web tree changes: a shell served to the plugin's users on the relay's domain",
621
+ },
622
+ ];
623
+ }
624
+
517
625
  /**
518
626
  * Update plugin `name` to `requestedVersion` (an exact version or a
519
627
  * dist-tag; default `latest`). Returns `{ upToDate }`, `{ pendingUpdate }`
@@ -573,13 +681,14 @@ export async function updatePlugin(
573
681
  approvedBy = actor;
574
682
  } else if (approvalRequired.length > 0) {
575
683
  // Auto-approve stands in for an administrator on a digest that changed,
576
- // when the agent half changes nothing that runs as root on the agent —
577
- // and on nothing else: every other reason (a secret setting turned
684
+ // when the agent half changes nothing that runs as root on the agent and
685
+ // the web tree does not change — and on nothing else: every other reason (a secret setting turned
578
686
  // plain, a role added or removed, an attribute widened) withholds it.
579
687
  const digestChanged = approvalRequired.some((reason) => reason.code === DIGEST_UNAPPROVED);
580
688
  const withheldReasons = state?.autoApprove
581
689
  ? [
582
690
  ...(digestChanged ? agentHalfWithheld(installedAgentHalf(plugin), staged.agentHalf) : []),
691
+ ...(digestChanged ? webTreeWithheld(plugin, staged) : []),
583
692
  ...approvalRequired
584
693
  .filter((reason) => reason.code !== DIGEST_UNAPPROVED)
585
694
  .map(({ code, reason }) => ({ code, message: reason })),
@@ -591,6 +700,7 @@ export async function updatePlugin(
591
700
  staged,
592
701
  settings,
593
702
  identity,
703
+ web: await webPreview(plugin, staged),
594
704
  approvalRequired,
595
705
  withheldReasons,
596
706
  actor,
@@ -670,6 +780,7 @@ async function awaitApproval({
670
780
  staged,
671
781
  settings,
672
782
  identity,
783
+ web,
673
784
  approvalRequired,
674
785
  withheldReasons,
675
786
  actor,
@@ -688,6 +799,7 @@ async function awaitApproval({
688
799
  : null,
689
800
  settings,
690
801
  identity,
802
+ web,
691
803
  approvalRequired,
692
804
  ...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
693
805
  };
@@ -787,6 +899,8 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
787
899
  hooksEntrySha256: installed.hooksEntrySha256,
788
900
  agent: installed.manifest.agent,
789
901
  identity: identityOf(installed.manifest),
902
+ userSurface: installed.manifest.userSurface,
903
+ webTreeSha256: installed.webTreeSha256,
790
904
  };
791
905
  let approval;
792
906
  try {
@@ -802,6 +916,7 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
802
916
  installed.manifest,
803
917
  installed.hooksEntrySha256,
804
918
  staged.agentHalfPin,
919
+ installed.webTreeSha256,
805
920
  );
806
921
  if (approval.required) {
807
922
  await recordApprovedSet(installedEntry, approval.digest, staged.version, approvedBy);
@@ -809,6 +924,13 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
809
924
  setPluginCapabilities(await getPluginCapabilities());
810
925
  createdSecrets = await syncOidcBestEffort(logger);
811
926
  },
927
+ // The new version's tree served now, as part of the update: users of
928
+ // its site reach the new half without a gap, and a tree that cannot be
929
+ // served reverts the update rather than leave the site at 503.
930
+ serve: async () => {
931
+ const { syncWebSurfaceTree } = await import('./web-surface.js');
932
+ await syncWebSurfaceTree(plugin.route, { logger });
933
+ },
812
934
  revert: async ({ applied }) => {
813
935
  if (applied) {
814
936
  const restored = await replacePluginPackage(
@@ -822,6 +944,7 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
822
944
  restored.manifest,
823
945
  restored.hooksEntrySha256,
824
946
  plugin.agentHalfPin ?? null,
947
+ restored.webTreeSha256,
825
948
  );
826
949
  await restoreHookState(name, priorHookState);
827
950
  setPluginCapabilities(await getPluginCapabilities());
@@ -8,6 +8,12 @@
8
8
  * manifest, so it must stay inside the package, be a regular file (not a
9
9
  * link) and be no larger than {@link HOOK_MODULE_MAX_BYTES}.
10
10
  *
11
+ * A web surface's tree (`userSurface.web.root`) is read the same two ways:
12
+ * from the staged tarball, so its digest can be approved, and from the
13
+ * installed package, which must hold the very same tree — every file a
14
+ * regular one, no link on the way (core `webTreeManifestOfTarball`,
15
+ * `webTreeManifestOfDirectory`).
16
+ *
11
17
  * An agent half Lamaste installs onto an agent is never fetched here: its
12
18
  * tarball's integrity is read from the registry ({@link pinAgentHalf}) and
13
19
  * pinned with the version, and the agent fetches the tarball itself and
@@ -26,8 +32,13 @@ import {
26
32
  hookEntrySha256,
27
33
  relayHookSetNeedsEntry,
28
34
  relayHookSetOf,
35
+ WebTreeError,
29
36
  } from '@lamalibre/lamaste';
30
- import { readTarballFiles } from '@lamalibre/lamaste/server';
37
+ import {
38
+ readTarballFiles,
39
+ webTreeManifestOfDirectory,
40
+ webTreeManifestOfTarball,
41
+ } from '@lamalibre/lamaste/server';
31
42
  import { httpError } from './app-error.js';
32
43
  import { withRegistryUserconfig } from './registry-credential.js';
33
44
 
@@ -88,6 +99,62 @@ export async function installedHookEntrySha256(packageName, manifest) {
88
99
  return hookEntrySha256(await readInstalledHookModule(dir, set.entry));
89
100
  }
90
101
 
102
+ /**
103
+ * Run a web tree read, turning a broken rule into a 400 that names the
104
+ * version's tree.
105
+ *
106
+ * @template T
107
+ * @param {string} what — the package (and version) whose tree it is
108
+ * @param {() => T | Promise<T>} read
109
+ * @returns {Promise<T>}
110
+ */
111
+ async function webTreeOrRefusal(what, read) {
112
+ try {
113
+ return await read();
114
+ } catch (err) {
115
+ if (err instanceof WebTreeError) {
116
+ throw httpError(`The web surface of ${what} cannot be served: ${err.message}`, 400, {
117
+ code: 'invalid_web_tree',
118
+ });
119
+ }
120
+ throw err;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The directory of an installed package's web tree (`userSurface.web.root`,
126
+ * already a relative path of safe segments — core `WebSurfaceBlockSchema`),
127
+ * refused when it leaves the package or reaches it through a link.
128
+ */
129
+ export async function installedWebTreeDir(packageName, manifest) {
130
+ const root = manifest.userSurface?.web?.root;
131
+ if (!root) return null;
132
+ const packageDir = await installedPackageDir(packageName);
133
+ const resolved = path.resolve(packageDir, root);
134
+ if (!resolved.startsWith(`${packageDir}${path.sep}`)) {
135
+ throw httpError('The web surface lies outside its package', 400, { code: 'invalid_web_tree' });
136
+ }
137
+ const real = await realpath(resolved).catch(() => null);
138
+ if (real !== null && real !== resolved) {
139
+ throw httpError('The web surface path goes through a link', 400, { code: 'invalid_web_tree' });
140
+ }
141
+ return resolved;
142
+ }
143
+
144
+ /**
145
+ * For a validated manifest of an installed package: its web tree's digest
146
+ * (core `webTreeManifest`'s `sha256`), or null when it declares no web
147
+ * surface. Throws 400 when the tree breaks a rule.
148
+ */
149
+ export async function installedWebTreeSha256(packageName, manifest) {
150
+ const dir = await installedWebTreeDir(packageName, manifest);
151
+ if (dir === null) return null;
152
+ const tree = await webTreeOrRefusal(`${packageName}@${manifest.version}`, () =>
153
+ webTreeManifestOfDirectory(dir),
154
+ );
155
+ return tree.sha256;
156
+ }
157
+
91
158
  /**
92
159
  * Fetch `<package>@<version>` from the registry the panel installs from
93
160
  * (`npm pack` through {@link withRegistryUserconfig}: the configured
@@ -97,7 +164,7 @@ export async function installedHookEntrySha256(packageName, manifest) {
97
164
  *
98
165
  * @param {string} packageName bare `@lamalibre/<name>`
99
166
  * @param {string} version an exact version or a dist-tag (`latest`)
100
- * @returns {Promise<{ version: string, manifestRaw: unknown, readHookModule: (entry: string) => Buffer | null }>}
167
+ * @returns {Promise<{ version: string, manifestRaw: unknown, readHookModule: (entry: string) => Buffer | null, readWebTree: (root: string) => Promise<import('@lamalibre/lamaste').WebTreeManifest> }>}
101
168
  */
102
169
  export async function readRegistryPackage(packageName, version) {
103
170
  if (!PACKAGE_RE.test(packageName)) throw httpError('Invalid package name', 400);
@@ -157,6 +224,11 @@ export async function readRegistryPackage(packageName, version) {
157
224
  }).get(name) ?? null
158
225
  );
159
226
  },
227
+ readWebTree(root) {
228
+ return webTreeOrRefusal(`${packageName}@${info.version}`, () =>
229
+ webTreeManifestOfTarball(tgz, root),
230
+ );
231
+ },
160
232
  };
161
233
  } finally {
162
234
  await rm(dir, { recursive: true, force: true });
@@ -31,6 +31,7 @@ import { RegistryCredentialError } from '@lamalibre/lamaste/server';
31
31
  import {
32
32
  bareLamalibrePackageName,
33
33
  installedHookEntrySha256,
34
+ installedWebTreeSha256,
34
35
  pinAgentHalf,
35
36
  } from './plugin-packages.js';
36
37
 
@@ -72,8 +73,9 @@ async function getStmts() {
72
73
  INSERT INTO plugins
73
74
  (name, route, display_name, package_name, version, description, capabilities,
74
75
  packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
75
- agent_half_pin, settings, identity, status, installed_at, enabled_at)
76
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ agent_half_pin, settings, identity, user_surface, web_tree_sha256, status,
77
+ installed_at, enabled_at)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77
79
  `),
78
80
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
79
81
  // The plugin's audience and the state of its identity feed
@@ -141,6 +143,8 @@ function rowToPlugin(row) {
141
143
  if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
142
144
  if (row.settings != null) entry.settings = JSON.parse(row.settings);
143
145
  if (row.identity != null) entry.identity = JSON.parse(row.identity);
146
+ if (row.user_surface != null) entry.userSurface = JSON.parse(row.user_surface);
147
+ if (row.web_tree_sha256 != null) entry.webTreeSha256 = row.web_tree_sha256;
144
148
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
145
149
  return entry;
146
150
  }
@@ -224,6 +228,8 @@ export async function writePlugins(data) {
224
228
  nullableJson(p.agentHalfPin),
225
229
  nullableJson(p.settings),
226
230
  nullableJson(p.identity),
231
+ nullableJson(p.userSurface),
232
+ nullableString(p.webTreeSha256),
227
233
  p.status,
228
234
  p.installedAt,
229
235
  nullableString(p.enabledAt),
@@ -457,6 +463,17 @@ export function installPlugin(packageName, logger) {
457
463
  throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
458
464
  }
459
465
 
466
+ // A web surface: its tree must be in the package and keep every rule of
467
+ // a web tree; its digest goes into the approval digest, and the relay
468
+ // serves only a copy that matches it.
469
+ let webTreeSha256 = null;
470
+ try {
471
+ webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
472
+ } catch (err) {
473
+ await discardPackage();
474
+ throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
475
+ }
476
+
460
477
  // An agent half Lamaste installs: its tarball's integrity is pinned now,
461
478
  // from the registry this install used — the approval covers it and the
462
479
  // agent installs only a tarball that matches it.
@@ -508,6 +525,8 @@ export function installPlugin(packageName, logger) {
508
525
  agentHalfPin,
509
526
  settings: manifest.settings,
510
527
  identity: identityOf(manifest),
528
+ userSurface: manifest.userSurface,
529
+ webTreeSha256,
511
530
  status: 'disabled',
512
531
  installedAt: new Date().toISOString(),
513
532
  };
@@ -536,6 +555,8 @@ export function installPlugin(packageName, logger) {
536
555
  nullableJson(entry.agentHalfPin),
537
556
  nullableJson(entry.settings),
538
557
  nullableJson(entry.identity),
558
+ nullableJson(entry.userSurface),
559
+ nullableString(entry.webTreeSha256),
539
560
  entry.status,
540
561
  entry.installedAt,
541
562
  null,
@@ -684,7 +705,7 @@ const updateRowSql = `
684
705
  UPDATE plugins SET
685
706
  display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
686
707
  config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
687
- agent_half_pin = ?, settings = ?, identity = ?
708
+ agent_half_pin = ?, settings = ?, identity = ?, user_surface = ?, web_tree_sha256 = ?
688
709
  WHERE name = ?`;
689
710
 
690
711
  /**
@@ -698,8 +719,16 @@ const updateRowSql = `
698
719
  * beside it, and a secret whose field became a plain value is no longer
699
720
  * reported set. The identity block is stored normalized; the update's
700
721
  * caller recomputes the audiences once the version is in place.
722
+ * `webTreeSha256` is the digest of the version's web tree, read from the
723
+ * installed package (null when it declares no web surface).
701
724
  */
702
- export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
725
+ export async function writePluginRow(
726
+ name,
727
+ manifest,
728
+ hooksEntrySha256,
729
+ agentHalfPin,
730
+ webTreeSha256 = null,
731
+ ) {
703
732
  const s = await getStmts();
704
733
  const { prunedSettingsCache } = await import('./plugin-settings.js');
705
734
  s.begin.run();
@@ -723,6 +752,8 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
723
752
  nullableJson(agentHalfPin),
724
753
  nullableJson(manifest.settings),
725
754
  nullableJson(identityOf(manifest)),
755
+ nullableJson(manifest.userSurface),
756
+ nullableString(webTreeSha256),
726
757
  name,
727
758
  );
728
759
  const pruned = prunedSettingsCache(
@@ -750,7 +781,8 @@ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalf
750
781
 
751
782
  /**
752
783
  * Install `<packageName>@<version>` over the installed copy and read it
753
- * back: the validated manifest and its relay entry module's SHA-256. The
784
+ * back: the validated manifest, its relay entry module's SHA-256 and its
785
+ * web tree's digest. The
754
786
  * row is not touched — the caller compares the result with what was
755
787
  * reviewed and then writes it. Refuses a package whose manifest names
756
788
  * another plugin.
@@ -790,7 +822,8 @@ export function replacePluginPackage(name, packageName, version, logger) {
790
822
  );
791
823
  }
792
824
  const hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
793
- return { manifest, hooksEntrySha256 };
825
+ const webTreeSha256 = await installedWebTreeSha256(packageName, manifest);
826
+ return { manifest, hooksEntrySha256, webTreeSha256 };
794
827
  });
795
828
  }
796
829
 
@@ -95,6 +95,24 @@ export async function writeSites(sites) {
95
95
  await rename(tmpPath, filePath);
96
96
  }
97
97
 
98
+ /** Serializes every read-modify-write of sites.json in this process. */
99
+ const sitesMutex = new PromiseChainMutex();
100
+
101
+ /**
102
+ * Run `fn` holding the sites lock: a read of sites.json, the decisions made
103
+ * on it and the write that follows must not interleave with another. A
104
+ * workflow that gives a site a hostname (creating it, adding an alias,
105
+ * taking a tunnel's host) takes core's tunnel lock first, then this one —
106
+ * always in that order — so a site and a tunnel never take one name at once.
107
+ *
108
+ * @template T
109
+ * @param {() => Promise<T>} fn
110
+ * @returns {Promise<T>}
111
+ */
112
+ export function withSitesLock(fn) {
113
+ return sitesMutex.run(fn);
114
+ }
115
+
98
116
  function invitationsPath() {
99
117
  return path.join(STATE_DIR, 'invitations.json');
100
118
  }
@@ -45,8 +45,11 @@ import {
45
45
  RELAY_PANEL_AUDIENCE,
46
46
  RELAY_PANEL_PORT,
47
47
  isAgentHostedPlugin,
48
+ siteUserSurfaceRoute,
48
49
  tunnelUserSurfaceRoutes,
50
+ userSurfaceHostServing,
49
51
  userSurfaceHostingAgent,
52
+ userSurfaceHosts,
50
53
  verifyRelayAssertion,
51
54
  } from '@lamalibre/lamaste';
52
55
  import {
@@ -62,7 +65,7 @@ import { getConfig } from './config.js';
62
65
  import { installedVersionApproved } from './plugin-hooks.js';
63
66
  import { findPluginByRoute } from './plugins.js';
64
67
  import { relaySigner } from './relay-signing.js';
65
- import { readTunnels } from './state.js';
68
+ import { readSites, readTunnels } from './state.js';
66
69
  import { tunnelCertbotDeps, tunnelNginxDeps, tunnelStateDeps } from './tunnel-deps.js';
67
70
 
68
71
  /**
@@ -102,7 +105,7 @@ export const USER_SURFACE_ROUTE_CONFIG = Object.freeze({
102
105
  * @param {readonly object[]} tunnels
103
106
  * @returns {Promise<string | null>}
104
107
  */
105
- async function hostingAgentOf(plugin, tunnels) {
108
+ export async function hostingAgentOf(plugin, tunnels) {
106
109
  if (!plugin.route) return null;
107
110
  const { ownHosting } = await import('./plugin-hosting.js');
108
111
  const record = await ownHosting(plugin);
@@ -160,7 +163,7 @@ async function routeServing(route, agentLabel, tunnels) {
160
163
  * @param {object} plugin
161
164
  * @param {readonly object[]} tunnels the tunnel state
162
165
  */
163
- async function internalTunnelBodyLimit(plugin, tunnels) {
166
+ export async function internalTunnelBodyLimit(plugin, tunnels) {
164
167
  const { ownHosting } = await import('./plugin-hosting.js');
165
168
  const record = await ownHosting(plugin);
166
169
  if (!record) return TUNNEL_MAX_BODY_MB.default;
@@ -287,6 +290,14 @@ async function assertBindableRoute(route) {
287
290
  'INVALID_SETTING',
288
291
  );
289
292
  }
293
+ // A route is served on one host at most: the plugin's own site, when it
294
+ // has one, is that host.
295
+ if ((await readSites()).some((site) => siteUserSurfaceRoute(site) === route)) {
296
+ throw new TunnelError(
297
+ `Plugin "${plugin.name}" has a web surface — its user surface is served there`,
298
+ 'INVALID_SETTING',
299
+ );
300
+ }
290
301
  }
291
302
 
292
303
  /** Whether an installed agent-hosted plugin holds `route`. */
@@ -413,7 +424,8 @@ export async function refreshTunnelUserSurfaceVhosts(ids, logger) {
413
424
  * Whether plugin `route`'s user surface is served may have changed — its
414
425
  * version was approved, updated or reverted, it was hosted or unhosted (the
415
426
  * body limit is its internal tunnel's): bring the vhosts of the tunnels it
416
- * is bound to in line. Best effort — logged, never thrown; the panel's
427
+ * is bound to in line, and its web surface — the approved tree served, the
428
+ * vhost of the site it owns (`lib/web-surface.js`). Best effort — logged, never thrown; the panel's
417
429
  * start reconciles every vhost again.
418
430
  *
419
431
  * @param {string | null | undefined} route
@@ -429,6 +441,9 @@ export async function refreshUserSurfaceVhosts(route, logger) {
429
441
  'Could not bring the user-surface vhosts of a plugin in line',
430
442
  );
431
443
  }
444
+ // Its web surface too: the approved tree served, the site's vhost in line.
445
+ const { refreshWebSurface } = await import('./web-surface.js');
446
+ await refreshWebSurface(route, logger);
432
447
  }
433
448
 
434
449
  /**
@@ -491,6 +506,10 @@ export async function releasePluginUserSurface(route, { plugin, logger }) {
491
506
  'Could not release the user surface of an uninstalled plugin',
492
507
  );
493
508
  }
509
+ // The site it owns stays, answering 503 until the plugin is back (or an
510
+ // administrator deletes it): its vhost goes in line.
511
+ const { refreshWebSurface } = await import('./web-surface.js');
512
+ await refreshWebSurface(route, logger);
494
513
  }
495
514
 
496
515
  // ---------------------------------------------------------------------------
@@ -560,33 +579,35 @@ export async function verifyUserSurfaceRelay(request) {
560
579
  }
561
580
 
562
581
  /**
563
- * The enabled tunnel whose host serves `route`'s user surface, or null:
564
- * bound to the route, and carried by the agent hosting its plugin — the
565
- * check of every request, whatever a vhost not yet re-rendered still
566
- * routes here.
582
+ * The enabled host that serves `route`'s user surface, or null — the check
583
+ * of every request, whatever a vhost not yet re-rendered still routes here:
584
+ * a tunnel bound to the route and carried by the agent hosting its plugin,
585
+ * or the site the plugin owns while its web surface is served
586
+ * (`lib/web-surface.js`). Read with core `userSurfaceHosts`, as Gatekeeper
587
+ * reads it, so a route two hosts would serve is served by neither.
567
588
  *
568
589
  * @param {string} route
590
+ * @returns {Promise<import('@lamalibre/lamaste').UserSurfaceHost | null>}
569
591
  */
570
- async function servedUserSurfaceTunnel(route) {
571
- const tunnels = await readTunnels();
572
- const tunnel =
573
- tunnels.find(
574
- (t) =>
575
- t.enabled !== false &&
576
- typeof t.fqdn === 'string' &&
577
- tunnelUserSurfaceRoutes(t).includes(route),
578
- ) ?? null;
579
- if (!tunnel) return null;
592
+ async function servedUserSurfaceHost(route) {
593
+ const [tunnels, sites] = await Promise.all([readTunnels(), readSites()]);
594
+ const host = userSurfaceHostServing(userSurfaceHosts(tunnels, sites), route);
595
+ if (!host) return null;
596
+ if (host.kind === 'site') {
597
+ const { webSurfaceServing } = await import('./web-surface.js');
598
+ return (await webSurfaceServing(route, tunnels)).served ? host : null;
599
+ }
580
600
  const plugin = await findPluginByRoute(route);
581
601
  if (!plugin) return null;
602
+ const tunnel = tunnels.find((t) => t.id === host.id);
582
603
  const hosting = await hostingAgentOf(plugin, tunnels);
583
- return hosting !== null && tunnel.agentLabel === hosting ? tunnel : null;
604
+ return hosting !== null && tunnel?.agentLabel === hosting ? host : null;
584
605
  }
585
606
 
586
607
  /**
587
608
  * The forward target of `route`'s user surface (core `userSurfacePlugin`'s
588
- * `resolveTarget`): null when no enabled tunnel its hosting agent carries
589
- * is bound to it, else what the admin forwarder resolves — the hosting's
609
+ * `resolveTarget`): null when no host serves it (see
610
+ * {@link servedUserSurfaceHost}), else what the admin forwarder resolves — the hosting's
590
611
  * internal tunnel, or why not (not found, disabled, not hosted) — and
591
612
  * `down` while the hosting's health is down: nothing of a user's request
592
613
  * reaches the relay-loopback port then (503).
@@ -594,7 +615,7 @@ async function servedUserSurfaceTunnel(route) {
594
615
  * @param {string} route
595
616
  */
596
617
  export async function resolveUserSurfaceTarget(route) {
597
- if (!(await servedUserSurfaceTunnel(route))) return null;
618
+ if (!(await servedUserSurfaceHost(route))) return null;
598
619
  const { resolveForwardTarget } = await import('./plugin-hosting.js');
599
620
  return resolveForwardTarget(route);
600
621
  }
@@ -602,15 +623,15 @@ export async function resolveUserSurfaceTarget(route) {
602
623
  /**
603
624
  * The origin a request that may change something on `route`'s user surface
604
625
  * must come from (core `userSurfacePlugin`'s `originFor`): the bound host,
605
- * as the tunnel state names it — never the request's `Host`. Null when no
606
- * enabled tunnel its hosting agent carries is bound to the route.
626
+ * as the state names it — never the request's `Host`. Null when no host
627
+ * serves the route.
607
628
  *
608
629
  * @param {string} route
609
630
  * @returns {Promise<string | null>}
610
631
  */
611
632
  export async function userSurfaceOrigin(route) {
612
- const tunnel = await servedUserSurfaceTunnel(route);
613
- return tunnel ? `https://${tunnel.fqdn.toLowerCase()}` : null;
633
+ const host = await servedUserSurfaceHost(route);
634
+ return host ? `https://${host.fqdn}` : null;
614
635
  }
615
636
 
616
637
  // ---------------------------------------------------------------------------
@@ -702,7 +723,7 @@ export async function sweepOpenUserSurfaceResponses(logger) {
702
723
  const { route, caller } = response;
703
724
  let verdict;
704
725
  try {
705
- if (!served.has(route)) served.set(route, (await servedUserSurfaceTunnel(route)) !== null);
726
+ if (!served.has(route)) served.set(route, (await servedUserSurfaceHost(route)) !== null);
706
727
  const key = `${route}\n${caller.username}`;
707
728
  if (!callers.has(key)) callers.set(key, await resolveUserCaller(route, caller.username));
708
729
  const resolution = callers.get(key);