Skip to content

feat(#2804): the licence lives on the instance — plan on the record, checked on every fetch, JWT on every call - #2811

Merged
rbuergi merged 2 commits into
mainfrom
feat/2804-instance-partition-plan-jwt
Aug 30, 2026
Merged

rbuergi merged 2 commits into
mainfrom
feat/2804-instance-partition-plan-jwt

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Slice 1 of Instance Identity and Setup — closes the licence hole and moves registry authentication to a JWT. The partition move is the next PR.

The hole (#2804)

PluginGrant.Allows documented itself: "a plan-less entry covers every tier." Every instance registered before plans existed carries plan-less entries — Admin/_PluginGrant/memex-cloud is Plugins/*, Education/*, Crm/* — so it could pull pro/enterprise bundles; and Plugins/*@pro licensed pro on any instance carrying it. The plan lived in N grant strings; "no suffix" meant "everything". Maintainer: "must check license of instance."

What changes

  • MeshWeaverInstance.Plan — the licence on the record. Registration writes it (open registration → free; a key minted for a plan → that plan). Absent reads as free, never as "everything".
  • Every registry decision (/api/plugins, bundle index, bundle download): entry matches source/package within term ∧ PlanTierRanks.CoversInstance(Narrower(instance.Plan, entry.Tier), package.Tier). A plan-less entry licenses at the instance's plan; an entry's plan can only cap, never raise. Unknown plan → baseline (never wider, never "a portal without its Store"); unknown package tier → nothing; a ladder-less registry serves free/untiered and refuses paid tiers.
  • Promotion is one field: InstancePlanService.SetPlan (new, in MeshWeaver.PluginCatalog) + a Plan section in the Instance grants admin tab (options = the ladder, never free text). It invalidates the process's cached verdict, so the promotion takes effect on the next request; other replicas within the cache minute.
  • JWT: SyncAccessToken is now a standard HS256 JWT (sub = instance id, aud, iat/exp, jti, kh = key hash it resolves by, scope), recognised by shape — alg:none and any other algorithm refused, the header never chooses. The exchange mints an unnarrowed token for any authenticated instance (403 only when a requested scope is unlicensed), so a fresh instance can authenticate before it is granted anything. The consumer (RegistryTokenResolver) exchanges its stored mwi_ for a JWT once per lifetime (cached until 60 s before expiry) and falls back to the key when a registry does not issue tokens — the legacy window.

Tests (local, Release, -warnaserror, .trx written)

  • MeshWeaver.PluginCatalog.Test 106/106 — PluginGrantTest (instance plan is the licence; caps never raise; unknown plan = baseline; ladder-less = baseline only), SyncAccessTokenTest (JWT shape, alg:none refused, tampering, expiry), SyncTokenSigningKeyTest, RegistrationPreflightTest.
  • Memex.Portal.Shared.Test 29/29 — PluginBundlePlanTest incl. ALegacyPlanLessGrant_IsCappedByTheInstancePlan (the hole, byte-identical 404) and PromotingTheInstance_WidensTheNextRequest; InstanceOpenRegistrationTest (lands on free, plan-less source entry).
  • MeshWeaver.Auth.Test 12/12 — SyncTokenAuthenticationTest; LocalizationTest 58/58 for the six new instancePlan.* keys (en + de).

Follow-ups (named, not hidden)

  • Partition move (Instance root node per instance) — next PR of this slice.
  • Whole-source prebuilt fetches (prebuilt/{identity}/{source}) still need a plan-less <source>/* and are not yet capped by the plan — the node-repo CI gates depend on them; capping is part of the partition PR once those callers carry a plan.
  • The six i18n keys need their mirror in MeshWeaver.Plugins/clients/react/src/i18n/ (core merges first; the mirror PR follows).
  • RegistryTokenResolver falls back to the raw key when the exchange fails; the legacy acceptance on the fetch routes is removed in a later slice.

Coordinated with the issue-sweep session: InstanceRegistryAuthenticator carries the JWT branch; #2483's GitHub-OIDC issuer rebases on it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C1SquDKJNKZxEUbuaPjUgu

…checked on every fetch, JWT on every call

The registry decided a package fetch with "a plan-less grant entry covers every tier", so every
instance registered before plans existed (memex-cloud included: Plugins/*, Education/*, Crm/*)
could pull pro and enterprise bundles, and a `@pro` suffix licensed pro on any instance that
carried it. The plan lived in N grant strings and "no suffix" silently meant "everything".

Now the INSTANCE carries its plan (MeshWeaverInstance.Plan): registration writes it (open → free;
a key minted for a plan → that plan), a global admin promotes it in one field (Instance grants ▸
Plan, options from the registry's own ladder), and every registry decision is
  granted for source/package by an entry within term
  ∧ PlanTierRanks.CoversInstance(Narrower(instance.Plan, entry.Tier), package.Tier).
A plan-less entry licenses at the instance's plan; a plan on an entry can only CAP it, never
raise it. A record naming no plan is a free instance, not an unlimited one. An unknown plan reads
as the baseline (never wider; never "a portal without its Store"); an unknown package tier is
covered by nothing; a ladder-less registry serves free and untiered packages and refuses every
paid tier. InstancePlanService.SetPlan invalidates the process's cached verdict so a promotion
takes effect on the very next request; other replicas follow within the cache minute.

Authentication: the short-lived access token is now a standard HS256 JWT (header.payload.signature;
sub = instance id, aud, iat/exp, jti, kh = the key hash it resolves by, scope) recognised by SHAPE
— `alg:none` and every other algorithm refused, the header never chooses — and the token exchange
mints an UNNARROWED token for any authenticated instance (403 only when a requested scope is not
licensed), so a fresh instance can authenticate before it has been granted anything. The consumer
(RegistryTokenResolver) exchanges its stored mwi_ key for a JWT once per lifetime, cached until 60 s
before expiry, and falls back to presenting the key when a registry does not issue tokens.

Tests: PluginGrantTest (instance plan is the licence, caps never raise, unknown = baseline),
PluginBundlePlanTest (legacy plan-less grant capped at free; promotion widens the next request;
ladder-less registry serves the baseline only), InstanceOpenRegistrationTest (lands on free, plan-less
source entry), SyncAccessTokenTest (JWT shape, alg:none refused), SyncTokenAuthenticationTest.
Docs: PluginRegistry.md "Plans — the licence on the instance"; What's New; i18n en+de.

Design: Doc/Architecture/InstanceIdentityAndSetup (#2807). The partition move (an instance as its
own partition) is the next PR in this slice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1SquDKJNKZxEUbuaPjUgu
Copilot AI lite review requested due to automatic review settings August 30, 2026 16:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes the registry “plan-less grant” licensing hole by moving the licence source of truth onto the instance record (MeshWeaverInstance.Plan, baseline = free) and enforcing instance-plan coverage on every registry listing/download decision. It also migrates instance→registry authentication from sending the durable mwi_ key on every call to exchanging it for a short-lived HS256 JWT (verified by shape + fixed algorithm, not caller-chosen header values), and adds an admin UI path to promote/demote an instance plan with cache invalidation.

Changes:

  • Add MeshWeaverInstance.Plan and enforce instance-plan coverage (CoversInstance + entry cap via Narrower) in PluginGrant / registry authorization.
  • Replace the short-lived access token wire format with a standard JWT and add exchange/caching behavior for consumers.
  • Add plan promotion support (InstancePlanService, admin tab section), update localization + docs, and pin behavior with new/updated tests.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.PluginCatalog.Test/SyncAccessTokenTest.cs Updates token tests for JWT shape and alg:none refusal.
test/MeshWeaver.PluginCatalog.Test/PluginGrantTest.cs Pins new instance-plan licensing semantics, caps, and ladder-less baseline behavior.
test/MeshWeaver.Auth.Test/SyncTokenAuthenticationTest.cs Adjusts end-to-end auth test to expect JWT bearer input.
test/Memex.Portal.Shared.Test/PluginBundlePlanTest.cs Verifies bundle listing/download gating by instance plan, including legacy plan-less grants and promotion cache behavior.
test/Memex.Portal.Shared.Test/InstanceOpenRegistrationTest.cs Verifies open registration lands on baseline plan and seeds plan-less source grants; adds helper to read plan.
src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs Registers InstancePlanService in plugin-catalog DI.
src/MeshWeaver.PluginCatalog/InstanceRegistryAuthenticator.cs Updates registry decision path to consult instance plan when evaluating Allows.
src/MeshWeaver.PluginCatalog/InstancePlanService.cs New service to locate an instance node and set its plan, invalidating cached auth verdicts.
src/MeshWeaver.PluginCatalog/InstanceGrantAdminSettingsTab.cs Adds admin UI section to set an instance plan from ladder-derived options.
src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs Updates registry credential resolution to exchange mwi_ keys for JWTs and cache until near expiry.
src/MeshWeaver.Messaging.Hub/Localization/strings.en.json Adds/updates i18n strings for instance plan section and revised tier hint.
src/MeshWeaver.Messaging.Hub/Localization/strings.de.json German equivalents for the new/updated instance plan strings.
src/MeshWeaver.Mesh.Contract/Security/SyncAccessToken.cs Reworks token format to JWT (HS256), adds shape recognition + verification rules + kid derivation.
src/MeshWeaver.Mesh.Contract/Security/PluginGrant.cs Changes tier decision to be instance-plan-based with entry caps; keeps baseline overload.
src/MeshWeaver.Mesh.Contract/Security/PlanTierRanks.cs Adds BaselinePlan, CoversInstance, and Narrower semantics used by registry decisions.
src/MeshWeaver.Mesh.Contract/Security/MeshWeaverInstance.cs Adds the Plan field to the instance record with detailed semantics.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-the-licence-lives-on-the-instance.md What’s New entry documenting the behavioral shift and JWT exchange.
src/MeshWeaver.Documentation/Data/Architecture/PluginRegistry.md Updates architecture doc to reflect instance-plan licensing and JWT token exchange.
memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs Writes the instance plan onto the record at registration and stops duplicating plan into grant entries.
memex/Memex.Portal.Shared/Api/InstanceTokenEndpoints.cs Updates token exchange to mint JWTs (including iss) and allow unnarrowed tokens when no scope is requested.
Suppressed comments (3)

src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs:146

  • This is the second await in the token-exchange leaf (ReadAsStringAsync). It should be removed together with the await on SendAsync to avoid async/await in src/; see the suggested blocking InvokeBlocking rewrite for the exchange leaf.
                    await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false), SyncTokenPayloads.Json);

src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs:180

  • Observable.Using(() => accessService.ImpersonateAsSystem(), …) is a known impersonation leak (AsyncLocal store/restore happens on different threads and the scope can escape via Rx disposal ordering). The codebase guidance is to use accessService.RunAsSystem(work) instead, which scopes impersonation to Subscribe and does not leak to the subscriber or downstream composition.
        return Observable.Using(
                () => accessService.ImpersonateAsSystem(),
                _ => hub.GetMeshNode(PluginRegistryCredentials.Path(registry.Url), ReadTimeout))

src/MeshWeaver.PluginCatalog/InstancePlanService.cs:66

  • The XML doc says storing an unknown plan would “license nothing (fail closed)”, but the implementation intentionally treats unknown instance plans as the baseline in PlanTierRanks.CoversInstance (free/untiered only). This comment should be updated to match the actual semantics so callers/admin UI don’t rely on the wrong failure mode.
    /// — the promotion (or demotion) a global admin makes. The caller validates the id against the
    /// registry's own ladder (<see cref="PlanTierLadder"/>); an unknown plan stored here would
    /// license nothing (fail closed), which is why the admin tab refuses it before calling.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

444 tests  ±0   444 ✅ ±0   4m 39s ⏱️ -10s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

♻️ This comment has been updated with latest results.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

   10 files  ±0     10 suites  ±0   5m 19s ⏱️ -13s
3 084 tests ±0  3 084 ✅ ±0  0 💤 ±0  0 ❌ ±0 
3 754 runs  +3  3 754 ✅ +3  0 💤 ±0  0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 181 tests  +3   988 ✅ +3   5m 35s ⏱️ -1s
    5 suites ±0   193 💤 ±0 
    5 files   ±0     0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

This pull request removes 9 and adds 12 tests. Note that renamed tests count towards both.
MeshWeaver.Documentation.Test.HandWovenGateRatchetGuard ‑ TheTestTreeInventoryOnlyShrinks
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ UnknownPlanOnTheEntry_LicensesNothing
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ WithoutALadder_PlanScopedEntriesFailClosed_AndPlanLessOnesStand
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_!!!not-base64!!!.@@@\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_.onlysignature\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_nodot\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_payloadonly.\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ TheTokenCarriesTheDistinguishingPrefix
MeshWeaver.Documentation.Test.HandWovenGateRatchetGuard ‑ TheTestTreeAllowFileStaysDeleted
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ TheInstancePlanIsTheLicence_AnEntryCanOnlyCapIt
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ UnknownPlan_ReadsAsTheBaseline_NeverWider
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ WithoutALadder_NothingWidensBeyondTheBaseline
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ AlgNoneIsRefused_TheHeaderNeverChoosesTheAlgorithm
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"!!!.@@@.###\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"..\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"a..c\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"eyJhbGciOiJIUzI1NiJ9.e30.notasignature\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"nodot\")
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

2 184 tests  ±0   2 180 ✅ ±0   6m 53s ⏱️ + 1m 11s
   10 suites ±0       4 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

624 tests  ±0   624 ✅ ±0   6m 6s ⏱️ -1s
  6 suites ±0     0 💤 ±0 
  6 files   ±0     0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

990 tests  +2   990 ✅ +2   6m 8s ⏱️ +19s
  5 suites ±0     0 💤 ±0 
  5 files   ±0     0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

This pull request removes 1 and adds 3 tests. Note that renamed tests count towards both.
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ WithoutTierNodes_APlanScopedGrantPullsNothing
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ ALegacyPlanLessGrant_IsCappedByTheInstancePlan
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ PromotingTheInstance_WidensTheNextRequest
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ WithoutTierNodes_APaidPlanReadsAsTheBaseline

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   42 files  ±0     42 suites  ±0   34m 42s ⏱️ + 1m 8s
8 507 tests +5  8 310 ✅ +5  197 💤 ±0  0 ❌ ±0 
9 177 runs  +8  8 980 ✅ +8  197 💤 ±0  0 ❌ ±0 

Results for commit aa9bdae. ± Comparison against base commit afb5a5e.

This pull request removes 10 and adds 15 tests. Note that renamed tests count towards both.
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ WithoutTierNodes_APlanScopedGrantPullsNothing
MeshWeaver.Documentation.Test.HandWovenGateRatchetGuard ‑ TheTestTreeInventoryOnlyShrinks
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ UnknownPlanOnTheEntry_LicensesNothing
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ WithoutALadder_PlanScopedEntriesFailClosed_AndPlanLessOnesStand
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_!!!not-base64!!!.@@@\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_.onlysignature\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_nodot\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"mwa_payloadonly.\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ TheTokenCarriesTheDistinguishingPrefix
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ ALegacyPlanLessGrant_IsCappedByTheInstancePlan
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ PromotingTheInstance_WidensTheNextRequest
Memex.Portal.Shared.Test.PluginBundlePlanTest ‑ WithoutTierNodes_APaidPlanReadsAsTheBaseline
MeshWeaver.Documentation.Test.HandWovenGateRatchetGuard ‑ TheTestTreeAllowFileStaysDeleted
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ TheInstancePlanIsTheLicence_AnEntryCanOnlyCapIt
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ UnknownPlan_ReadsAsTheBaseline_NeverWider
MeshWeaver.PluginCatalog.Test.PluginGrantTest ‑ WithoutALadder_NothingWidensBeyondTheBaseline
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ AlgNoneIsRefused_TheHeaderNeverChoosesTheAlgorithm
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"!!!.@@@.###\")
MeshWeaver.PluginCatalog.Test.SyncAccessTokenTest ‑ MalformedTokensAreRejectedRatherThanThrowing(candidate: \"..\")
…

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants