@lamalibre/create-lamaste 4.5.0 → 4.7.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 (178) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +20 -3
  3. package/scripts/lamaste-priv +56 -5
  4. package/scripts/pin-release.js +98 -0
  5. package/src/index.js +8 -1
  6. package/src/lib/env.js +19 -0
  7. package/src/lib/service-config.js +7 -0
  8. package/src/tasks/redeploy.js +8 -0
  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 +6 -2
  13. package/vendor/docs/01-concepts/authorization.md +4 -0
  14. package/vendor/docs/01-concepts/certificates.md +3 -3
  15. package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/security-model.md +28 -3
  18. package/vendor/docs/01-concepts/tickets.md +2 -2
  19. package/vendor/docs/01-concepts/tunneling.md +4 -4
  20. package/vendor/docs/02-guides/agent-setup.md +5 -5
  21. package/vendor/docs/02-guides/certificate-management.md +2 -2
  22. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  23. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  24. package/vendor/docs/02-guides/installation.md +3 -3
  25. package/vendor/docs/02-guides/managing-users.md +5 -11
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/second-domain.md +82 -0
  28. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  29. package/vendor/docs/02-guides/static-sites.md +4 -0
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +80 -12
  34. package/vendor/docs/03-architecture/overview.md +5 -5
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +3 -3
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +116 -37
  40. package/vendor/docs/04-api-reference/app-host.md +3 -3
  41. package/vendor/docs/04-api-reference/domains.md +192 -0
  42. package/vendor/docs/04-api-reference/gatekeeper.md +54 -8
  43. package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
  44. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  45. package/vendor/docs/04-api-reference/overview.md +9 -0
  46. package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
  47. package/vendor/docs/04-api-reference/sites.md +9 -0
  48. package/vendor/docs/04-api-reference/tunnels.md +35 -28
  49. package/vendor/docs/04-api-reference/users.md +41 -29
  50. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  51. package/vendor/docs/05-operations/uninstalling.md +1 -1
  52. package/vendor/docs/05-operations/upgrades.md +15 -6
  53. package/vendor/docs/06-reference/config-files.md +19 -15
  54. package/vendor/docs/06-reference/glossary.md +3 -3
  55. package/vendor/docs/06-reference/installer-flags.md +13 -13
  56. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  57. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  58. package/vendor/docs/_index.json +105 -19
  59. package/vendor/docs/external-members-design.md +816 -0
  60. package/vendor/docs/gatekeeper-design.md +8 -8
  61. package/vendor/docs/identity-directory-design.md +3 -3
  62. package/vendor/docs/multi-domain-relay-design.md +836 -0
  63. package/vendor/docs/package.json +1 -1
  64. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  65. package/vendor/gatekeeper/dist/index.d.ts +3 -3
  66. package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/index.js +2 -2
  68. package/vendor/gatekeeper/dist/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
  70. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
  71. package/vendor/gatekeeper/dist/lib/authz.js +31 -0
  72. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
  73. package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
  74. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
  75. package/vendor/gatekeeper/dist/lib/constants.js +5 -2
  76. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -1
  77. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
  78. package/vendor/gatekeeper/dist/lib/grants.js +20 -13
  79. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
  80. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
  81. package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
  82. package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
  83. package/vendor/gatekeeper/dist/lib/secret.js +12 -0
  84. package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
  85. package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
  86. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
  87. package/vendor/gatekeeper/dist/lib/templates.js +80 -14
  88. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
  89. package/vendor/gatekeeper/dist/lib/tunnels.d.ts +3 -6
  90. package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -1
  91. package/vendor/gatekeeper/dist/lib/tunnels.js +4 -16
  92. package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -1
  93. package/vendor/gatekeeper/dist/lib/types.d.ts +0 -19
  94. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  95. package/vendor/gatekeeper/dist/server/index.d.ts +6 -1
  96. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  97. package/vendor/gatekeeper/dist/server/index.js +56 -11
  98. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  99. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  100. package/vendor/gatekeeper/dist/server/routes/authz.js +325 -55
  101. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  102. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
  103. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +5 -2
  104. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
  105. package/vendor/gatekeeper/package.json +2 -2
  106. package/vendor/server/bin/lamaste-server.js +7 -0
  107. package/vendor/server/package.json +2 -2
  108. package/vendor/server/src/commands/domains.js +214 -0
  109. package/vendor/server/src/commands/plugin-identity.js +2 -16
  110. package/vendor/server/src/commands/plugin-web-surface.js +269 -0
  111. package/vendor/server/src/commands/plugins.js +175 -14
  112. package/vendor/server/src/commands/registry-credential.js +10 -3
  113. package/vendor/server/src/commands/sites.js +18 -1
  114. package/vendor/server/src/commands/status.js +19 -0
  115. package/vendor/server/src/commands/tunnels.js +4 -1
  116. package/vendor/server/src/commands/users.js +8 -19
  117. package/vendor/server/src/domains.js +92 -0
  118. package/vendor/server/src/fail.js +21 -0
  119. package/vendor/server/src/oidc-client-grants.js +63 -0
  120. package/vendor/server/src/panel-api.js +15 -0
  121. package/vendor/server/src/plugin-approval.js +80 -1
  122. package/vendor/server/src/plugin-web-surface.js +307 -0
  123. package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
  124. package/vendor/server-ui/dist/assets/index-Cb-ADsbB.css +1 -0
  125. package/vendor/server-ui/dist/index.html +2 -2
  126. package/vendor/serverd/package.json +2 -2
  127. package/vendor/serverd/src/index.js +50 -19
  128. package/vendor/serverd/src/lib/app-error.js +24 -0
  129. package/vendor/serverd/src/lib/audience.js +12 -3
  130. package/vendor/serverd/src/lib/authelia.js +341 -35
  131. package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
  132. package/vendor/serverd/src/lib/chisel.js +10 -2
  133. package/vendor/serverd/src/lib/directory.js +67 -52
  134. package/vendor/serverd/src/lib/domains.js +1792 -0
  135. package/vendor/serverd/src/lib/gatekeeper-client.js +34 -7
  136. package/vendor/serverd/src/lib/hook-pipelines.js +8 -3
  137. package/vendor/serverd/src/lib/identity-sync.js +35 -0
  138. package/vendor/serverd/src/lib/migrations/state/0015_plugin_web_surface.sql +14 -0
  139. package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
  140. package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
  141. package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
  142. package/vendor/serverd/src/lib/nginx.js +576 -131
  143. package/vendor/serverd/src/lib/owned-groups.js +326 -0
  144. package/vendor/serverd/src/lib/plugin-domain.js +86 -0
  145. package/vendor/serverd/src/lib/plugin-hooks.js +40 -11
  146. package/vendor/serverd/src/lib/plugin-lifecycle.js +129 -5
  147. package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
  148. package/vendor/serverd/src/lib/plugin-packages.js +74 -2
  149. package/vendor/serverd/src/lib/plugins.js +82 -9
  150. package/vendor/serverd/src/lib/registry-credential.js +39 -6
  151. package/vendor/serverd/src/lib/services.js +9 -1
  152. package/vendor/serverd/src/lib/state.js +29 -0
  153. package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
  154. package/vendor/serverd/src/lib/user-surface.js +77 -31
  155. package/vendor/serverd/src/lib/users-lock.js +23 -0
  156. package/vendor/serverd/src/lib/vhost-reconcile.js +151 -41
  157. package/vendor/serverd/src/lib/web-surface.js +783 -0
  158. package/vendor/serverd/src/middleware/user-access-session.js +11 -4
  159. package/vendor/serverd/src/routes/invite.js +15 -5
  160. package/vendor/serverd/src/routes/management/domains.js +154 -0
  161. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
  162. package/vendor/serverd/src/routes/management/identity.js +21 -7
  163. package/vendor/serverd/src/routes/management/invitations.js +33 -4
  164. package/vendor/serverd/src/routes/management/plugin-web-surface.js +151 -0
  165. package/vendor/serverd/src/routes/management/plugins.js +6 -2
  166. package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
  167. package/vendor/serverd/src/routes/management/sites.js +188 -71
  168. package/vendor/serverd/src/routes/management/tunnels.js +37 -22
  169. package/vendor/serverd/src/routes/management/users.js +184 -64
  170. package/vendor/serverd/src/routes/management.js +4 -0
  171. package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
  172. package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
  173. package/vendor/serverd/src/routes/user-access.js +13 -1
  174. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
  175. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
  176. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
  177. package/vendor/server-ui/dist/assets/index-D-fer5ef.css +0 -1
  178. 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(
@@ -816,12 +938,14 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
816
938
  plugin.packageName,
817
939
  plugin.version,
818
940
  logger,
941
+ { restoring: true },
819
942
  );
