Repository navigation
Registered instances + per-plugin grants close the anonymous plugin registry - #842
Conversation
…open token allowlist The plugin registry's gate was a flat PluginCatalog:RegistryTokens allowlist that was OPEN when unset — which is how https://memex.meshweaver.cloud/api/plugins served 28 packages from the private MeshWeaver.Plugins and education repos, and their full file content, to any anonymous caller (found 2026-08-06). A flat list also cannot express WHICH plugins a given install may pull, so even configured it was all-or-nothing. Identity now resolves to a node, and authorization is a separate admin-owned one: - MeshWeaverInstance — the logical App ID an install identifies itself with, registered self-service in the owner's own partition. Creation maps to Permission.Api, alongside ApiToken. - PluginGrant — what that instance may pull, as (source, package) pairs so both a whole repo (Plugins/*) and a single plugin out of one (Reinsurance/UWDeepfield) are expressible. Lives in the ADMIN partition, which the instance's owner cannot write — that split is what makes self-service registration safe. No grant node = authenticates, entitled to nothing. - InstanceKeys — mint/hash/extract, reusing the personal-token MECHANISM (32 random bytes, SHA-256, raw value never persisted) but NOT the ApiToken type: an ApiToken carries a user id and captured roles, so an instance key that was one could be replayed as its owner against the whole mesh API. Distinct mwi_ prefix, disjoint from mw_, so neither validator accepts the other's credential. - InstanceRegistryAuthenticator — key -> instance -> grant. Reads run as System (this IS the step that turns an anonymous caller into a known one); the hash is re-checked against the instance node, not just the routing index, so a stale index cannot authenticate. Fails CLOSED: a read error yields null -> 401, never "allow because the mesh was briefly unreachable". Mesh-scoped singleton, so its 1-minute cache dies with the mesh. Endpoint enforcement: the catalog is filtered BEFORE sources merge, while each package still knows its source, so a caller cannot learn an ungranted package exists. /files folds the grant into resolution rather than checking after it, so an ungranted id can neither be fetched nor shadow a granted same-named package in a later source; it answers 404, not 403, because the listing already hides ungranted packages and 403 would make /files an enumeration oracle. PluginCatalog:RequireInstanceKey defaults to TRUE — a registry that configures nothing refuses anonymous callers instead of serving them everything. The open mode is local-dev/e2e only and warns on every request. Consumers need no config change: PluginCatalog:RegistryToken keeps its name, its value becomes the key. Tests: 32 covering the grant decision with the real shape (Plugins/* plus Reinsurance/UWDeepfield, denying the rest of that repo), cross-source leakage, case rules, the empty grant of a fresh registration, and the key contract including that a personal mw_ token is rejected as an instance key.⚠️ NOT deployable alone: issuance + the Settings registration tab are not in yet, so nothing can mint a key. Enforcement defaults on — registration must land and the live instances must be registered BEFORE this rolls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo
…d claim MeshWeaverInstanceService is the instance-facing counterpart of ApiTokenService: mint -> hash -> write the instance node in the owner's partition -> write the routing index under System identity. The raw key is returned once and never persisted. The instance id is claimed GLOBALLY, not per user. Grants are keyed on it in the Admin partition, so two users holding the same id — or an id silently re-registered after a deletion — would mean inheriting someone else's grant. Registration therefore validates the id shape (3-48 chars, lowercase, single hyphens, no leading/trailing hyphen) and checks availability first, so a collision is a clean error rather than a half-written registration that quietly picks up an old grant. IsIdAvailable runs as System: the claim is global, and a caller must not be told "available" merely because someone else's instance is invisible to them under access filtering. ReissueKey writes the new index entry BEFORE updating the instance node, so there is no window where a key just handed out resolves to nothing. The instance node's hash stays the authority, so the superseded entry stops authenticating the moment the update lands. No async anywhere: IMeshService.Query is reactive by contract (no async surface), so the existence check takes the first live snapshot directly — no Observable.FromAsync (forbidden: its synchronous prologue runs on the subscribing thread) and no IIoPool bridge, which an earlier draft of this reached for on a mistaken reading of the query API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo
…okens tab InstancesSettingsTab lets any user register their own MeshWeaver installation and get it an instance key. The copy states plainly, in both languages, that a fresh registration can authenticate but download nothing until an admin grants it plugins — registering is identity, not entitlement. ApiTokensSettingsTab is corrected rather than copied. It had two defects that are explicit rules today: - Every element was a hand-built HTML string — headings, rows, status badges, error boxes — and the token display shipped an inline onclick reaching into document.createRange and navigator.clipboard. Script injected through Controls.Html, in the one view that renders a freshly minted credential. Now Controls.Title/Markdown/Button throughout; the token renders in a fenced block, natively selectable, so the tab ships no clipboard JavaScript. Rendering the label and hash as Markdown also drops the hand-rolled HtmlEncode escaping. - Every string was hard-coded English — "Create New Token", "Your Tokens", "Copy your token now", Active/Expired/Revoked, "No tokens yet", "Error:". A German viewer read all of it in English, and LocalizationTest could not catch it because the strings never entered the catalog. 47 new keys across en + de (687/687 matched, both parse). Revoke and TokenActionOutcome are untouched — ApiTokensSettingsTabRevokeTests binds only those, and its 6 tests pass against the rewritten rendering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo
… pull
InstanceGrantAdminSettingsTab is the authorization half of instance
registration: global-admin only, gated a second time on the settings page being
the admin's OWN (the pattern the other Administration tabs use — without it the
tab renders while viewing somebody else's settings). Grants are written to
Admin/_PluginGrant/{instanceId}, out of the instance owner's reach; registration
is self-service precisely because granting is not.
Source is a SELECT over the registry's configured sources, never free text, and
an unrecognised source is refused before the write. A typo'd source name would
otherwise store cleanly and silently authorize nothing — a grant that looks
applied and isn't is the worst failure mode this surface has.
The write is a read-modify-write through GetMeshNodeStream(path).Update, so the
Admin hub serialises concurrent admins rather than letting one clobber the
other's entry.
20 more localization keys, en + de (707/707 matched). Both tabs are now wired in
MemexConfiguration: Instances under Security for every signed-in user, Instance
grants under Administration for platform admins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo
There was a problem hiding this comment.
Pull request overview
This PR closes the anonymous exposure of the plugin registry by introducing registered MeshWeaver instances + per-plugin grants, replacing the legacy “registry token allowlist” gate and adding user/admin UI and configuration to manage instance identities and entitlements.
Changes:
- Add instance-key authentication (
mwi_…) and Admin-ownedPluginGrantauthorization, and apply it to/api/pluginslisting and/api/plugins/filesresolution. - Add Settings UI to register instances (self-service) and an Administration UI to grant/withdraw plugin access per instance; refactor API tokens UI to framework controls + localization.
- Wire new node types/contracts, localization keys, and Helm configuration (
PluginCatalog:RequireInstanceKey) to support secure-by-default operation.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.PluginCatalog.Test/PluginRegistryTokensTest.cs | Keeps backward-compat coverage for the now-obsolete registry-token validator. |
| test/MeshWeaver.PluginCatalog.Test/PluginGrantTest.cs | Adds tests for grant matching semantics (source-wide vs single-package, casing, isolation). |
| test/MeshWeaver.PluginCatalog.Test/InstanceKeysTest.cs | Pins instance-key prefix/hash/header parsing contract and disjointness from personal tokens. |
| src/MeshWeaver.PluginCatalog/PluginRegistryTokens.cs | Marks legacy registry-token config/validator as obsolete in favor of instance keys + grants. |
| src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs | Registers InstanceRegistryAuthenticator as a mesh-scoped singleton. |
| src/MeshWeaver.PluginCatalog/InstanceRegistryAuthenticator.cs | Implements key→instance→grant resolution (system reads) with short-lived caching. |
| src/MeshWeaver.PluginCatalog/InstanceGrantAdminSettingsTab.cs | Adds global-admin UI to grant/withdraw per-instance plugin access. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.en.json | Adds English strings for API tokens, instances, and instance grants UI. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.de.json | Adds German strings for API tokens, instances, and instance grants UI. |
| src/MeshWeaver.Mesh.Contract/Security/PluginGrant.cs | Introduces PluginGrant and PluginGrantEntry contract + matching logic. |
| src/MeshWeaver.Mesh.Contract/Security/MeshWeaverInstance.cs | Introduces MeshWeaverInstance + routing index contract. |
| src/MeshWeaver.Mesh.Contract/Security/InstanceKeys.cs | Defines instance-key generation/hash/parse/fixed-time hash compare. |
| src/MeshWeaver.Mesh.Contract/CreateNodeRequest.cs | Maps MeshWeaverInstance creation to Permission.Api like ApiToken. |
| src/MeshWeaver.Graph/Configuration/MeshWeaverInstanceNodeType.cs | Registers node types and hub type-registry entries for instances and grants. |
| src/MeshWeaver.Graph/Configuration/GraphConfigurationExtensions.cs | Wires instance node types into AddGraph(). |
| memex/Memex.Portal.Shared/Settings/InstancesSettingsTab.cs | Adds user-facing settings tab to register installations and show instance keys once. |
| memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs | Replaces hand-built HTML/JS with Controls.* + localization keys. |
| memex/Memex.Portal.Shared/MemexConfiguration.cs | Registers services and settings tabs for instances and admin instance grants. |
| memex/Memex.Portal.Shared/Authentication/MeshWeaverInstanceService.cs | Implements instance registration, key issuance, and global routing-index writes. |
| memex/Memex.Portal.Shared/Api/PluginRegistryEndpoints.cs | Enforces instance-key auth, filters listing by grant before merge, and scopes file resolution. |
| deploy/helm/values.yaml | Adds pluginCatalog.requireInstanceKey value (secure-by-default). |
| deploy/helm/templates/memex-portal/config.yaml | Templates PluginCatalog__RequireInstanceKey env var into the portal configmap. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # and the only way to wire it was a hand-applied `kubectl set env` that no redeploy reproduces. | ||
| # Tokens are secrets and live in secrets.yaml / the Key Vault CSI mount, never here. | ||
| # See Doc/Architecture/PluginRegistry.md. | ||
| PluginCatalog__RequireInstanceKey: "{{ .Values.pluginCatalog.requireInstanceKey | default true }}" |
| /// <para>A grant names a <b>source</b> and either one <b>package</b> or all of them | ||
| /// (<c>Plugins/*</c> vs <c>Reinsurance/UWDeepfield</c>). The source comes from a fixed list — the | ||
| /// registry's configured sources — and the package is checked against the live catalog before the | ||
| /// grant is written, because a typo'd name would otherwise be stored happily and silently authorize | ||
| /// nothing.</para> |
| if (string.IsNullOrEmpty(instanceId) || string.IsNullOrEmpty(source)) | ||
| { | ||
| target.UpdateData(ResultDataId, host.Localize("instanceGrants.error.incomplete")); | ||
| return; | ||
| } | ||
| if (!SourceNames(host).Contains(source, StringComparer.OrdinalIgnoreCase)) | ||
| { | ||
| target.UpdateData(ResultDataId, host.Localize("instanceGrants.error.unknownSource")); | ||
| return; | ||
| } |
| var hash = InstanceKeys.Hash(rawKey); | ||
| if (cache.TryGetValue(hash, out var hit) && DateTimeOffset.UtcNow - hit.At < CacheDuration) | ||
| return Observable.Return(hit.Result); | ||
|
|
||
| return Resolve(hash) | ||
| .Do(result => cache[hash] = (DateTimeOffset.UtcNow, result)) | ||
| .Catch((Exception ex) => |
| group.AddEndpointFilter(async (ctx, next) => | ||
| { | ||
| var config = ctx.HttpContext.RequestServices.GetRequiredService<IConfiguration>(); | ||
| var issued = IssuedTokens(config); | ||
| if (issued.Count == 0) | ||
| return await next(ctx); // no tokens issued → the open local-dev / e2e-stub registry | ||
| if (PluginRegistryTokens.Validate(ctx.HttpContext.Request.Headers.Authorization, issued)) | ||
| var http = ctx.HttpContext; |
Test Results (shard 2)1 636 tests 1 635 ✅ 5m 5s ⏱️ Results for commit ab09bc3. |
Test Results (shard 0)718 tests 714 ✅ 4m 52s ⏱️ Results for commit ab09bc3. |
Test Results (shard 3) 11 files 11 suites 5m 38s ⏱️ Results for commit ab09bc3. |
Test Results (shard 4)1 020 tests 1 013 ✅ 5m 13s ⏱️ Results for commit ab09bc3. |
Test Results (shard 5)1 537 tests 1 433 ✅ 5m 47s ⏱️ Results for commit ab09bc3. |
Test Results (shard 1)1 616 tests 1 429 ✅ 6m 50s ⏱️ Results for commit ab09bc3. |
Test Results 63 files 63 suites 33m 28s ⏱️ Results for commit ab09bc3. |
…stance registration always failed (#852) * feat(registry): registered instances + per-plugin grants replace the open token allowlist The plugin registry's gate was a flat PluginCatalog:RegistryTokens allowlist that was OPEN when unset — which is how https://memex.meshweaver.cloud/api/plugins served 28 packages from the private MeshWeaver.Plugins and education repos, and their full file content, to any anonymous caller (found 2026-08-06). A flat list also cannot express WHICH plugins a given install may pull, so even configured it was all-or-nothing. Identity now resolves to a node, and authorization is a separate admin-owned one: - MeshWeaverInstance — the logical App ID an install identifies itself with, registered self-service in the owner's own partition. Creation maps to Permission.Api, alongside ApiToken. - PluginGrant — what that instance may pull, as (source, package) pairs so both a whole repo (Plugins/*) and a single plugin out of one (Reinsurance/UWDeepfield) are expressible. Lives in the ADMIN partition, which the instance's owner cannot write — that split is what makes self-service registration safe. No grant node = authenticates, entitled to nothing. - InstanceKeys — mint/hash/extract, reusing the personal-token MECHANISM (32 random bytes, SHA-256, raw value never persisted) but NOT the ApiToken type: an ApiToken carries a user id and captured roles, so an instance key that was one could be replayed as its owner against the whole mesh API. Distinct mwi_ prefix, disjoint from mw_, so neither validator accepts the other's credential. - InstanceRegistryAuthenticator — key -> instance -> grant. Reads run as System (this IS the step that turns an anonymous caller into a known one); the hash is re-checked against the instance node, not just the routing index, so a stale index cannot authenticate. Fails CLOSED: a read error yields null -> 401, never "allow because the mesh was briefly unreachable". Mesh-scoped singleton, so its 1-minute cache dies with the mesh. Endpoint enforcement: the catalog is filtered BEFORE sources merge, while each package still knows its source, so a caller cannot learn an ungranted package exists. /files folds the grant into resolution rather than checking after it, so an ungranted id can neither be fetched nor shadow a granted same-named package in a later source; it answers 404, not 403, because the listing already hides ungranted packages and 403 would make /files an enumeration oracle. PluginCatalog:RequireInstanceKey defaults to TRUE — a registry that configures nothing refuses anonymous callers instead of serving them everything. The open mode is local-dev/e2e only and warns on every request. Consumers need no config change: PluginCatalog:RegistryToken keeps its name, its value becomes the key. Tests: 32 covering the grant decision with the real shape (Plugins/* plus Reinsurance/UWDeepfield, denying the rest of that repo), cross-source leakage, case rules, the empty grant of a fresh registration, and the key contract including that a personal mw_ token is rejected as an instance key.⚠️ NOT deployable alone: issuance + the Settings registration tab are not in yet, so nothing can mint a key. Enforcement defaults on — registration must land and the live instances must be registered BEFORE this rolls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo * feat(registry): issue instance keys — registration service + global id claim MeshWeaverInstanceService is the instance-facing counterpart of ApiTokenService: mint -> hash -> write the instance node in the owner's partition -> write the routing index under System identity. The raw key is returned once and never persisted. The instance id is claimed GLOBALLY, not per user. Grants are keyed on it in the Admin partition, so two users holding the same id — or an id silently re-registered after a deletion — would mean inheriting someone else's grant. Registration therefore validates the id shape (3-48 chars, lowercase, single hyphens, no leading/trailing hyphen) and checks availability first, so a collision is a clean error rather than a half-written registration that quietly picks up an old grant. IsIdAvailable runs as System: the claim is global, and a caller must not be told "available" merely because someone else's instance is invisible to them under access filtering. ReissueKey writes the new index entry BEFORE updating the instance node, so there is no window where a key just handed out resolves to nothing. The instance node's hash stays the authority, so the superseded entry stops authenticating the moment the update lands. No async anywhere: IMeshService.Query is reactive by contract (no async surface), so the existence check takes the first live snapshot directly — no Observable.FromAsync (forbidden: its synchronous prologue runs on the subscribing thread) and no IIoPool bridge, which an earlier draft of this reached for on a mistaken reading of the query API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo * feat(settings): self-service instance registration, and fix the API-tokens tab InstancesSettingsTab lets any user register their own MeshWeaver installation and get it an instance key. The copy states plainly, in both languages, that a fresh registration can authenticate but download nothing until an admin grants it plugins — registering is identity, not entitlement. ApiTokensSettingsTab is corrected rather than copied. It had two defects that are explicit rules today: - Every element was a hand-built HTML string — headings, rows, status badges, error boxes — and the token display shipped an inline onclick reaching into document.createRange and navigator.clipboard. Script injected through Controls.Html, in the one view that renders a freshly minted credential. Now Controls.Title/Markdown/Button throughout; the token renders in a fenced block, natively selectable, so the tab ships no clipboard JavaScript. Rendering the label and hash as Markdown also drops the hand-rolled HtmlEncode escaping. - Every string was hard-coded English — "Create New Token", "Your Tokens", "Copy your token now", Active/Expired/Revoked, "No tokens yet", "Error:". A German viewer read all of it in English, and LocalizationTest could not catch it because the strings never entered the catalog. 47 new keys across en + de (687/687 matched, both parse). Revoke and TokenActionOutcome are untouched — ApiTokensSettingsTabRevokeTests binds only those, and its 6 tests pass against the rewritten rendering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo * feat(registry): the admin surface that decides what each instance may pull InstanceGrantAdminSettingsTab is the authorization half of instance registration: global-admin only, gated a second time on the settings page being the admin's OWN (the pattern the other Administration tabs use — without it the tab renders while viewing somebody else's settings). Grants are written to Admin/_PluginGrant/{instanceId}, out of the instance owner's reach; registration is self-service precisely because granting is not. Source is a SELECT over the registry's configured sources, never free text, and an unrecognised source is refused before the write. A typo'd source name would otherwise store cleanly and silently authorize nothing — a grant that looks applied and isn't is the worst failure mode this surface has. The write is a read-modify-write through GetMeshNodeStream(path).Update, so the Admin hub serialises concurrent admins rather than letting one clobber the other's entry. 20 more localization keys, en + de (707/707 matched). Both tabs are now wired in MemexConfiguration: Instances under Security for every signed-in user, Instance grants under Administration for platform admins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo * fix(registry): provision the meshweaverinstance schema — the first registration always failed Registering an instance on memex-cloud failed with 42P01: relation "meshweaverinstance.mesh_nodes" does not exist The instance-key index lives at the TOP-LEVEL path MeshWeaverInstance/{keyHashPrefix}, and a top-level segment maps to its own Postgres schema. #842 never declared that partition, and the router deliberately does not lazy-create schemas — so the very first registration was always going to fail. The design was right; the provisioning step was missing. Two exact precedents, both fixed the same way and for the same reason: - ApiToken — the same global index shape (ApiToken/{hashPrefix} -> user-scoped node) - VUser — whose comment records the identical `42P01: relation "vuser.mesh_nodes" does not exist` on a fresh DB Both are declared in DefaultPartitionProvider AND created explicitly in SchemaInitialization, because the boot-time provisioning covers a running portal while the explicit create covers the migration container and any DB that booted before the partition was declared. This does both, matching that convention rather than inventing a workaround (and NOT via a PartitionDefinition node, which would provision the schema verbatim-cased while writes hit the lowercased name). Found by actually running the rollout: the MCP index write was correctly denied (that namespace needs System identity), the service path then failed loudly instead of silently, and the error named the missing relation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo * fix(registry): provision the instance-index schema LAZILY, not at every mesh boot Reworks the previous commit. Same defect — the first registration died with 42P01: relation "meshweaverinstance.mesh_nodes" does not exist because the index sits at the TOP-LEVEL path MeshWeaverInstance/{keyHashPrefix}, which is its own Postgres schema, and the router does not lazy-create schemas. But the fix belongs at the write, not at boot. Declaring it in DefaultPartitionProvider provisions the schema on EVERY mesh boot, including every test mesh, for a partition written only when someone registers an installation. ApiToken and VUser are declared eagerly because they sit on per-request paths (token validation, anonymous VUser creation) and cannot afford a first-request provision; registration is rare and deliberate, so it takes the same lazy EnsurePartitionProvisioned route PackageInstaller already uses for exactly this "first write would fault with 42P01" problem. Idempotent, promise-cached, no-op on non-Postgres providers. Two things drove the rework: - CI went red on THREE shards (StaleActivationSeedRollbackTest, NodeTypeProgressAreaTest, PaywallRealGateShapeTests) while main and two other PR branches were green in the same window — heavier than the known single-test flake pattern. The PG failure carried `[WATCHDOG-SOFT] ran 45.3s — exceeded soft deadline (30s)`, which reads as added boot cost rather than a lone race. - The SchemaInitialization half carried a LATENT second bug: DetectFreshDbAsync counts any schema with a mesh_nodes table as a content partition unless it is on an explicit exclusion list (public, admin, apitoken, vuser, auth). Creating `meshweaverinstance` there without extending that list would make a FRESH db look non-fresh and run the legacy `user`-schema repair chain instead of fast-forwarding. Lazy provisioning sidesteps this entirely — the schema only ever appears long after the first migration. Both eager edits are reverted to main's version; the change is now one EnsurePartitionProvisioned before the System-scoped index write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The defect
https://memex.meshweaver.cloud/api/pluginsreturns HTTP 200 with 28 packages to an unauthenticated caller, andPOST /api/plugins/filesreturns full file content — from the privateMeshWeaver.Pluginsandeducationrepos, including paid course content. Found 2026-08-06 while wiring the deployment ramp-up docs.Two causes, both fixed here:
PluginCatalog:RegistryTokensallowlist that was OPEN when unset. It also cannot express which plugins an install may pull — even configured, it was all-or-nothing.PluginCatalog__AutoUpdateByDefault, so every other plugin key was silently dropped (the documented "chart configMap only emits keys it templates" trap). memex-cloud's registry config therefore exists only as hand-appliedkubectl set envvars that no redeploy reproduces — and the token list was simply never applied. (Chart pass-through landed separately in feat(deploy): make the plugin catalog configurable from the chart, and skill the deployment ramp-up #829.)The model
Identity resolves to a node; authorization is a separate, admin-owned one.
MeshWeaverInstance— the logical App ID an install identifies itself with. Any user may register one; creation maps toPermission.ApialongsideApiToken.PluginGrant—(source, package)pairs, so both a whole repo (Plugins/*) and a single plugin out of one (Reinsurance/UWDeepfield) are expressible. In the Admin partition, out of the owner's reach — that split is what makes self-service registration safe. No grant node = authenticates, entitled to nothing.InstanceKeys— reuses the personal-token mechanism (32 random bytes, SHA-256, raw key never persisted) but not theApiTokentype: anApiTokencarries a user id and captured roles, so an instance key that was one could be replayed as its owner against the whole mesh API. Distinctmwi_prefix, disjoint frommw_.InstanceRegistryAuthenticator— key → instance → grant. Reads run as System (this is the step that turns an anonymous caller into a known one). The hash is re-checked against the instance node, not just the routing index, so a stale index cannot authenticate. Fails closed: a read error yields null → 401, never "allow because the mesh was briefly unreachable."Enforcement
GET /api/pluginsfilters before sources merge, while each package still knows its source — a caller cannot learn that an ungranted package exists.POST /filesfolds the grant into resolution, so an ungranted id can neither be fetched nor shadow a granted same-named package in a later source. It answers 404, not 403 — the listing already hides ungranted packages, and 403 would make/filesan enumeration oracle. The log records which instance asked.PluginCatalog:RequireInstanceKeydefaults to true. A registry that configures nothing refuses anonymous callers instead of serving them everything. The open mode is local-dev/e2e only and warns on every request.PluginCatalog:RegistryTokenkeeps its name, its value becomes the instance key.UI
GetMeshNodeStream(path).Update, so concurrent admins serialise rather than clobber.Also corrected
ApiTokensSettingsTabemitted hand-built HTML for every element — including an inlineonclickreaching intodocument.createRangeandnavigator.clipboard, in the one view that renders a freshly minted credential — and hard-coded its English copy, so a German viewer read it in English andLocalizationTestcould not catch it. NowControls.*throughout with the token in a fenced block (natively selectable, no clipboard JS), and every string in the catalog.Verification
MeshWeaver.PluginCatalog.Test110,MeshWeaver.Auth.Test116, both full projectsPlugins/*+Reinsurance/UWDeepfield, denying the rest of that repo), cross-source leakage, case rules, the empty grant of a fresh registration, and the key contract including that a personalmw_token is rejected-warnaserrorclean; localization catalogs 707/707 matched, both parseRollout
Enforcement defaults on, so the instances must be registered and granted before this image rolls, or memex-cloud loses its own catalog in the window (it is the registry and consumes itself). Planned: register
memex/memex-cloud/client-i, addMeshWeaver.Reinsuranceas a fourth source, verify each pulls exactly its grant, then roll.No What's New entry — the user-visible surfaces are new admin/security tabs; happy to add one if you'd rather it be announced.
🤖 Generated with Claude Code
https://claude.ai/code/session_01398UXRgmg2E3xS7xkTHdAo