Skip to content

Registered instances + per-plugin grants close the anonymous plugin registry - #842

Merged
rbuergi merged 5 commits into
mainfrom
feat/instance-registry-auth
Aug 6, 2026
Merged

rbuergi merged 5 commits into
mainfrom
feat/instance-registry-auth

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

The defect

https://memex.meshweaver.cloud/api/plugins returns HTTP 200 with 28 packages to an unauthenticated caller, and POST /api/plugins/files returns full file content — from the private MeshWeaver.Plugins and education repos, including paid course content. Found 2026-08-06 while wiring the deployment ramp-up docs.

Two causes, both fixed here:

  1. The gate was a flat PluginCatalog:RegistryTokens allowlist that was OPEN when unset. It also cannot express which plugins an install may pull — even configured, it was all-or-nothing.
  2. The Helm chart templated only 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-applied kubectl set env vars 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.

{userId}/MeshWeaverInstance/{slug}   user-owned: name, URL, key hash   (self-service)
Admin/_PluginGrant/{instanceId}      ADMIN-owned: [Plugins/*, Reinsurance/UWDeepfield]
MeshWeaverInstance/{keyHashPrefix}   index: key -> instance            (System-written)
  • MeshWeaverInstance — the logical App ID an install identifies itself with. Any user may register one; creation maps to Permission.Api alongside ApiToken.
  • 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 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_.
  • 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/plugins filters before sources merge, while each package still knows its source — a caller cannot learn that an ungranted package exists.
  • POST /files folds 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 /files an enumeration oracle. The log records which instance asked.
  • 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 instance key.

UI

  • Instances (Security, every signed-in user) — register an install, get its key once. States in both languages that a fresh registration can authenticate but download nothing until an admin grants it plugins.
  • Instance grants (Administration, global admin, plus the own-settings-page gate the other admin tabs use) — source is a SELECT over configured sources and an unrecognised source is refused before the write, because a typo'd name would store cleanly and silently authorize nothing. Read-modify-write through GetMeshNodeStream(path).Update, so concurrent admins serialise rather than clobber.

Also corrected

ApiTokensSettingsTab emitted hand-built HTML for every element — including an inline onclick reaching into document.createRange and navigator.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 and LocalizationTest could not catch it. Now Controls.* throughout with the token in a fenced block (natively selectable, no clipboard JS), and every string in the catalog.

Verification

  • 226/226 — MeshWeaver.PluginCatalog.Test 110, MeshWeaver.Auth.Test 116, both full projects
  • 38 new tests: grant matching with the real shape (Plugins/* + 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
  • Release -warnaserror clean; localization catalogs 707/707 matched, both parse

Rollout

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, add MeshWeaver.Reinsurance as 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

rbuergi and others added 5 commits August 6, 2026 09:23
…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
Copilot AI lite review requested due to automatic review settings August 6, 2026 08:45
@rbuergi
rbuergi enabled auto-merge (squash) August 6, 2026 08:45

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 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-owned PluginGrant authorization, and apply it to /api/plugins listing and /api/plugins/files resolution.
  • 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 }}"
Comment on lines +28 to +32
/// <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>
Comment on lines +198 to +207
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;
}
Comment on lines +51 to +57
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) =>
Comment on lines 52 to +54
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;
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 636 tests   1 635 ✅  5m 5s ⏱️
   11 suites      1 💤
   11 files        0 ❌

Results for commit ab09bc3.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

718 tests   714 ✅  4m 52s ⏱️
  9 suites    4 💤
  9 files      0 ❌

Results for commit ab09bc3.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files     11 suites   5m 38s ⏱️
1 372 tests 1 372 ✅ 0 💤 0 ❌
1 449 runs  1 449 ✅ 0 💤 0 ❌

Results for commit ab09bc3.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 020 tests   1 013 ✅  5m 13s ⏱️
   11 suites      7 💤
   11 files        0 ❌

Results for commit ab09bc3.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 537 tests   1 433 ✅  5m 47s ⏱️
   11 suites    104 💤
   11 files        0 ❌

Results for commit ab09bc3.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 616 tests   1 429 ✅  6m 50s ⏱️
   10 suites    187 💤
   10 files        0 ❌

Results for commit ab09bc3.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Test Results

   63 files     63 suites   33m 28s ⏱️
7 899 tests 7 596 ✅ 303 💤 0 ❌
7 976 runs  7 673 ✅ 303 💤 0 ❌

Results for commit ab09bc3.

@rbuergi
rbuergi merged commit 2d77814 into main Aug 6, 2026
17 checks passed
rbuergi added a commit that referenced this pull request Aug 6, 2026
…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>
@rbuergi
rbuergi deleted the feat/instance-registry-auth branch August 7, 2026 07:30
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