820
943
  await writePluginRow(
821
944
  name,
822
945
  restored.manifest,
823
946
  restored.hooksEntrySha256,
824
947
  plugin.agentHalfPin ?? null,
948
+ restored.webTreeSha256,
825
949
  );
826
950
  await restoreHookState(name, priorHookState);
827
951
  setPluginCapabilities(await getPluginCapabilities());
@@ -19,6 +19,16 @@
19
19
  * `identity_providers.oidc` section is written; Authelia is restarted only
20
20
  * when the section actually changed.
21
21
  *
22
+ * Every client belongs to a domain — its plugin's (the panel's own: the
23
+ * registry credential's issuer domain): `{domain}` in its audience and
24
+ * redirect URIs is that domain, and a user-facing client has its own
25
+ * authorization policy (core `renderOidcClientPolicies`): that domain's
26
+ * members, and its external members granted the client (a Gatekeeper grant
27
+ * `resourceType: 'oidc-client'`, rendered by lib/domains.js into users.yml's
28
+ * owned groups). The provider is written under lib/domains.js
29
+ * `withDomainRendering` — the one writer of what members may do — with the
30
+ * domains read under it.
31
+ *
22
32
  * Best effort by design where a plugin lifecycle calls it: a failure here
23
33
  * must not fail the plugin operation — the caller logs it and the next
