Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,40 @@ edit, so neither is evidence of equal content. It is the same reason
an unversioned partition instead of a version number, and why `ModuleLandingService.GenerationIdOf`
appends each file's bytes and not only its length.

## 🚨 An update comes only from the repository the install came from (#6526)

A package id is not an identity. Two watched repositories can list the same id, and the reconciler
used to match install records by id alone, so a green build in the SECOND repository could update —
or remind about — a package it never installed. The install record now carries `Origin`: the
canonical identity of the source the install ran through (`InstallOrigin`) — the repository URL
without scheme, `.git` or trailing `/`, lower-cased, plus `#subdir` when packages live in one; a
registry is `registry:<url>`.

- **Where it comes from.** The origin is attached to the SOURCE where it is built from its configured
repository and subdirectory (`PackageSources.FromRepo`) or by the registry client, and the one
install funnel (`CatalogLayoutAreas.InstallOrUpdate`) stamps it onto the record — the catalog
click, the boot install and the unattended apply alike. Whatever `origin` a listed manifest
authors is overwritten: a repository cannot claim to be another one, the same rule
[Install Completeness](/Doc/Architecture/InstallCompleteness) applies to the repair lane's
`Source` (#6512). A re-stamp from a source with no origin keeps the recorded one; an install from
another repository rebinds the package there.
- **The gate.** The watcher binds each pass to its repository. After the content-identity gate and
before the floor and policy decisions, a candidate is taken only for a record whose `Origin` is
that repository; a candidate from any other repository is ignored, with an Information line — no
hold stamped, no reminder raised.
- **A record that predates the field: adopt or refuse.** On its first changed candidate the watcher
counts the watched repositories (every catalog node with a repository URL) that list the id.
Exactly one — this one — and the record ADOPTS it as its origin, then proceeds. Two or more and
the update is REFUSED: a Warning line and a notification on the platform bell, `Update refused`,
naming the package and every candidate repository; it stays refused until an administrator
installs it again from the right repository, which records the origin. A watched repository that
cannot be listed makes the count incomplete, and then the record is neither adopted nor updated
that pass: an adoption over a census with a hole could adopt the impostor.
- **Not bound:** a pinned package (nothing moves for it), and the registry lane
(`RegistryUpdateReconciler`), whose single configured registry is the consumer's trust root and
stamps provenance itself. A package installed through a registry records `registry:<url>`, so a
watcher on the same instance never adopts it for a repository.

## 🚨 One policy PER PACKAGE — separate from the platform's image policy (2026-09-14)

> **Maintainer, 2026-09-14:** *"continuous update is set globally — it should be separate for the
Expand Down
2 changes: 2 additions & 0 deletions src/MeshWeaver.Messaging.Hub/Localization/strings.de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,8 @@
"notification.packageUpdate.authorizationRequired.title": "Update benötigt einen globalen Administrator: {name}",
"notification.packageUpdate.authorizationRequired.bodyFiles": "Ein neuer Build von {name} ist verfügbar ({changed} Datei(en) geändert, {removed} entfernt), es ist jedoch ein kommerzielles Paket und wurde nicht automatisch angewendet. {reason}",
"notification.packageUpdate.authorizationRequired.bodyContent": "Ein neuer Build von {name} ist verfügbar (Inhalt geändert), es ist jedoch ein kommerzielles Paket und wurde nicht automatisch angewendet. {reason}",
"notification.packageUpdate.originAmbiguous.title": "Update abgelehnt: {name}",
"notification.packageUpdate.originAmbiguous.body": "Ein neuer Build von {name} ist verfügbar, aber diese Installation hält nicht fest, aus welchem Repository sie stammt, und {count} beobachtete Repositories führen das Paket: {origins}. Es wurde NICHT angewendet. Installieren Sie es erneut aus dem Repository, zu dem es gehört; spätere Updates kommen dann nur noch von dort.",
"notification.import.startupFailed.title": "Import beim Start fehlgeschlagen: {partition}",
"notification.import.startupFailed.body": "Der Import ist beim Start fehlgeschlagen.",
"notification.accessGranted.title": "Sie haben Zugriff auf {name} erhalten",
Expand Down
2 changes: 2 additions & 0 deletions src/MeshWeaver.Messaging.Hub/Localization/strings.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,8 @@
"notification.packageUpdate.authorizationRequired.title": "Update needs a Global Admin: {name}",
"notification.packageUpdate.authorizationRequired.bodyFiles": "A new build of {name} is available ({changed} file(s) changed, {removed} removed), but it is a commercial package and was not applied automatically. {reason}",
"notification.packageUpdate.authorizationRequired.bodyContent": "A new build of {name} is available (content changed), but it is a commercial package and was not applied automatically. {reason}",
"notification.packageUpdate.originAmbiguous.title": "Update refused: {name}",
"notification.packageUpdate.originAmbiguous.body": "A new build of {name} is available, but this install does not record which repository it came from, and {count} watched repositories list it: {origins}. It was NOT applied. Install it again from the repository it belongs to; later updates then come from there only.",
"notification.import.startupFailed.title": "Startup import failed: {partition}",
"notification.import.startupFailed.body": "Import failed during startup.",
"notification.accessGranted.title": "You've been given access to {name}",
Expand Down
7 changes: 7 additions & 0 deletions src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,13 @@ internal static IObservable<InstallResult> InstallOrUpdate(
IMessageHub hub, IPackageSource source, string sourceRef, PackageManifest pkg, ILogger? logger,
string? authorizingUserId = null)
{
// 🚨 WHERE this install comes from is the SOURCE's identity, never the manifest's: a listed
// package.json may author any `origin` it likes, so it is overwritten here — on the one
// funnel every lane (click, boot install, unattended apply) goes through — and the record
// binds every later unattended update to it (MeshWeaver#6526). A source with no origin
// stamps null, and the record keeps the one it already had (PackageInstaller.SeedOrigin).
pkg = pkg with { Origin = InstallOrigin.Of(source) };

// The entitlement gate runs FIRST, before a single file travels: a commercial package needs
// a global admin (#830), and fetching a package that may not be installed is work nobody
// asked for. The installer carries the same gate — that one is the enforcement (no caller
Expand Down
150 changes: 150 additions & 0 deletions src/MeshWeaver.PluginCatalog/InstallOrigin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Collections.Immutable;

namespace MeshWeaver.PluginCatalog;

/// <summary>
/// WHERE an install came from — the repository (and subdirectory) or the registry whose source
/// performed it — as one canonical string, and the rule that binds an unattended update to it
/// (MeshWeaver#6526).
///
/// <para>🚨 <b>The origin is the identity of the SOURCE, never a field a repository writes.</b> A
/// manifest listed straight off a repository carries whatever its <c>package.json</c> authors, so
/// an origin read from the manifest would let any repository claim to be any other. The origin is
/// therefore carried by the <see cref="IPackageSource"/> the install ran through — attached where
/// that source is BUILT from its configured repository path and subdirectory
/// (<see cref="PackageSources.FromRepo"/>), or by the registry client itself — and stamped onto the
/// install record by the one install funnel (<see cref="CatalogLayoutAreas.InstallOrUpdate"/>),
/// which overwrites whatever the manifest carried. The same reasoning as MeshWeaver#6512, where the
/// repair lane stopped trusting an authored <see cref="PackageManifest.Source"/>.</para>
///
/// <para>Canonical form: a repository URL loses its scheme, a trailing <c>/</c> and <c>.git</c>, and
/// is lower-cased (a hosted repository's owner and name are case-insensitive); a local path keeps
/// its case. A non-empty subdirectory is appended after <c>#</c>. A registry is
/// <c>registry:</c> + its base URL, lower-cased and without a trailing <c>/</c>.</para>
/// </summary>
internal static class InstallOrigin
{
/// <summary>The origin of a git source configured at <paramref name="repoPath"/> /
/// <paramref name="subdir"/> — the SAME string whichever surface builds the source (a catalog
/// node, the configured source list, the update watcher), so they can be compared.</summary>
/// <param name="repoPath">The repository URL or local path.</param>
/// <param name="subdir">The subdirectory packages live under, or empty.</param>
/// <returns>The canonical origin, or null when no repository is named.</returns>
public static string? ForRepo(string? repoPath, string? subdir)
{
if (string.IsNullOrWhiteSpace(repoPath))
return null;
var repo = repoPath.Trim().TrimEnd('/');
if (repo.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
repo = repo[..^4];
if (PackageSources.IsUrl(repo))
{
var scheme = repo.IndexOf("://", StringComparison.Ordinal);
repo = (scheme >= 0 ? repo[(scheme + 3)..] : repo).ToLowerInvariant();
}
Comment on lines +40 to +44
var folder = (subdir ?? "").Trim().Trim('/');
return folder.Length == 0 ? repo : $"{repo}#{folder}";
}

/// <summary>The origin of a registry client reading <paramref name="registryUrl"/>.</summary>
/// <param name="registryUrl">The registry's base URL.</param>
public static string ForRegistry(string registryUrl) =>
"registry:" + (registryUrl ?? "").Trim().TrimEnd('/').ToLowerInvariant();

/// <summary>The origin <paramref name="source"/> carries, or null for a source built without
/// one (a test double, a source constructed outside the factories).</summary>
/// <param name="source">The source an install runs through.</param>
public static string? Of(IPackageSource? source) => (source as IOriginatedPackageSource)?.Origin;

/// <summary>
/// <paramref name="source"/>, carrying <paramref name="origin"/>. Everything the source does is
/// forwarded untouched — the origin is an identity, not a behaviour.
/// </summary>
/// <param name="source">The source to label.</param>
/// <param name="origin">Its canonical origin (<see cref="ForRepo"/>); null returns the source as is.</param>
public static IPackageSource Bind(IPackageSource source, string? origin) =>
string.IsNullOrEmpty(origin) ? source : new OriginatedPackageSource(source, origin);

/// <summary>
/// The binding decision for one candidate (pure — MeshWeaver#6526).
/// <list type="bullet">
/// <item>The record names an origin: <see cref="OriginVerdict.Bound"/> when it is the
/// candidate's, otherwise <see cref="OriginVerdict.Foreign"/> — the candidate is ignored.</item>
/// <item>The record names none (it predates the field): <see cref="OriginVerdict.Adopt"/> when
/// the candidate's origin is the ONLY watched origin listing the package,
/// <see cref="OriginVerdict.Ambiguous"/> when two or more list it, and
/// <see cref="OriginVerdict.Undetermined"/> when a watched origin could not be read — an
/// adoption over an incomplete census could adopt the impostor, so it fails closed.</item>
/// </list>
/// </summary>
/// <param name="recordOrigin">The install record's origin, or null.</param>
/// <param name="candidateOrigin">The origin the candidate was listed from.</param>
/// <param name="census">Which watched origins list the package (read only when the record names
/// no origin; null otherwise).</param>
public static OriginVerdict Decide(string? recordOrigin, string candidateOrigin, OriginCensus? census)
{
if (!string.IsNullOrEmpty(recordOrigin))
return string.Equals(recordOrigin, candidateOrigin, StringComparison.Ordinal)
? OriginVerdict.Bound
: OriginVerdict.Foreign;
if (census is null || census.Unread.Count > 0)
return OriginVerdict.Undetermined;
return census.Listing.Add(candidateOrigin).Count == 1 ? OriginVerdict.Adopt : OriginVerdict.Ambiguous;
}

private sealed class OriginatedPackageSource(IPackageSource inner, string origin)
: IPackageSource, IOriginatedPackageSource
{
public string Origin => origin;

public IObservable<IReadOnlyList<PackageManifest>> ListPackages(string gitRef) =>
inner.ListPackages(gitRef);

public IObservable<IReadOnlyList<PackageFile>> FetchPackageFiles(PackageManifest package, string gitRef) =>
inner.FetchPackageFiles(package, gitRef);

public IObservable<IReadOnlyList<PackageFile>> FetchPackageFiles(
PackageManifest package, string gitRef, IReadOnlyCollection<string>? paths) =>
inner.FetchPackageFiles(package, gitRef, paths);
}
}

/// <summary>A package source that knows its own <see cref="InstallOrigin"/>.</summary>
internal interface IOriginatedPackageSource
{
/// <summary>The canonical origin of everything this source serves.</summary>
string Origin { get; }
}

/// <summary>The outcome of <see cref="InstallOrigin.Decide"/>. Never persisted or serialised.</summary>
internal enum OriginVerdict
{
/// <summary>The record names this candidate's origin — reconcile as before.</summary>
Bound,

/// <summary>The record names a different origin — the candidate is ignored.</summary>
Foreign,

/// <summary>No origin recorded and exactly one watched origin lists the package — record it.</summary>
Adopt,

/// <summary>No origin recorded and two or more watched origins list the package — refuse, by name.</summary>
Ambiguous,

/// <summary>No origin recorded and the census is incomplete — neither adopt nor refuse this pass.</summary>
Undetermined,
}

/// <summary>Which watched origins list a package id.</summary>
/// <param name="Listing">The origins whose listing contains the id.</param>
/// <param name="Unread">The watched origins whose listing could not be read this pass.</param>
internal sealed record OriginCensus(ImmutableSortedSet<string> Listing, ImmutableSortedSet<string> Unread);

/// <summary>
/// What binds a reconcile pass to the install records' origins: the origin its candidates were
/// listed from, and how to learn which watched origins list a given package id (asked only for a
/// record that names no origin yet).
/// </summary>
/// <param name="Origin">The candidates' origin.</param>
/// <param name="Census">Which watched origins list a package id.</param>
internal sealed record InstallOriginBinding(string Origin, Func<string, IObservable<OriginCensus>> Census);
15 changes: 15 additions & 0 deletions src/MeshWeaver.PluginCatalog/Package.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ public record PackageManifest
/// </summary>
public string? Source { get; init; }

/// <summary>
/// 🚨 WHERE this install came from — the repository (and subdirectory) or the registry whose
/// source performed it, in <see cref="InstallOrigin"/>'s canonical form. Install-record only:
/// stamped by the install funnel (<see cref="CatalogLayoutAreas.InstallOrUpdate"/>) from the
/// SOURCE's own identity, overwriting anything a listed manifest carried, and carried forward
/// on a re-stamp by a source that names none (<c>PackageInstaller.SeedOrigin</c>).
///
/// <para>It is what an UNATTENDED update binds to (MeshWeaver#6526): the update watcher takes a
/// candidate only from the origin recorded here, so a second watched repository listing the same
/// id is ignored. A record that predates the field ADOPTS its origin on its first unattended
/// update when exactly one watched repository lists the package, and is refused — by name, on
/// the platform bell — when two or more do (<see cref="InstallOrigin.Decide"/>).</para>
/// </summary>
public string? Origin { get; init; }

/// <summary>
/// The licence this package is offered under, as an SPDX id or expression
/// (<c>Apache-2.0</c>, <c>Apache-2.0 OR MIT</c>). Authored on the plugin root's
Expand Down
16 changes: 16 additions & 0 deletions src/MeshWeaver.PluginCatalog/PackageInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2787,6 +2787,20 @@ IObservable<bool> MayWarm(string root)
internal static string? SeedSource(PackageManifest? existingRecord, PackageManifest manifest) =>
string.IsNullOrWhiteSpace(manifest.Source) ? existingRecord?.Source : manifest.Source;

/// <summary>
/// The install record's <see cref="PackageManifest.Origin"/> on a (re-)stamp: the origin of the
/// source THIS install ran through when it has one, otherwise the one already recorded — the
/// same carry-forward as <see cref="SeedSource"/>. The manifest's value is trustworthy here
/// because <see cref="CatalogLayoutAreas.InstallOrUpdate"/> overwrote it from the source.
/// A stated origin wins: an administrator installing from another repository rebinds the
/// package there, which is the newer fact (MeshWeaver#6526).
/// </summary>
/// <param name="existingRecord">The install record being re-stamped, or null on a first install.</param>
/// <param name="manifest">The manifest this install is being written from.</param>
/// <returns>The origin to record.</returns>
internal static string? SeedOrigin(PackageManifest? existingRecord, PackageManifest manifest) =>
string.IsNullOrWhiteSpace(manifest.Origin) ? existingRecord?.Origin : manifest.Origin;

/// <summary>
/// Publishes the package's CONTENT-COLLECTION assets — the raw binaries it commits under
/// <c>{package}/content/**</c> (course videos and their posters, og images, fonts) — into the
Expand Down Expand Up @@ -3054,6 +3068,8 @@ private static IObservable<MeshNode> WriteInstalledRecord(
// against when it asks this instance for the package's bundle (#1772). Carried
// forward for the same reason as AuthorizedBy: not every lister stamps it.
Source = SeedSource(existingRecord, manifest),
// WHERE it came from — what an unattended update is bound to (#6526).
Origin = SeedOrigin(existingRecord, manifest),
},
};
// System-impersonated like every installer write (Using — see Upsert): this runs after
Expand Down
11 changes: 9 additions & 2 deletions src/MeshWeaver.PluginCatalog/PackageSources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,14 @@ static bool AnyChildHas(string root, string file) =>
// edit on the very next listing (LocalSourceContentVersionTest). Caching one would make a
// local-dev or air-gapped registry report yesterday's content for five minutes.
var inner = Build(hub, src, subdir, logger, nodeRepo);
return inner is null || !IsUrl(src) ? inner : Cached(hub, inner, src, subdir, nodeRepo);
if (inner is null)
return null;
// 🚨 The ORIGIN rides on the source built from the configured repository, never on what a
// listed manifest authors (MeshWeaver#6526): the install funnel stamps it onto the record,
// and an unattended update is bound to it (InstallOrigin).
return InstallOrigin.Bind(
IsUrl(src) ? Cached(hub, inner, src, subdir, nodeRepo) : inner,
InstallOrigin.ForRepo(src, subdir));
}

private static IPackageSource? Build(
Expand Down Expand Up @@ -441,7 +448,7 @@ private static bool Flag(string? value) =>
public static bool IsLocalCheckout(string? repo) =>
!string.IsNullOrWhiteSpace(repo) && !IsUrl(repo!.Trim());

private static bool IsUrl(string s) =>
internal static bool IsUrl(string s) =>
s.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("git@", StringComparison.Ordinal);
Expand Down
Loading