@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
@@ -18,7 +18,11 @@
18
18
  * a version that turns a secret setting into a plain one always waits for
19
19
  * an administrator's approval, its digest approved or not, and whatever
20
20
  * auto-approve says (`pendingUpdate.approvalRequired` says why an update
21
- * waits).
21
+ * waits). The identity block is inside the digest; a version that adds or
22
+ * removes a role or widens the attributes its users' records carry waits
23
+ * for an administrator too, whatever auto-approve says
24
+ * (`identity.changed`; `pendingUpdate.identity` shows the change), and
25
+ * after the update the audiences are recomputed and the agent half told.
22
26
  */
23
27
 
24
28
  import {
@@ -27,26 +31,30 @@ import {
27
31
  derivePluginRoute,
28
32
  hasAgentHalf,
29
33
  hookEntrySha256,
34
+ identityChangeNeedsApproval,
35
+ identityDiff,
30
36
  relayHookSetNeedsEntry,
31
37
  relayHookSetOf,
32
38
  relayHookSetPreview,
33
39
  settingsSchemaDiff,
34
40
  validateManifest,
35
41
  } from '@lamalibre/lamaste';
36
- import { pluginHostingPhase } from '@lamalibre/lamaste/server';
42
+ import { pluginHostingPhase, webTreeManifestOfDirectory } from '@lamalibre/lamaste/server';
37
43
  import { httpError } from './app-error.js';
38
44
  import { setPluginCapabilities } from './mtls.js';
39
45
  import {
40
46
  findPlugin,
41
47
  getPluginCapabilities,
48
+ identityOf,
42
49
  manifestPolicyError,
43
50
  replacePluginPackage,
44
51
  uninstallPlugin,
45
52
  writePluginRow,
46
53
  } from './plugins.js';
47
- import { pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
54
+ import { installedWebTreeDir, pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
48
55
  import {
49
56
  DIGEST_UNAPPROVED,
57
+ IDENTITY_CHANGED,
50
58
  SETTINGS_SECRET_TO_PLAIN,
51
59
  digestUnapprovedReason,
52
60
  installRunnerModule,
@@ -82,6 +90,8 @@ import { getConfig } from './config.js';
82
90
  import { recordAudit, recordAuditBestEffort } from './audit.js';
83
91
  import { notifyAdminsBestEffort } from './notifications.js';
84
92
  import { refreshPluginSettingsBestEffort, settingsBlockOf } from './plugin-settings.js';
93
+ import { refreshPluginIdentity } from './identity-sync.js';
94
+ import { refreshUserSurfaceVhosts } from './user-surface.js';
85
95
 
86
96
  /** Replaces the OIDC client sync (tests: there is no Authelia to write to). */
87
97
  let oidcSyncOverride = null;
@@ -163,6 +173,16 @@ export async function uninstallPluginWithHosting(
163
173
  }
164
174
  await op.step('remove the package', () => uninstallPlugin(name, logger));
165
175
  setPluginCapabilities(await getPluginCapabilities());
176
+ // With purge nothing of the plugin is kept — not who held its roles.
177
+ if (purge) {
178
+ const { revokePluginRoleGrants } = await import('./entitlements.js');
179
+ const revoked = await revokePluginRoleGrants(plugin, { actor, logger });
180
+ if (!revoked.ok) {
181
+ op.warn(
182
+ `The plugin's role grants were not revoked (${revoked.error}) — revoke them in Gatekeeper`,
183
+ );
184
+ }
185
+ }
166
186
  await recordAuditBestEffort(
167
187
  { actor, action: 'plugin.uninstalled', plugin: name, details: { operation: op.id } },
168
188
  logger,
@@ -261,6 +281,32 @@ export function settingsApprovalRequired(diff) {
261
281
  ];
262
282
  }
263
283
 
284
+ /**
285
+ * Why a version waits for an administrator because of how it changes the
286
+ * plugin's identity block (`identity.changed`): it adds or removes a role —
287
+ * entitlements name roles, and a new role is a new power over its users —
288
+ * or widens the attributes it receives about them. Narrowing the
289
+ * attributes and editing labels only change the digest. Auto-approve never
290
+ * covers it.
291
+ *
292
+ * @param {(import('@lamalibre/lamaste').IdentityDiff & { before: object | null, after: object | null }) | null} identity
293
+ * @returns {Array<{ code: string, reason: string }>}
294
+ */
295
+ function identityApprovalRequired(identity) {
296
+ if (!identity || !identityChangeNeedsApproval(identity)) return [];
297
+ const changes = [];
298
+ if (identity.rolesAdded.length > 0) {
299
+ changes.push(`adds roles (${identity.rolesAdded.join(', ')})`);
300
+ }
301
+ if (identity.rolesRemoved.length > 0) {
302
+ changes.push(`removes roles (${identity.rolesRemoved.join(', ')})`);
303
+ }
304
+ if (identity.attributesAdded.length > 0) {
305
+ changes.push(`receives more about its users (${identity.attributesAdded.join(', ')})`);
306
+ }
307
+ return [{ code: IDENTITY_CHANGED, reason: `it ${changes.join(' and ')}` }];
308
+ }
309
+
264
310
  /**
265
311
  * Why a staged version waits for an administrator's approval (`[{ code,
266
312
  * reason }]`; empty: it needs none and runs at once) — what the approval of
@@ -268,21 +314,32 @@ export function settingsApprovalRequired(diff) {
268
314
  * in for an administrator on the first reason alone:
269
315
  *
270
316
  * - `digest.unapproved` — what it brings (relay hooks, the agent half
271
- * Lamaste installs) is not what was approved;
272
- * - `settings.secret-to-plain` — {@link settingsApprovalRequired}.
317
+ * Lamaste installs, its identity block, its web tree) is not what was
318
+ * approved;
319
+ * - `settings.secret-to-plain` — {@link settingsApprovalRequired};
320
+ * - `identity.changed` — {@link identityApprovalRequired}.
273
321
  *
274
- * @param {{ approvalRequired: boolean, digest: string | null, relaySet: object | null, agentHalf: object | null }} staged
322
+ * @param {{ approvalRequired: boolean, digest: string | null, relaySet: object | null, agentHalf: object | null, identity: object | null }} staged
275
323
  * @param {{ approvedDigest: string | null } | null} state
276
324
  * @param {import('@lamalibre/lamaste').SettingsSchemaDiff | null} settings
325
+ * @param {(import('@lamalibre/lamaste').IdentityDiff & { before: object | null, after: object | null }) | null} identity
277
326
  * @returns {Array<{ code: string, reason: string }>}
278
327
  */
279
- function approvalRequiredFor(staged, state, settings) {
328
+ function approvalRequiredFor(staged, state, settings, identity) {
280
329
  const digestApproved = !staged.approvalRequired || state?.approvedDigest === staged.digest;
281
330
  return [
282
331
  ...(digestApproved
283
332
  ? []
284
- : [digestUnapprovedReason({ relay: staged.relaySet, agentHalf: staged.agentHalf })]),
333
+ : [
334
+ digestUnapprovedReason({
335
+ relay: staged.relaySet,
336
+ agentHalf: staged.agentHalf,
337
+ identity: staged.identity,
338
+ web: staged.webTree,
339
+ }),
340
+ ]),
285
341
  ...settingsApprovalRequired(settings),
342
+ ...identityApprovalRequired(identity),
286
343
  ];
287
344
  }
288
345
 
@@ -391,6 +448,10 @@ async function stageVersion(plugin, requestedVersion, record) {
391
448
  }
392
449
  entrySha256 = hookEntrySha256(moduleBytes);
393
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;
394
455
  // The staged version as a registry entry, so it is judged exactly as the
395
456
  // installed one will be.
396
457
  const entry = {
@@ -401,6 +462,9 @@ async function stageVersion(plugin, requestedVersion, record) {
401
462
  agent: manifest.agent,
402
463
  agentHalfPin,
403
464
  settings: manifest.settings,
465
+ identity: identityOf(manifest),
466
+ userSurface: manifest.userSurface,
467
+ webTreeSha256: webTree?.sha256 ?? null,
404
468
  };
405
469
  const approval = installedApproval(entry);
406
470
  return {
@@ -412,6 +476,10 @@ async function stageVersion(plugin, requestedVersion, record) {
412
476
  entrySha256,
413
477
  agentHalfPin,
414
478
  agentHalf: approval.agentHalf,
479
+ // The staged identity block, normalized (null: it declares none).
480
+ identity: approval.identity,
481
+ // The staged web tree's manifest (null: it declares no web surface).
482
+ webTree,
415
483
  // The relay hook set's own digest — what the runner holds.
416
484
  relayDigest: approval.relay?.digest ?? null,
417
485
  approvalRequired: approval.required,
@@ -439,6 +507,121 @@ function settingsPreview(plugin, manifest) {
439
507
  return settingsSchemaDiff(installed, staged);
440
508
  }
441
509
 
510
+ /**
511
+ * How a staged version changes the identity block, for the update's
512
+ * preview: both blocks, normalized (null: that version declares none), and
513
+ * core's `identityDiff` — roles added and removed, attributes added and
514
+ * removed, roles whose label or description changed. Null when neither
515
+ * version declares one.
516
+ *
517
+ * @param {object} plugin the installed registry entry
518
+ * @param {object} manifest the staged version's manifest
519
+ */
520
+ function identityPreview(plugin, manifest) {
521
+ const before = plugin.identity ?? null;
522
+ const after = identityOf(manifest) ?? null;
523
+ if (!before && !after) return null;
524
+ return { before, after, ...identityDiff(before, after) };
525
+ }
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
+
442
625
  /**
443
626
  * Update plugin `name` to `requestedVersion` (an exact version or a
444
627
  * dist-tag; default `latest`). Returns `{ upToDate }`, `{ pendingUpdate }`
@@ -489,7 +672,8 @@ export async function updatePlugin(
489
672
 
490
673
  const state = await readHookState(name);
491
674
  const settings = settingsPreview(plugin, staged.manifest);
492
- const approvalRequired = approvalRequiredFor(staged, state, settings);
675
+ const identity = identityPreview(plugin, staged.manifest);
676
+ const approvalRequired = approvalRequiredFor(staged, state, settings, identity);
493
677
  let approvedBy = null;
494
678
  let autoApproved = false;
495
679
  if (approvedUpdate !== null) {
@@ -497,13 +681,14 @@ export async function updatePlugin(
497
681
  approvedBy = actor;
498
682
  } else if (approvalRequired.length > 0) {
499
683
  // Auto-approve stands in for an administrator on a digest that changed,
500
- // when the agent half changes nothing that runs as root on the agent —
501
- // and on nothing else: every other reason (a secret setting turned
502
- // plain) withholds it.
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
686
+ // plain, a role added or removed, an attribute widened) withholds it.
503
687
  const digestChanged = approvalRequired.some((reason) => reason.code === DIGEST_UNAPPROVED);
504
688
  const withheldReasons = state?.autoApprove
505
689
  ? [
506
690
  ...(digestChanged ? agentHalfWithheld(installedAgentHalf(plugin), staged.agentHalf) : []),
691
+ ...(digestChanged ? webTreeWithheld(plugin, staged) : []),
507
692
  ...approvalRequired
508
693
  .filter((reason) => reason.code !== DIGEST_UNAPPROVED)
509
694
  .map(({ code, reason }) => ({ code, message: reason })),
@@ -514,6 +699,8 @@ export async function updatePlugin(
514
699
  plugin,
515
700
  staged,
516
701
  settings,
702
+ identity,
703
+ web: await webPreview(plugin, staged),
517
704
  approvalRequired,
518
705
  withheldReasons,
519
706
  actor,
@@ -592,6 +779,8 @@ async function awaitApproval({
592
779
  plugin,
593
780
  staged,
594
781
  settings,
782
+ identity,
783
+ web,
595
784
  approvalRequired,
596
785
  withheldReasons,
597
786
  actor,
@@ -609,6 +798,8 @@ async function awaitApproval({
609
798
  ? agentHalfPreview(derivePluginRoute(plugin.name), staged.agentHalf)
610
799
  : null,
611
800
  settings,
801
+ identity,
802
+ web,
612
803
  approvalRequired,
613
804
  ...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
614
805
  };
@@ -654,7 +845,10 @@ async function awaitApproval({
654
845
  },
655
846
  logger,
656
847
  );
657
- return { pendingUpdate: pendingUpdateView(pendingUpdate) };
848
+ const { withIdentityGrantImpact } = await import('./entitlements.js');
849
+ return {
850
+ pendingUpdate: await withIdentityGrantImpact(pendingUpdateView(pendingUpdate), plugin.route),
851
+ };
658
852
  }
659
853
 
660
854
  async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
@@ -704,6 +898,9 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
704
898
  hooks: installed.manifest.hooks,
705
899
  hooksEntrySha256: installed.hooksEntrySha256,
706
900
  agent: installed.manifest.agent,
901
+ identity: identityOf(installed.manifest),
902
+ userSurface: installed.manifest.userSurface,
903
+ webTreeSha256: installed.webTreeSha256,
707
904
  };
708
905
  let approval;
709
906
  try {
@@ -719,6 +916,7 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
719
916
  installed.manifest,
720
917
  installed.hooksEntrySha256,
721
918
  staged.agentHalfPin,
919
+ installed.webTreeSha256,
722
920
  );
723
921
  if (approval.required) {
724
922
  await recordApprovedSet(installedEntry, approval.digest, staged.version, approvedBy);
@@ -726,6 +924,13 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
726
924
  setPluginCapabilities(await getPluginCapabilities());
727
925
  createdSecrets = await syncOidcBestEffort(logger);
728
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
+ },
729
934
  revert: async ({ applied }) => {
730
935
  if (applied) {
731
936
  const restored = await replacePluginPackage(
@@ -739,6 +944,7 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
739
944
  restored.manifest,
740
945
  restored.hooksEntrySha256,
741
946
  plugin.agentHalfPin ?? null,
947
+ restored.webTreeSha256,
742
948
  );
743
949
  await restoreHookState(name, priorHookState);
744
950
  setPluginCapabilities(await getPluginCapabilities());
@@ -760,6 +966,14 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
760
966
  // The agent half may have dropped values the new version retyped or no
761
967
  // longer knows (and keeps that after a revert): the relay re-reads them.
762
968
  if (hosted) await refreshPluginSettingsBestEffort(name, logger);
969
+ // The roles and attributes may have changed with the version (or back,
970
+ // after a revert); the agent half of the installed version must answer
971
+ // protocol 1 again before its users reach it.
972
+ await refreshPluginIdentity(name, logger);
973
+ // So may whether it declares identity at all: the hosts its user surface
974
+ // is bound to serve it from the version that does — a plugin's
975
+ // switch-over to the relay's user surface is this update.
976
+ await refreshUserSurfaceVhosts(plugin.route, logger);
763
977
 
764
978
  // Hooks the new version no longer has: the old runner goes once the new
765
979
  // version is in place.
@@ -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 });