24
34
  * lifecycle change retries.
@@ -26,9 +36,11 @@
26
36
 
27
37
  import { readFile } from 'node:fs/promises';
28
38
  import { createRequire } from 'node:module';
29
- import { validateManifest } from '@lamalibre/lamaste';
39
+ import { PromiseChainMutex, validateInstalledManifest } from '@lamalibre/lamaste';
30
40
  import {
31
41
  LAMASTE_REGISTRY_OIDC_SCOPES,
42
+ LAMASTE_SERVER_OIDC_CLIENT_ID,
43
+ registryIssuerDomain,
32
44
  lamasteServerOidcClient,
33
45
  listOidcClientSecrets,
34
46
  reconcileOidcClientSecrets,
@@ -36,32 +48,43 @@ import {
36
48
  rotateOidcClientSecret,
37
49
  } from '@lamalibre/lamaste/server';
38
50
 
39
- import { getConfig } from './config.js';
40
51
  import { updateOidcProvider } from './authelia.js';
52
+ import { listGrantsOfType } from './gatekeeper-client.js';
53
+ import { OIDC_CLIENT_RESOURCE, isUserFacingOidcClient } from './owned-groups.js';
54
+ import { pluginDomainOr, primaryDomainName, withDomainRendering } from './domains.js';
41
55
  import { readPlugins } from './plugins.js';
42
56
  import { readRegistryCredentialConfig } from './registry-credential.js';
43
57
 
44
58
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
45
59
 
60
+ /** The domains a rendering read, as the OIDC provider's policies name them: name and group key. */
61
+ const keyed = (domains) => domains.map(({ name, groupKey }) => ({ name, groupKey }));
62
+
46
63
  /**
47
64
  * The OIDC clients every installed plugin declares, tagged with the plugin
48
- * name and whether the plugin is enabled. Manifests are re-read from the
49
- * installed packages — the plugin registry does not store them.
65
+ * name, its domain and whether the plugin is enabled. Manifests are re-read
66
+ * from the installed packages — the plugin registry does not store them.
50
67
  * @returns {Promise<Array<import('@lamalibre/lamaste/server').AutheliaOidcClient & { enabled: boolean }>>}
51
68
  */
52
69
  export async function collectPluginOidcClients() {
53
70
  const require = createRequire(`${STATE_DIR}/`);
71
+ const primary = await primaryDomainName();
54
72
  const clients = [];
55
73
  for (const plugin of (await readPlugins()).plugins) {
56
74
  let manifest;
57
75
  try {
58
76
  const manifestPath = require.resolve(`${plugin.packageName}/lamaste-plugin.json`);
59
- manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
77
+ manifest = validateInstalledManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
60
78
  } catch {
61
79
  continue; // an unreadable manifest contributes no clients
62
80
  }
63
81
  for (const client of manifest.oidcClients) {
64
- clients.push({ ...client, plugin: manifest.name, enabled: plugin.status === 'enabled' });
82
+ clients.push({
83
+ ...client,
84
+ plugin: manifest.name,
85
+ domain: pluginDomainOr(plugin, primary),
86
+ enabled: plugin.status === 'enabled',
87
+ });
65
88
  }
66
89
  }
67
90
  return clients;
@@ -69,31 +92,77 @@ export async function collectPluginOidcClients() {
69
92
 
70
93
  /**
71
94
  * The panel's own client, when the registry credential (stored, or the
72
- * candidate being verified) uses it.
73
- * @param {{ registryUrl: string } | null | undefined} registryAuth
95
+ * candidate being verified) uses it — of the credential's issuer domain.
96
+ * @param {{ registryUrl: string, issuerDomain?: string } | null | undefined} registryAuth
74
97
  */
75
- function lamasteOwnClient(registryAuth) {
98
+ async function lamasteOwnClient(registryAuth) {
76
99
  if (!registryAuth) return [];
77
- return [{ ...lamasteServerOidcClient(registryAuth.registryUrl), enabled: true }];
100
+ const domain = registryIssuerDomain(
101
+ { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID, ...registryAuth },
102
+ await primaryDomainName(),
103
+ );
104
+ return [{ ...lamasteServerOidcClient(registryAuth.registryUrl, domain), enabled: true }];
105
+ }
106
+
107
+ /**
108
+ * A registry credential being set (`PUT /api/registry-credential`): its
109
+ * `registryAuth` while the panel's client is synced, the chain verified and
110
+ * the credential stored. Every sync that names no override serves it — the
111
+ * identity tick's every-minute sync included, which otherwise reads the
112
+ * stored credential, removes the client the candidate needs and restarts
113
+ * Authelia under the verification's token request. Every sync reads it under
114
+ * the one writer ({@link syncOidcClients}), so none can write a plan made
115
+ * before it was set.
116
+ * @type {{ registryUrl: string, issuerDomain?: string } | undefined}
117
+ */
118
+ let registryCandidate;
119
+ const registryCandidateMutex = new PromiseChainMutex();
120
+
121
+ /**
122
+ * Run `fn` with `registryAuth` as the candidate registry credential
123
+ * (see {@link registryCandidate}); one candidate at a time. Without one
124
+ * (a pasted token), `fn` just runs.
125
+ * @template T
126
+ * @param {{ registryUrl: string, issuerDomain?: string } | undefined} registryAuth
127
+ * @param {() => Promise<T>} fn
128
+ * @returns {Promise<T>}
129
+ */
130
+ export function withRegistryCandidate(registryAuth, fn) {
131
+ if (registryAuth === undefined) return fn();
132
+ return registryCandidateMutex.run(async () => {
133
+ registryCandidate = registryAuth;
134
+ try {
135
+ return await fn();
136
+ } finally {
137
+ registryCandidate = undefined;
138
+ }
139
+ });
78
140
  }
79
141
 
80
142
  /**
81
143
  * Everything Authelia should know, from the stored state.
82
- * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides]
144
+ * @param {{ registryAuth?: { registryUrl: string, issuerDomain?: string } | null }} [overrides]
83
145
  * `registryAuth` replaces the stored registry credential's say in the
84
- * matter: `{ registryUrl }` while a candidate oidc credential is being
85
- * verified, `null` to leave the panel's client out.
146
+ * matter: `{ registryUrl, issuerDomain }` while a candidate oidc
147
+ * credential is being verified, `null` to leave the panel's client out.
86
148
  */
87
149
  async function collectAllClients(overrides = {}) {
88
150
  let registryAuth;
89
151
  if (overrides.registryAuth !== undefined) {
90
152
  registryAuth = overrides.registryAuth;
153
+ } else if (registryCandidate !== undefined) {
154
+ registryAuth = registryCandidate;
91
155
  } else {
92
156
  const stored = await readRegistryCredentialConfig().catch(() => null);
93
157
  registryAuth =
94
- stored && stored.auth.kind === 'oidc' ? { registryUrl: stored.registryUrl } : null;
158
+ stored && stored.auth.kind === 'oidc'
159
+ ? {
160
+ registryUrl: stored.registryUrl,
161
+ ...(stored.auth.issuerDomain ? { issuerDomain: stored.auth.issuerDomain } : {}),
162
+ }
163
+ : null;
95
164
  }
96
- return [...(await collectPluginOidcClients()), ...lamasteOwnClient(registryAuth)];
165
+ return [...(await collectPluginOidcClients()), ...(await lamasteOwnClient(registryAuth))];
97
166
  }
98
167
 
99
168
  /**
@@ -103,7 +172,7 @@ async function collectAllClients(overrides = {}) {
103
172
  * enabled plugins served, and the panel's own scopes when its client is.
104
173
  * Pure with respect to Authelia — nothing is written.
105
174
  *
106
- * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
175
+ * @param {{ registryAuth?: { registryUrl: string, issuerDomain?: string } | null }} [overrides] see collectAllClients
107
176
  * @returns {Promise<{
108
177
  * confidential: Array<{ id: string, plugin: string }>,
109
178
  * served: Array<import('@lamalibre/lamaste/server').AutheliaOidcClient>,
@@ -123,37 +192,41 @@ export async function planOidcClients(overrides = {}) {
123
192
  /**
124
193
  * Sync Authelia's OIDC clients and the secret store with the plugins and
125
194
  * the registry credential. Returns what was done, or `null` when the panel
126
- * has no domain yet (onboarding).
195
+ * has no domain yet (onboarding). Plan, secrets and write all happen under
196
+ * the one writer: a sync that waited for it plans from what is true then,
197
+ * never from before another sync's change (a registry credential being
198
+ * set, a plugin moved).
127
199
  *
128
200
  * @param {import('pino').Logger} logger
129
- * @param {{ registryAuth?: { registryUrl: string } | null }} [overrides] see collectAllClients
130
201
  * @returns {Promise<{ changed: boolean, clients: string[], createdSecrets: Array<{ id: string, plugin: string, secret: string }> } | null>}
131
202
  */
132
- export async function syncOidcClients(logger, overrides = {}) {
133
- let domain = null;
134
- try {
135
- domain = getConfig().domain;
136
- } catch {
137
- domain = null;
138
- }
139
- if (!domain) {
203
+ export async function syncOidcClients(logger) {
204
+ if (!(await primaryDomainName())) {
140
205
  logger.debug('OIDC client sync skipped — no domain configured yet');
141
206
  return null;
142
207
  }
143
208
 
144
- const { confidential, served, scopes } = await planOidcClients(overrides);
145
- const reconciled = await reconcileOidcClientSecrets(STATE_DIR, confidential);
209
+ const { served, reconciled, result } = await withDomainRendering(
210
+ async (domains) => {
211
+ const plan = await planOidcClients();
212
+ const secrets = await reconcileOidcClientSecrets(STATE_DIR, plan.confidential);
213
+ return {
214
+ served: plan.served,
215
+ reconciled: secrets,
216
+ result: await updateOidcProvider(keyed(domains), plan.served, {
217
+ secretDigests: secrets.digests,
218
+ scopes: plan.scopes,
219
+ }),
220
+ };
221
+ },
222
+ { logger },
223
+ );
146
224
  if (reconciled.created.length > 0 || reconciled.removed.length > 0) {
147
225
  logger.info(
148
226
  { created: reconciled.created.map((c) => c.id), removed: reconciled.removed },
149
227
  'OIDC client secrets reconciled',
150
228
  );
151
229
  }
152
-
153
- const result = await updateOidcProvider(domain, served, {
154
- secretDigests: reconciled.digests,
155
- scopes,
156
- });
157
230
  if (result.changed) {
158
231
  logger.info({ clients: served.map((c) => c.id) }, 'Authelia OIDC provider updated');
159
232
  }
@@ -166,16 +239,37 @@ export async function syncOidcClients(logger, overrides = {}) {
166
239
 
167
240
  /**
168
241
  * Metadata of a plugin's OIDC clients: what the manifest declares, whether
169
- * it is served, and — for confidential clients — when the secret was
170
- * created and last rotated. Never the secret.
242
+ * it is served, whether it signs users in (`userFacing`) and — for those —
243
+ * the Gatekeeper grants on it (`grants`: who among the external members of
244
+ * its domain may sign in with it; members of the domain always may; null
245
+ * when Gatekeeper cannot be read), and — for confidential clients — when
246
+ * the secret was created and last rotated. Never the secret.
171
247
  * @param {string} pluginName
172
248
  */
173
249
  export async function listPluginOidcClients(pluginName) {
174
250
  const declared = (await collectPluginOidcClients()).filter((c) => c.plugin === pluginName);
175
251
  const secrets = new Map((await listOidcClientSecrets(STATE_DIR)).map((m) => [m.id, m]));
252
+ const grants = declared.some(isUserFacingOidcClient)
253
+ ? await listGrantsOfType(OIDC_CLIENT_RESOURCE)
254
+ : null;
176
255
  return declared.map((client) => {
177
256
  const secret = client.confidential ? (secrets.get(client.id) ?? null) : null;
178
257
  return {
258
+ userFacing: isUserFacingOidcClient(client),
259
+ ...(isUserFacingOidcClient(client)
260
+ ? {
261
+ grants: grants?.ok
262
+ ? grants.grants
263
+ .filter((g) => g.resourceId === client.id)
264
+ .map((g) => ({
265
+ grantId: g.grantId,
266
+ principalType: g.principalType,
267
+ principalId: g.principalId,
268
+ createdAt: g.createdAt,
269
+ }))
270
+ : null,
271
+ }
272
+ : {}),
179
273
  id: client.id,
180
274
  name: client.name ?? null,
181
275
  confidential: client.confidential,
@@ -216,13 +310,21 @@ export async function rotatePluginOidcClientSecret(pluginName, clientId, logger)
216
310
  // The store is reconciled first so a client that was never synced (an
217
311
  // earlier Authelia failure) gets its record now.
218
312
  await syncOidcClients(logger);
219
- const rotated = await rotateOidcClientSecret(STATE_DIR, clientId);
220
- const digests = await readOidcClientSecretDigests(STATE_DIR);
221
- if (client.enabled) {
222
- const domain = getConfig().domain;
223
- const { served, scopes } = await planOidcClients();
224
- await updateOidcProvider(domain, served, { secretDigests: digests, scopes });
225
- }
313
+ // Rotate, plan and write under the one writer (see syncOidcClients).
314
+ const rotated = await withDomainRendering(
315
+ async (domains) => {
316
+ const secret = await rotateOidcClientSecret(STATE_DIR, clientId);
317
+ if (client.enabled) {
318
+ const { served, scopes } = await planOidcClients();
319
+ await updateOidcProvider(keyed(domains), served, {
320
+ secretDigests: await readOidcClientSecretDigests(STATE_DIR),
321
+ scopes,
322
+ });
323
+ }
324
+ return secret;
325
+ },
326
+ { logger },
327
+ );
226
328
  logger.info({ plugin: pluginName, clientId }, 'OIDC client secret rotated');
227
329
  return {
228
330
  id: rotated.id,
@@ -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 });