diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 457cfd108..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(aspire deploy:*)", - "Bash(aspire mcp:*)", - "Bash(az account:*)", - "Bash(az containerapp * list:*)", - "Bash(az containerapp * logs:*)", - "Bash(az containerapp * show:*)", - "Bash(az containerapp list:*)", - "Bash(az containerapp logs:*)", - "Bash(az containerapp show:*)", - "Bash(az group list:*)", - "Bash(az group show:*)", - "Bash(az monitor * list:*)", - "Bash(az monitor * show:*)", - "Bash(az monitor activity-log:*)", - "Bash(az monitor list:*)", - "Bash(az monitor log-analytics query:*)", - "Bash(az monitor metrics:*)", - "Bash(az monitor show:*)", - "Bash(az postgres * list:*)", - "Bash(az postgres * show:*)", - "Bash(az postgres list:*)", - "Bash(az postgres show:*)", - "Bash(az resource list:*)", - "Bash(az resource show:*)", - "Bash(cat:*)", - "Bash(claude:*)", - "Bash(cmp:*)", - "Bash(cp:*)", - "Bash(curl:*)", - "Bash(diff:*)", - "Bash(docker cp:*)", - "Bash(docker exec:*)", - "Bash(docker inspect:*)", - "Bash(docker ps:*)", - "Bash(docker run:*)", - "Bash(dotnet-dump analyze:*)", - "Bash(dotnet-ildasm:*)", - "Bash(dotnet:*)", - "Bash(echo:*)", - "Bash(exit 0)", - "Bash(file:*)", - "Bash(find:*)", - "Bash(gh:*)", - "Bash(git add:*)", - "Bash(git blame:*)", - "Bash(git check-ignore:*)", - "Bash(git checkout:*)", - "Bash(git diff:*)", - "Bash(git fetch:*)", - "Bash(git grep:*)", - "Bash(git log:*)", - "Bash(git ls-remote:*)", - "Bash(git ls-tree:*)", - "Bash(git mv:*)", - "Bash(git pull:*)", - "Bash(git restore:*)", - "Bash(git rev-parse:*)", - "Bash(git show:*)", - "Bash(git stash:*)", - "Bash(git status:*)", - "Bash(grep:*)", - "Bash(head:*)", - "Bash(iconv:*)", - "Bash(ilspycmd:*)", - "Bash(kill:*)", - "Bash(ls:*)", - "Bash(lsof:*)", - "Bash(meshweaver-thumbnails:*)", - "Bash(mkdir:*)", - "Bash(mv:*)", - "Bash(netstat:*)", - "Bash(nm:*)", - "Bash(node --check:*)", - "Bash(pgrep:*)", - "Bash(pkill:*)", - "Bash(python3:*)", - "Bash(python:*)", - "Bash(rg:*)", - "Bash(sed:*)", - "Bash(sleep:*)", - "Bash(tail:*)", - "Bash(tee:*)", - "Bash(test:*)", - "Bash(timeout:*)", - "Bash(tr:*)", - "Bash(tree:*)", - "Bash(true)", - "Bash(unzip:*)", - "Bash(wait:*)", - "Bash(wc:*)", - "Bash(xargs:*)", - - "WebFetch(domain:aspire.dev)", - "WebFetch(domain:cdnjs.cloudflare.com)", - "WebFetch(domain:docs.anthropic.com)", - "WebFetch(domain:en.wikipedia.org)", - "WebFetch(domain:fluent2.microsoft.design)", - "WebFetch(domain:gist.github.com)", - "WebFetch(domain:github.com)", - "WebFetch(domain:localhost)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:support.claude.com)", - "WebFetch(domain:www.fluentui-blazor.net)", - "WebFetch(domain:www.nuget.org)", - "WebFetch(domain:xunit.net)", - - "WebSearch", - - "mcp__aspire__list_apphosts", - "mcp__aspire__list_console_logs", - "mcp__aspire__list_resources", - "mcp__aspire__list_structured_logs", - "mcp__aspire__list_traces", - "mcp__aspire__select_apphost" - ], - "deny": [ - "Bash(az group delete:*)", - "Bash(az resource delete:*)", - "Bash(git push --force:*)", - "Bash(git push -f:*)", - "Bash(git reset --hard:*)", - "Bash(rm -rf /:*)", - "Bash(rm -rf ~:*)", - "Bash(sudo:*)" - ] - }, - "enableAllProjectMcpServers": true -} diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml index de60b2c73..842d71f7f 100644 --- a/.github/workflows/dotnet-test.yml +++ b/.github/workflows/dotnet-test.yml @@ -13,7 +13,12 @@ on: jobs: build: name: Build and Run Unit tests - timeout-minutes: 30 + # 60 min: setup+restore+build runs ~3 min; tests currently take ~35-40 min + # (FutuRe ~6m, Autocomplete ~4m, AI ~3m, Acme ~3m, plus ~30 smaller projects). + # Headroom for a single hung project burning its 6m wallclock cap without + # truncating the suite. If this gets tight again, the next move is matrix- + # parallelizing the per-project loop instead of bumping the cap further. + timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -34,20 +39,110 @@ jobs: DOTNET_ENVIRONMENT: Development Logging__LogLevel__Default: Warning run: | - find test -name '*.csproj' \ - ! -path '*PostgreSql*' \ - ! -path '*Cosmos*' \ - ! -path '*Orleans*' \ - ! -path '*Acme*' \ - ! -path '*FutuRe*' \ - -exec dotnet test {} --no-build --verbosity normal -l:trx \ - --blame-hang-timeout 3m --blame-hang-dump-type mini \; 2>&1 | tee test/test-results.log + # Only Cosmos is excluded — needs the Cosmos emulator which is heavy to + # set up on a runner. Everything else runs (PostgreSql via Testcontainers + # using pre-installed Docker, Orleans via in-proc TestCluster, Acme/FutuRe + # via dynamic Code-piece compilation). + # + # set +e so a hung/aborted test host (non-zero exit) doesn't terminate + # the loop — we want EVERY csproj to attempt to run, then surface what + # broke after the loop completes. Without this, pipefail propagates a + # single hang into a hard SIGTERM that kills sibling test projects and + # the artifact-upload step has nothing to publish. + # + # Disk hygiene: previous "verbose normal | tee -a test-results.log" + # form was bloating one log file with every test's structured trace + # output. With ~30 csproj × 100s of tests × verbose Trace logs from + # MessageHub, the file grew faster than the 14 GB ephemeral disk could + # absorb and the runner sent SIGTERM (exit 143) mid-run. Switch to + # `--verbosity minimal`, drop the tee, and only persist the per-project + # exit markers — trx files still capture full per-test results. + set +e + : > test/test-results.log + echo "::group::Per-project test runs" + # `! -path '*/bin/*'`: the build copies sample csproj fixtures into test + # bin dirs (e.g. MeshWeaver.Samples.Graph.csproj under + # test/MeshWeaver.Acme.Test/bin/Debug/net10.0/SamplesGraph/). They match + # the *.csproj pattern but aren't test projects — invoking dotnet test on + # each wastes ~1 s × 23 entries per run. + # + for csproj in $(find test -name '*.csproj' \ + ! -path '*Cosmos*' \ + ! -path '*/bin/*' \ + | sort); do + name=$(basename "$csproj" .csproj) + echo "::endgroup::" + echo "::group::$name" + # `timeout 6m`: per-project wall-clock cap. blame-hang-timeout 30s only + # fires when xUnit considers a *test* to have stalled — fixture-init + # and between-class hangs slip past it. 6 min covers the slowest + # legitimate project today (FutuRe ~5 min) with headroom; defense-in- + # depth backstop in case any of the skipped Linux-hang projects gets + # re-enabled or another regresses to the same pattern. + # blame-hang-timeout: 90 s of test-runner inactivity before blame + # collects a hang dump and aborts the test host. The previous 30 s + # was firing on legitimate teardown — multiple projects (Hosting.Blazor, + # Hosting.Orleans, Autocomplete, NodeOperations) showed all tests + # passing in <5 s but the testhost taking 30+ s in finalizer/dispose + # cleanup of hosted hubs / static caches, then getting killed and + # reported as "Test host process crashed" exit=1. 90 s gives genuine + # teardown enough room without masking real hangs (the per-project + # 6 m wall-clock cap below is the hard backstop). + timeout --signal=TERM --kill-after=30s 6m \ + dotnet test "$csproj" --no-build --verbosity minimal -l:trx \ + --blame-hang-timeout 90s --blame-hang-dump-type mini + rc=$? + if [ "$rc" = "124" ] || [ "$rc" = "137" ]; then + marker="[CI] $name exit=$rc TIMEOUT (6m wall-clock cap hit — likely fixture/init hang)" + else + marker="[CI] $name exit=$rc" + fi + echo "$marker" + echo "$marker" >> test/test-results.log + done + echo "::endgroup::" + exit 0 + - name: Fail on hang / aborted test run + # blame-hang aborts the test host and writes a minidump (blame-*.dmp). + # Test *failures* are surfaced separately by Publish Test Results below; + # this step's only job is to fail loudly on a HANG so it doesn't get + # masked behind a "test results published" green checkmark when the trx + # contains only what completed before the abort. + run: | + hang_dumps=$(find test -name 'blame-*.dmp' 2>/dev/null | wc -l) + if [ "$hang_dumps" -gt 0 ]; then + echo "::error::A test process hung — blame-*.dmp files were written." + echo "--- Hang dumps ---" + find test -name 'blame-*.dmp' 2>/dev/null + echo "--- Per-project exit codes (non-zero indicates the hung project) ---" + cat test/test-results.log + exit 1 + fi - name: Collect test logs for artifact if: always() run: | mkdir -p collected-logs # Find all test-logs directories and collect their contents without renaming find . -path "*/bin/*/test-logs/*.log" -type f -exec cp {} collected-logs/ \; 2>/dev/null || true + # The MonolithMeshTestBase phase trace + dispose trace live in the + # process tempdir (Path.GetTempPath() == /tmp on the runner). They + # carry the per-class INIT_MEM / DISPOSE_MEM lines that pinpoint + # which test class drives the AI.Test / Autocomplete.Test / Content.Test + # / Hosting.Monolith.Test OOM. Copy with rename so they don't collide. + if [ -f /tmp/meshweaver-test-trace.log ]; then + cp /tmp/meshweaver-test-trace.log collected-logs/_meshweaver-test-trace.log + fi + if [ -f /tmp/meshweaver-dispose-trace.log ]; then + cp /tmp/meshweaver-dispose-trace.log collected-logs/_meshweaver-dispose-trace.log + fi + # Per-class INIT → DISPOSE memory delta summary (one line per test instance): + # managed=… rss=… rssAnon=… unmanaged=… shared=0|1 + # rssAnon (Linux only) is where the Autofac Reflection.Emit factory pin lives; + # unmanaged = rss − managed is the portable approximation. Grep this file to + # find the worst leakers without wading through the full per-event trace. + if [ -f /tmp/meshweaver-memory-delta.log ]; then + cp /tmp/meshweaver-memory-delta.log collected-logs/_meshweaver-memory-delta.log + fi - name: Publish Test Results # uses: EnricoMi/publish-unit-test-result-action/composite@v2 uses: EnricoMi/publish-unit-test-result-action@v2.12.0 diff --git a/CLAUDE.md b/CLAUDE.md index 3c3e8db27..743d57f52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,569 +1,313 @@ # AGENTS.md -This file provides guidance to AI agents when working with code in this repository. +This file provides guidance to AI agents working with this repository. ## Git Workflow -**NEVER commit or push automatically.** Always wait for the user to explicitly ask for a commit or push. Present changes for review first. +**NEVER commit or push automatically.** Always wait for the user to explicitly ask. + +## Test Triage + +When CI fails, **DO NOT run entire test projects** — iterate one test at a time: + +1. Read failed test names from CI logs (`gh run view --log`) +2. `dotnet test --filter "FullyQualifiedName~" --no-build --no-restore` +3. **No skipping** — CI-only failures catch real timing/state bugs + +Full guidance: [WritingTests.md](src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md) · [CqrsAndContentAccess.md](src/MeshWeaver.Documentation/Data/Architecture/CqrsAndContentAccess.md) · [TestStateIsolation.md](src/MeshWeaver.Documentation/Data/Architecture/TestStateIsolation.md) ## GitHub PR Operations -The `gh` CLI token has **read + push** permissions but **cannot** merge PRs, resolve review threads, or request reviewers. For these operations: +`gh` CLI has **read + push** only — cannot merge, resolve threads, or request reviewers. -### Resolve review threads + merge via GraphQL ```bash -# 1. Find unresolved threads -gh api graphql -f query=' -query($owner:String!, $repo:String!, $pr:Int!) { - repository(owner:$owner, name:$repo) { - pullRequest(number:$pr) { - reviewThreads(first:100) { - nodes { id isResolved } - } - } - } -}' -f owner=Systemorph -f repo=MeshWeaver -F pr=PR_NUMBER \ +# Find unresolved review threads +gh api graphql -f query='query($owner:String!, $repo:String!, $pr:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$pr) { reviewThreads(first:100) { nodes { id isResolved } } } } }' \ + -f owner=Systemorph -f repo=MeshWeaver -F pr=PR_NUMBER \ --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved==false) | .id' - -# 2. Resolve each thread +# Resolve a thread gh api graphql -f query='mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ clientMutationId }}' -f id=THREAD_ID - -# 3. Merge gh pr merge PR_NUMBER --merge ``` -**If these fail with `FORBIDDEN`**, the token lacks write scope — do it from the GitHub UI or re-authenticate with `! gh auth login`. - -## Documentation - -Documentation is embedded in `src/MeshWeaver.Documentation/` and served under the `Doc/` namespace at runtime. +**If `FORBIDDEN`**: re-authenticate with `! gh auth login`. -### Architecture +## 🚨 Postgres: One Schema Per Partition -The documentation on the architecture is accessible via src/MeshWeaver.Documentation/Data/Architecture/ +**`public.mesh_nodes` is empty by design.** Data lives in per-partition schemas (`acme.mesh_nodes`, `rbuergi.mesh_nodes`, etc.). -Topics: Message-based communication, Actor model, UI streaming, AI agents, Data versioning, Serialization, Access control, Partitioned persistence, Business rules & calculations +Satellite table routing by path segment: -### DataMesh +| Path segment | Table | +|---|---| +| `…/_Access/…` | `access` | +| `…/_Thread/…` | `threads` | +| `…/_Activity/…` | `activities` | +| `…/_Comment/…`, `_Approval`, `_Tracking` | `annotations` | +| `…/Source/…` or `…/Test/…` | `code` | +| (none) | `mesh_nodes` | -The documentation on the data mesh is accessible via src/MeshWeaver.Documentation/Data/DataMesh/ +**`namespace` keeps the partition prefix — never strip it.** `namespace = rbuergi/ApiToken`, not `ApiToken`. -Topics: Node type configuration, Query syntax, Unified Path references, Interactive markdown, Collaborative editing, CRUD operations, Data modeling +**Never run raw `psql UPDATE` on a live portal** — bypasses the workspace cache. Use `MoveNodeRequest` or add a Repair vN migration. If you must SQL-edit, restart `Memex.Portal.Distributed`. -### GUI +Full reference: [PostgresSchemaArchitecture.md](src/MeshWeaver.Documentation/Data/Architecture/PostgresSchemaArchitecture.md) -The documentation on the GUI is accessible via src/MeshWeaver.Documentation/Data/GUI/ - -Topics: Container controls (Stack, Tabs, Toolbar, Splitter), Layout grid, DataGrid, Editor, Observables, Data binding, Attributes, Reactive dialogs - -### AI Integration - -The documentation on AI integration is accessible via src/MeshWeaver.Documentation/Data/AI/ +## Documentation -Topics: Agentic AI, MCP authentication, MeshPlugin tools (Get, Search, Create, Update, Delete, NavigateTo) +All docs embedded in `src/MeshWeaver.Documentation/` and served under `Doc/` at runtime. -### Deployment +| Topic area | Path | +|---|---| +| Architecture | `src/MeshWeaver.Documentation/Data/Architecture/` | +| DataMesh | `src/MeshWeaver.Documentation/Data/DataMesh/` | +| GUI | `src/MeshWeaver.Documentation/Data/GUI/` | +| AI Integration | `src/MeshWeaver.Documentation/Data/AI/` | +| Agent definitions | `src/MeshWeaver.AI/Data/Agent/` | -The documentation on deployment is accessible via src/MeshWeaver.Documentation/Data/Architecture/Deployment.md +**Hub-handler test hangs or message disappears:** read [DebuggingMessageFlow.md](src/MeshWeaver.Documentation/Data/Architecture/DebuggingMessageFlow.md) first — it tells you which trace tags to grep and why you should never rerun a hung test "to see". -Topics: Aspire CLI deployment, deployment modes (local/test/prod/monolith), secrets management, Azure Container Apps, PostgreSQL, Orleans clustering, infrastructure provisioning +**`type 'X' is not registered in this hub's TypeRegistry`:** Fix is `WithType(typeof(X), nameof(X))` on the receiving hub. See DebuggingMessageFlow.md → "Type-registry mismatch". -**Quick deploy commands** (run from repo root): -- **Prod**: `aspire deploy --project memex/aspire/Memex.AppHost/Memex.AppHost.csproj -- --mode prod` -- **Test**: `aspire deploy --project memex/aspire/Memex.AppHost/Memex.AppHost.csproj -- --mode test` +**Use `hub.Observe(...)` not `RegisterCallback`/`AwaitResponse`** — those overloads are `[Obsolete]` and deadlock. Tests use `MonolithMeshTestBase.AwaitResponseAsync(...)`. -Prerequisites: Azure CLI authenticated, Aspire CLI installed, Docker running. See the full deployment doc for details. +## Deployment -### Agents +**Never run bare `aspire deploy`** — Aspire 13.2 reports success even when the db-migration container crashes. Always use: -Built-in agent definitions are embedded in src/MeshWeaver.AI/Data/Agent/ +```bash +tools/deploy.sh prod # production +tools/deploy.sh test # test environment +``` -Agents: Executor, Navigator, Planner, Research +Full reference: [Deployment.md](src/MeshWeaver.Documentation/Data/Architecture/Deployment.md) ## Bash Command Guidelines -**Stay in the root directory** (`C:\dev\MeshWeaver`) and use simple, single commands. Chained commands (`&&`, `||`), `for` loops, and `cd` all require user confirmation — avoid them. -```bash -# CORRECT — simple single commands from root directory -dotnet build src/MeshWeaver.Graph/MeshWeaver.Graph.csproj -dotnet test test/MeshWeaver.Graph.Test --no-build - -# WRONG — these all require extra approval: -cd /c/dev/MeshWeaver && dotnet build # chained cd -for d in test/*; do dotnet test $d; done # for loop -dotnet build && dotnet test # chained commands -``` +**Stay in root** (`C:\dev\MeshWeaver`). Avoid chained commands (`&&`, `||`), `for` loops, and `cd` — they all require user confirmation. ## Development Commands -### Build and Test ```bash -# Build entire solution -dotnet build - -# Run tests (uses xUnit v3) -dotnet test +dotnet build # Build solution +dotnet test test/MeshWeaver.Data.Test --no-restore # Run one test project +dotnet run --project memex/Memex.Portal.Monolith # Dev portal (https://localhost:7122) +dotnet run --project memex/aspire/Memex.AppHost # Aspire (requires Docker) +aspire run --project memex/aspire/Memex.AppHost # Aspire via CLI (registers with `aspire mcp`) +``` -# Run specific test project (example) -dotnet test test/MeshWeaver.Data.Test/MeshWeaver.Data.Test.csproj +### Restarting just the Portal (no full Aspire restart) -# Clean solution -dotnet clean +When you change code in `Memex.Portal.Distributed` or any project it references, you do NOT need to kill the whole AppHost. Three options, ordered by cost: -# Restore packages -dotnet restore -``` +1. **Hot reload (cheapest)** — start with `dotnet watch` instead of `dotnet run` / `aspire run`: + ```bash + dotnet watch --project memex/aspire/Memex.AppHost + ``` + File save → Aspire restarts the affected resource only. Preserves the dashboard, the Postgres container, and the SignalR endpoints. Most code changes apply within seconds. +2. **Aspire dashboard UI** — open `https://localhost:17200/` → Resources tab → click the ⋯ next to `memex-portal-distributed` → **Restart**. Runs `dotnet build` + restart in-place. +3. **Process kill (last resort, when watch missed a change)**: + ```powershell + Get-Process Memex.Portal.Distributed -ErrorAction SilentlyContinue | Stop-Process -Force + ``` + Aspire's resource watcher detects the exit and restarts the resource within ~5 s. Avoids a full `aspire run` restart (which would also rebuild every other resource and re-launch Postgres / blob-storage containers). -### Running Applications +**Don't** kill the whole `aspire` / `Memex.AppHost` process unless you changed AppHost wiring itself — full restart costs 30-60 s and loses the dashboard auth token. -#### Memex Portal (Recommended for Development) -```bash -dotnet run --project memex/Memex.Portal.Monolith -# Access at https://localhost:7122 -``` +Full reference: [LocalDevWorkflow.md](src/MeshWeaver.Documentation/Data/Architecture/LocalDevWorkflow.md) -The Memex Portal uses `AddGraph()` to dynamically load Graph nodes from `samples/Graph/Data/`, and `AddDocumentation()` to serve embedded documentation under the `Doc/` namespace. This is the recommended portal for development. +## 🚨 NodeMutations: `stream.Update()` only — never request/response -#### Microservices Portal (.NET Aspire) -```bash -dotnet run --project memex/aspire/Memex.AppHost -# Access Aspire dashboard for service management -# Requires Docker for dependencies -``` +**Threads, thread messages, NodeType compile state, Code editing — every mesh-node mutation goes through `workspace.GetMeshNodeStream(path).Update(current => modified)`. NO bespoke `IRequest`/`IResponse` pairs.** -## Reactive Pattern — NO AWAIT IN UI / HUB FLOWS +This is the unification of three rules we used to write separately: -**Rule: `await` inside hub handlers, button click actions, and service layers that are called from those paths is FORBIDDEN. It deadlocks.** Every write/read to the mesh must be composed as an `IObservable` chain. +1. **Writes**: `stream.Update(current => current with { Content = ... })`. The owning hub's action block serialises; no race. State-machine semantics? Set a `RequestedX` field — the owning hub's watcher reacts (see [ActivityControlPlane.md](src/MeshWeaver.Documentation/Data/Architecture/ActivityControlPlane.md)). +2. **Reads**: `workspace.GetMeshNodeStream(path)` (server-side, backed by [IMeshNodeStreamCache](src/MeshWeaver.Hosting/MeshNodeStreamCache.cs)) or `workspace.GetRemoteStream(addr, new MeshNodeReference())` (client/Blazor — see [GUI Data Binding](src/MeshWeaver.Documentation/Data/GUI/DataBinding.md)). Never `meshService.QueryAsync(path:X)` for a single node's content (stale by design). +3. **Delete the request type.** If you find yourself writing `class XxxRequest` to mutate a thread / message / NodeType, stop. Add a `RequestedXxx` field to the node's content and watch it from the owning hub. -This is the single most important pattern in MeshWeaver. Violating it is the cause of most "button does nothing", "popup doesn't show", and "freezes under load" bugs. +Sanctioned exceptions (NOT for state mutations): +- `CreateNodeRequest` / `DeleteNodeRequest` / `MoveNodeRequest` — node-lifecycle on the mesh hub. These route, they don't mutate node content. +- Transient queries that don't belong on any node (e.g. autocomplete completions). -### The three building blocks +Why this rule unblocks tests: every "hub becomes unresponsive after the second compile" failure (CodeEditRecompile, NodeTypeRelease, LinkedInPullActions, ThreadAgentIntegration in CI 26036857424) traces back to bespoke request/response patterns that race the watcher → two concurrent activities → leaked callbacks → wedged hub. -1. **`IMeshService.CreateNode / UpdateNode / DeleteNode` return `IObservable`** (NOT `Task`). They internally `hub.Post` + `hub.RegisterCallback`. Subscribe to drive them — never call `.ToTask()` / `.FirstAsync()` / `await` on them from a click action or hub handler. -2. **Click actions must be synchronous**: `WithClickAction(ctx => { ...; return Task.CompletedTask; })`. Never `async ctx => await ...`. -3. **Read form data via `Subscribe(...)` with `Take(1)`**, not `await FirstAsync()`. The data stream emits its current value synchronously on subscribe. +Canonical references: +- [RequestViaStreamUpdate.md](src/MeshWeaver.Documentation/Data/Architecture/RequestViaStreamUpdate.md) — the canonical pattern + helpers (`hub.WatchControlPlane`, `hub.WatchSubmission`). +- [ActivityControlPlane.md](src/MeshWeaver.Documentation/Data/Architecture/ActivityControlPlane.md) — `Status`/`RequestedStatus` pair, operations-as-scripts. +- [CqrsAndContentAccess.md](src/MeshWeaver.Documentation/Data/Architecture/CqrsAndContentAccess.md) — read semantics + why `QueryAsync` lags. +- [DataBinding.md](src/MeshWeaver.Documentation/Data/GUI/DataBinding.md) — the Blazor-side mirror of the same pattern. -### The canonical reactive click handler +## 🚨 Reactive Pattern — Nothing Async Ever -```csharp -.WithClickAction(ctx => -{ - // Immediate optimistic UI feedback — the click registered. - ctx.Host.UpdateData(resultId, "

Working…

"); - - // Read form data via Subscribe (sync emission for BehaviorSubject-style streams). - ctx.Host.Stream.GetDataStream>(formId) - .Take(1) - .Subscribe(data => - { - var label = data?.GetValueOrDefault("label")?.ToString() ?? ""; - if (string.IsNullOrEmpty(label)) - { - ctx.Host.UpdateData(resultId, "

Please enter a label.

"); - return; - } - - // Reactive service call — returns IObservable, no await. - // Service internally composes meshService.CreateNode/UpdateNode/DeleteNode chains. - myService.DoWork(label).Subscribe( - result => ctx.Host.UpdateData(resultId, $"

Done: {result}

"), - ex => ctx.Host.UpdateData(resultId, $"

Error: {ex.Message}

")); - }); - - return Task.CompletedTask; // ← click action itself is sync -}) -``` +**No `await`, no `async`, no `Task` in hub-reachable code.** All hub code is `IObservable` end-to-end. -### Writing reactive services +- Handlers, services, layout areas → return `IObservable` (or `void` for fire-and-forget). Never `Task`. +- Compose with `.SelectMany`, `.Select`, `.Where`, `.Timeout`. +- Task-returning primitives: convert at the boundary only via `Observable.FromAsync(() => task)`. +- MCP/SDK surface adapters: one-line `public Task Patch(...) => ops.Patch(...).FirstAsync().ToTask();` is the only sanctioned exception. +- Click actions: `WithClickAction(ctx => { ...; return Task.CompletedTask; })` — never `async ctx =>`. +- `TaskCompletionSource` in hub code = red flag — delete it, return `IObservable`. +- **Tests only**: `await .FirstAsync().ToTask()` is acceptable. -Compose `IObservable` chains with `SelectMany`, `Select`, `FirstOrDefaultAsync`. Return `IObservable` (not `Task`) from any method that will be called from a hub handler or click action. +**🚨 Cold observables: Subscribe is mandatory.** Every method that performs a write returns a cold `IObservable` — the side effect runs on `Subscribe`, not on call. Forgetting to subscribe means the work silently doesn't happen. ```csharp -public IObservable CreateToken(...) -{ - var userNode = new MeshNode(...); - return nodeFactory.CreateNode(userNode) // IObservable - .SelectMany(created => - { - var indexNode = new MeshNode(...) { ... }; - return nodeFactory.CreateNode(indexNode) // chain the second write - .Select(_ => new TokenCreationResult(raw, created)); - }); - // No await anywhere. The consumer calls .Subscribe(onNext, onError). -} - -// Wrap IAsyncEnumerable queries into observables: -public IObservable DeleteToken(string path) => - Observable.FromAsync(() => - meshQuery.QueryAsync(MeshQueryRequest.FromQuery($"path:{path}")) - .FirstOrDefaultAsync().AsTask()) - .SelectMany(node => - { - /* ... */ - return nodeFactory.DeleteNode(path); // IObservable - }); -``` +// ❌ WRONG — fire-and-forget. UpdateMeshNode is cold; the dsStream.Update side +// effect only runs on Subscribe. This was the chat-doesn't-work root cause. +workspace.GetMeshNodeStream().Update(node => node with { … }); -### What NOT to do - -```csharp -// ❌ DEADLOCKS the hub under load. -.WithClickAction(async ctx => -{ - var data = await ctx.Host.Stream.GetDataStream(id).FirstAsync(); - var result = await myService.DoWorkAsync(data); // never awaiting hub-backed services - ctx.Host.UpdateData(resultId, result); -}) - -// ❌ Task.Run is a crutch, not a fix — identity doesn't flow, failures are invisible. -.WithClickAction(ctx => -{ - _ = Task.Run(async () => { await myService.DoWorkAsync(); }); - return Task.CompletedTask; -}) - -// ❌ Hub handlers must NOT await mesh writes either. -public async Task HandleFoo(IMessageDelivery req) -{ - await meshService.CreateNodeAsync(...); // deadlock risk - return req.Processed(); -} +// ✅ RIGHT — subscribe with explicit error propagation. +workspace.GetMeshNodeStream().Update(node => node with { … }) + .Subscribe(_ => { }, ex => logger.LogWarning(ex, "Update failed for {Path}", path)); ``` -### When `await` IS acceptable +`workspace.GetMeshNodeStream()` returns a `MeshNodeStreamHandle` that is both `IObservable` (read) AND has `.Update(update)` (write). Writes return `RequireSubscribeObservable` which **logs a warning at GC if Subscribe was never called** — search the `MeshWeaver.Mesh.RequireSubscribe` log channel after every CI run. Old API `workspace.UpdateMeshNode(...)` is `[Obsolete]`. -- Top-level app startup code (`Main`, `ConfigureServices`, `InitializeAsync` of test base classes). -- Pure CPU / file-I/O work that does NOT flow through the hub (e.g., `File.ReadAllTextAsync`). -- Test code that explicitly wants to block until a stream emits (use `.FirstAsync().ToTask()` then await, but only in tests). +**Auto-save pattern:** Form fields update the MeshNode via `stream.UpdateMeshNode` (debounced). The click action reads nothing — just flips a trigger field. No `Take(1)` on a hot stream. -**Everywhere else, the shape is `Subscribe(onNext, onError)`.** If a service you need only exposes `…Async` / `Task`, add a reactive overload that returns `IObservable` and refactor. +Full patterns + mistake ledger: [AsynchronousCalls.md](src/MeshWeaver.Documentation/Data/Architecture/AsynchronousCalls.md) -## Collections Policy +## 🚨 CQRS — Never Query for a Single Node's Content -**NEVER use mutable collections.** Always use `System.Collections.Immutable`: -- `List` → `ImmutableList.Empty` + `= list.Add(item)` -- `Dictionary` → `ImmutableDictionary.Empty` + `= dict.SetItem(key, val)` -- `HashSet` → `ImmutableHashSet.Empty` + `= set.Add(item)` -- `Queue` → `ImmutableQueue.Empty` + `= queue.Enqueue(item)` / `= queue.Dequeue(out var item)` -- `.ToList()` → `.ToImmutableList()`, `.ToHashSet()` → `.ToImmutableHashSet()` +`QueryAsync`/`ObserveQuery` are eventually consistent — **stale after writes**. To read a specific node: -The codebase is distributed (Orleans, reactive streams). Mutable collections cause race conditions and unpredictable behavior. The only exception is `ConcurrentDictionary` for thread-safe concurrent mutation patterns. +```csharp +// ❌ WRONG — lagged index, stale after writes +var node = await mesh.QueryAsync($"path:{path}").FirstOrDefaultAsync(); -## Architecture Overview +// ✅ CORRECT — authoritative, live +workspace.GetRemoteStream(new Address(path), new MeshNodeReference()) + .Take(1).Timeout(TimeSpan.FromSeconds(10)).Select(change => change.Value); +``` -### Core Concepts +**Valid query uses:** listing children (`path/*`), searching by predicate, existence checks, autocomplete. +**Wrong:** reading content by exact path, reading state before a write, polling for job completion. -**Message Hub Architecture**: MeshWeaver is built on an actor-model message hub system (`MeshWeaver.Messaging.Hub`). All application interactions flow through hierarchical message routing with address-based partitioning (e.g., `@app/Address/AreaName`). +`GetRemoteStream` + `Where(...).Take(1)` is also the right primitive for **waiting for work to finish**. -**Layout Areas**: The UI system uses reactive Layout Areas - framework-agnostic UI abstractions that render in Blazor Server. Layout areas are addressed by route and automatically update via reactive streams. +Full treatment: [CqrsAndContentAccess.md](src/MeshWeaver.Documentation/Data/Architecture/CqrsAndContentAccess.md) -**AI-First Design**: First-class AI integration using Microsoft.Extensions.AI with plugins (MeshPlugin, LayoutAreaPlugin) that provide agents access to application state and functionality. +## Mesh URL Shape -### Key Directory Structure +`{baseUrl}/{meshpath}` — no `/node/` segment, no URL-encoding of separators. -- **`src/`** - Core framework libraries (50+ projects) - - `MeshWeaver.Messaging.Hub` - Actor-based message routing - - `MeshWeaver.Layout` - Framework-agnostic UI abstractions - - `MeshWeaver.AI` - Agent framework with plugin architecture - - `MeshWeaver.Blazor` - Blazor Server implementation - - `MeshWeaver.Data` - CRUD operations with activity tracking - - `MeshWeaver.Documentation` - Embedded documentation (served under Doc/) - - `MeshWeaver.Graph` - Graph node configuration and node type system +| Environment | Base URL | +|---|---| +| Prod | `https://memex.meshweaver.cloud` | +| Dev | `http://localhost:5000` (Memex.Portal.Monolith) | -- **`samples/`** - Sample business domain applications - - `Graph/Data/` - Sample data nodes (ACME, Northwind, Cornerstone, etc.) - - `Graph/content/` - Static content files (icons, images, attachments) +## `@/` is Local-Only -- **`memex/`** - Memex Portal (recommended for development) - - `Memex.Portal.Monolith/` - Development portal with full Graph support - - `aspire/` - Microservices with .NET Aspire orchestration +`@/path` is a Unified Content Reference for markdown links (`[text](@/Path)`), autocomplete, and agent tool args — **never in `href=""` attributes or HTTP URLs**. Markdig strips `@` in native markdown syntax but NOT inside ``. -### Architectural Patterns +## Collections Policy -**Request-Response**: Use `hub.AwaitResponse(request, o => o.WithTarget(address))` for operations requiring results. -The response is submitted as `hub.Post(responseMessage, o => o.ResponseFor(request))`. +**NEVER use mutable collections.** Always `System.Collections.Immutable`: +`List` → `ImmutableList`, `Dictionary` → `ImmutableDictionary`, `HashSet` → `ImmutableHashSet`, `Queue` → `ImmutableQueue`. +Exception: `ConcurrentDictionary` for concurrent mutation. -**Fire-and-Forget**: Use `hub.Post(message, o => o.WithTarget(address))` for notifications and events. +## Architecture Overview -**Address-Based Routing**: Services register at specific addresses (e.g., `bookings/q1_2025`, `app/northwind`, `pricing/id`). -Layout areas follow the pattern `@{address}/{areaName}/{areaId}`. The areaId is optional and depends on the view. -E.g. `{address}/Details/{itemId}` would render a details view for the item with `itemId`. +Actor-model message hub (`MeshWeaver.Messaging.Hub`) with address-based partitioning. UI is reactive Layout Areas rendered in Blazor Server. AI agents use plugins (MeshPlugin, LayoutAreaPlugin). -Layout areas are typically kept on the same address as the underlying data. +| Directory | Contents | +|---|---| +| `src/` | Core framework (50+ projects) | +| `samples/Graph/Data/` | Sample data nodes (ACME, Northwind, Cornerstone, etc.) | +| `memex/Memex.Portal.Monolith/` | Dev portal with full Graph + Documentation support | +| `memex/aspire/` | Microservices with .NET Aspire orchestration | -**Reactive UI**: All UI state changes flow through the message hub. Controls are immutable records that specify their current state. +**Request-Response:** `hub.Observe(request, o => o.WithTarget(address)).Subscribe(resp => …, ex => …)` +Response sent as: `hub.Post(responseMessage, o => o.ResponseFor(request))` +**Fire-and-Forget:** `hub.Post(message, o => o.WithTarget(address))` +**Layout area route:** `@{address}/{areaName}/{areaId}` ## Data Access Patterns -**IMPORTANT:** Application code must never use `IMeshStorage` or `IMeshCatalog` directly — these are internal infrastructure interfaces. +Never use `IMeshStorage` or `IMeshCatalog` directly — internal infrastructure only. -### Reads — Use IMeshService -```csharp -var query = hub.ServiceProvider.GetRequiredService(); -var node = await query.QueryAsync("path:org/Acme", maxResults: 1).FirstOrDefaultAsync(ct); -``` +| Operation | API | +|---|---| +| Read (query) | `IMeshService.QueryAsync(...)` | +| Read (single node) | `workspace.GetRemoteStream(...)` | +| Create/Delete | `IMeshNodeFactory.CreateNodeAsync / DeleteNodeAsync` | +| Update | `hub.Post(new UpdateNodeRequest(node))` | +| Move | `hub.Observe(new MoveNodeRequest(src, dst)).Subscribe(...)` | -### Creates/Deletes — Use IMeshNodeFactory -```csharp -var factory = hub.ServiceProvider.GetRequiredService(); -await factory.CreateNodeAsync(node, createdBy: userId, ct); -await factory.DeleteNodeAsync(path, recursive: true, ct); -``` +Always `GetRequiredService()` — never `GetService()` + null check for required services. -### Updates/Moves — Use message requests -```csharp -hub.Post(new UpdateNodeRequest(updatedNode)); -await hub.AwaitResponse(new MoveNodeRequest(sourcePath, targetPath), ct); -hub.Post(new DataChangeRequest { Updates = [entity] }); -``` +Full reference: [DataAccessPatterns.md](src/MeshWeaver.Documentation/Data/Architecture/DataAccessPatterns.md) -### Service Resolution -Always use `GetRequiredService()` for core services (`IMeshNodeFactory`, `IMeshService`). Never use `GetService()` + null check for services that must be registered. +## MCP Mutations — Always Show a Diff -For full documentation see `src/MeshWeaver.Documentation/Data/Architecture/DataAccessPatterns.md`. +For every MCP mutation (`patch`, `update`, `create`, `delete`, `move`, `copy`): +1. `get @path` **before** — cache the JSON +2. Mutate +3. `get @path` **after** — cache the new JSON +4. Render a ` ```diff ` block showing the changed region in your response + +Read-only tools skip this: `get`, `search`, `recycle`, `get_diagnostics`, `navigate_to`, `execute_script`. ## Development Patterns -### Adding New Layout Areas -```csharp -public static class MyLayoutArea -{ - public static void AddMyLayoutArea(this LayoutConfiguration config) => - config.AddLayoutArea(nameof(MyLayout), MyLayout); - - public static UiControl MyLayout(LayoutAreaHost host, RenderingContext ctx) => - Controls.Stack - .WithView(Controls.Html("Some text") - .WithView(Controls.Markdown("Some markdown view")) - ); - -} -``` -We support rich markdown with mermaid diagrams, code blocks, MathJax, -and live execution via dynamic markdown. Layout areas can be inserted by -using `@{address}/{areaName}/{areaId}` +For detailed patterns with code examples, read: +- Layout areas + UI controls: [UserInterface.md](src/MeshWeaver.Documentation/Data/Architecture/UserInterface.md) and [GUI docs](src/MeshWeaver.Documentation/Data/GUI/) +- Message handling: [MessageBasedCommunication.md](src/MeshWeaver.Documentation/Data/Architecture/MessageBasedCommunication.md) +- AI plugins: [AI docs](src/MeshWeaver.Documentation/Data/AI/) +- Activity control plane / operations as scripts: [ActivityControlPlane.md](src/MeshWeaver.Documentation/Data/Architecture/ActivityControlPlane.md) +- Reactive click handlers + service patterns: [AsynchronousCalls.md](src/MeshWeaver.Documentation/Data/Architecture/AsynchronousCalls.md) -### Message Handling -Messages are registered in the configuration of the hub. Also DI is set up on the level of hub configuration: -```csharp -public static class NorthwindHubConfiguration -{ - public static MessageHubConfiguration AddNorthwindHub(this MessageHubConfiguration config) - { - return config.AddHandler(HandleMyRequestAsync) - .AddHandler(HandleMyRequest); - - } - - public static async Task HandleMyRequestAsync(MessageHub hub, IMessageDelivery request, CancellationToken ct) - { - // Process the request - var result = await SomeService.ProcessAsync(request.Message); - - // Send response - await hub.Post(new MyResponse(result), o => o.ResponseFor(request)); - return request.Processed(); - } - - public static IMessageDelivery HandleMyRequest(MessageHub hub, IMessageDelivery request) - { - // Process the request - var result = SomeService.Process(request.Input); - - // Send response - hub.Post(new MyResponse(result), o => o.ResponseFor(request)); - return request.Processed(); - } -} -``` +**Static handlers for one-shot pipelines** — don't extract `IFooService` for DI cleanliness when there's no state. Resolve deps via `hub.ServiceProvider.GetRequiredService()` inside the static handler. -### AI Plugin Development -```csharp -public class MyPlugin(IMessageHub hub, IAgentChat chat) -{ - [Description("Description on how to use")] - public async Task DoSomething([Description("Description for input")]string input) - { - var request = new MyRequest(input); // Create a request object - var address = GetAddress(request); // Get the address for the plugin, e.g., "app/northwind" - // Use the message hub to send a request and receive a response - var response = await hub.AwaitResponse(request, o => o.WithTarget(address)); - return JsonSerializer.Serialize(response.Message, hub.JsonSerializationOptions); - } - - public Address GetAddress(MyRequest request) - { - // Logic to determine the address based on the request - // the chat contains a context, which is usually good to use. - // can also contain agent specific mapping logic. - return chat.Context.Address; - } -} -``` +**Operations with inputs + progress + output** (export, import, compile, mirror) → Code MeshNode template + form-bound inputs + `RequestedStatus = Running` trigger. Not a bespoke `XxxRequest/XxxResponse` handler. See [ActivityControlPlane.md](src/MeshWeaver.Documentation/Data/Architecture/ActivityControlPlane.md). ## Key Dependencies -- **.NET 10.0** - Target framework -- **Orleans** - Distributed deployment (distributed deployment, microservices) -- **Blazor Server** - Web UI framework -- **Microsoft.Extensions.AI** - AI integration -- **xUnit v3** - Testing framework -- **FluentAssertions** - Test assertions -- **Chart.js** - Data visualization -- **Azure SDKs** - Cloud integration -- **Markdig** - Markdown processing - +.NET 10.0 · Orleans · Blazor Server · Microsoft.Extensions.AI · xUnit v3 · FluentAssertions · Markdig · Chart.js · Azure SDKs ## Testing Guidelines -Tests use xUnit v3 with structured logging and test parallelization configured via `xunit.runner.json`: -- `parallelizeAssembly: false` -- `parallelizeTestCollections: false` -- `maxParallelThreads: 1` -- `methodTimeout: 60000ms` (1 minute per test method) - -**No mocking.** Tests that need infrastructure (persistence, messaging, DI) must use `MonolithMeshTestBase` or `OrleansTestBase` — never mock `IMessageHub`, `IMeshService`, or other core interfaces. +Before building NodeTypes, data models, layout areas, or CSV loaders — read [Coder.md](src/MeshWeaver.AI/Data/Agent/Coder.md) first (canonical guide + non-negotiable testing standards). -### Satellite Entity Patterns +**No mocking.** Use `MonolithMeshTestBase` or `OrleansTestBase` — never mock `IMessageHub`, `IMeshService`, or core interfaces. +**Always `run_in_background: true`** for test runs (they take minutes). +**Never `--verbosity minimal`** when tests may fail — it hides stack traces. -For implementing and testing satellite entities (comments, threads, tracked changes), see `src/MeshWeaver.Documentation/Data/Architecture/SatelliteEntityPatterns.md`. +xUnit v3 config (`xunit.runner.json`): `parallelizeAssembly: false`, `maxParallelThreads: 1`, `methodTimeout: 60000ms`. -**Key rules:** -- Handler must be synchronous (`IMessageDelivery`, not `async Task`) -- Use `meshService.CreateNode()` (Observable) + `.Subscribe(onNext, onError)` — never `await` -- Use `workspace.UpdateMeshNode()` for parent node content updates (in-memory, persisted via debounce) -- Post response inside the `Subscribe(onNext)` callback, not before -- Orleans tests: client configurator must call `AddGraph()` for type registry alignment -- Verify via `GetDataRequest` or `GetRemoteStream` — never `QueryAsync` in distributed tests +Full guidance: [WritingTests.md](src/MeshWeaver.Documentation/Data/Architecture/WritingTests.md) ### Running Tests -Run tests from the root directory using sub-paths. Do NOT write output to `/tmp` or temp directories — test results (.trx) are automatically collected in the project's `bin/` directory. - -**CRITICAL: Always use `run_in_background: true`** for test runs. Tests can take minutes — never block the conversation waiting for them. Use `timeout: 180000` (3 min) max for Bash test commands. The xunit.runner.json `methodTimeout` is 60000ms (1 min) per test method. - -**Do NOT use `--verbosity minimal`** (or `-v m`) when tests are expected to fail. Minimal verbosity hides error details (stack traces, assertion messages), forcing you to re-run with normal verbosity — wasting time and frustrating the user. Use default verbosity or `--verbosity normal` so failures are visible on the first run. Only use `--verbosity minimal` when you are confident all tests will pass and just need a quick green/red check. - ```bash -# Run from root directory with sub-path dotnet test test/MeshWeaver.Hosting.Monolith.Test --no-restore - -# Run a specific test project -dotnet test test/MeshWeaver.Graph.Test --no-restore - -# Filter to specific tests dotnet test test/MeshWeaver.Graph.Test --filter "ClassName~AccessAssignment" --no-restore ``` -**Workflow:** -1. Run tests **once** in background (`run_in_background: true`) -2. If failures: read the output to understand errors — do NOT re-run -3. Fix the code -4. Run tests **once** again to verify fixes -5. Repeat 2–4 until green - -### DevLogin and Access Control in Tests - -`MonolithMeshTestBase` automatically logs in `rbuergi@systemorph.com` as Admin via `TestUsers.DevLogin(Mesh)` in `InitializeAsync()`. This means all tests start with a logged-in admin user — no manual setup needed for basic CRUD. +Workflow: run once in background → read failures → fix → run once more. Never re-run to see if it was a flake. -**TestUsers** (`MeshWeaver.Hosting.Monolith.TestBase.TestUsers`): -- `TestUsers.Admin` — default admin AccessContext -- `TestUsers.SampleUsers()` — MeshNode array of sample users from `samples/Graph/Data/User/` -- `TestUsers.DevLogin(mesh)` — logs in the admin user (called automatically by base class) -- `builder.AddSampleUsers()` — extension to pre-seed user MeshNodes in `ConfigureMesh` +### DevLogin and Access Control -When tests with `AddRowLevelSecurity()` need **per-user** access control (e.g., testing that User1 can't see User2's data), use explicit admin setup for data creation: - -```csharp -// Before creating test data: set up admin context -var accessService = Mesh.ServiceProvider.GetRequiredService(); -var securityService = Mesh.ServiceProvider.GetRequiredService(); -await securityService.AddUserRoleAsync("setup-admin", "Admin", null, "system"); -accessService.SetCircuitContext(new AccessContext { ObjectId = "setup-admin", Name = "Setup Admin" }); +`MonolithMeshTestBase` auto-logs in `rbuergi@systemorph.com` as Admin. Available helpers: `TestUsers.Admin`, `TestUsers.SampleUsers()`, `builder.AddSampleUsers()`. -// ... create test nodes ... - -// After setup: clear admin context so tests start clean -accessService.SetCircuitContext(null); -``` +For per-user access control tests, use `accessService.SetCircuitContext(new AccessContext { ObjectId = "...", Name = "..." })` before creating test data; set `null` after. ### Node Types -Only use **registered** node types in tests. Standard types registered by `AddGraph()`: -`Markdown`, `Code`, `Agent`, `Group`, `User`, `VUser`, `Role`, `Notification`, `Approval`, `AccessAssignment`, `GroupMembership`, `PartitionAccessPolicy`, `ActivityLog`, `UserActivity`, `Comment`, `Thread`, `ThreadMessage` - -Custom types can be registered via `builder.AddMeshNodes(new MeshNode("MyType") { Name = "My Type" })` in `ConfigureMesh`. - -### MonolithMeshTestBase (recommended for most tests) - -Reference `MeshWeaver.Hosting.Monolith.TestBase` and inherit from `MonolithMeshTestBase`: - -```csharp -public class MyTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // Override ConfigureMesh to add services and sample users - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddGraph() - .AddSampleUsers() - .ConfigureHub(hub => hub.AddMyHub()); - - [Fact] - public async Task MyTestMethod() - { - var meshQuery = Mesh.ServiceProvider.GetRequiredService(); - var nodeFactory = Mesh.ServiceProvider.GetRequiredService(); - - // Create test data - await nodeFactory.CreateNodeAsync(new MeshNode("test", "Namespace") { Name = "Test" }, "testuser"); - - // Query - var result = await meshQuery.QueryAsync("path:Namespace/test").FirstOrDefaultAsync(); - result.Should().NotBeNull(); - } -} -``` - -### HubTestBase (for message routing / layout tests) - -```csharp -public class MyTest : HubTestBase, IAsyncLifetime -{ - protected override MessageHubConfiguration ConfigureHost(MessageHubConfiguration config) - => base.ConfigureHost(config).AddNorthwindHub(); - - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration config) - => base.ConfigureClient(config).AddLayoutClient(); - - [Fact] - public async Task MyTestMethod() - { - var hub = GetClient(); - var response = await hub.AwaitResponse(request, o => o.WithTarget(new HostAddress())); - response.Should().NotBeNull(); - } -} -``` +Standard types from `AddGraph()`: `Markdown`, `Code`, `Agent`, `Group`, `User`, `VUser`, `Role`, `Notification`, `Approval`, `AccessAssignment`, `GroupMembership`, `PartitionAccessPolicy`, `ActivityLog`, `UserActivity`, `Comment`, `Thread`, `ThreadMessage` -## Project Structure Guidelines +Custom types: `builder.AddMeshNodes(new MeshNode("MyType") { Name = "My Type" })` in `ConfigureMesh`. -- Framework code belongs in `src/` -- Test code belongs in `test/` -- Sample applications go in `samples/` -- Each module should have its own set of hubs and address spaces (e.g., `@app/northwind`) -- UI components should be framework-agnostic in the layout layer. The language are the controls inheriting from `UiControl`. -- AI agents should use plugins to access application functionality +### Test Base Classes -## Solution Management +- **`MonolithMeshTestBase`** (recommended) — full integration with persistence, messaging, DI; use `AwaitResponseAsync(request, ...)` for request/response in tests +- **`HubTestBase`** — message routing / layout tests; bridge to Task via `.FirstAsync().ToTask(ct)` -The solution uses centralized package management via `Directory.Packages.props`. When adding new dependencies, update the central package file rather than individual project files. +For satellite entities (comments, threads, tracked changes): [SatelliteEntityPatterns.md](src/MeshWeaver.Documentation/Data/Architecture/SatelliteEntityPatterns.md) -### Key Configuration Files -- `Directory.Build.props` - Global MSBuild properties and versioning -- `Directory.Packages.props` - Centralized NuGet package version management -- `nuget.config` - NuGet package sources configuration -- `xunit.runner.json` - Test execution configuration +## Project Structure -### Branch and Development -- Main branch: `main` (use for PRs) -- Solution file: `MeshWeaver.slnx` contains 50+ projects +Framework code in `src/`, tests in `test/`, samples in `samples/`. +Main branch: `main`. Solution file: `MeshWeaver.slnx` (50+ projects). +Package management: `Directory.Packages.props` — update this, not individual `.csproj` files. diff --git a/Directory.Build.props b/Directory.Build.props index 825857cef..2d8695d1c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,10 +17,17 @@ - + opt out of CPM and don't need these packages. --> + +
diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d6884b08..88b2c2a86 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,40 +4,40 @@ $(NoWarn);NU1608 - + - - - - - - - - - - - - + + + + + + + + + + + + - - + + - + - + - - + + - - - + + + - - + + @@ -45,141 +45,147 @@ - - + + - + - - + + + + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - + + + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + - - - - + + + + - + - + - - + + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + + + - + + + + + + - + - - - - - - + + + + + + + + - - - - - - - + + + + + + + - - - - + + + + - + - - + + - \ No newline at end of file + + diff --git a/Doc/Architecture/DebuggingPostgres.md b/Doc/Architecture/DebuggingPostgres.md new file mode 100644 index 000000000..8129b816f --- /dev/null +++ b/Doc/Architecture/DebuggingPostgres.md @@ -0,0 +1,137 @@ +--- +Name: Debugging Postgres in Prod / Test +Category: Documentation +Description: How to connect to the Aspire-deployed Azure Postgres Flexible Server using Azure AD auth, run ad-hoc queries, and inspect migration state. +Icon: +--- + +# Connecting to Aspire's Azure Postgres + +The deployed clusters (`prod`, `test`) provision **Azure Postgres Flexible Server with password auth disabled** — only Azure AD entra-id auth is accepted. Password from `dotnet user-secrets` is for legacy/break-glass and **does not work**. + +## Quick connect + +| Mode | FQDN | +|---|---| +| prod | `memexpostgres-d272wxvys4nvo.postgres.database.azure.com` | +| test | look up: `az postgres flexible-server list -g test-memex --query "[].fullyQualifiedDomainName" -o tsv` | + +```bash +# Verify which AAD identity you're signed in as — your user must be granted +# Postgres AAD admin (or be a member of an AAD group that is). +az account show --query "user.name" -o tsv + +# 1-shot token good for ~1 hour +PGPASSWORD=$(az account get-access-token \ + --resource-type oss-rdbms --query accessToken -o tsv) + +psql "host=memexpostgres-d272wxvys4nvo.postgres.database.azure.com \ + port=5432 dbname=memex user=$(az account show --query user.name -o tsv) \ + sslmode=require" +``` + +The `oss-rdbms` token resource maps to `https://ossrdbms-aad.database.windows.net/.default`. SSL is mandatory. + +If `psql` is not installed: `winget install PostgreSQL.PostgreSQL` (Windows) — picks up `psql.exe` on `PATH`. Or use the C# script below. + +## C# alternative (no psql install needed) + +Drop a script under `tools/`: + +```csharp +#r "nuget: Npgsql, 9.0.2" +#r "nuget: Azure.Identity, 1.13.1" +using Azure.Core; +using Azure.Identity; +using Npgsql; + +const string Host = "memexpostgres-d272wxvys4nvo.postgres.database.azure.com"; +const string Db = "memex"; +const string User = "rbuergi@systemorph.com"; // your AAD UPN + +var token = await new DefaultAzureCredential().GetTokenAsync( + new TokenRequestContext(new[] { "https://ossrdbms-aad.database.windows.net/.default" })); +await using var conn = new NpgsqlConnection( + $"Host={Host};Database={Db};Username={User};Password={token.Token};SSL Mode=Require"); +await conn.OpenAsync(); + +await using var cmd = new NpgsqlCommand("SELECT current_user, version()", conn); +await using var rdr = await cmd.ExecuteReaderAsync(); +while (await rdr.ReadAsync()) + Console.WriteLine($"{rdr[0]} {rdr[1]}"); +``` + +Run with `dotnet script tools/your-query.csx` (one-time `dotnet tool install -g dotnet-script` if missing). + +There's a worked example at `tools/check-prod-db.csx` — uses the same pattern to run a battery of diagnostic queries (migration version, per-user schemas, access assignments, thread distribution). + +## Cheat sheet for migration / partition state + +```sql +-- 1. What migration version did the runner reach? +SELECT id, content + FROM admin.mesh_nodes + WHERE id = 'db_version'; + +-- 2. Per-user / per-org content schemas (post-V10 layout) +SELECT schema_name FROM information_schema.schemata s + WHERE EXISTS (SELECT 1 FROM information_schema.tables t + WHERE t.table_schema = s.schema_name AND t.table_name='mesh_nodes') + AND s.schema_name NOT IN ('public','admin','information_schema','pg_catalog','pg_toast','user') + AND s.schema_name NOT LIKE '%\_versions' ESCAPE '\' + ORDER BY schema_name; + +-- 3. Where do AccessAssignments live for a given user? +SELECT 'user' AS schema, namespace, content + FROM "user".access WHERE content->>'accessObject' = 'rbuergi' +UNION ALL +SELECT 'partnerre' AS schema, namespace, content + FROM partnerre.access WHERE content->>'accessObject' = 'rbuergi'; + +-- 4. Cross-schema search for a node by id (use when "where does X live?" is the question) +DO $$ +DECLARE r RECORD; +BEGIN + FOR r IN SELECT schema_name FROM information_schema.schemata s + WHERE EXISTS (SELECT 1 FROM information_schema.tables t + WHERE t.table_schema = s.schema_name AND t.table_name='mesh_nodes') + AND s.schema_name NOT IN ('information_schema','pg_catalog','pg_toast','public') + LOOP + EXECUTE format( + 'SELECT %L AS schema, id, namespace, node_type FROM %I.mesh_nodes WHERE id = ''loss-model''', + r.schema_name, r.schema_name); + END LOOP; +END $$; +``` + +## Reading migration logs + +The migration runs as an Aspire `db-migration` resource that completes **before** the portal starts. Logs are in Container Apps: + +```bash +az containerapp logs show -n db-migration -g prod-memex --tail 200 +# follow live: +az containerapp logs show -n db-migration -g prod-memex --follow +``` + +If migration crashed mid-run, you'll see the `Unhandled exception` at the bottom and the partial schema state in the DB. The `db_version` row is only written **after** all migrations complete cleanly — so a missing `db_version` plus a non-empty schema set means the runner crashed mid-flight. + +## Common failure modes + +- **`28000: no pg_hba.conf entry for host … user "app"`** — the migration code built an `NpgsqlDataSource` from the raw connection string instead of going through the Aspire-configured Azure-AD password provider. Every per-schema datasource (e.g. `SchemaHelpers.BuildSchemaDataSource`) needs the same AAD token-acquisition hook the main runner uses. Fix the helper to wire `dsb.UsePeriodicPasswordProvider(...)` instead of `dsb.Build()` directly. +- **Migration aborts mid-run, `db_version` missing** — see above. The runner only persists `db_version` after the loop completes; a single failed `Vxx` leaves the version unchanged. After fixing the underlying issue, the runner re-runs every migration `> 0` (i.e., everything) on next deploy. +- **AAD token expired during long migration** — `az account get-access-token` issues a token with ~1h lifetime. Migrations that exceed that need `UsePeriodicPasswordProvider` (refreshes automatically) rather than a one-shot password. The Aspire `AddAzureNpgsqlDataSource` already does this for the main connection. +- **Wrong AAD identity** — token is for whoever `az login` was last run as. If your user isn't a Postgres AAD admin, `28000` again. Add via portal: *Azure Database for PostgreSQL → Authentication → Add Microsoft Entra admin*. + +## Where the prod DB lives + +| Resource | Value | +|---|---| +| Resource Group | `prod-memex` | +| Server | `memexpostgres-d272wxvys4nvo.postgres.database.azure.com` | +| Database | `memex` | +| Auth | Azure AD only (password disabled) | +| Tenant | `3a01d7ac-3330-444d-942d-975eb491b5d6` | +| App Insights | `appinsights-d272wxvys4nvo` (same RG; for portal logs) | + +For test cluster, swap `prod-memex` → `test-memex` and discover the FQDN with the `az postgres flexible-server list` command above. diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index 52e80bbad..80aa17a4e 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -93,9 +93,12 @@ + + + @@ -118,12 +121,14 @@ + + @@ -132,7 +137,6 @@ - @@ -150,8 +154,8 @@ + - diff --git a/failing-tests.txt b/failing-tests.txt new file mode 100644 index 000000000..230d601dd --- /dev/null +++ b/failing-tests.txt @@ -0,0 +1,75 @@ +# Failing tests as of 2026-04-28 13:50 (after Acme cache-dir fix + ValidateToken IObservable conversion). +# 30 known failures across 13 projects (excluding FutuRe — owned by another agent). +# Format: | + +# ========== Markdown.Test (1) ========== +MeshWeaver.Markdown.Test|InteractiveMarkdownExecutionTest.MultipleBlocks_ShareKernelState_ViaSharedAddress + +# ========== AccessControl.Test (2) ========== +MeshWeaver.AccessControl.Test|AccessAssignmentThumbnailTest.Thumbnail_ClickRemoveRole_RemovesChip +MeshWeaver.AccessControl.Test|AccessAssignmentThumbnailTest.UpdateAccessObject_ChangesSubject_ViaDataChange + +# ========== Insurance.Test (1) ========== +MeshWeaver.Insurance.Test|PricingCatalogTests.GetPricingCatalog_UsingLayoutAreaReference_ShouldReturnPricingsControl + +# ========== Todo.Test (1) ========== +MeshWeaver.Todo.Test|TodoDataChangeTest.Step1_SetupDataContext_WithTodoItems + +# ========== Content.Test (1) ========== +MeshWeaver.Content.Test|VersionViewsTest.VersionsArea_SingleVersion_RendersWithoutError + +# ========== Persistence.Test (3) ========== +MeshWeaver.Persistence.Test|MonolithKernelTest.InteractiveShowcaseMd_FullPipeline_AllBlocksExecute +MeshWeaver.Persistence.Test|MonolithKernelTest.MultipleSubmissions_ShareKernelState +MeshWeaver.Persistence.Test|PageLoadingTest.MarkdownNode_LoadsWithoutHanging + +# ========== Auth.Test (1) — pending verification of ValidateToken IObservable refactor ========== +MeshWeaver.Auth.Test|ApiTokenServiceTests.ValidateToken_RevokedToken_ReturnsNull + +# ========== Security.Test (9) ========== +MeshWeaver.Security.Test|AccessControlPipelineTest.SubscribeRequest_WithReadPermission_Succeeds +MeshWeaver.Security.Test|AccessControlPipelineTest.SubscribeRequest_WithoutReadPermission_ReturnsDeliveryFailure +MeshWeaver.Security.Test|McpAccessControlTests.McpSearch_User1SeesOnlyPermittedNodes +MeshWeaver.Security.Test|McpAccessControlTests.McpUpdate_User1CannotUpdatePrivateOrg_User2Can +MeshWeaver.Security.Test|McpAccessControlTests.McpGet_User1CanReadPublicNode +MeshWeaver.Security.Test|McpAccessControlTests.McpSearch_User1CannotSearchPrivateOrg +MeshWeaver.Security.Test|McpAccessControlTests.McpUpdate_User1CannotUpdate_User2Can +MeshWeaver.Security.Test|McpAccessControlTests.McpGet_User1CannotReadPrivateOrg_User2Can +MeshWeaver.Security.Test|McpAccessControlTests.McpGet_User1CannotReadConfidentialNode_User2Can + +# ========== Autocomplete.Test (4) ========== +MeshWeaver.Autocomplete.Test|MeshNodeAutocompleteTest.CanCreateTypeAtPath_ReturnsTrueForValidType +MeshWeaver.Autocomplete.Test|MeshNodeAutocompleteTest.GetCreatableTypes_DifferentNodesDifferentTypes +MeshWeaver.Autocomplete.Test|MeshNodeAutocompleteTest.GetCreatableTypes_ReturnsTypesForNode +MeshWeaver.Autocomplete.Test|AutocompleteMultiSourceTest.LocalFirst_ChildrenOfContextScoreHigherThanDistant + +# ========== Hosting.PostgreSql.Test (1) — likely infra-related (no Docker?) ========== +MeshWeaver.Hosting.PostgreSql.Test|EffectivePermissionPostgresTest.CreateOrganization_HasPermission_ReturnsAdmin + +# ========== Query.Test (3) ========== +MeshWeaver.Query.Test|ChatCompletionOrchestratorTest.AtText_ReturnsCurrentNodeAndGlobal +MeshWeaver.Query.Test|RemoteStreamCacheTest.GetRemoteStream_AfterDispose_ReturnsFreshInstance +MeshWeaver.Query.Test|SyncedQueryTest.PropertyChange_NoLongerMatchesQuery_RemovesFromCollection + +# ========== Acme.Test (4) — was 12, fixed 8 via per-session cache dir ========== +MeshWeaver.Acme.Test|TodoDataChangeWorkflowTest.MultipleTodoHubs_CanBeAccessedIndependently +MeshWeaver.Acme.Test|AcmeSearchTest.DescendantsSearch_FindsOrganizationRootNode +MeshWeaver.Acme.Test|AcmeSearchTest.AcmeOrganization_IsAccessibleToAuthenticatedUser +MeshWeaver.Acme.Test|AcmeSearchTest.SubtreeSearch_FindsOrganizationRootNode + +# ========== Hosting.Orleans.Test (3) — sub-agent owned ========== +MeshWeaver.Hosting.Orleans.Test|OrleansReentrancyTest.ToolCall_DuringStreaming_DoesNotDeadlock +MeshWeaver.Hosting.Orleans.Test|OrleansMarkdownExportTest.SubHub_WithExportTypesRegistered_DeserializesPolymorphicExportDocumentControl +MeshWeaver.Hosting.Orleans.Test|OrleansMarkdownExportTest.ExportPdfArea_RendersExportDocumentControl_ClientDeserializes + +# ========== Fixed in this session (verified passing) ========== +# Hosting.Blazor.Test|NavigationServiceTest.* (2) — IMeshQueryCore + ObserveQuery + select projection +# Threading.Test (2) — pre-existing fix in dependency +# NodeOperations.Test (3) — pre-existing fix in dependency +# Persistence.Test|ResolvePathAsync_*, Move_LargeSubtree (3) — pre-existing fix +# Content.Test|VersionsMenu_AppearsInNodeMenu, VersionsArea_RendersVersionList (2) — pre-existing fix +# AccessControl.Test|Overview_RendersChangeSubjectButton (1) — pre-existing fix +# Insurance.Test|GetPricingCatalog_ShouldReturnPricings (1) — pre-existing fix +# Auth.Test|ValidateToken_ValidToken_ReturnsApiToken (1) — pre-existing fix +# Query.Test (6 of 9) — pre-existing fix +# Acme.Test (8 of 12) — per-session cache dir fix diff --git a/memex/Memex.Portal.Monolith/Program.cs b/memex/Memex.Portal.Monolith/Program.cs index 8815af949..dfe5523c9 100644 --- a/memex/Memex.Portal.Monolith/Program.cs +++ b/memex/Memex.Portal.Monolith/Program.cs @@ -7,6 +7,7 @@ using MeshWeaver.Hosting.Monolith; using MeshWeaver.Messaging; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); @@ -21,6 +22,15 @@ builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(keysPath)); +// NodeType compile cache: filesystem-backed in monolith (shared-blob isn't available +// without an Azure account). Versioned entries under {LocalAppData}/Memex/assembly-cache +// persist across restarts; cross-replica sharing isn't applicable here since the +// monolith runs in a single process. +var assemblyCachePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Memex", "assembly-cache"); +builder.Services.AddFileSystemAssemblyStore(assemblyCachePath); + // Add Aspire service defaults (health checks, OpenTelemetry, service discovery) builder.AddServiceDefaults(); diff --git a/memex/Memex.Portal.Monolith/appsettings.Development.json b/memex/Memex.Portal.Monolith/appsettings.Development.json index 4b76908f2..0fd566969 100644 --- a/memex/Memex.Portal.Monolith/appsettings.Development.json +++ b/memex/Memex.Portal.Monolith/appsettings.Development.json @@ -4,13 +4,7 @@ "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", - "MeshWeaver.Hosting": "Warning", - "MeshWeaver.Blazor": "Warning", - "MeshWeaver.Graph": "Debug", - "MeshWeaver.Mesh": "Debug", - "MeshWeaver.AccessContext": "Debug", - "MeshWeaver.AI": "Debug", - "MeshWeaver.AI.Threading": "Debug" + "MeshWeaver": "Warning" } }, "Styles": { diff --git a/memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs b/memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs new file mode 100644 index 000000000..1e5083a0e --- /dev/null +++ b/memex/Memex.Portal.Shared/Api/MeshApiEndpoints.cs @@ -0,0 +1,230 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text.Json; +using MeshWeaver.AI; +using MeshWeaver.Blazor.AI; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Memex.Portal.Shared.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Memex.Portal.Shared.Api; + +/// +/// REST surface for the mesh — a transport-mirror of McpMeshPlugin. +/// +/// +/// Every endpoint is a thin wrapper over (the same +/// shared core that backs the MCP tools), so REST and MCP cannot drift: a change +/// to a verb's semantics happens once, in MeshOperations, and both +/// transports inherit it. +/// +/// +/// +/// Auth: gated by the existing +/// policy — same Authorization: Bearer mw_… token format as /mcp, validated +/// by ApiTokenAuthenticationHandler. +/// +/// +/// +/// Session hub: each request resolves a per-caller hosted hub via +/// (shared with the MCP plugin), so REST callers +/// get the same routing semantics that MCP already has — kernel dispatch, workspace +/// isolation, response routing back to the caller's stream. +/// +/// +/// +/// Shape: RPC-mirror — POST /api/mesh/<verb> with JSON body, 1:1 +/// with MCP tool names. Multipart for binary upload. +/// +/// +public static class MeshApiEndpoints +{ + public const string RoutePrefix = "/api/mesh"; + + /// + /// Maps the /api/mesh/* endpoint group. Call after UseAuthentication / + /// UseAuthorization, alongside MapMeshMcp. + /// + public static IEndpointRouteBuilder MapMeshApi(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(RoutePrefix) + .RequireAuthorization(Memex.Portal.Shared.Authentication.McpAuthenticationExtensions.PolicyName); + + group.MapPost("/get", (HttpContext http, IMessageHub rootHub, GetBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Get(body.Path))); + + group.MapPost("/search", (HttpContext http, IMessageHub rootHub, SearchBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Search(body.Query, body.BasePath))); + + group.MapPost("/create", (HttpContext http, IMessageHub rootHub, CreateBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Create(body.Node))); + + group.MapPost("/update", (HttpContext http, IMessageHub rootHub, UpdateBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Update(body.Nodes))); + + group.MapPost("/patch", (HttpContext http, IMessageHub rootHub, PatchBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Patch(body.Path, body.Fields))); + + group.MapPost("/delete", (HttpContext http, IMessageHub rootHub, DeleteBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Delete(body.Paths))); + + group.MapPost("/move", (HttpContext http, IMessageHub rootHub, MoveBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Move(body.SourcePath, body.TargetPath))); + + group.MapPost("/copy", (HttpContext http, IMessageHub rootHub, CopyBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Copy(body.SourcePath, body.TargetNamespace, body.Force))); + + group.MapPost("/recycle", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Recycle(body.Path))); + + group.MapPost("/compile", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.Compile(body.Path))); + + group.MapPost("/diagnostics", (HttpContext http, IMessageHub rootHub, PathBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.GetDiagnostics(body.Path))); + + group.MapPost("/execute-script", (HttpContext http, IMessageHub rootHub, ExecuteScriptBody body, CancellationToken ct) => + RunString(http, rootHub, ct, ops => ops.ExecuteScript(body.Path, body.TimeoutSeconds ?? 120))); + + // Mirror Push/Pull — these talk to the mesh hub directly (same as MCP plugin's PostMirror). + group.MapPost("/mirror", HandleMirror); + + // Local helpers — same logic as the MCP plugin's NavigateTo / GetBaseUrl. + group.MapPost("/navigate-to", HandleNavigateTo); + group.MapPost("/base-url", HandleBaseUrl); + + // Binary upload — multipart so `curl -F file=@logo.png -F path=@Foo/content/logo.png` works. + // DisableAntiforgery: bearer-auth form posts can't carry an antiforgery token; the request + // is already authenticated by ApiTokenAuthenticationHandler, which is the protection here. + group.MapPost("/upload", HandleUpload).DisableAntiforgery(); + + return endpoints; + } + + private static async Task HandleMirror( + HttpContext http, IMessageHub rootHub, MirrorRequest body, CancellationToken ct) + { + var sessionHub = ResolveSession(http, rootHub); + var delivery = await sessionHub.Observe(body, o => o.WithTarget(new Address("mesh"))) + .Catch((Exception _) => Observable.Return((IMessageDelivery)null!)) + .FirstAsync().ToTask(ct); + var result = delivery?.Message ?? new MirrorResult + { + Status = "Error", + Direction = body.Direction, + SourcePath = body.SourcePath, + TargetPath = body.TargetPath ?? body.SourcePath, + Error = "No response from mirror handler — is the mesh hub reachable and AddPersistence configured?", + }; + return Results.Content(JsonSerializer.Serialize(result, sessionHub.JsonSerializerOptions), "application/json"); + } + + private static IResult HandleNavigateTo(HttpContext http, IOptions? mcp, NavigateBody body) + { + var baseUrl = ResolveBaseUrl(http, mcp); + var resolved = MeshOperations.ResolvePath(body.Path).TrimStart('/'); + return Results.Json(new { url = $"{baseUrl}/{resolved}" }); + } + + private static IResult HandleBaseUrl(HttpContext http, IOptions? mcp) => + Results.Json(new { url = ResolveBaseUrl(http, mcp) }); + + private static async Task HandleUpload(HttpContext http, IMessageHub rootHub, CancellationToken ct) + { + if (!http.Request.HasFormContentType) + return Results.BadRequest(new { error = "Content-Type must be multipart/form-data." }); + + var form = await http.Request.ReadFormAsync(ct); + var path = form["path"].FirstOrDefault(); + var file = form.Files.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(path)) + return Results.BadRequest(new { error = "Form field 'path' is required." }); + if (file is null || file.Length == 0) + return Results.BadRequest(new { error = "Form file 'file' is required." }); + + using var ms = new MemoryStream(); + await using (var stream = file.OpenReadStream()) + await stream.CopyToAsync(ms, ct); + + var sessionHub = ResolveSession(http, rootHub); + var ops = new MeshOperations(sessionHub); + var result = await ops.Upload(path, ms.ToArray()).FirstAsync().ToTask(ct); + return Results.Content(result, "application/json"); + } + + /// + /// Registers the bits the REST module needs that aren't already in DI from the + /// MCP wiring: lift the multipart upload size cap (default 30 MB is too small + /// for typical document uploads) and ensure is + /// bound (shared with MCP — same Mcp__BaseUrl env var). + /// + public static IServiceCollection AddMeshApi(this IServiceCollection services) + { + services.Configure(o => + { + // 200 MB — generous but bounded. Matches the working assumption that + // document / image / spreadsheet uploads are the common case; binaries + // larger than this should go through a different ingest path. + o.MultipartBodyLengthLimit = 200L * 1024 * 1024; + o.ValueLengthLimit = int.MaxValue; + o.MultipartHeadersLengthLimit = int.MaxValue; + }); + + // McpConfiguration is already bound by AddMeshMcp(); BindConfiguration is + // idempotent so a second call is harmless if the MCP wiring is absent. + services.AddOptions().BindConfiguration("Mcp"); + + return services; + } + + private static IMessageHub ResolveSession(HttpContext http, IMessageHub rootHub) + { + var logger = http.RequestServices.GetRequiredService().CreateLogger(typeof(MeshApiEndpoints)); + return SessionHubResolver.ResolveSessionHub(rootHub, http, "api", logger); + } + + private static async Task RunString( + HttpContext http, + IMessageHub rootHub, + CancellationToken ct, + Func> work) + { + var sessionHub = ResolveSession(http, rootHub); + var ops = new MeshOperations(sessionHub); + var result = await work(ops).FirstAsync().ToTask(ct); + // MeshOperations returns either a JSON document or an "Error: …" sentinel string. + // Both are safe to ship as application/json — the error string is just a JSON-quoted + // value the client can branch on (mirrors the MCP-tool contract). + return Results.Content(result, "application/json"); + } + + private static string ResolveBaseUrl(HttpContext http, IOptions? mcp) + { + var configured = mcp?.Value.BaseUrl; + if (!string.IsNullOrEmpty(configured)) + return configured.TrimEnd('/'); + var req = http.Request; + return $"{req.Scheme}://{req.Host.Value}".TrimEnd('/'); + } + + // Request DTOs — the framework's System.Text.Json infrastructure binds JSON bodies + // by property name (case-insensitive). All optional fields default to null / false. + public record GetBody(string Path); + public record SearchBody(string Query, string? BasePath); + public record CreateBody(string Node); + public record UpdateBody(string Nodes); + public record PatchBody(string Path, string Fields); + public record DeleteBody(string Paths); + public record MoveBody(string SourcePath, string TargetPath); + public record CopyBody(string SourcePath, string TargetNamespace, bool Force = false); + public record PathBody(string Path); + public record ExecuteScriptBody(string Path, int? TimeoutSeconds); + public record NavigateBody(string Path); +} diff --git a/memex/Memex.Portal.Shared/App.razor b/memex/Memex.Portal.Shared/App.razor index ba7f6d51a..58898e0f0 100644 --- a/memex/Memex.Portal.Shared/App.razor +++ b/memex/Memex.Portal.Shared/App.razor @@ -21,6 +21,26 @@ + + @* + Load Monaco early, in parallel with HTML parsing, and expose a readiness + Promise that Blazor.start() awaits below. BlazorMonaco 3.4 ships Monaco + 0.54 whose `editor/editor.main.js` is a tiny AMD stub — the real 3.6MB + bundle (editor.api-*.js) is fetched asynchronously by the AMD loader. + If Blazor activates a circuit and renders a + before that async load finishes, BlazorMonaco's jsInterop calls + `monaco.editor.create(...)` while `monaco` is undefined, the circuit + crashes, and the user sees a broken page. Using require([...], cb) to + gate Blazor.start removes the race. + *@ + + + + Memex Portal @if (!string.IsNullOrEmpty(aiConnectionString)) @@ -36,11 +56,142 @@ - - - + + @* + Custom Blazor Server reconnect UI. Blazor adds one of these classes to + the container while handling a lost circuit: + - components-reconnect-show → attempting to reconnect + - components-reconnect-hide → hidden (reconnected) + - components-reconnect-retrying → retry in flight + - components-reconnect-failed → retries exhausted + - components-reconnect-rejected → server doesn't know this circuit (redeploy) + A deploy invalidates every circuit on the server, so stale clients hit + `rejected` (404 on reconnect). Instead of keeping the user stuck on a + generic "Reconnecting…" modal for minutes, auto-reload on the terminal + states so the user is back on a fresh circuit within a few seconds. + *@ +
+
+
+
Reconnecting…
+
The server was updated. Reloading the page to pick up the latest version.
+
+
+ + + - + + diff --git a/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs b/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs index 26510edd5..5cd18497f 100644 --- a/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs +++ b/memex/Memex.Portal.Shared/Authentication/ApiTokenAuthenticationHandler.cs @@ -1,5 +1,8 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Security.Claims; using System.Text.Encodings.Web; +using MeshWeaver.Messaging; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -32,14 +35,63 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.NoResult(); var tokenService = serviceProvider.GetRequiredService(); - var apiToken = await tokenService.ValidateTokenAsync(rawToken); + // HTTP boundary — bridge IObservable to Task once. The service exposes + // IObservable per the "no async in hub-reachable code" rule. + var apiToken = await tokenService.ValidateToken(rawToken).FirstAsync().ToTask(); if (apiToken == null) return AuthenticateResult.Fail("Invalid or expired API token"); + var claims = BuildClaims(apiToken).ToList(); + + // Enrich with DB-resolved AccessAssignment roles so Bearer requests + // see the same role set as cookie/OAuth sessions. Without this, the + // principal only carries roles that were stamped on the API token at + // creation time — any AccessAssignment granted to the user later + // (e.g. an admin promotion after the token was minted) would silently + // not apply for MCP requests, even though the same user logging in + // through the browser would see them. Live mesh query, bounded so a + // wedged data source can't slow auth. + try + { + var dbRoles = await UserRoleResolver.LoadDbRolesAsync(serviceProvider, apiToken.UserId); + foreach (var role in dbRoles) + { + if (string.IsNullOrEmpty(role)) continue; + if (claims.Any(c => c.Type == ClaimTypes.Role && c.Value == role)) + continue; + claims.Add(new Claim(ClaimTypes.Role, role)); + } + } + catch + { + // Role enrichment is best-effort; the token's own Roles still apply. + } + + var identity = new ClaimsIdentity(claims, SchemeName); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, SchemeName); + + // Login tracking lives in UserContextMiddleware so it fires for both + // Bearer and cookie authentication on the same code path — see + // UserContextMiddleware.TrackLogin. + return AuthenticateResult.Success(ticket); + } + + /// + /// Builds the claim list for an authenticated API token. Public + static + /// so unit tests can assert the claim shape (in particular: that + /// become + /// claims) without needing an HTTP host. + /// Mirrors what UserContextMiddleware.ExtractUserContext() reads + /// back into . + /// + public static IReadOnlyList BuildClaims(MeshWeaver.Mesh.Security.ApiToken apiToken) + { // Build claims matching UserContextMiddleware.ExtractUserContext(): // ObjectId = preferred_username // Name = ClaimTypes.Name or "name" // Email = ClaimTypes.Email or "email" + // Roles = each ClaimTypes.Role claim → AccessContext.Roles var claims = new List { new("preferred_username", apiToken.UserId), @@ -51,10 +103,21 @@ protected override async Task HandleAuthenticateAsync() new("token_label", apiToken.Label), }; - var identity = new ClaimsIdentity(claims, SchemeName); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, SchemeName); + // Stamp the token's Roles list as ClaimTypes.Role claims. Without + // this, UserContextMiddleware sets AccessContext.Roles to an empty + // list and SecurityService.GetEffectivePermissions can't resolve + // claim-based Admin — every API-token request that depended on a + // role grant rather than a static AccessAssignment got denied. + // The token's Roles surface is exactly the right vehicle: the + // creator chose them at token creation; the validator preserves + // them through ValidateTokenResponse.Roles; the auth handler + // mints them onto the principal here. + foreach (var role in apiToken.Roles) + { + if (!string.IsNullOrEmpty(role)) + claims.Add(new Claim(ClaimTypes.Role, role)); + } - return AuthenticateResult.Success(ticket); + return claims; } } diff --git a/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs b/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs index fe49f8f7f..5b6a7107d 100644 --- a/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs +++ b/memex/Memex.Portal.Shared/Authentication/ApiTokenController.cs @@ -1,4 +1,7 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Security.Claims; +using System.Threading; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +22,7 @@ public class ApiTokenController(IServiceProvider serviceProvider) : ControllerBa /// Creates a new API token. Returns the raw token once — it cannot be retrieved again. /// [HttpPost] - public async Task CreateToken([FromBody] CreateTokenRequest request) + public Task CreateToken([FromBody] CreateTokenRequest request, CancellationToken ct) { var userId = User.FindFirstValue("preferred_username") ?? User.FindFirstValue(ClaimTypes.NameIdentifier) @@ -32,62 +35,70 @@ public async Task CreateToken([FromBody] CreateTokenRequest reque ?? ""; if (string.IsNullOrEmpty(userId)) - return Unauthorized("No user identity found"); + return Task.FromResult(Unauthorized("No user identity found")); DateTimeOffset? expiresAt = request.ExpiresInDays > 0 ? DateTimeOffset.UtcNow.AddDays(request.ExpiresInDays.Value) : null; - var (rawToken, node) = await tokenService.CreateTokenAsync( - userId, userName, userEmail, request.Label ?? "API Token", expiresAt); + var label = request.Label ?? "API Token"; - return Ok(new CreateTokenResponse - { - RawToken = rawToken, - NodePath = node.Path, - Label = request.Label ?? "API Token", - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = expiresAt, - }); + // No await: pull IObservable up to the controller's return type. The + // single bridge to Task happens at .ToTask(ct) — passing the + // request's cancellation token so a client disconnect tears down the + // reactive subscription instead of orphaning it. + return tokenService.CreateToken(userId, userName, userEmail, label, expiresAt) + .Select(creation => (IActionResult)Ok(new CreateTokenResponse + { + RawToken = creation.RawToken, + NodePath = creation.Node.Path, + Label = label, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = expiresAt, + })) + .FirstAsync() + .ToTask(ct); } /// /// Lists all tokens for the current user. Never returns raw tokens. /// [HttpGet] - public async Task ListTokens() + public Task ListTokens(CancellationToken ct) { var userId = User.FindFirstValue("preferred_username") ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; if (string.IsNullOrEmpty(userId)) - return Unauthorized("No user identity found"); + return Task.FromResult(Unauthorized("No user identity found")); - var tokens = await tokenService.GetTokensForUserAsync(userId); - return Ok(tokens); + return tokenService.GetTokensForUser(userId) + .Select(tokens => (IActionResult)Ok(tokens)) + .FirstAsync() + .ToTask(ct); } /// /// Revokes a token by its node path. /// [HttpDelete("{*nodePath}")] - public async Task RevokeToken(string nodePath) + public Task RevokeToken(string nodePath, CancellationToken ct) { - // Verify the token belongs to the current user var userId = User.FindFirstValue("preferred_username") ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; if (string.IsNullOrEmpty(userId)) - return Unauthorized("No user identity found"); + return Task.FromResult(Unauthorized("No user identity found")); - var tokens = await tokenService.GetTokensForUserAsync(userId); - if (!tokens.Any(t => t.NodePath == nodePath)) - return NotFound("Token not found or does not belong to you"); - - var success = await tokenService.RevokeTokenAsync(nodePath); - return success ? Ok() : NotFound(); + return tokenService.GetTokensForUser(userId) + .SelectMany(tokens => tokens.Any(t => t.NodePath == nodePath) + ? tokenService.RevokeToken(nodePath) + .Select(success => success ? (IActionResult)Ok() : NotFound()) + : Observable.Return(NotFound("Token not found or does not belong to you"))) + .FirstAsync() + .ToTask(ct); } } diff --git a/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs b/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs index 43a1686ee..744bc7b53 100644 --- a/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs +++ b/memex/Memex.Portal.Shared/Authentication/ApiTokenService.cs @@ -1,6 +1,9 @@ using System.Reactive.Linq; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Security; using MeshWeaver.Mesh.Services; @@ -14,10 +17,22 @@ namespace Memex.Portal.Shared.Authentication; /// /// Service for creating, validating, and revoking API tokens. -/// Tokens are stored as MeshNodes with nodeType "ApiToken". -/// Raw tokens are never persisted — only their SHA-256 hash. +/// Tokens are stored as MeshNodes with nodeType "ApiToken". Raw tokens +/// are never persisted — only their SHA-256 hash. +/// +/// +/// 🚨 No async / Task / FromAsync / await anywhere in this file. Every +/// reachable method returns and the chain +/// stays observable end-to-end. Reads of known paths go through +/// hub.GetMeshNode(path) (one-shot) or +/// workspace.GetMeshNodeStream(path) (live); listings go through +/// workspace.GetQuery(id, queries...) (synced + path-keyed dedup). +/// QueryAsync / iteration is forbidden +/// in this file per Doc/Architecture/AsynchronousCalls.md and +/// Doc/Architecture/SyncedMeshNodeQueries.md. +/// /// -internal class ApiTokenService(IMeshService nodeFactory, IMeshService meshQuery, IMessageHub hub, ILogger logger) +internal class ApiTokenService(IMeshService nodeFactory, IMessageHub hub, ILogger logger) { private const string TokenPrefix = "mw_"; private const int TokenByteLength = 32; @@ -39,375 +54,399 @@ public IObservable CreateToken( var hash = HashToken(rawToken); var hashPrefix = hash[..12]; - var apiToken = new ApiToken - { - TokenHash = hash, - UserId = userId, - UserName = userName, - UserEmail = userEmail, - Label = label, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = expiresAt, - }; - - var userTokenNamespace = $"User/{userId}/{ApiTokenNamespace}"; - var userNode = new MeshNode(hashPrefix, userTokenNamespace) - { - Name = $"API Token: {label}", - NodeType = NodeTypeApiToken, - State = MeshNodeState.Active, - Content = apiToken, - }; + // Per-user partition layout (Repair v10): tokens live at + // {userId}/ApiToken/{hashPrefix}, NOT under User/{userId}/ApiToken. + // The global ApiToken/{hashPrefix} index entry routes incoming + // bearer tokens to the right user-scoped node at validation time. + var userTokenNamespace = $"{userId}/{ApiTokenNamespace}"; + var assignmentPath = $"{userId}/_Access/{userId}_Access"; - var accessService = hub.ServiceProvider.GetService(); + var rolesObs = ResolveSelfScopeRoles(assignmentPath); - // Reactive chain: create user node (as current user), then create index node - // (promoted to System identity). Emits the raw token + created node once both - // writes commit, or errors on the first failure. - return nodeFactory.CreateNode(userNode) - .SelectMany(created => + return rolesObs.SelectMany(capturedRoles => + { + var apiToken = new ApiToken { - var indexNode = new MeshNode(hashPrefix, ApiTokenNamespace) + TokenHash = hash, + UserId = userId, + UserName = userName, + UserEmail = userEmail, + Label = label, + CreatedAt = DateTimeOffset.UtcNow, + ExpiresAt = expiresAt, + Roles = capturedRoles, + }; + + var userNode = new MeshNode(hashPrefix, userTokenNamespace) + { + Name = $"API Token: {label}", + NodeType = NodeTypeApiToken, + State = MeshNodeState.Active, + MainNode = userId, + Content = apiToken, + }; + + var accessService = hub.ServiceProvider.GetService(); + + return nodeFactory.CreateNode(userNode) + .SelectMany(created => { - Name = $"API Token: {label}", - NodeType = NodeTypeApiToken, - State = MeshNodeState.Active, - Content = new ApiTokenIndex + var indexNode = new MeshNode(hashPrefix, ApiTokenNamespace) { - TokenHash = hash, - TokenPath = created.Path, - }, - }; - - // Index writes require System identity (users don't have Create on ApiToken/). - IObservable indexObs; - if (accessService != null) - { - using (accessService.SwitchAccessContext( - new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" })) + Name = $"API Token: {label}", + NodeType = NodeTypeApiToken, + State = MeshNodeState.Active, + Content = new ApiTokenIndex + { + TokenHash = hash, + TokenPath = created.Path, + }, + }; + + // Index writes require System identity — the global ApiToken/ + // namespace is a separately-gated partition for security + // infrastructure that ordinary users don't have Create on. + // See git history on this file for the SwitchAccessContext- + // outside-Defer bug that this lambda layout fixes (System + // context must be active during CaptureContext at Subscribe + // time, not when the outer using-block returned). + IObservable indexObs; + if (accessService != null) + { + indexObs = Observable.Defer(() => + { + var disp = accessService.SwitchAccessContext( + new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" }); + return nodeFactory.CreateNode(indexNode).Finally(() => disp.Dispose()); + }); + } + else { indexObs = nodeFactory.CreateNode(indexNode); } - } - else - { - indexObs = nodeFactory.CreateNode(indexNode); - } - logger.LogInformation("Creating API token {Label} for user {UserId} (hash prefix {HashPrefix})", - label, userId, hashPrefix); + logger.LogInformation("Creating API token {Label} for user {UserId} (hash prefix {HashPrefix})", + label, userId, hashPrefix); - return indexObs.Select(_ => new TokenCreationResult(rawToken, created)); - }); + return indexObs.Select(_ => new TokenCreationResult(rawToken, created)); + }); + }); } - public async Task<(string RawToken, MeshNode Node)> CreateTokenAsync( - string userId, string userName, string userEmail, string label, DateTimeOffset? expiresAt = null) + /// + /// Reads the user's self-scope at + /// {userId}/_Access/{userId}_Access and emits the (non-denied) + /// role IDs assigned there. Pure observable composition — one-shot + /// under System + /// identity, then .Select. Emits an empty array on missing + /// assignment or read failure (the issued token still has identity + /// but no role grants — correct outcome). + /// + private IObservable> ResolveSelfScopeRoles(string assignmentPath) { - var rawBytes = RandomNumberGenerator.GetBytes(TokenByteLength); - var rawToken = TokenPrefix + Convert.ToBase64String(rawBytes) - .Replace("+", "-").Replace("/", "_").TrimEnd('='); - - var hash = HashToken(rawToken); - var hashPrefix = hash[..12]; + var accessService = hub.ServiceProvider.GetService(); - var apiToken = new ApiToken - { - TokenHash = hash, - UserId = userId, - UserName = userName, - UserEmail = userEmail, - Label = label, - CreatedAt = DateTimeOffset.UtcNow, - ExpiresAt = expiresAt, - }; - - // Store the full token under the user's namespace - var userTokenNamespace = $"User/{userId}/{ApiTokenNamespace}"; - var userNode = new MeshNode(hashPrefix, userTokenNamespace) - { - Name = $"API Token: {label}", - NodeType = NodeTypeApiToken, - State = MeshNodeState.Active, - Content = apiToken, - }; - - var created = await nodeFactory.CreateNodeAsync(userNode); - - // Store a lightweight index pointer at the original location for O(1) validation lookup. - // Promote to System identity — users don't have Create permission on the top-level - // ApiToken/ namespace, but this index is infrastructure (not user data) so it must - // always be creatable as part of token issuance. - var indexNode = new MeshNode(hashPrefix, ApiTokenNamespace) - { - Name = $"API Token: {label}", - NodeType = NodeTypeApiToken, - State = MeshNodeState.Active, - Content = new ApiTokenIndex + // Observable.Using ties the AsyncLocal System scope's lifetime to + // the Subscribe of the inner observable, not to the lambda body's + // return — same shape used by ApiTokenNodeType.HandleValidateToken + // for the same reason (Defer-style subscribe-time capture). + var readUnderSystem = accessService != null + ? Observable.Using( + () => accessService.ImpersonateAsSystem(), + _ => hub.GetMeshNode(assignmentPath, TimeSpan.FromSeconds(5))) + : hub.GetMeshNode(assignmentPath, TimeSpan.FromSeconds(5)); + + return readUnderSystem + .Select(node => { - TokenHash = hash, - TokenPath = created.Path, - }, - }; - - var accessService = hub.ServiceProvider.GetService(); - if (accessService != null) - { - using (accessService.SwitchAccessContext(new AccessContext { ObjectId = WellKnownUsers.System, Name = "system-security" })) + var assignment = node?.Content as AccessAssignment ?? ExtractAccessAssignment(node); + if (assignment is null) + return (IReadOnlyCollection)Array.Empty(); + return assignment.Roles + .Where(r => !r.Denied && !string.IsNullOrEmpty(r.Role)) + .Select(r => r.Role) + .Distinct() + .ToArray(); + }) + .Catch, Exception>(ex => { - await nodeFactory.CreateNodeAsync(indexNode); - } - } - else - { - await nodeFactory.CreateNodeAsync(indexNode); - } - - logger.LogInformation("Created API token {Label} for user {UserId} (hash prefix {HashPrefix})", - label, userId, hashPrefix); - - return (rawToken, created); + logger.LogWarning(ex, + "Failed to resolve self-scope roles from {Path} for token creation; continuing with empty role set", + assignmentPath); + return Observable.Return>(Array.Empty()); + }); } /// - /// Queries nodes using the system identity to bypass access control. - /// ApiTokenService is infrastructure code that needs unrestricted read access. + /// Reactive token validation. Reads index node at + /// ApiToken/{hashPrefix} via hub.GetMeshNode (one-shot, + /// authoritative — never QueryAsync for a known path per + /// Doc/Architecture/AsynchronousCalls.md); when the index + /// points at a user-scoped token, follows the pointer with a second + /// one-shot read. The chain is fully observable — no + /// FromAsync, no FirstOrDefaultAsync.AsTask(), no + /// await. /// - private IAsyncEnumerable QueryAsSystemAsync(string query, CancellationToken ct = default) - => meshQuery.QueryAsync( - MeshQueryRequest.FromQuery(query, WellKnownUsers.System), ct: ct); - - public async Task ValidateTokenAsync(string rawToken) + public IObservable ValidateToken(string rawToken) { if (string.IsNullOrEmpty(rawToken) || !rawToken.StartsWith(TokenPrefix)) - return null; + return Observable.Return(null); var hash = HashToken(rawToken); var hashPrefix = hash[..12]; var indexPath = $"{ApiTokenNamespace}/{hashPrefix}"; - var indexNode = await QueryAsSystemAsync($"path:{indexPath}").FirstOrDefaultAsync(); - if (indexNode == null) - return null; + return ReadAsSystem(indexPath) + .SelectMany(indexNode => + { + if (indexNode == null) + return Observable.Return<(MeshNode? node, ApiToken? token)>((null, null)); - // Follow index pointer to the full token, or handle legacy tokens directly - MeshNode? tokenNode; - ApiToken? apiToken; - var index = indexNode.Content as ApiTokenIndex ?? ExtractApiTokenIndex(indexNode); - if (index != null) - { - // New format: index pointer -> follow to user namespace - if (!string.Equals(index.TokenHash, hash, StringComparison.OrdinalIgnoreCase)) - return null; - tokenNode = await QueryAsSystemAsync($"path:{index.TokenPath}").FirstOrDefaultAsync(); - apiToken = tokenNode?.Content as ApiToken ?? ExtractApiToken(tokenNode); - } - else - { - // Legacy format: full ApiToken at index path - tokenNode = indexNode; - apiToken = indexNode.Content as ApiToken ?? ExtractApiToken(indexNode); - } + var index = indexNode.Content as ApiTokenIndex ?? ExtractApiTokenIndex(indexNode); + if (index != null) + { + if (!string.Equals(index.TokenHash, hash, StringComparison.OrdinalIgnoreCase)) + return Observable.Return<(MeshNode? node, ApiToken? token)>((null, null)); + return ReadAsSystem(index.TokenPath) + .Select(tn => ( + node: tn, + token: (tn?.Content as ApiToken) ?? ExtractApiToken(tn))); + } + // Legacy format: full ApiToken at index path. + return Observable.Return(( + node: (MeshNode?)indexNode, + token: (indexNode.Content as ApiToken) ?? ExtractApiToken(indexNode))); + }) + .Select(t => FinalizeToken(t.node, t.token, hash, hashPrefix)); + } + + private IObservable ReadAsSystem(string path) + { + var accessService = hub.ServiceProvider.GetService(); + return accessService != null + ? Observable.Using( + () => accessService.ImpersonateAsSystem(), + _ => hub.GetMeshNode(path, TimeSpan.FromSeconds(5))) + : hub.GetMeshNode(path, TimeSpan.FromSeconds(5)); + } + private ApiToken? FinalizeToken(MeshNode? tokenNode, ApiToken? apiToken, string hash, string hashPrefix) + { if (apiToken == null) return null; - if (!string.Equals(apiToken.TokenHash, hash, StringComparison.OrdinalIgnoreCase)) return null; - if (apiToken.IsRevoked) { logger.LogDebug("Token {HashPrefix} is revoked", hashPrefix); return null; } - if (apiToken.ExpiresAt.HasValue && apiToken.ExpiresAt.Value < DateTimeOffset.UtcNow) { logger.LogDebug("Token {HashPrefix} has expired", hashPrefix); return null; } - // Update LastUsedAt (fire-and-forget, non-critical) - try + // Update LastUsedAt via the canonical workspace remote stream — + // fire-and-forget (non-critical telemetry). Subscribe is mandatory + // because Update is cold; the empty error handler keeps the cold + // observable's GC-time fire-and-forget warning quiet on writes + // that hit a deleted node. + if (tokenNode != null) { - var updated = apiToken with { LastUsedAt = DateTimeOffset.UtcNow }; - var updatedNode = tokenNode! with { Content = updated }; - hub.Post(new UpdateNodeRequest(updatedNode)); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to update LastUsedAt for token {HashPrefix}", hashPrefix); + hub.GetWorkspace() + .GetMeshNodeStream(tokenNode.Path) + .Update(node => node with { Content = (node.Content as ApiToken ?? apiToken) with { LastUsedAt = DateTimeOffset.UtcNow } }) + .Subscribe(_ => { }, _ => { }); } return apiToken; } /// - /// Reactive token revocation — marks the token as revoked via - /// and removes the index pointer. - /// No async/await. Emits true on success, false if token not found, errors on failure. + /// Reactive token revocation. Writes the IsRevoked flag through + /// workspace.GetMeshNodeStream(path).Update(...) — the + /// canonical remote-stream write per + /// Doc/Architecture/AsynchronousCalls.md. No + /// forwarding (the previous shape + /// timed out in distributed deployments when the per-node hub's + /// forwarded request didn't get a response within ~30s). + /// + /// The global index entry is hard-deleted as a fire-and-forget + /// side effect — the index miss is a defense-in-depth gate on top of + /// the authoritative IsRevoked flag, not a primary requirement + /// for the revoke to be effective. /// - public IObservable RevokeToken(string tokenNodePath) => - Observable.FromAsync(() => meshQuery.QueryAsync( - MeshQueryRequest.FromQuery($"path:{tokenNodePath}", WellKnownUsers.System)) - .FirstOrDefaultAsync().AsTask()) - .SelectMany(node => - { - var apiToken = node?.Content as ApiToken ?? ExtractApiToken(node); - if (node == null || apiToken == null) - return Observable.Return(false); - - var revoked = apiToken with { IsRevoked = true }; - var updatedNode = node with { Content = revoked }; - - // Delete index entry if distinct from the main node. - if (apiToken.TokenHash.Length >= 12) - { - var hashPrefix = apiToken.TokenHash[..12]; - var indexPath = $"{ApiTokenNamespace}/{hashPrefix}"; - if (tokenNodePath != indexPath) - hub.Post(new DeleteNodeRequest(indexPath)); - } + public IObservable RevokeToken(string tokenNodePath) + { + var workspace = hub.GetWorkspace(); + var indexPath = DeriveIndexPath(tokenNodePath); - logger.LogInformation("Revoking API token at {Path}", tokenNodePath); - return nodeFactory.UpdateNode(updatedNode).Select(_ => true); - }); + logger.LogInformation("Revoking API token at {Path}", tokenNodePath); - /// - /// Reactive hard-delete — removes both the primary token node and its index entry. - /// No async/await. Emits true on success, errors on failure. - /// - public IObservable DeleteToken(string tokenNodePath) => - Observable.FromAsync(() => meshQuery.QueryAsync( - MeshQueryRequest.FromQuery($"path:{tokenNodePath}", WellKnownUsers.System)) - .FirstOrDefaultAsync().AsTask()) - .SelectMany(node => + var primary = workspace.GetMeshNodeStream(tokenNodePath) + .Update(current => { - var apiToken = node?.Content as ApiToken ?? ExtractApiToken(node); - var hashPrefix = apiToken?.TokenHash is { Length: >= 12 } h ? h[..12] : null; - - if (!string.IsNullOrEmpty(hashPrefix)) - { - var indexPath = $"{ApiTokenNamespace}/{hashPrefix}"; - if (indexPath != tokenNodePath) - hub.Post(new DeleteNodeRequest(indexPath)); - } - - logger.LogInformation("Deleting API token at {Path}", tokenNodePath); - return nodeFactory.DeleteNode(tokenNodePath); + var token = current.Content as ApiToken ?? ExtractApiToken(current); + if (token == null) return current; + // Drop the in-memory ValidationCache entry so the next + // ValidateToken call re-reads from this hub and observes + // IsRevoked=true. Without this, the 5-min cache outlives + // the revoke and validation keeps succeeding. + ApiTokenNodeType.InvalidateValidationCache(token.TokenHash); + return current with { Content = token with { IsRevoked = true } }; + }) + .Do(updatedNode => + { + // Force the per-node hub to persist the patched node. The + // sync-protocol path (workspace.GetMeshNodeStream(remote) + // .Update) updates the mesh-hub-side stream and emits a + // DataChangeRequest to the per-node hub, but the per-node + // hub's data source `saveSub` only fires on `ownStream` + // emissions — and those don't fire for sync-driven changes, + // so persistence never sees the IsRevoked=true update. The + // SaveMeshNodeRequest below routes to the per-node hub's + // HandleSaveMeshNode which writes through IStorageService + // (firing IDataChangeNotifier.Updated, so the synced + // GetTokensForUser view picks up the change). + hub.Post(new SaveMeshNodeRequest(updatedNode), + o => o.WithTarget(new Address(tokenNodePath))); + }) + .Select(_ => true) + .Catch(ex => + { + logger.LogWarning(ex, "RevokeToken failed for {Path}", tokenNodePath); + return Observable.Return(false); }); + // Chain the global-index delete into the returned observable rather + // than firing a separate Subscribe — see the matching comment in + // DeleteToken. A missing index entry is fine: the Catch returns false + // and the primary revoke result wins. + if (indexPath == null || indexPath == tokenNodePath) + return primary; + + return primary.SelectMany(result => + nodeFactory.DeleteNode(indexPath) + .Catch(_ => Observable.Return(false)) + .Select(_ => result)); + } + /// - /// Hard-deletes a token node (and its index entry, if present). - /// Used to clean up revoked/expired tokens from the UI list. + /// Reactive hard-delete. Removes the user-scoped token node and the + /// global index entry (fire-and-forget). The user-scoped delete goes + /// through ; this is the + /// authoritative removal and the only outcome the caller observes. /// - public async Task DeleteTokenAsync(string tokenNodePath) + public IObservable DeleteToken(string tokenNodePath) { - // Look up the node to find the hash prefix so we can clean the index too. - var node = await QueryAsSystemAsync($"path:{tokenNodePath}").FirstOrDefaultAsync(); - var apiToken = node?.Content as ApiToken ?? ExtractApiToken(node); - var hashPrefix = apiToken?.TokenHash is { Length: >= 12 } h ? h[..12] : null; + var indexPath = DeriveIndexPath(tokenNodePath); - // Delete the primary token node (under User/{userId}/ApiToken/...) - hub.Post(new DeleteNodeRequest(tokenNodePath)); + logger.LogInformation("Deleting API token at {Path}", tokenNodePath); - // Delete the index pointer at the top-level ApiToken namespace. - if (!string.IsNullOrEmpty(hashPrefix)) - { - var indexPath = $"{ApiTokenNamespace}/{hashPrefix}"; - if (indexPath != tokenNodePath) - hub.Post(new DeleteNodeRequest(indexPath)); - } + var primary = nodeFactory.DeleteNode(tokenNodePath) + .Select(_ => true) + .Catch(ex => + { + logger.LogWarning(ex, "DeleteToken failed for {Path}", tokenNodePath); + return Observable.Return(false); + }); - logger.LogInformation("Deleted API token at {Path}", tokenNodePath); + // Chain the index-entry delete into the returned observable rather than + // firing a separate Subscribe. The previous shape leaked a pending + // hub.Observe callback past test dispose — the response arrives only + // after routing surfaces NotFound (~15ms+) but the test's await + // completes faster, so the dispose-time Quiescing watchdog flags the + // pending callback as a leaked subscription. Chaining here also makes + // a missing-index case (token already gone) a non-failure of the whole + // operation: the inner Catch swallows it and the primary result wins. + if (indexPath == null || indexPath == tokenNodePath) + return primary; + + return primary.SelectMany(result => + nodeFactory.DeleteNode(indexPath) + .Catch(_ => Observable.Return(false)) + .Select(_ => result)); } - public async Task RevokeTokenAsync(string tokenNodePath) + /// + /// Live list of the user's tokens via the canonical synced query + /// (workspace.GetQuery). The synced query gives us path-keyed + /// dedup across the user-scope and legacy global namespaces, + /// all-Initial gating, and provider fan-out — see + /// Doc/Architecture/SyncedMeshNodeQueries.md. The cache id is + /// per-user so re-mounts (settings tab re-render) reuse the upstream + /// subscription instead of cycling Initial waves. + /// + public IObservable> GetTokensForUser(string userId) { - var node = await QueryAsSystemAsync($"path:{tokenNodePath}").FirstOrDefaultAsync(); - if (node == null) - return false; - - var apiToken = node.Content as ApiToken ?? ExtractApiToken(node); - if (apiToken == null) - return false; - - var revoked = apiToken with { IsRevoked = true }; - var updatedNode = node with { Content = revoked }; - hub.Post(new UpdateNodeRequest(updatedNode)); - - // Also revoke the index node at ApiToken/{hashPrefix} if it exists - if (apiToken.TokenHash.Length >= 12) - { - var hashPrefix = apiToken.TokenHash[..12]; - var indexPath = $"{ApiTokenNamespace}/{hashPrefix}"; - if (tokenNodePath != indexPath) + var workspace = hub.GetWorkspace(); + var userTokenNamespace = $"{userId}/{ApiTokenNamespace}"; + + return workspace.GetQuery( + $"api-tokens:{userId}", + $"namespace:{userTokenNamespace} nodeType:{NodeTypeApiToken}", + // Legacy fallback: tokens at the global ApiToken namespace + // that pre-date the per-user partition migration. Filtered + // by UserId in the projection below — the synced query + // can't express that predicate, so we over-fetch globally + // and prune. + $"namespace:{ApiTokenNamespace} nodeType:{NodeTypeApiToken}") + .Select(snapshot => { - var indexNode = await QueryAsSystemAsync($"path:{indexPath}").FirstOrDefaultAsync(); - if (indexNode != null) + var tokens = new List(); + var seenPrefixes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var node in snapshot) { - hub.Post(new DeleteNodeRequest(indexPath)); + if (node.Path is null) continue; + var apiToken = node.Content as ApiToken ?? ExtractApiToken(node); + if (apiToken == null) continue; + + // Legacy nodes in the global namespace must match the + // calling userId; per-user-partition nodes are scoped + // by namespace and don't need this filter, but the + // check is cheap and unifies the projection. + if (apiToken.UserId != userId) continue; + + var hashPrefix = apiToken.TokenHash.Length >= 8 + ? apiToken.TokenHash[..8] + : apiToken.TokenHash; + if (!seenPrefixes.Add(hashPrefix)) continue; + + tokens.Add(ToInfo(node, apiToken)); } - } - } - - logger.LogInformation("Revoked API token at {Path}", tokenNodePath); - return true; + return (IReadOnlyList)tokens; + }); } - public async Task> GetTokensForUserAsync(string userId) + /// + /// Derives the global ApiToken/{hashPrefix} index path from a + /// user-scoped token node path. sets the + /// node Id to the 12-char hash prefix, so the last path segment is + /// reliably the prefix used to build the index entry. Returns null + /// for malformed paths (no slash, trailing slash). + /// + private static string? DeriveIndexPath(string tokenNodePath) { - var tokens = new List(); - - // Query user-scoped tokens (new format) - // ApiToken is a satellite type (MainNode != Path), so we need nodeType: condition - // to trigger GetAllChildrenAsync which includes satellites in the results. - var userTokenNamespace = $"User/{userId}/{ApiTokenNamespace}"; - await foreach (var node in QueryAsSystemAsync($"namespace:{userTokenNamespace} nodeType:{NodeTypeApiToken}")) - { - var apiToken = node.Content as ApiToken ?? ExtractApiToken(node); - if (apiToken == null) - continue; - - tokens.Add(new ApiTokenInfo - { - NodePath = node.Path, - Label = apiToken.Label, - CreatedAt = apiToken.CreatedAt, - ExpiresAt = apiToken.ExpiresAt, - LastUsedAt = apiToken.LastUsedAt, - IsRevoked = apiToken.IsRevoked, - HashPrefix = apiToken.TokenHash.Length >= 8 ? apiToken.TokenHash[..8] : apiToken.TokenHash, - }); - } - - // Fallback: also check legacy tokens at top-level ApiToken namespace - await foreach (var node in QueryAsSystemAsync($"namespace:{ApiTokenNamespace} nodeType:{NodeTypeApiToken}")) - { - var apiToken = node.Content as ApiToken ?? ExtractApiToken(node); - if (apiToken == null || apiToken.UserId != userId) - continue; - - // Skip if we already found this token in the user namespace - var hashPrefix = apiToken.TokenHash.Length >= 8 ? apiToken.TokenHash[..8] : apiToken.TokenHash; - if (tokens.Any(t => t.HashPrefix == hashPrefix)) - continue; - - tokens.Add(new ApiTokenInfo - { - NodePath = node.Path, - Label = apiToken.Label, - CreatedAt = apiToken.CreatedAt, - ExpiresAt = apiToken.ExpiresAt, - LastUsedAt = apiToken.LastUsedAt, - IsRevoked = apiToken.IsRevoked, - HashPrefix = hashPrefix, - }); - } - - return tokens; + if (string.IsNullOrEmpty(tokenNodePath)) return null; + var lastSlash = tokenNodePath.LastIndexOf('/'); + if (lastSlash < 0 || lastSlash >= tokenNodePath.Length - 1) return null; + var hashPrefix = tokenNodePath[(lastSlash + 1)..]; + return $"{ApiTokenNamespace}/{hashPrefix}"; } + private static ApiTokenInfo ToInfo(MeshNode node, ApiToken apiToken) => new() + { + NodePath = node.Path, + Label = apiToken.Label, + CreatedAt = apiToken.CreatedAt, + ExpiresAt = apiToken.ExpiresAt, + LastUsedAt = apiToken.LastUsedAt, + IsRevoked = apiToken.IsRevoked, + HashPrefix = apiToken.TokenHash.Length >= 8 ? apiToken.TokenHash[..8] : apiToken.TokenHash, + }; + public static string HashToken(string rawToken) { var bytes = System.Text.Encoding.UTF8.GetBytes(rawToken); @@ -450,6 +489,24 @@ public static string HashToken(string rawToken) } return null; } + + private AccessAssignment? ExtractAccessAssignment(MeshNode? node) + { + if (node?.Content is AccessAssignment direct) return direct; + if (node?.Content is System.Text.Json.JsonElement jsonElement) + { + try + { + return System.Text.Json.JsonSerializer.Deserialize( + jsonElement.GetRawText(), hub.JsonSerializerOptions); + } + catch + { + return null; + } + } + return null; + } } /// diff --git a/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs b/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs index 78d407314..7d99255cc 100644 --- a/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs +++ b/memex/Memex.Portal.Shared/Authentication/DevAuthController.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Reactive.Linq; +using System.Security.Claims; using System.Text.Json; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Services; @@ -26,8 +27,12 @@ public DevAuthController(IMeshService meshQuery) [HttpPost("signin")] public async Task Login([FromForm] string personId, [FromForm] string? returnUrl) { - // Fetch the person node via IMeshService (bypasses security) - var node = await _meshQuery.QueryAsync($"path:User/{personId}").FirstOrDefaultAsync(); + // TODO(persistence-cull): framework boundary — review whether this should + // route through UserIdentityCache (sync) instead of awaiting the observable. + var change = await _meshQuery + .ObserveQuery(MeshQueryRequest.FromQuery($"path:User/{personId}")) + .FirstAsync(); + var node = change.Items.FirstOrDefault(); if (node?.NodeType != "User" || node.Content == null) { return BadRequest("Person not found"); @@ -39,7 +44,15 @@ public async Task Login([FromForm] string personId, [FromForm] st return BadRequest("Could not extract person info"); } - // Create claims: username is the node ID, email in content + // Create claims: username is the node ID, email in content. + // 🚨 preferred_username MUST be the username (node Id), NOT the email. + // UserContextMiddleware.ExtractUserContext takes ObjectId from + // preferred_username first; if that's the email, every downstream + // route targets `` instead of the user's partition and the + // portal renders "No node found at 'rbuergi@systemorph.com'". + // ApiTokenAuthenticationHandler already puts the username here — keep + // the dev login consistent so the user's partition (path = node Id) + // is the resolved home. var email = person.Email ?? ""; var username = node.Id; var claims = new List @@ -47,13 +60,13 @@ public async Task Login([FromForm] string personId, [FromForm] st new(ClaimTypes.NameIdentifier, username), new(ClaimTypes.Name, username), new("name", username), + new("preferred_username", username), }; if (!string.IsNullOrEmpty(email)) { claims.Add(new Claim(ClaimTypes.Email, email)); claims.Add(new Claim("email", email)); - claims.Add(new Claim("preferred_username", email)); } if (!string.IsNullOrEmpty(person.Role)) diff --git a/memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs b/memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs new file mode 100644 index 000000000..1022509bd --- /dev/null +++ b/memex/Memex.Portal.Shared/Authentication/GlobalAdminSeed.cs @@ -0,0 +1,67 @@ +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using Microsoft.Extensions.Configuration; + +namespace Memex.Portal.Shared.Authentication; + +/// +/// Seeds root-scope nodes that grant the Admin +/// role to each user listed under Auth:GlobalAdmins in configuration. +/// +/// Background: SecurityService.GetEffectiveRoles walks scopes from root +/// down and accumulates role assignments. Without a root-scope AccessAssignment +/// granting Admin, a configured Microsoft Entra ID user has zero roles on the +/// root scope, which surfaces as "lacks Read permission on 'Organization'" +/// when navigating to the NodeType detail page (and equivalent denials on +/// cross-partition operations like creating a new Organization). +/// +/// The test base ships an equivalent seed via +/// TestUsers.PublicAdminAccess() — production needs the same shape, +/// driven by config instead of hardcoded so each deployment can declare its own +/// admin list. See Doc/Architecture/AccessControl.md for the role +/// accumulation rules and src/MeshWeaver.Mesh.Contract/Security/AccessAssignment.cs +/// for the schema. +/// +public static class GlobalAdminSeed +{ + private const string ConfigSection = "Auth:GlobalAdmins"; + + /// + /// Builds AccessAssignment MeshNodes for every user id in + /// Auth:GlobalAdmins. Returns an empty array when the section is + /// missing or empty — safe to chain via builder.AddMeshNodes(...) + /// in environments that have no admins configured. + /// + public static MeshNode[] Build(IConfiguration configuration) + { + var ids = configuration.GetSection(ConfigSection).Get() + ?? []; + if (ids.Length == 0) + return []; + + var nodes = new MeshNode[ids.Length]; + for (var i = 0; i < ids.Length; i++) + { + var userId = ids[i].Trim(); + var assignment = new AccessAssignment + { + AccessObject = userId, + DisplayName = userId, + Roles = [new RoleAssignment { Role = "Admin" }], + }; + // Root-scope assignments live at namespace "_Access" with id + // "{userId}_Access" → path "_Access/{userId}_Access". + // SecurityService.Consume maps this to scope = "" (global). + // Namespace "" would put them at path "{userId}_Access" with no + // scope mapping. See TestUsers.CreateAccessNode for the same shape. + nodes[i] = new MeshNode(userId + "_Access", "_Access") + { + NodeType = "AccessAssignment", + Name = $"{userId} — Admin", + Content = assignment, + MainNode = "", + }; + } + return nodes; + } +} diff --git a/memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs b/memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs new file mode 100644 index 000000000..3abcbef95 --- /dev/null +++ b/memex/Memex.Portal.Shared/Authentication/McpAuthenticationExtensions.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.Authentication; + +namespace Memex.Portal.Shared.Authentication; + +/// +/// Separate auth wiring for the MCP endpoint. +/// +/// The Blazor portal uses cookie-based auth with a redirect-to-login challenge, which is +/// correct for browser users but fatal for MCP clients: Claude Desktop / Claude.ai follow +/// a 302 to an HTML login page and fail with "couldn't reach the server" instead of doing +/// OAuth discovery. +/// +/// MCP auth must be strictly Bearer-only: +/// * token validation goes to +/// * unauthed requests get 401 + WWW-Authenticate: Bearer resource_metadata="..." +/// emitted by the MCP SDK's own scheme, so clients can run OAuth discovery +/// * no leakage to cookie — no redirects, ever +/// +public static class McpAuthenticationExtensions +{ + public const string PolicyName = "McpAuth"; + + /// + /// Registers the ApiToken + MCP authentication schemes and the McpAuth + /// authorization policy. Call after the primary (cookie / OIDC) auth has been + /// registered — this adds to the existing authentication builder without + /// touching its defaults. + /// + public static IServiceCollection AddMcpAuthentication(this IServiceCollection services) + { + services.AddAuthentication() + .AddScheme( + ApiTokenAuthenticationHandler.SchemeName, _ => { }) + .AddMcp(ConfigureMcpAuth); + + services.AddAuthorization(options => + { + options.AddPolicy(PolicyName, policy => + { + policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme); + policy.RequireAuthenticatedUser(); + }); + }); + + return services; + } + + private static void ConfigureMcpAuth(McpAuthenticationOptions options) + { + // Bearer token validation → ApiToken handler. The MCP SDK constructor hardcodes + // ForwardAuthenticate = "Bearer" (a scheme that doesn't exist here); point it at + // the real scheme so token validation actually runs. + options.ForwardAuthenticate = ApiTokenAuthenticationHandler.SchemeName; + + // Leave Challenge on the MCP scheme itself so it emits + // 401 + WWW-Authenticate: Bearer resource_metadata="..." — that's what lets + // MCP clients discover the auth server. NEVER forward to cookie: that would + // produce a 302 to /login which MCP clients can't follow. + options.ForwardChallenge = null; + options.ForwardForbid = null; + options.ForwardDefaultSelector = null; + + options.ResourceMetadata = new ProtectedResourceMetadata + { + BearerMethodsSupported = { "header" }, + ScopesSupported = { "mcp" }, + }; + + options.Events = new McpAuthenticationEvents + { + OnResourceMetadataRequest = ctx => + { + var req = ctx.HttpContext.Request; + var origin = $"{req.Scheme}://{req.Host}"; + ctx.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = $"{origin}/mcp", + BearerMethodsSupported = { "header" }, + ScopesSupported = { "mcp" }, + AuthorizationServers = { origin }, + }; + return Task.CompletedTask; + }, + }; + } +} diff --git a/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs b/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs index c2f12aa05..afe2b518e 100644 --- a/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs +++ b/memex/Memex.Portal.Shared/Authentication/OAuthConnectController.cs @@ -1,5 +1,10 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -8,8 +13,8 @@ namespace Memex.Portal.Shared.Authentication; /// /// Minimal OAuth 2.0 authorization server for MCP clients (claude.ai Connectors, Claude Desktop). -/// Implements authorization code flow with PKCE. Issues mw_ API tokens as access tokens, -/// reusing the existing ApiTokenService infrastructure. +/// Implements authorization code flow with PKCE + RFC 7591 Dynamic Client Registration. +/// Issues mw_ API tokens as access tokens, reusing the existing ApiTokenService infrastructure. /// [ApiController] public class OAuthConnectController( @@ -28,22 +33,73 @@ public class OAuthConnectController( public IActionResult GetServerMetadata() { var origin = $"{Request.Scheme}://{Request.Host}"; + logger.LogInformation("OAuth metadata requested from {Origin}", origin); return Ok(new { - issuer = $"{origin}/connect", - authorization_endpoint = $"{origin}/connect/authorize", - token_endpoint = $"{origin}/connect/token", + issuer = origin, + authorization_endpoint = $"{origin}/authorize", + token_endpoint = $"{origin}/token", + registration_endpoint = $"{origin}/register", response_types_supported = new[] { "code" }, grant_types_supported = new[] { "authorization_code" }, code_challenge_methods_supported = new[] { "S256" }, + token_endpoint_auth_methods_supported = new[] { "none" }, }); } + /// + /// RFC 7591 — Dynamic Client Registration. + /// MCP clients (Claude Desktop, claude.ai Connectors) self-register here with their redirect URIs + /// before running the authorization flow. The mesh does not persist client registrations — + /// it issues a random client_id that the caller echoes back in /authorize and + /// /token; the code store validates client_id+redirect_uri consistency between those calls. + /// + [HttpPost("/register")] + [AllowAnonymous] + public IActionResult RegisterClient([FromBody] ClientRegistrationRequest? request) + { + if (request is null) + { + logger.LogWarning("OAuth /register called with empty or invalid body"); + return BadRequest(new { error = "invalid_client_metadata", error_description = "Request body is required" }); + } + + logger.LogInformation( + "OAuth client registration: client_name={ClientName}, redirect_uris={RedirectUris}, grant_types={GrantTypes}, auth_method={AuthMethod}", + request.ClientName ?? "(unset)", + request.RedirectUris is null ? "(none)" : string.Join(",", request.RedirectUris), + request.GrantTypes is null ? "(unset)" : string.Join(",", request.GrantTypes), + request.TokenEndpointAuthMethod ?? "(unset)"); + + if (request.RedirectUris is null || request.RedirectUris.Length == 0) + { + logger.LogWarning("OAuth /register rejected: redirect_uris missing for client {ClientName}", request.ClientName); + return BadRequest(new { error = "invalid_redirect_uri", error_description = "redirect_uris is required" }); + } + + var clientId = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24)) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + + var response = new ClientRegistrationResponse + { + ClientId = clientId, + ClientIdIssuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ClientName = request.ClientName, + RedirectUris = request.RedirectUris, + GrantTypes = request.GrantTypes ?? new[] { "authorization_code" }, + ResponseTypes = request.ResponseTypes ?? new[] { "code" }, + TokenEndpointAuthMethod = request.TokenEndpointAuthMethod ?? "none", + }; + + logger.LogInformation("Issued OAuth client_id {ClientId} for {ClientName}", clientId, request.ClientName); + return StatusCode(StatusCodes.Status201Created, response); + } + /// /// OAuth Authorization Endpoint — redirects authenticated users to the client's redirect_uri /// with an authorization code. Unauthenticated users are sent to /login first. /// - [HttpGet("connect/authorize")] + [HttpGet("/authorize")] public IActionResult Authorize( [FromQuery] string response_type, [FromQuery] string client_id, @@ -53,17 +109,30 @@ public IActionResult Authorize( [FromQuery] string? code_challenge, [FromQuery] string? code_challenge_method) { + logger.LogInformation( + "OAuth /authorize: response_type={ResponseType}, client_id={ClientId}, redirect_uri={RedirectUri}, has_state={HasState}, has_pkce={HasPkce}, authenticated={Authenticated}", + response_type, client_id, redirect_uri, + !string.IsNullOrEmpty(state), !string.IsNullOrEmpty(code_challenge), + User?.Identity?.IsAuthenticated == true); + if (response_type != "code") + { + logger.LogWarning("OAuth /authorize rejected: unsupported response_type={ResponseType}", response_type); return BadRequest(new { error = "unsupported_response_type" }); + } if (string.IsNullOrEmpty(client_id) || string.IsNullOrEmpty(redirect_uri)) + { + logger.LogWarning("OAuth /authorize rejected: missing client_id or redirect_uri"); return BadRequest(new { error = "invalid_request", error_description = "client_id and redirect_uri are required" }); + } // If user is not authenticated, redirect to login with return URL if (User?.Identity?.IsAuthenticated != true) { var authorizeUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; var loginUrl = $"/login?returnUrl={Uri.EscapeDataString(authorizeUrl)}"; + logger.LogInformation("OAuth /authorize: redirecting unauthenticated caller to {LoginUrl}", loginUrl); return Redirect(loginUrl); } @@ -79,7 +148,10 @@ public IActionResult Authorize( ?? email; if (string.IsNullOrEmpty(email)) + { + logger.LogWarning("OAuth /authorize rejected: authenticated principal has no email/preferred_username claim"); return BadRequest(new { error = "invalid_request", error_description = "Unable to determine user identity" }); + } // Generate authorization code var code = CodeStore.GenerateCode( @@ -105,15 +177,26 @@ public IActionResult Authorize( /// OAuth Token Endpoint — exchanges an authorization code for an API token. /// The issued token is a standard mw_ API token, indistinguishable from manually created ones. /// - [HttpPost("connect/token")] + [HttpPost("/token")] [AllowAnonymous] - public async Task ExchangeToken([FromForm] TokenRequest request) + public Task ExchangeToken([FromForm] TokenRequest request, CancellationToken ct) { + logger.LogInformation( + "OAuth /token: grant_type={GrantType}, client_id={ClientId}, redirect_uri={RedirectUri}, has_code={HasCode}, has_verifier={HasVerifier}", + request.grant_type, request.client_id, request.redirect_uri, + !string.IsNullOrEmpty(request.code), !string.IsNullOrEmpty(request.code_verifier)); + if (request.grant_type != "authorization_code") - return BadRequest(new { error = "unsupported_grant_type" }); + { + logger.LogWarning("OAuth /token rejected: unsupported grant_type={GrantType}", request.grant_type); + return Task.FromResult(BadRequest(new { error = "unsupported_grant_type" })); + } if (string.IsNullOrEmpty(request.code) || string.IsNullOrEmpty(request.client_id) || string.IsNullOrEmpty(request.redirect_uri)) - return BadRequest(new { error = "invalid_request" }); + { + logger.LogWarning("OAuth /token rejected: missing code/client_id/redirect_uri"); + return Task.FromResult(BadRequest(new { error = "invalid_request" })); + } var entry = CodeStore.ExchangeCode( request.code, @@ -124,26 +207,46 @@ public async Task ExchangeToken([FromForm] TokenRequest request) if (entry == null) { logger.LogWarning("OAuth token exchange failed: invalid or expired code for client {ClientId}", request.client_id); - return BadRequest(new { error = "invalid_grant" }); + return Task.FromResult(BadRequest(new { error = "invalid_grant" })); } - // Create an mw_ API token via the existing token service - var (rawToken, _) = await TokenService.CreateTokenAsync( - userId: entry.UserId, - userName: entry.UserName, - userEmail: entry.UserEmail, - label: $"OAuth: {request.client_id}", - expiresAt: DateTimeOffset.UtcNow.AddDays(30)); - - logger.LogInformation("Issued OAuth access token for user {Email}, client {ClientId}", entry.UserEmail, request.client_id); - - return Ok(new - { - access_token = rawToken, - token_type = "Bearer", - expires_in = (int)TimeSpan.FromDays(30).TotalSeconds, - }); + // Create an mw_ API token via the existing token service. Lifetime + // is long-lived because OAuth clients (MCP, CLI tools) typically + // can't run interactive re-auth flows — a token that expires in 30 + // days surprises users who connect once and come back months later. + // Refresh-token flow isn't implemented yet; until it is, default to + // 1 year. Bump if needed via TokenLifetime below. + // + // No await: pull IObservable up to the controller's return type. + // Single bridge to Task happens at .ToTask(ct) — passing the + // request's cancellation token so a client disconnect tears down + // the reactive subscription. + return TokenService.CreateToken( + userId: entry.UserId, + userName: entry.UserName, + userEmail: entry.UserEmail, + label: $"OAuth: {request.client_id}", + expiresAt: DateTimeOffset.UtcNow.Add(TokenLifetime)) + .Select(creation => + { + logger.LogInformation("Issued OAuth access token for user {Email}, client {ClientId}", entry.UserEmail, request.client_id); + return (IActionResult)Ok(new + { + access_token = creation.RawToken, + token_type = "Bearer", + expires_in = (int)TokenLifetime.TotalSeconds, + }); + }) + .FirstAsync() + .ToTask(ct); } + + /// + /// Lifetime for OAuth-issued API tokens. Single source of truth — the + /// expiresAt timestamp on the token row and the expires_in OAuth response + /// field both read from this so they can't drift apart. + /// + private static readonly TimeSpan TokenLifetime = TimeSpan.FromDays(365); } /// @@ -157,3 +260,54 @@ public class TokenRequest public string? redirect_uri { get; set; } public string? code_verifier { get; set; } } + +/// +/// RFC 7591 Dynamic Client Registration request. Fields use snake_case JSON names per the spec. +/// +public class ClientRegistrationRequest +{ + [JsonPropertyName("client_name")] + public string? ClientName { get; set; } + + [JsonPropertyName("redirect_uris")] + public string[]? RedirectUris { get; set; } + + [JsonPropertyName("grant_types")] + public string[]? GrantTypes { get; set; } + + [JsonPropertyName("response_types")] + public string[]? ResponseTypes { get; set; } + + [JsonPropertyName("token_endpoint_auth_method")] + public string? TokenEndpointAuthMethod { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } +} + +/// +/// RFC 7591 Dynamic Client Registration response. +/// +public class ClientRegistrationResponse +{ + [JsonPropertyName("client_id")] + public string ClientId { get; set; } = ""; + + [JsonPropertyName("client_id_issued_at")] + public long ClientIdIssuedAt { get; set; } + + [JsonPropertyName("client_name")] + public string? ClientName { get; set; } + + [JsonPropertyName("redirect_uris")] + public string[]? RedirectUris { get; set; } + + [JsonPropertyName("grant_types")] + public string[]? GrantTypes { get; set; } + + [JsonPropertyName("response_types")] + public string[]? ResponseTypes { get; set; } + + [JsonPropertyName("token_endpoint_auth_method")] + public string? TokenEndpointAuthMethod { get; set; } +} diff --git a/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs b/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs index 01572a555..3d9eae3d5 100644 --- a/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs +++ b/memex/Memex.Portal.Shared/Authentication/OnboardingMiddleware.cs @@ -1,5 +1,9 @@ -using System.Text.Json; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text.Json; using MeshWeaver.Blazor.Infrastructure; +using MeshWeaver.Data; +using MeshWeaver.Graph; using MeshWeaver.Mesh; using MeshWeaver.Mesh.Security; using MeshWeaver.Mesh.Services; @@ -14,12 +18,48 @@ namespace Memex.Portal.Shared.Authentication; /// Middleware that redirects authenticated users without an Active user node /// to the onboarding page. Runs after UserContextMiddleware. /// -/// Flow: -/// - No user node (or Transient) → redirect /onboarding -/// - Active node → update AccessContext with username, pass through +/// Flow: +/// +/// No user node (or Transient) → redirect /onboarding +/// Active node → update AccessContext with username, pass through +/// +/// +/// +/// The user lookup uses workspace.GetQuery (the canonical synced-query +/// API from SyncedMeshNodeQueries.md). The synced layer bypasses RLS internally +/// (System identity), dedupes by path, gates on Initial, and includes static-node +/// providers — same guarantees as ApiTokenService.GetTokensForUser and +/// AgentChatClient.Initialize. Direct IMeshQueryCore.ObserveQuery calls +/// from application code are pedestrian queries and were forbidden in 2026-05. +/// +/// Internally the lookup is a reactive observable chain +/// (workspace.GetQueryWhereTake(1)Timeout); +/// the single await at the middleware boundary is unavoidable because +/// ASP.NET Core's RequestDelegate is Task-based. /// public class OnboardingMiddleware(RequestDelegate next, ILogger logger) { + /// + /// Hard cap on the user-node lookup. Sized for cold start: the User + /// catalog partition can take 5–10s to hydrate on a fresh portal + /// process, and the previous 5s budget routinely bounced legitimate + /// users to /onboarding right after a restart. Bumped to 20s + /// so the timeout is reserved for genuinely-pathological cases (mesh + /// down, query layer wedged) rather than cold-start hydration race. + /// + private static readonly TimeSpan LookupTimeout = TimeSpan.FromSeconds(20); + + /// + /// If the FIRST snapshot is empty, + /// resubscribe once after this delay before giving up. Covers the case + /// where the catalog grain replied to the subscription with an empty + /// pre-hydration snapshot but never fires a follow-up Added once + /// hydration completes (we've seen this with the InMemory catalog when + /// the partition is loaded synchronously by a different request that + /// holds the grain lock). + /// + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(750); + private static readonly HashSet ExcludedPrefixes = new(StringComparer.OrdinalIgnoreCase) { "/onboarding", @@ -39,130 +79,221 @@ public class OnboardingMiddleware(RequestDelegate next, ILogger(); - if (portalApp != null) - { - var accessService = portalApp.Hub.ServiceProvider.GetRequiredService(); - var userContext = accessService.Context ?? accessService.CircuitContext; - - // Skip virtual users — they don't need onboarding - if (userContext is { IsVirtual: false } && !string.IsNullOrEmpty(userContext.ObjectId)) - { - var email = userContext.Email ?? userContext.ObjectId; - - // If the context's ObjectId was already resolved to a username - // (different from the email), this user was onboarded in the current - // session. Skip the query — it may not find newly created nodes - // immediately due to routing/caching in the mesh query layer. - if (!string.IsNullOrEmpty(email) && - !string.IsNullOrEmpty(userContext.ObjectId) && - userContext.ObjectId != email) - { - await next(context); - return; - } + // Pull the reactive composition all the way up: the user-resolution + // pipeline (FindUserByEmail → conditional LoadUserRoles → SetContext) + // is a single observable chain. The only Task bridge is on the line + // below — ASP.NET's RequestDelegate signature forces Task at this + // boundary, but everything else stays observable so a slow query + // layer can't deadlock by awaiting a result the awaiting thread is + // supposed to publish. + // + // Outcome semantics: + // • Result = "Redirect" — middleware bounces to /onboarding, doesn't + // call next. + // • Result = "PassThrough" — context updated (or skipped because + // unauthenticated / virtual / excluded path); fall through to next. + var outcome = await BuildPipeline(context).FirstAsync().ToTask(); - var meshQuery = portalApp.Hub.ServiceProvider.GetService(); - if (meshQuery != null) - { - try - { - // Look up User node by email stored in content. - // Use ImpersonateAsHub scope because user context may not have - // sufficient permissions yet at this point in the pipeline. - MeshNode? node; - using (accessService.ImpersonateAsHub(portalApp.Hub)) - { - node = await meshQuery.QueryAsync( - $"nodeType:User namespace:User content.email:{email} limit:1").FirstOrDefaultAsync(); - } - - if (node == null || node.State == MeshNodeState.Transient) - { - // No user node or incomplete onboarding — redirect - logger.LogInformation( - "OnboardingMiddleware: Redirecting to onboarding for {Email}", - email); - context.Response.Redirect("/onboarding"); - return; - } - - // Active user — update AccessContext with username (node ID) - var username = node.Id; - - // Query global AccessAssignment to populate roles - var roles = await LoadUserRolesAsync( - meshQuery, accessService, portalApp.Hub, username); - - var updatedContext = userContext with - { - ObjectId = username, - Name = node.Name ?? username, - Roles = roles - }; - // Set per-request context only. CircuitAccessHandler handles - // per-circuit persistence via CreateInboundActivityHandler. - accessService.SetContext(updatedContext); - } - catch (Exception ex) - { - // Non-critical — don't block the request on onboarding check failure - logger.LogWarning(ex, - "OnboardingMiddleware: Failed to check user node for {UserId}", - userContext.ObjectId); - } - } - } - } + if (outcome == OnboardingOutcome.Redirect) + { + context.Response.Redirect("/onboarding"); + return; } await next(context); } + private enum OnboardingOutcome { PassThrough, Redirect } + /// - /// Loads the user's role names from AccessAssignment nodes across all scopes. - /// Used to populate AccessContext.Roles so permission checks work in Blazor components. + /// Builds the reactive onboarding pipeline. Returns an observable that + /// emits exactly one describing what the + /// middleware should do next. Composition is end-to-end reactive — no + /// intermediate await, no fire-and-forget Subscribe, no + /// TaskCompletionSource. The single Task bridge lives in + /// . /// - private static async Task> LoadUserRolesAsync( - IMeshService meshQuery, AccessService accessService, IMessageHub hub, string username) + private IObservable BuildPipeline(HttpContext context) { - try - { - var roles = new HashSet(StringComparer.OrdinalIgnoreCase); - using (accessService.ImpersonateAsHub(hub)) + if (context.User?.Identity?.IsAuthenticated != true || IsExcludedPath(context.Request.Path)) + return Observable.Return(OnboardingOutcome.PassThrough); + + var portalApp = context.RequestServices.GetService(); + if (portalApp == null) + return Observable.Return(OnboardingOutcome.PassThrough); + + var accessService = portalApp.Hub.ServiceProvider.GetRequiredService(); + var userContext = accessService.Context ?? accessService.CircuitContext; + + // Skip virtual users — they don't need onboarding. + if (userContext is not { IsVirtual: false } || string.IsNullOrEmpty(userContext.ObjectId)) + return Observable.Return(OnboardingOutcome.PassThrough); + + var email = userContext.Email ?? userContext.ObjectId; + + // ObjectId already resolved to a username (different from the email)? + // This session has been onboarded — skip the query (which would race + // routing/caching in the mesh query layer for newly created nodes). + if (!string.IsNullOrEmpty(email) + && !string.IsNullOrEmpty(userContext.ObjectId) + && userContext.ObjectId != email) + return Observable.Return(OnboardingOutcome.PassThrough); + + var workspace = portalApp.Hub.GetWorkspace(); + + // Reactive composition: FindUser → SelectMany → either Redirect (no + // node / Transient) or LoadRoles → set context → PassThrough. + return FindUserByEmail(workspace, email, logger) + .SelectMany(node => { - await foreach (var accessNode in meshQuery.QueryAsync( - $"nodeType:AccessAssignment content.accessObject:\"{username}\" scope:subtree limit:10")) + if (node == null || node.State == MeshNodeState.Transient) { - if (accessNode.Content == null) - continue; + logger.LogInformation( + "OnboardingMiddleware: Redirecting to onboarding for {Email} (node={NodeState})", + email, node?.State.ToString() ?? "(null — lookup returned no match)"); + return Observable.Return(OnboardingOutcome.Redirect); + } - AccessAssignment? assignment = accessNode.Content switch + var username = node.Id; + return LoadUserRoles(workspace, username, logger) + .Select(roles => { - AccessAssignment aa => aa, - JsonElement je => JsonSerializer.Deserialize( - je.GetRawText(), hub.JsonSerializerOptions), - _ => null - }; + var updatedContext = userContext with + { + ObjectId = username, + Name = node.Name ?? username, + Roles = roles + }; + // Set per-request context. CircuitAccessHandler handles + // per-circuit persistence via CreateInboundActivityHandler. + accessService.SetContext(updatedContext); + return OnboardingOutcome.PassThrough; + }); + }) + .Catch(ex => + { + // Non-critical — don't block the request on onboarding check failure. + logger.LogWarning(ex, + "OnboardingMiddleware: Failed to check user node for {UserId}", + userContext.ObjectId); + return Observable.Return(OnboardingOutcome.PassThrough); + }); + } - if (assignment == null) - continue; + /// + /// Reactive lookup of the User node by email via the canonical synced query + /// (workspace.GetQuery). The synced layer dedupes by path, gates on + /// Initial, includes static providers, and runs queries with System identity + /// internally — so this RLS-bypassing lookup uses exactly the same machinery + /// as every other "live mesh node set" consumer in the codebase + /// (ApiTokenService.GetTokensForUser, AgentChatClient, etc.). + /// Direct IMeshQueryCore.ObserveQuery here was a pedestrian-query + /// antipattern — replaced 2026-05 per SyncedMeshNodeQueries.md. + /// + /// Returns rather than + /// so the caller composes the chain; the middleware is the single allowed + /// bridge point (ASP.NET's RequestDelegate is Task-based). + /// + /// Robustness: the synced layer's Initial-gating means the first + /// emission is already the authoritative snapshot — no per-emission Where + /// filter needed. We Take(1) and Timeout (cold start can take seconds while + /// the partition hydrates). Empty snapshot → null → "redirect to + /// /onboarding". + /// + internal static IObservable FindUserByEmail( + IWorkspace workspace, string email, ILogger? logger) + { + // Cache id per-email — synced query result snapshot is shared across + // any concurrent request for the same email. The synced registry holds + // the entry for the workspace's lifetime; live mesh change events keep + // the snapshot fresh, so subsequent requests see up-to-date state. + return workspace.GetQuery( + $"auth:userByEmail:{email}", + $"nodeType:User content.email:{email} limit:1") + .Do(items => logger?.LogDebug( + "FindUserByEmail({Email}): synced query emit, items={Count}", + email, items.Count())) + .Take(1) + .Select(items => (MeshNode?)items.FirstOrDefault()) + .Timeout(LookupTimeout, Observable.Defer(() => + { + logger?.LogWarning( + "FindUserByEmail({Email}): no user node within {Timeout} — falling back to null (will redirect to /onboarding)", + email, LookupTimeout); + return Observable.Return(null); + })); + } - foreach (var r in assignment.Roles.Where(r => !r.Denied && !string.IsNullOrEmpty(r.Role))) - roles.Add(r.Role); - } - } + /// Back-compat overload used by callers that don't yet pass a logger. + internal static IObservable FindUserByEmail( + IWorkspace workspace, string email) + => FindUserByEmail(workspace, email, logger: null); - return roles.ToList(); - } - catch + /// + /// Reactive load of the user's role names from AccessAssignment nodes via the + /// canonical synced query (workspace.GetQuery). Same machinery as + /// — bypasses RLS, dedupes, gates on Initial, + /// includes static providers. Bearer auth uses this via + /// to enrich principals with + /// DB-resolved roles rather than only the roles stamped on the API token at + /// creation time. + /// + internal static IObservable> LoadUserRoles( + IWorkspace workspace, string username, ILogger? logger) + { + var jsonOptions = workspace.Hub.JsonSerializerOptions; + + return workspace.GetQuery( + $"auth:userRoles:{username}", + $"nodeType:AccessAssignment content.accessObject:\"{username}\" scope:subtree limit:10") + .Do(items => logger?.LogDebug( + "LoadUserRoles({User}): synced query emit, items={Count}", + username, items.Count())) + .Take(1) + .Select(items => FoldRoles(items, jsonOptions)) + .Timeout(LookupTimeout, Observable.Defer(() => + { + logger?.LogWarning( + "LoadUserRoles({User}): no snapshot within {Timeout} — defaulting to no roles", + username, LookupTimeout); + return Observable.Return((IReadOnlyCollection)Array.Empty()); + })) + .Catch, Exception>(ex => + { + logger?.LogWarning(ex, "LoadUserRoles({User}) failed — defaulting to no roles", username); + return Observable.Return((IReadOnlyCollection)Array.Empty()); + }); + } + + /// Back-compat overload used by callers that don't yet pass a logger. + internal static IObservable> LoadUserRoles( + IWorkspace workspace, string username) + => LoadUserRoles(workspace, username, logger: null); + + private static IReadOnlyCollection FoldRoles( + IEnumerable items, JsonSerializerOptions options) + { + var roles = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var accessNode in items) { - // Non-critical — return empty roles on failure - return []; + if (accessNode.Content == null) + continue; + + AccessAssignment? assignment = accessNode.Content switch + { + AccessAssignment aa => aa, + JsonElement je => JsonSerializer.Deserialize( + je.GetRawText(), options), + _ => null + }; + + if (assignment == null) + continue; + + foreach (var r in assignment.Roles.Where(r => !r.Denied && !string.IsNullOrEmpty(r.Role))) + roles.Add(r.Role); } + return roles.ToList(); } private static bool IsExcludedPath(PathString path) diff --git a/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs new file mode 100644 index 000000000..483714955 --- /dev/null +++ b/memex/Memex.Portal.Shared/Authentication/UserOnboardingService.cs @@ -0,0 +1,204 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.Logging; + +namespace Memex.Portal.Shared.Authentication; + +/// +/// Materialises a new user's identity in all the places login + routing + partition +/// activation need to find them. Extracted from Onboarding.razor so the dual-write +/// shape is unit-testable end-to-end (see UserOnboardingServiceTests). +/// +/// 100% reactive — IObservable<T> end-to-end. Every method returns a +/// cold observable. Callers Subscribe (and may chain). No await, no +/// FirstAsync().ToTask() — those would block hub message processing on the +/// publishing thread and deadlock under load. +/// See Doc/Architecture/AsynchronousCalls.md. +/// +/// Three rows, one onboarding write: +/// +/// Per-user partition root — {username}.mesh_nodes at +/// (namespace='', id={username}). This is what /{username} +/// resolves to via the standard partition router; renders the User layout +/// (Activity area) from 's +/// HubConfiguration. +/// User-catalog mirror — user.mesh_nodes at +/// (namespace='User', id={username}). The login flow runs +/// nodeType:User content.email:X and scans the user schema. +/// Without this mirror, the catalog query finds nothing and every signed-in +/// user bounces back to /onboarding. +/// Admin/Partition catalog entry — admin.mesh_nodes at +/// (namespace='Admin/Partition', id={username}). Registers the +/// per-user partition with the storage provider so the routing layer's +/// first-segment lookup matches {username}. +/// +/// +/// +/// Ordering matters — Admin/Partition is created FIRST so the per-user +/// partition root's first-segment lookup matches when the partition-root write +/// arrives. The user-catalog mirror is independent (lives in the pre-existing +/// user schema). Sequencing is expressed reactively via +/// SelectMany: the partition-root subscribe is triggered by the +/// Admin/Partition emission; the catalog-mirror subscribe by the partition-root +/// emission. +/// +public sealed class UserOnboardingService( + IMeshService meshService, + AccessService accessService, + ILogger? logger = null) +{ + /// + /// Drives the full dual-write. Returns a cold observable that, on Subscribe, + /// creates the three rows in order and emits the per-user-partition-root + /// (path = {username}) as its single value + /// before completing. Errors surface via OnError — callers wrap in a UI + /// Catch block. Subscribe to drive. + /// + public IObservable CreateUser(UserOnboardingRequest request) + { + var username = request.Username; + var fullDisplayName = string.IsNullOrWhiteSpace(request.FullName) ? username : request.FullName!; + var avatarIcon = string.IsNullOrWhiteSpace(request.AvatarUrl) ? null : request.AvatarUrl!.Trim(); + + var userContent = new User + { + FullName = string.IsNullOrWhiteSpace(request.FullName) ? null : request.FullName!.Trim(), + Email = request.Email.Trim(), + Bio = string.IsNullOrWhiteSpace(request.Bio) ? null : request.Bio!.Trim(), + Role = string.IsNullOrWhiteSpace(request.Role) ? null : request.Role!.Trim(), + PinnedPaths = ["Doc"], + }; + + var partitionCatalogEntry = new MeshNode(username, "Admin/Partition") + { + Name = username, + NodeType = "Partition", + State = MeshNodeState.Active, + Content = new PartitionDefinition + { + Namespace = username, + DataSource = "default", + Schema = username.ToLowerInvariant(), + Table = "mesh_nodes", + TableMappings = PartitionDefinition.StandardTableMappings, + Versioned = true, + Description = $"User partition for {fullDisplayName}", + } + }; + + var partitionRootNode = new MeshNode(username) + { + Name = fullDisplayName, + NodeType = "User", + State = MeshNodeState.Active, + Icon = avatarIcon, + Content = userContent, + }; + + var userCatalogMirror = new MeshNode(username, "User") + { + Name = fullDisplayName, + NodeType = "User", + State = MeshNodeState.Active, + Icon = avatarIcon, + Content = userContent, + }; + + // SelectMany sequences the three writes — Admin/Partition first so the + // partition-root write can route, then the user-catalog mirror. The + // outer observable emits ONLY the partition-root node (the canonical + // identity) so callers can treat the return value as `the User node`. + // + // Wrap in Observable.Using + ImpersonateAsSystem so the whole onboarding + // chain runs as the System identity (Permission.All unconditionally). + // Reason: the new user does not yet exist, the partition root they + // would own doesn't yet exist either, and the caller (signed-in admin + // OR the user-being-onboarded themselves during first-login) can't + // have Create permission on a brand-new top-level partition. This is + // the canonical "infrastructure operation" use case ImpersonateAsSystem + // was built for — explicitly documented in AccessService.cs. + return Observable.Using( + () => accessService.ImpersonateAsSystem(), + _ => meshService.CreateNode(partitionCatalogEntry) + .Do(__ => logger?.LogInformation( + "Onboarding: registered partition '{Username}' via Admin/Partition catalog", username)) + .SelectMany(__ => meshService.CreateNode(partitionRootNode)) + .Do(__ => logger?.LogInformation( + "Onboarding: wrote partition-root User '{Username}' to {Schema}.mesh_nodes", + username, username.ToLowerInvariant())) + .SelectMany(rootNode => meshService.CreateNode(userCatalogMirror) + .Do(__ => logger?.LogInformation( + "Onboarding: wrote login-catalog mirror at user.mesh_nodes (namespace=User, id={Username})", + username)) + .Select(__ => rootNode))); + } + + /// + /// Self-AccessAssignment write — the new user gets Admin on their own scope. + /// Lives in the per-user partition's access satellite. Without this, + /// the user can read their own partition root (public read on User nodes) + /// but every subsequent write ("Create permission required") fails. + /// Returns a cold observable that emits the created AccessAssignment node; + /// subscribe to drive. + /// + public IObservable GrantSelfAdmin(string username) + { + var assignment = new MeshNode($"{username}_Access", $"{username}/_Access") + { + NodeType = "AccessAssignment", + Name = $"{username} Access", + MainNode = username, + Content = new AccessAssignment + { + AccessObject = username, + DisplayName = username, + Roles = [new RoleAssignment { Role = Role.Admin.Id, Denied = false }] + } + }; + return meshService.CreateNode(assignment) + .Do(_ => logger?.LogInformation( + "Onboarding: granted self-Admin to '{Username}' at {Path}", username, assignment.Path)); + } + + /// + /// First-user-only: grants the user global Admin at Admin/_Access. Caller + /// gates this on the "no existing User nodes" check. Subscribe to drive — a + /// silent failure would leave the platform with no admins, so callers must + /// surface OnError. + /// + public IObservable GrantPlatformAdmin(string username) + { + var assignment = new MeshNode($"{username}_Access", "Admin/_Access") + { + NodeType = "AccessAssignment", + Name = $"{username} Access", + MainNode = "Admin", + Content = new AccessAssignment + { + AccessObject = username, + DisplayName = username, + Roles = [new RoleAssignment { Role = Role.Admin.Id, Denied = false }] + } + }; + return meshService.CreateNode(assignment) + .Do(_ => logger?.LogInformation( + "Onboarding: granted platform Admin (first user) to '{Username}' at Admin/_Access", username)); + } +} + +/// +/// Input shape for . Mirrors the form +/// model in Onboarding.razor; kept in this assembly so unit tests can +/// construct it without taking a dependency on the Blazor page. +/// +public sealed record UserOnboardingRequest( + string Username, + string Email, + string? FullName = null, + string? Bio = null, + string? Role = null, + string? AvatarUrl = null); diff --git a/memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs b/memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs new file mode 100644 index 000000000..29dfe2da4 --- /dev/null +++ b/memex/Memex.Portal.Shared/Authentication/UserRoleResolver.cs @@ -0,0 +1,57 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using MeshWeaver.Data; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; + +namespace Memex.Portal.Shared.Authentication; + +/// +/// Thin façade over so callers +/// outside this assembly can resolve a user's AccessAssignment-derived roles in +/// one call. +/// +/// Used by to enrich Bearer +/// principals with DB-resolved roles, so MCP / API-token sessions see the +/// same role set as cookie / OAuth sessions. Without this layer, roles would +/// be limited to whatever was stamped on the API token at creation time — +/// any later AccessAssignment grant would silently not apply for Bearer +/// requests, even though the same user logging in through a browser would +/// see them. +/// +/// Resolution goes through the canonical synced-query API +/// (workspace.GetQuery) — same path-keyed dedup + Initial gating + static +/// provider fan-out as every other live mesh-node collection consumer in the +/// codebase. Direct IMeshQueryCore.ObserveQuery calls from auth code +/// were a pedestrian-query antipattern (replaced 2026-05). +/// +internal static class UserRoleResolver +{ + /// + /// Resolves the user's AccessAssignment-derived role names. Returns an + /// empty list when no resolution is possible (services missing, workspace + /// unavailable, query layer faulted) — auth flows must keep working even + /// when role enrichment can't. + /// + /// The single Task bridge here lives at the ASP.NET + /// AuthenticationHandler.HandleAuthenticateAsync boundary — + /// callers expect a Task-returning helper, but everything below + /// stays observable. + /// + public static async Task> LoadDbRolesAsync( + IServiceProvider services, string userId) + { + var hub = services.GetService(); + if (hub is null || string.IsNullOrEmpty(userId)) + return Array.Empty(); + + var workspace = hub.GetWorkspace(); + if (workspace is null) + return Array.Empty(); + + return await OnboardingMiddleware + .LoadUserRoles(workspace, userId) + .FirstAsync() + .ToTask(); + } +} diff --git a/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs b/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs index e8cbddb3c..6bd82b057 100644 --- a/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs +++ b/memex/Memex.Portal.Shared/Authentication/VirtualUserMiddleware.cs @@ -111,7 +111,7 @@ private async Task EnsureVirtualUserNodeAsync(PortalApplication portalApp, strin { try { - await VUserHelper.EnsureVUserNodeAsync(portalApp, virtualUserId, logger); + VUserHelper.EnsureVUserNode(portalApp, virtualUserId, logger); } catch (Exception ex) { diff --git a/memex/Memex.Portal.Shared/Layout/MemexMobileMenu.razor b/memex/Memex.Portal.Shared/Layout/MemexMobileMenu.razor index d9ea89bb1..2fa105d68 100644 --- a/memex/Memex.Portal.Shared/Layout/MemexMobileMenu.razor +++ b/memex/Memex.Portal.Shared/Layout/MemexMobileMenu.razor @@ -1,22 +1,27 @@ @using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons @using MeshWeaver.Blazor.Infrastructure @using MeshWeaver.Mesh.Services +@using Microsoft.AspNetCore.Components.Authorization
- - - - Create New... - - - - - Settings - + + + + + + Create New... + + + + + Settings + + +
@code { diff --git a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj index 74f5b3aa5..a5492e689 100644 --- a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj +++ b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj @@ -27,6 +27,8 @@ + + diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index 5d7d45f23..27b112365 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -1,6 +1,9 @@ using System.IdentityModel.Tokens.Jwt; +using Memex.Portal.Shared.Api; using Memex.Portal.Shared.Authentication; using Memex.Portal.Shared.Settings; +using Memex.Portal.Shared.Social; +using Microsoft.Extensions.DependencyInjection.Extensions; using MeshWeaver.AI; using MeshWeaver.AI.AzureFoundry; using MeshWeaver.AI.AzureOpenAI; @@ -19,6 +22,7 @@ using MeshWeaver.ContentCollections; using MeshWeaver.Documentation; using MeshWeaver.GoogleMaps; +using MeshWeaver.Data; using MeshWeaver.Graph; using MeshWeaver.Graph.Configuration; using MeshWeaver.Markdown.Export.Configuration; @@ -42,8 +46,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Identity.Web; using Microsoft.Identity.Web.UI; -using ModelContextProtocol.AspNetCore.Authentication; -using ModelContextProtocol.Authentication; using PortalAuthOptions = MeshWeaver.Blazor.Portal.Authentication.AuthenticationOptions; namespace Memex.Portal.Shared; @@ -83,20 +85,18 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) }) .AddBlazorPortalServices(); + // Onboarding service — pulls the three-row dual-write out of + // Onboarding.razor so it's unit-testable end-to-end. + services.AddScoped(); + // Configure Radzen services.AddRadzenServices(); - // AI services — thread persistence is handled via MeshNodes - - // Configure AI factories (read from appsettings, including Order) - services.AddAzureFoundryClaude(config => - builder.Configuration.GetSection("Anthropic").Bind(config)); - - services.AddAzureFoundry(config => - builder.Configuration.GetSection("AzureAIS").Bind(config)); - - services.AddAzureOpenAI(config => - builder.Configuration.GetSection("AzureOpenAIS").Bind(config)); + // AI services — thread persistence is handled via MeshNodes. + // Anthropic / AzureFoundry / AzureOpenAI registration is now a + // single per-provider builder extension (.AddAnthropic() etc.) + // wired in ConfigureMemexMesh — that one call registers the catalog + // source + IOptions binding + IChatClientFactory. services.AddCopilot(config => builder.Configuration.GetSection("Copilot").Bind(config)); @@ -125,6 +125,39 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) // Register API token service for MCP bearer auth and OAuth code store services.AddSingleton(); services.AddSingleton(); + // ModelProviderService backs the Models settings tab — users store + // their own AI provider credentials as MeshNodes in their namespace. + services.AddSingleton(); + + // Social publishing — minimal registration for the LinkedIn connect + pull endpoints. + // (The full hosted-service pipeline is gated behind AddSocialPublishing which needs + // IApprovalPublishBridge / IStatsRefreshSource / IPastPostIngestSource — those come + // in Phase 4. For now the publisher is enough for /connect/linkedin/pull to work.) + var linkedInClientId = builder.Configuration["Social:LinkedIn:ClientId"]; + if (!string.IsNullOrEmpty(linkedInClientId)) + { + services.AddHttpClient(); + services.AddSingleton(new MeshWeaver.Social.LinkedInOptions + { + ClientId = linkedInClientId!, + ClientSecret = builder.Configuration["Social:LinkedIn:ClientSecret"] ?? "" + }); + + // Add the menu provider so "Connect LinkedIn" + "Pull LinkedIn posts" + // appear on the viewer's own user page. + services.TryAddEnumerable( + Microsoft.Extensions.DependencyInjection.ServiceDescriptor.Scoped< + MeshWeaver.Mesh.INodeMenuProvider, + Memex.Portal.Shared.Social.LinkedInCredentialMenuProvider>()); + + // (Removed: SocialMediaUserMenuProvider — hardcoded a NodeType + // ("Systemorph/SocialMediaHub") that isn't registered anywhere in + // the codebase. NodeTypes belong in the database (NodeTypeDefinition + // MeshNodes), not as DLL-side string constants. The SocialMedia + // hub feature should be added back when its NodeType is defined + // through the regular mesh node creation flow rather than wired + // through a DLL-time CreateNode that fails on the receiver.) + } // Configure authentication var authSection = builder.Configuration.GetSection(PortalAuthOptions.SectionName); @@ -167,10 +200,6 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) JwtSecurityTokenHandler.DefaultMapInboundClaims = false; services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(entraIdConfig); - services.AddAuthentication() - .AddScheme( - ApiTokenAuthenticationHandler.SchemeName, _ => { }) - .AddMcp(ConfigureMcpResourceMetadata); services.AddControllersWithViews() .AddMicrosoftIdentityUI(); } @@ -200,68 +229,17 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) .AddGoogleAuthentication(builder.Configuration) .AddLinkedInAuthentication(builder.Configuration) .AddAppleAuthentication(builder.Configuration); - - // Add API token auth scheme for MCP bearer authentication - authBuilder.AddScheme( - ApiTokenAuthenticationHandler.SchemeName, _ => { }) - .AddMcp(ConfigureMcpResourceMetadata); } - // Add authorization with McpAuth policy (MCP scheme forwards to ApiToken or Cookie) - services.AddAuthorization(options => - { - options.AddPolicy("McpAuth", policy => - { - policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme); - policy.RequireAuthenticatedUser(); - }); - }); - } - - /// - /// Configures the MCP authentication scheme with OAuth resource metadata discovery - /// and request-based forwarding to the appropriate authentication handler. - /// - private static void ConfigureMcpResourceMetadata(McpAuthenticationOptions options) - { - // CRITICAL: SDK constructor sets ForwardAuthenticate = "Bearer" which takes - // priority over ForwardDefaultSelector in ASP.NET Core's ResolveTarget(). - // Clear it so our selector works. - options.ForwardAuthenticate = null; - - // Route Bearer tokens to ApiToken handler, everything else to Cookie - options.ForwardDefaultSelector = ctx => - { - var authHeader = ctx.Request.Headers.Authorization.ToString(); - if (!string.IsNullOrEmpty(authHeader) && - authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return ApiTokenAuthenticationHandler.SchemeName; - return CookieAuthenticationDefaults.AuthenticationScheme; - }; - - // Fallback resource metadata (overridden per-request by Events) - options.ResourceMetadata = new ProtectedResourceMetadata - { - BearerMethodsSupported = { "header" }, - ScopesSupported = { "mcp" }, - }; + // MCP auth is deliberately separate from the Blazor cookie pipeline above — + // see McpAuthenticationExtensions for the "why". Bearer-only, no cookie leakage, + // proper 401 + WWW-Authenticate on anonymous requests so MCP clients can + // discover the auth server. + services.AddMcpAuthentication(); - options.Events = new McpAuthenticationEvents - { - OnResourceMetadataRequest = ctx => - { - var req = ctx.HttpContext.Request; - var origin = $"{req.Scheme}://{req.Host}"; - ctx.ResourceMetadata = new ProtectedResourceMetadata - { - Resource = $"{origin}/mcp", - BearerMethodsSupported = { "header" }, - ScopesSupported = { "mcp" }, - AuthorizationServers = { $"{origin}/connect" }, - }; - return Task.CompletedTask; - } - }; + // REST surface for the mesh — same Bearer-token policy as MCP, lifts the + // multipart upload size cap. See MeshApiEndpoints. + services.AddMeshApi(); } extension(TBuilder builder) where TBuilder : MeshBuilder @@ -335,10 +313,11 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm return (TBuilder)builder // Configure persistence from Graph:Storage section. - // Skip if IPartitionedStoreFactory already registered (e.g., PostgreSQL from Program.cs) + // Skip if any IPartitionStorageProvider was already registered upstream + // (e.g., AddPartitionedPostgreSqlPersistence in Memex.Portal.Distributed/Program.cs). .ConfigureServices(services => { - if (services.Any(sd => sd.ServiceType == typeof(IPartitionedStoreFactory))) + if (services.Any(sd => sd.ServiceType == typeof(IPartitionStorageProvider))) return services; return usePartitioned @@ -349,14 +328,32 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm .AddRowLevelSecurity() // Configure graph from the same base path .AddGraph() + // Seed root-scope Admin AccessAssignments for users listed under + // `Auth:GlobalAdmins` so configured admins bypass per-partition + // RLS for cross-partition operations (list Organizations, create + // a new Organization, etc.). Empty / missing section = no-op. + .AddMeshNodes(Authentication.GlobalAdminSeed.Build(configuration)) .AddOrganizationType() .AddPortalType() .AddAI() + // Each AI provider self-registers everything (catalog + // source + IOptions binding + IChatClientFactory) via one + // builder extension. The Models settings tab + the + // ModelProviderService read these out of the live + // LanguageModelCatalogOptions — no central registry. + .AddAnthropic() + .AddAzureFoundry() + .AddAzureOpenAI() .AddSelfRegistry() .AddDocumentation() .AddMarkdownExport() // Register Azure Blob support for content collections. .ConfigureServices(services => services.AddAzureBlob()) + // Shared NodeType assembly cache (versioned, cross-replica consistent). + // Requires `AddKeyedAzureBlobServiceClient("nodetype-cache")` to have + // registered a keyed BlobServiceClient — Aspire wires this via the + // `nodetype-cache` container reference on the portal resource. + .ConfigureServices(services => services.AddBlobAssemblyStore()) // Register the mesh catalog and its public interfaces .ConfigureServices(services => services.AddMeshCatalog()) // Configure default views and content collections for each node hub @@ -397,7 +394,8 @@ public TBuilder ConfigureMemexMesh(IConfiguration configuration, bool isDevelopm .WithHeartBeatHandler() // silently ack heartbeats on every per-node hub .AddDefaultLayoutAreas() .AddThreadsLayoutArea() - .AddApiTokensSettingsTab(); + .AddApiTokensSettingsTab() + .AddModelsSettingsTab(); }) // Add activity tracking to record user access patterns via ActivityLogBundler .AddActivityTracking(); @@ -417,7 +415,30 @@ public TBuilder ConfigureMemexPortal() => (TBuilder)builder .AddUserProfileViews() // Register UserProfilePageView ) .AddBlazor(layoutClient => layoutClient - .WithPortalConfiguration(c => c) + // 🚨 The portal hub is the per-user sub-hub that hosts the + // Blazor circuit's chat input, autocomplete, navigation + // tracking, etc. Without these registrations: + // • Chat: AppendUserMessageResponse arrives as RawJson and the + // original Observe() hangs forever ("Allocating agent…" + // spinner). Need AI types in the portal's TypeRegistry. + // • Activity tracking: TrackActivityRequest emits + // "No handler found for delivery TrackActivityRequest in + // portal/" on every login + navigation. Need the + // graph-types handler chain (which includes + // HandleTrackActivity) registered on the portal. + // • Data layer: layout areas hosted in the portal (e.g. chat + // view) hold remote streams that depend on workspace + + // EntityStore serialisation; .AddData() wires that. + // + // Lives here in MemexConfiguration (not in MeshWeaver.Blazor's + // PortalApplication.DefaultPortalConfig) so the base portal + // library doesn't take a hard dependency on MeshWeaver.AI / + // MeshWeaver.Graph. + .WithPortalConfiguration(c => + { + c.TypeRegistry.AddAITypes(); + return c.AddData().WithGraphTypes(); + }) ); } @@ -446,6 +467,21 @@ public static void StartMemexApplication(this WebApplication app) where TA // in local dev it's a no-op since no proxy sets those headers. app.UseForwardedHeaders(); + // `@/` is a markdown-authoring / autocomplete prefix — not a URL segment. + // Authors occasionally leak `@/` into raw HTML hrefs or users paste broken links. + // Permanent-redirect `/@/X` → `/X` so those never 404. + app.Use((ctx, next) => + { + var path = ctx.Request.Path.Value; + if (path != null && path.StartsWith("/@/", StringComparison.Ordinal)) + { + var target = path.Substring(2) + ctx.Request.QueryString; + ctx.Response.Redirect(target, permanent: true); + return Task.CompletedTask; + } + return next(); + }); + // Static files middleware must run before routing to serve _content/* paths from RCLs app.UseStaticFiles(); @@ -455,15 +491,34 @@ public static void StartMemexApplication(this WebApplication app) where TA app.UseAntiforgery(); app.UseCookiePolicy(); + // User-context middleware MUST run BEFORE the terminal endpoint maps + // (MapMeshMcp / MapMeshWeaver / MapLinkedInConnect). Once a request + // matches a terminal endpoint, no further `app.UseMiddleware<…>()` + // registered AFTER the Map* call ever sees it. With UserContextMiddleware + // after MapMeshMcp, MCP-Bearer requests skipped it entirely → + // accessService.Context stayed null → PostPipeline fell through to its + // hub-address fallback and stamped the message identity as + // `mesh/`. SecurityService then matched accessObject="mesh/" + // (no match) instead of accessObject="rbuergi" (Admin) → cross-partition + // writes denied while same-partition self-rule writes still passed. + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + //app.MapMeshWeaverSignalRHubs(); // Map MCP endpoint app.MapMeshMcp(); + // REST surface that mirrors MCP — POST /api/mesh/* (1:1 with MCP tools). + // Same Bearer auth policy as /mcp; multipart upload at /api/mesh/upload. + app.MapMeshApi(); + app.MapMeshWeaver(); - app.UseMiddleware(); - app.UseMiddleware(); - app.UseMiddleware(); + + // Social publishing — LinkedIn connect/pull endpoints. Must be AFTER + // UseAuthentication so HttpContext.User is populated. + app.MapLinkedInConnect(); // Use HTTPS redirection only for non-MCP paths (MCP needs HTTP for Claude Code) app.UseWhen( diff --git a/memex/Memex.Portal.Shared/Models/ModelProviderService.cs b/memex/Memex.Portal.Shared/Models/ModelProviderService.cs new file mode 100644 index 000000000..f75b518ea --- /dev/null +++ b/memex/Memex.Portal.Shared/Models/ModelProviderService.cs @@ -0,0 +1,373 @@ +using System.Reactive; +using System.Reactive.Linq; +using System.Security.Cryptography; +using MeshWeaver.AI; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Memex.Portal.Shared.Models; + +/// +/// Service for creating, rotating, and deleting AI model provider credentials. +/// Modelled on +/// — credentials are stored as nodeType:ModelProvider MeshNodes in the +/// owner's namespace (typically {userId}/Model/{providerName}, but any +/// node's namespace works for shared / org-level credentials). +/// +/// +/// 🚨 Reactive end-to-end. No async, no await, no +/// FromAsync. Reads go through workspace.GetQuery (synced) or +/// workspace.GetMeshNodeStream (live single-node) per +/// SyncedMeshNodeQueries. +/// Writes go through / +/// and +/// on the workspace remote +/// stream. +/// +/// +/// Layout per provider entry (Anthropic example, owner = rbuergi): +/// +/// rbuergi/Model/Anthropic ← ModelProvider (carries ApiKey, RLS-gated) +/// rbuergi/Model/Anthropic/claude-opus-4-7 ← LanguageModel, ProviderRef → ../Anthropic +/// rbuergi/Model/Anthropic/claude-sonnet-4-6 ← LanguageModel, same ProviderRef +/// rbuergi/Model/Anthropic/claude-haiku-4-5-20251001 ← LanguageModel, same ProviderRef +/// +/// +/// The default model ids come from +/// the live ; callers +/// can override. +/// +public class ModelProviderService(IMeshService meshService, IMessageHub hub, ILogger logger) +{ + // Per-owner cached snapshot — feeds the Models settings UI without + // hitting the synced query on every render. Wrapped around the live + // workspace.GetQuery observable (which is itself Replay(1).RefCount), + // so the cache holds the latest projection for an hour and writes + // (Create/RotateKey/Delete) explicitly invalidate the entry. The + // upstream synced query continues to push live updates into the cached + // observable so consumers always see fresh data within the TTL. + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1); + private readonly System.Collections.Concurrent.ConcurrentDictionary> Stream, DateTimeOffset ExpiresAt)> + cachedStreams = new(StringComparer.Ordinal); + + private LanguageModelCatalogSource? FindCatalogSource(string providerName) + { + var opts = hub.ServiceProvider.GetService(); + return opts?.Sources.FirstOrDefault(s => + string.Equals(s.ProviderName, providerName, StringComparison.OrdinalIgnoreCase)); + } + + private void InvalidateCache(string ownerPath) + { + cachedStreams.TryRemove(ownerPath, out _); + } + + /// + /// Reactive provider creation. Creates the ModelProvider node at + /// {ownerPath}/Model/{provider} with the supplied key + endpoint, + /// then creates one LanguageModel child per default model id + /// (each pointing back at the provider node via + /// ). Defaults come from the + /// live registered by + /// each provider's AddXxxCatalog extension — no central registry. + /// + public IObservable CreateProvider( + string ownerPath, + string provider, + string? apiKey, + string? label = null, + string? endpointOverride = null, + IReadOnlyList? modelIdsOverride = null) + { + if (string.IsNullOrEmpty(ownerPath)) + return Observable.Throw(new ArgumentException("ownerPath required", nameof(ownerPath))); + if (string.IsNullOrEmpty(provider)) + return Observable.Throw(new ArgumentException("provider required", nameof(provider))); + + var source = FindCatalogSource(provider); + var endpoint = endpointOverride ?? source?.DefaultEndpoint; + if (string.IsNullOrEmpty(endpoint)) endpoint = null; + + var modelIds = (modelIdsOverride ?? (IReadOnlyList?)source?.EffectiveModelIds) + ?? Array.Empty(); + + var providerNamespace = $"{ownerPath}/{ModelProviderNodeType.RootNamespace}"; + var providerPath = $"{providerNamespace}/{provider}"; + + var providerConfig = new ModelProviderConfiguration + { + Provider = provider, + ApiKey = apiKey, + Endpoint = endpoint, + Label = label ?? source?.EffectiveLabel ?? provider, + CreatedAt = DateTimeOffset.UtcNow, + Models = modelIds.Where(m => !string.IsNullOrWhiteSpace(m)) + .ToImmutableArrayCompat(), + }; + + var providerNode = new MeshNode(provider, providerNamespace) + { + NodeType = ModelProviderNodeType.NodeType, + Name = providerConfig.Label, + State = MeshNodeState.Active, + MainNode = ownerPath, + Content = providerConfig, + }; + + logger.LogInformation("Creating ModelProvider {Provider} for owner {Owner} with {ModelCount} models, keyFp={KeyFp}", + provider, ownerPath, modelIds.Count, Fingerprint(apiKey)); + + // 1. Create the ModelProvider node. + // 2. After commit, fan out N CreateNode calls for the LanguageModel children. + // The children reference the provider via ProviderRef. + InvalidateCache(ownerPath); + return meshService.CreateNode(providerNode) + .SelectMany(createdProvider => + { + var modelObservables = modelIds + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Select(modelId => + { + var modelDef = new ModelDefinition + { + Id = modelId, + DisplayName = modelId, + Provider = provider, + Endpoint = null, // resolver follows ProviderRef + ApiKeySecretRef = null, + ProviderRef = createdProvider.Path, + Order = source?.Order ?? 0 + }; + var modelNode = new MeshNode(modelId, providerPath) + { + NodeType = LanguageModelNodeType.NodeType, + Name = modelId, + Category = "Models", + State = MeshNodeState.Active, + MainNode = ownerPath, + Content = modelDef, + }; + return meshService.CreateNode(modelNode) + .Catch(ex => + { + logger.LogWarning(ex, "Failed to create LanguageModel {ModelId} under {Path}", modelId, providerPath); + return Observable.Return(null!); + }); + }) + .ToArray(); + + if (modelObservables.Length == 0) + return Observable.Return(new ProviderCreationResult(createdProvider, Array.Empty())); + + return Observable.CombineLatest(modelObservables) + .Take(1) + .Select(children => new ProviderCreationResult( + createdProvider, + children.Where(c => c != null).ToArray())); + }); + } + + /// + /// Reactive rotate-key. Updates the ApiKey field on the + /// ModelProvider node via + /// . Other fields are + /// preserved. + /// + public IObservable RotateKey(string providerNodePath, string? newApiKey) + { + if (string.IsNullOrEmpty(providerNodePath)) + return Observable.Return(false); + + logger.LogInformation("Rotating ModelProvider key at {Path} newKeyFp={KeyFp}", + providerNodePath, Fingerprint(newApiKey)); + + var workspace = hub.GetWorkspace(); + return workspace.GetMeshNodeStream(providerNodePath) + .Update(current => + { + var cfg = current.Content as ModelProviderConfiguration + ?? ExtractContent(current); + if (cfg == null) return current; + return current with { Content = cfg with { ApiKey = newApiKey } }; + }) + .Do(updatedNode => + { + // Force persistence at the per-node hub. Sync-protocol updates + // don't always fire the per-node hub's `saveSub` for + // remote-driven changes (see ApiTokenService.RevokeToken for + // the matching pattern + comment). + hub.Post(new SaveMeshNodeRequest(updatedNode), + o => o.WithTarget(new Address(providerNodePath))); + }) + .Select(_ => true) + .Catch(ex => + { + logger.LogWarning(ex, "RotateKey failed for {Path}", providerNodePath); + return Observable.Return(false); + }); + } + + /// + /// Reactive cascade-delete. Removes all child LanguageModel nodes + /// (their paths recorded in the provider's + /// snapshot), then the + /// ModelProvider node itself. + /// + public IObservable DeleteProvider(string providerNodePath) + { + if (string.IsNullOrEmpty(providerNodePath)) + return Observable.Return(false); + + logger.LogInformation("Deleting ModelProvider {Path} (cascade includes child LanguageModels)", providerNodePath); + + var workspace = hub.GetWorkspace(); + return workspace.GetMeshNodeStream(providerNodePath) + .Take(1) + .SelectMany(current => + { + var cfg = current?.Content as ModelProviderConfiguration + ?? ExtractContent(current); + var childPaths = cfg?.Models + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Select(m => $"{providerNodePath}/{m}") + .ToArray() + ?? Array.Empty(); + + IObservable childDeletes = childPaths.Length == 0 + ? Observable.Return(Unit.Default) + : Observable.CombineLatest(childPaths.Select(p => + meshService.DeleteNode(p) + .Catch(ex => + { + logger.LogDebug(ex, "Child LanguageModel delete failed for {Path}", p); + return Observable.Return(false); + }))) + .Take(1) + .Select(_ => Unit.Default); + + return childDeletes + .SelectMany(_ => meshService.DeleteNode(providerNodePath)) + .Select(_ => true); + }) + .Catch(ex => + { + logger.LogWarning(ex, "DeleteProvider failed for {Path}", providerNodePath); + return Observable.Return(false); + }); + } + + /// + /// Live list of ModelProviders owned by . + /// Same shape as + /// + /// — synced via workspace.GetQuery. + /// + public IObservable> GetProvidersForOwner(string ownerPath) + { + if (string.IsNullOrEmpty(ownerPath)) + return Observable.Return((IReadOnlyList)Array.Empty()); + + if (cachedStreams.TryGetValue(ownerPath, out var entry) && entry.ExpiresAt > DateTimeOffset.UtcNow) + return entry.Stream; + + var workspace = hub.GetWorkspace(); + var providerNamespace = $"{ownerPath}/{ModelProviderNodeType.RootNamespace}"; + + var stream = workspace.GetQuery( + $"model-providers:{ownerPath}", + $"namespace:{providerNamespace} nodeType:{ModelProviderNodeType.NodeType}") + .Select(snapshot => + { + var providers = new List(); + foreach (var node in snapshot) + { + if (node.Path is null) continue; + if (!string.Equals(node.NodeType, ModelProviderNodeType.NodeType, StringComparison.OrdinalIgnoreCase)) + continue; + var cfg = node.Content as ModelProviderConfiguration + ?? ExtractContent(node); + if (cfg == null) continue; + providers.Add(new ProviderInfo + { + NodePath = node.Path, + Provider = cfg.Provider, + Label = cfg.Label, + Endpoint = cfg.Endpoint, + CreatedAt = cfg.CreatedAt, + LastUsedAt = cfg.LastUsedAt, + ModelIds = cfg.Models.ToArray(), + ApiKeyFingerprint = Fingerprint(cfg.ApiKey), + }); + } + return (IReadOnlyList)providers; + }) + // Replay the latest projected snapshot to subsequent subscribers + // without re-subscribing upstream. The upstream synced query + // pushes live changes through; the TTL bounds how long we keep + // the projection alive when nobody is actively watching. + .Replay(1) + .RefCount(); + + cachedStreams[ownerPath] = (stream, DateTimeOffset.UtcNow + CacheTtl); + return stream; + } + + private T? ExtractContent(MeshNode? node) where T : class + { + if (node?.Content is null) return null; + if (node.Content is T typed) return typed; + if (node.Content is System.Text.Json.JsonElement je) + { + try { return System.Text.Json.JsonSerializer.Deserialize(je.GetRawText(), hub.JsonSerializerOptions); } + catch { return null; } + } + return null; + } + + /// + /// 8-char SHA-256 prefix — never the raw key. Same shape as the + /// factories' Fingerprint helper so logs/UI can correlate across + /// layers. + /// + private static string Fingerprint(string? value) + { + if (string.IsNullOrEmpty(value)) return "(empty)"; + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash, 0, 4).ToLowerInvariant(); + } +} + +/// +/// Returned by once the +/// provider node + all child LanguageModel nodes have been written. +/// +public record ProviderCreationResult(MeshNode ProviderNode, IReadOnlyList ModelNodes); + +/// +/// Safe DTO for listing providers — exposes a SHA-256 fingerprint of the +/// key rather than the key itself, so the UI can show "is this set / has +/// this changed" without reading the literal credential. +/// +public record ProviderInfo +{ + public string NodePath { get; init; } = ""; + public string Provider { get; init; } = ""; + public string? Label { get; init; } + public string? Endpoint { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastUsedAt { get; init; } + public IReadOnlyList ModelIds { get; init; } = Array.Empty(); + public string ApiKeyFingerprint { get; init; } = "(empty)"; +} + +internal static class ImmutableArrayExtensions +{ + public static System.Collections.Immutable.ImmutableArray ToImmutableArrayCompat(this IEnumerable source) => + System.Collections.Immutable.ImmutableArray.CreateRange(source); +} diff --git a/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs b/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs index 83efad56f..0832cc457 100644 --- a/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs +++ b/memex/Memex.Portal.Shared/OrganizationLayoutAreas.cs @@ -1,10 +1,9 @@ -using System.Reactive.Linq; +using System.Reactive.Linq; using MeshWeaver.Data; using MeshWeaver.Graph; using MeshWeaver.Layout; using MeshWeaver.Layout.Composition; using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; namespace Memex.Portal.Shared; @@ -13,9 +12,13 @@ namespace Memex.Portal.Shared; /// public static class OrganizationLayoutAreas { + private const string ThinScrollbar = "scrollbar-width: thin; scrollbar-color: rgba(128,128,128,0.3) transparent;"; + private const string ContentMaxWidth = "max-width: 1280px; margin: 0 auto; padding: 0 24px;"; + /// - /// GitHub-style organization header view with standard children section. - /// Shows logo, name, description, verified badge, contact info, then delegates to standard view for children. + /// GitHub-style organization header view with live dashboard below. + /// Shows logo, name, description, stats, then a set of MeshSearch sections scoped + /// to the organization's own partition, and a chat input inviting content creation. /// public static IObservable Overview(LayoutAreaHost host, RenderingContext _) { @@ -29,26 +32,44 @@ public static class OrganizationLayoutAreas ?.Select(nodes => nodes?.FirstOrDefault(n => n.Path == hubPath)) ?? Observable.Return(null); - return orgStream.CombineLatest(nodeStream).SelectMany(async t => + return orgStream.CombineLatest(nodeStream).Select(t => { var (org, node) = t; if (org == null && node == null) return Controls.Markdown("*Loading...*") as UiControl; - var perms = await PermissionHelper.GetEffectivePermissionsAsync(host.Hub, hubPath); - var canEdit = perms.HasFlag(Permission.Update); - return BuildOrganizationView(host, org, node, hubPath, canEdit); + return BuildOrganizationView(host, org, node); }); } private static UiControl BuildOrganizationView( LayoutAreaHost host, Organization? org, - MeshNode? node, - string hubPath, - bool canEdit = false) + MeshNode? node) + { + var orgPath = node?.Path ?? host.Hub.Address.ToString(); + var orgName = org?.Name ?? node?.Name ?? orgPath; + + var shell = Controls.Stack + .WithWidth("100%") + .WithStyle($"height: 100%; overflow-y: auto; {ThinScrollbar}"); + + shell = shell.WithView(BuildHeader(org, node, orgName)); + shell = shell.WithView(BuildBodyContent(org, node)); + + if (IsSystemorph(orgPath)) + shell = shell.WithView(BuildSystemorphHighlights(orgPath)); + else + shell = shell.WithView(BuildDashboardGrid(orgPath)); + + return shell; + } + + /// + /// Logo + name + description + stats row. GitHub-style org header, fixed at the top. + /// + private static UiControl BuildHeader(Organization? org, MeshNode? node, string orgName) { - var name = org?.Name ?? node?.Name ?? "Organization"; var description = org?.Description; var logo = org?.Logo ?? GetNodeLogo(node); var website = org?.Website; @@ -57,12 +78,11 @@ private static UiControl BuildOrganizationView( var isVerified = org?.IsVerified ?? false; var container = Controls.Stack - .WithStyle("padding: 24px 0; width: 100%;"); + .WithStyle("flex-shrink: 0; padding: 24px 0 16px 0; width: 100%;"); - // Main header row: logo + info + menu (menu on far right) var headerRow = Controls.Stack .WithOrientation(Orientation.Horizontal) - .WithStyle("gap: 24px; align-items: flex-start; width: 100%; max-width: 1280px; margin: 0 auto; padding: 0 24px;"); + .WithStyle($"gap: 24px; align-items: flex-start; width: 100%; {ContentMaxWidth}"); // Logo (large, rounded square like GitHub) UiControl logoControl; @@ -73,46 +93,36 @@ private static UiControl BuildOrganizationView( } else { - // Placeholder image with initials - var initials = GetInitials(name); + var initials = GetInitials(orgName); logoControl = Controls.Html( $"
" + $"{System.Web.HttpUtility.HtmlEncode(initials)}
"); } - if (canEdit) - { - logoControl = BuildEditableLogo(host, node, logoControl); - } headerRow = headerRow.WithView(logoControl); - // Info column (flex: 1 to take remaining space) var infoColumn = Controls.Stack.WithStyle("gap: 8px; flex: 1;"); - // Organization name (large) infoColumn = infoColumn.WithView(Controls.Html( - $"

{System.Web.HttpUtility.HtmlEncode(name)}

")); + $"

{System.Web.HttpUtility.HtmlEncode(orgName)}

")); - // Description/tagline (rendered as markdown for rich formatting) if (!string.IsNullOrEmpty(description)) { infoColumn = infoColumn.WithView( Controls.Markdown(description).WithStyle("color: var(--neutral-foreground-hint); font-size: 1rem;")); } - // Verified badge if (isVerified) { infoColumn = infoColumn.WithView(Controls.Html( - "" + + "" + "" + "Verified")); } - // Stats row: location, website, email var statsRow = Controls.Stack .WithOrientation(Orientation.Horizontal) - .WithStyle("gap: 24px; margin-top: 16px; flex-wrap: wrap;"); + .WithStyle("gap: 24px; margin-top: 12px; flex-wrap: wrap;"); if (!string.IsNullOrEmpty(location)) { @@ -146,48 +156,241 @@ private static UiControl BuildOrganizationView( // Divider container = container.WithView(Controls.Html( - "
")); + $"

")); + + return container; + } + + /// + /// Body content — priority: node.PreRenderedHtml → org.Body → default welcome markdown. + /// + private static UiControl BuildBodyContent(Organization? org, MeshNode? node) + { + var bodyStyle = $"{ContentMaxWidth} padding-top: 24px; padding-bottom: 8px;"; - // Markdown body from index.md — PreRenderedHtml is set by MarkdownFileParser - // for any .md file; MarkdownView handles mermaid, code blocks, math, UCR links if (!string.IsNullOrWhiteSpace(node?.PreRenderedHtml)) - { - container = container.WithView( - new MarkdownControl("") { Html = node.PreRenderedHtml } - .WithStyle("max-width: 1280px; margin: 0 auto; padding: 0 24px 48px 24px;")); - } + return new MarkdownControl("") { Html = node.PreRenderedHtml }.WithStyle(bodyStyle); - // Use LayoutAreaControl to render the standard Catalog view for children - container = container.WithView( - LayoutAreaControl.Children(host.Hub)); + if (!string.IsNullOrWhiteSpace(org?.Body)) + return Controls.Markdown(org!.Body!).WithStyle(bodyStyle); - return container; + return Controls.Markdown(OrganizationNodeType.WelcomeMarkdown).WithStyle(bodyStyle); + } + + /// + /// Dashboard grid mirroring the UserActivity layout but scoped to this organization's partition: + /// Latest Threads, Items, Activity Feed. + /// + private static UiControl BuildDashboardGrid(string orgPath) + { + var grid = Controls.LayoutGrid + .WithStyle($"{ContentMaxWidth} padding-top: 24px; padding-bottom: 24px; gap: 24px; width: 100%;"); + + // Latest Threads — full width + grid = grid.WithView(BuildLatestThreads(orgPath), skin => skin.WithXs(12)); + + // Items in this organization — full width, grouped by type + grid = grid.WithView(BuildItems(orgPath), skin => skin.WithXs(12)); + + // Activity feed — 2/3 width on desktop + grid = grid.WithView(BuildActivityFeed(orgPath), skin => skin.WithXs(12).WithSm(8)); + + // Recently updated main content — 1/3 width on desktop + grid = grid.WithView(BuildRecentUpdates(orgPath), skin => skin.WithXs(12).WithSm(4)); + + return grid; } /// - /// Wraps the logo control with a hover overlay and click handler to open a file browser - /// for uploading a new logo/icon. Reuses the same dialog pattern as BuildHeader's editable icon. + /// Systemorph-specific highlight strip — Featured Stories grid, embedded Event Calendar, + /// and a Post Pipeline of Social Media posts. Each section uses rich Thumbnail layout areas + /// where available so cards have visual punch instead of plain icon+name rows. /// - private static UiControl BuildEditableLogo(LayoutAreaHost host, MeshNode? node, UiControl logoControl) + private static UiControl BuildSystemorphHighlights(string orgPath) { - var nodePath = node?.Path ?? host.Hub.Address.ToString(); - - var wrapper = Controls.Stack - .WithStyle("position: relative; width: 100px; height: 100px; cursor: pointer; border-radius: 12px; overflow: hidden; flex-shrink: 0;") - .WithView(logoControl) - .WithView(Controls.Html( - "
" + - "
")) - .WithClickAction(ctx => - { - MeshNodeLayoutAreas.OpenChangeIconDialog(ctx.Host, node, nodePath); - }); - - return wrapper; + var stack = Controls.Stack + .WithStyle($"{ContentMaxWidth} padding-top: 24px; padding-bottom: 24px; gap: 32px; width: 100%;"); + + stack = stack.WithView(BuildFeaturedStories(orgPath)); + stack = stack.WithView(BuildEventCalendar(orgPath)); + stack = stack.WithView(BuildPostShowcase(orgPath)); + + return stack; } + /// + /// Featured Marketing Stories — Markdown children of the Story series hub at {orgPath}/Story. + /// + private static UiControl BuildFeaturedStories(string orgPath) + { + var heading = Controls.Html( + $"
" + + $"

✦ Featured Stories

" + + $"
See all →" + + $"
"); + + var grid = Controls.MeshSearch + .WithHiddenQuery($"namespace:{orgPath}/Story scope:children nodeType:Markdown sort:LastModified-desc") + .WithShowSearchBox(false) + .WithShowEmptyMessage(true) + .WithRenderMode(MeshSearchRenderMode.Flat) + .WithCollapsibleSections(false) + .WithSectionCounts(false) + .WithMaxColumns(3) + .WithItemLimit(6) + .WithMaxRows(2) + .WithReactiveMode(true); + + return Controls.Stack + .WithStyle("gap: 12px; width: 100%;") + .WithView(heading) + .WithView(grid); + } + + /// + /// Embed the existing EventCalendar Overview from {orgPath}/Events so the month grid + /// shows inline on the organization page. Single source of truth — same widget the + /// dedicated calendar page uses. + /// + private static UiControl BuildEventCalendar(string orgPath) + { + var eventsPath = $"{orgPath}/Events"; + + var heading = Controls.Html( + $"
" + + $"

📅 Upcoming Events

" + + $"Open calendar →" + + $"
"); + + var calendar = new LayoutAreaControl(eventsPath, new LayoutAreaReference("Overview")) + .WithShowProgress(false) + .WithStyle("width: 100%;"); + + return Controls.Stack + .WithStyle("gap: 12px; width: 100%;") + .WithView(heading) + .WithView(calendar); + } + + /// + /// Social Media post pipeline — all Posts under {orgPath}/SocialMedia rendered as + /// LinkedIn-style preview cards (PostThumbnail layout area). Status pills on each card + /// distinguish Draft / Scheduled / Published at a glance. + /// + private static UiControl BuildPostShowcase(string orgPath) + { + var socialPath = $"{orgPath}/SocialMedia"; + + var heading = Controls.Html( + $"
" + + $"

📱 Social Media

" + + $"See all →" + + $"
"); + + var posts = Controls.MeshSearch + .WithHiddenQuery($"namespace:{socialPath} nodeType:{orgPath}/Post scope:subtree sort:LastModified-desc") + .WithShowSearchBox(false) + .WithShowEmptyMessage(true) + .WithRenderMode(MeshSearchRenderMode.Flat) + .WithCollapsibleSections(false) + .WithSectionCounts(false) + .WithItemArea("Thumbnail") + .WithMaxColumns(2) + .WithItemLimit(12) + .WithMaxRows(6) + .WithReactiveMode(true) + .WithCreateNodeType($"{orgPath}/Post") + .WithCreateNamespace(socialPath); + + return Controls.Stack + .WithStyle("gap: 12px; width: 100%;") + .WithView(heading) + .WithView(posts); + } + + /// + /// Threads created against this organization or its descendants. + /// + private static UiControl BuildLatestThreads(string orgPath) + { + return Controls.MeshSearch + .WithTitle("Latest Threads") + .WithHiddenQuery($"nodeType:Thread namespace:{orgPath}/*/_Thread sort:LastModified-desc") + .WithShowSearchBox(false) + .WithShowEmptyMessage(true) + .WithRenderMode(MeshSearchRenderMode.Flat) + .WithCollapsibleSections(false) + .WithSectionCounts(false) + .WithItemLimit(40) + .WithMaxRows(2) + .WithMaxColumns(4) + .WithReactiveMode(true) + .WithCreateNodeType("Thread") + .WithCreateNamespace(orgPath); + } + + /// + /// Child content of the organization, grouped by node type. Mirrors the standard catalog view + /// but with a create-page affordance so empty organizations invite content creation. + /// + private static UiControl BuildItems(string orgPath) + { + return Controls.MeshSearch + .WithTitle("Content") + .WithHiddenQuery($"namespace:{orgPath} is:main context:search scope:descendants sort:LastModified-desc") + .WithShowSearchBox(true) + .WithShowEmptyMessage(true) + .WithRenderMode(MeshSearchRenderMode.Grouped) + .WithSectionCounts(true) + .WithItemLimit(60) + .WithMaxRows(3) + .WithMaxColumns(4) + .WithCollapsibleSections(true) + .WithReactiveMode(true) + .WithCreateHref($"/create?type=Markdown&namespace={Uri.EscapeDataString(orgPath)}"); + } + + /// + /// Activity timeline scoped to this organization — recent edits, comments, threads. + /// + private static UiControl BuildActivityFeed(string orgPath) + { + return Controls.MeshSearch + .WithTitle("Activity Feed") + .WithHiddenQuery($"source:activity namespace:{orgPath} scope:subtree is:main sort:LastModified-desc") + .WithShowSearchBox(false) + .WithShowEmptyMessage(true) + .WithRenderMode(MeshSearchRenderMode.Flat) + .WithCollapsibleSections(false) + .WithSectionCounts(false) + .WithMaxColumns(2) + .WithItemLimit(40) + .WithMaxRows(4) + .WithReactiveMode(true); + } + + /// + /// Recently updated main content in the organization — compact sidebar column. + /// + private static UiControl BuildRecentUpdates(string orgPath) + { + return Controls.MeshSearch + .WithTitle("Recently Updated") + .WithHiddenQuery($"namespace:{orgPath} is:main scope:subtree sort:LastModified-desc") + .WithShowSearchBox(false) + .WithShowEmptyMessage(true) + .WithRenderMode(MeshSearchRenderMode.Flat) + .WithCollapsibleSections(false) + .WithSectionCounts(false) + .WithMaxColumns(1) + .WithItemLimit(20) + .WithMaxRows(4) + .WithReactiveMode(true); + } + + private static bool IsSystemorph(string orgPath) => + string.Equals(orgPath, "Systemorph", StringComparison.OrdinalIgnoreCase); + private static string? GetNodeLogo(MeshNode? node) { return MeshNodeThumbnailControl.GetImageUrlForNode(node); diff --git a/memex/Memex.Portal.Shared/OrganizationNodeType.cs b/memex/Memex.Portal.Shared/OrganizationNodeType.cs index 6f9b470d7..dabc697ac 100644 --- a/memex/Memex.Portal.Shared/OrganizationNodeType.cs +++ b/memex/Memex.Portal.Shared/OrganizationNodeType.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Reactive.Linq; using MeshWeaver.ContentCollections; using MeshWeaver.Domain; using MeshWeaver.Graph; @@ -24,6 +25,13 @@ public record Organization public string? Description { get; init; } + /// + /// Long-form markdown body shown on the organization's Overview. Leave empty + /// to fall back to the default welcome message; fill it to author the page + /// yourself (mission statement, team intros, curated links, etc.). + /// + public string? Body { get; init; } + public string? Website { get; init; } [ContentItem] @@ -51,6 +59,32 @@ public static class OrganizationNodeType { public const string NodeType = "Organization"; + /// + /// Default welcome body rendered for an Organization when the node has no PreRenderedHtml of its own. + /// Plain markdown — no pseudo-HTML. Per-organization overrides live in each organization's + /// own index.md (set on ), e.g. the Systemorph + /// organization ships its own bespoke landing page that replaces this text. + /// + public const string WelcomeMarkdown = """ + # Welcome + + This is your organization's home page. + + Start by structuring the content you want to share here — a short introduction, + a mission statement, links to the teams and projects that matter to you. + + ## Tips to get started + + - **Create some content.** Use the menu above to add pages, demos, or documents. + You can always come back and ask the assistant to summarize what's inside. + - **Bring in existing files.** Drop markdown, images, or documents into the + content collection; they show up automatically. + - **Chat with your organization.** Use the chat input below to ask questions, + kick off an agent, or draft content together. + + Once you're ready, replace this text with whatever fits your organization best. + """; + public static TBuilder AddOrganizationType(this TBuilder builder) where TBuilder : MeshBuilder { builder.AddMeshNodes(CreateMeshNode()); @@ -62,7 +96,7 @@ public static TBuilder AddOrganizationType(this TBuilder builder) wher new OrganizationAccessRule(sp.GetService() ?? new NullSecurityService())); services.AddSingleton(sp => new OrganizationPostCreationHandler( - sp.GetService() ?? new NullSecurityService(), + sp.GetRequiredService(), sp.GetService()?.CreateLogger())); return services; }); @@ -84,13 +118,12 @@ public IEnumerable GetStaticNodes() Name = "Organization", NodeType = "NodeType", Icon = "/static/NodeTypeIcons/building.svg", - AssemblyLocation = typeof(OrganizationNodeType).Assembly.Location, Content = new NodeTypeDefinition { DefaultNamespace = "" }, HubConfiguration = config => config .AddMeshDataSource(source => source .WithContentType()) .AddContentCollections() - .AddNodeTypeLayoutAreas() + .AddDefaultLayoutAreas() .AddLayout(layout => layout .WithView(MeshNodeLayoutAreas.OverviewArea, OrganizationLayoutAreas.Overview)) }; @@ -101,21 +134,40 @@ public IEnumerable GetStaticNodes() /// via normal CreateNodeRequest. /// private class OrganizationPostCreationHandler( - ISecurityService securityService, + IMeshService meshService, ILogger? logger) : INodePostCreationHandler { public string NodeType => OrganizationNodeType.NodeType; - public async Task HandleAsync(MeshNode createdNode, string? createdBy, CancellationToken ct) + public Task HandleAsync(MeshNode createdNode, string? createdBy, CancellationToken ct) { if (string.IsNullOrEmpty(createdBy)) { logger?.LogWarning("Cannot assign Admin role: no creator identity for Organization at {Path}", createdNode.Path); - return; + return Task.CompletedTask; } logger?.LogInformation("Granting Admin role to {User} on Organization {Path}", createdBy, createdNode.Path); - await securityService.AddUserRoleAsync(createdBy, Role.Admin.Id, createdNode.Id, assignedBy: "system", ct); + // Replaces the obsolete ISecurityService.AddUserRoleAsync — write the + // AccessAssignment node directly via IMeshService.CreateNode (the only + // entry point that boots the per-node hub). Fire-and-forget Subscribe; + // failures surface in the data-layer error path. + var assignmentNode = new MeshNode($"{createdBy}_Access", $"{createdNode.Id}/_Access") + { + NodeType = "AccessAssignment", + Name = $"{createdBy} Access", + MainNode = createdNode.Id, + Content = new AccessAssignment + { + AccessObject = createdBy, + DisplayName = createdBy, + Roles = [new RoleAssignment { Role = Role.Admin.Id, Denied = false }] + } + }; + meshService.CreateNode(assignmentNode).Subscribe( + _ => { }, + ex => logger?.LogWarning(ex, "Failed to grant Admin role to {User} on Organization {Path}", createdBy, createdNode.Path)); + return Task.CompletedTask; } public IEnumerable GetAdditionalNodes(MeshNode createdNode) @@ -149,24 +201,24 @@ private class OrganizationAccessRule(ISecurityService securityService) : INodeTy public IReadOnlyCollection SupportedOperations => [NodeOperation.Read, NodeOperation.Create, NodeOperation.Update, NodeOperation.Delete]; - public async Task HasAccessAsync(NodeValidationContext context, string? userId, CancellationToken ct = default) + public IObservable HasAccess(NodeValidationContext context, string? userId) { if (string.IsNullOrEmpty(userId)) - return false; + return Observable.Return(false); if (context.Operation == NodeOperation.Read) - return await securityService.HasPermissionAsync(context.Node.Path, userId, Permission.Read, ct); + return securityService.HasPermission(context.Node.Path, userId, Permission.Read); if (context.Operation == NodeOperation.Create) { var parentPath = context.Node.GetParentPath() ?? context.Node.Path; - return await securityService.HasPermissionAsync(parentPath, userId, Permission.Create, ct); + return securityService.HasPermission(parentPath, userId, Permission.Create); } if (context.Operation is NodeOperation.Update or NodeOperation.Delete) - return await securityService.HasPermissionAsync(context.Node.Path, userId, Permission.Update, ct); + return securityService.HasPermission(context.Node.Path, userId, Permission.Update); - return false; + return Observable.Return(false); } } } diff --git a/memex/Memex.Portal.Shared/Pages/DevLogin.razor b/memex/Memex.Portal.Shared/Pages/DevLogin.razor index 96cc1e694..3c52da6fe 100644 --- a/memex/Memex.Portal.Shared/Pages/DevLogin.razor +++ b/memex/Memex.Portal.Shared/Pages/DevLogin.razor @@ -82,28 +82,38 @@ private bool isLoading = true; private string? error; - protected override async Task OnInitializedAsync() + private IDisposable? _personsSubscription; + + protected override void OnInitialized() { - try - { - var nodes = await MeshQuery.QueryAsync("nodeType:User namespace:User").ToListAsync(); - persons = nodes - .Select(n => ExtractPersonInfo(n)) - .Where(p => p != null) - .Select(p => p!) - .OrderBy(p => p.Name) - .ToList(); - } - catch (Exception ex) - { - error = $"Error loading persons: {ex.Message}"; - } - finally - { - isLoading = false; - } + _personsSubscription = MeshQuery + .ObserveQuery(MeshQueryRequest.FromQuery("nodeType:User namespace:User")) + .Subscribe( + change => + { + var items = change.Items + .Select(n => ExtractPersonInfo(n)) + .Where(p => p != null) + .Select(p => p!) + .OrderBy(p => p.Name) + .ToList(); + InvokeAsync(() => + { + persons = items; + isLoading = false; + StateHasChanged(); + }); + }, + ex => InvokeAsync(() => + { + error = $"Error loading persons: {ex.Message}"; + isLoading = false; + StateHasChanged(); + })); } + public void Dispose() => _personsSubscription?.Dispose(); + private static PersonInfo? ExtractPersonInfo(MeshNode node) { if (node.Content is not JsonElement jsonElement) diff --git a/memex/Memex.Portal.Shared/Pages/Index.razor b/memex/Memex.Portal.Shared/Pages/Index.razor index ec5a34ed9..8942cdc9e 100644 --- a/memex/Memex.Portal.Shared/Pages/Index.razor +++ b/memex/Memex.Portal.Shared/Pages/Index.razor @@ -21,7 +21,9 @@ && !UserContext.IsVirtual && !string.Equals(UserContext.ObjectId, WellKnownUsers.Anonymous, StringComparison.OrdinalIgnoreCase); - private string UserAddress => $"User/{UserContext?.ObjectId}"; + // Post-v10: the User node lives at the root of its own partition + // (path = ObjectId, e.g. `rbuergi`) — not under a `User/` prefix. + private string UserAddress => UserContext?.ObjectId ?? string.Empty; protected override void OnInitialized() { diff --git a/memex/Memex.Portal.Shared/Pages/Login.razor b/memex/Memex.Portal.Shared/Pages/Login.razor index 1c3b41c81..fd6d33f71 100644 --- a/memex/Memex.Portal.Shared/Pages/Login.razor +++ b/memex/Memex.Portal.Shared/Pages/Login.razor @@ -10,7 +10,6 @@ @@ -64,6 +80,14 @@ Navigation.NavigateTo(url, forceLoad: true); } + private void NavigateToConnectLinkedIn() + { + // /connect/linkedin/me uses the signed-in user's path as the profile. + // If the user isn't signed in yet, the endpoint issues a Challenge that + // returns them here after authentication. + Navigation.NavigateTo("/connect/linkedin/me", forceLoad: true); + } + protected override async Task OnInitializedAsync() { if (AuthStateTask is not null) @@ -91,15 +115,22 @@ return url; } - private static string GetProviderIcon(string provider) + private static string GetProviderLogoSvg(string provider) => provider.ToLowerInvariant() switch { - return provider.ToLowerInvariant() switch - { - "microsoft" => "\U0001faaa", - "google" => "\U0001f310", - "linkedin" => "\U0001f4bc", - "apple" => "\U0001f34e", - _ => "\U0001f511" - }; - } + "microsoft" => MicrosoftLogo, + "google" => GoogleLogo, + "linkedin" => LinkedInLogo, + "apple" => AppleLogo, + _ => DefaultLogo + }; + + private const string MicrosoftLogo = """"""; + + private const string GoogleLogo = """"""; + + private const string LinkedInLogo = """"""; + + private const string AppleLogo = """"""; + + private const string DefaultLogo = """"""; } diff --git a/memex/Memex.Portal.Shared/Pages/Login.razor.css b/memex/Memex.Portal.Shared/Pages/Login.razor.css index bfe176eaf..2221b2610 100644 --- a/memex/Memex.Portal.Shared/Pages/Login.razor.css +++ b/memex/Memex.Portal.Shared/Pages/Login.razor.css @@ -18,7 +18,7 @@ .login-header { text-align: center; - margin-bottom: 28px; + margin-bottom: 24px; } .login-header h1 { @@ -27,51 +27,108 @@ font-weight: 700; } -.login-subtitle { - color: var(--neutral-foreground-hint); - margin: 0; +.signin-label { + text-align: center; + color: var(--neutral-foreground-rest); + margin: 0 0 14px; font-size: 0.95rem; + font-weight: 500; } -.provider-btn { +.provider-row { display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.provider-row .provider-btn { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; align-items: center; - gap: 12px; - padding: 14px 20px; + justify-content: center; + gap: 8px; + padding: 16px 6px; border-radius: 6px; - font-size: 1rem; - font-weight: 500; - text-decoration: none; - cursor: pointer; - transition: background 0.15s, transform 0.1s; border: 1px solid var(--neutral-stroke-rest); + background: var(--neutral-layer-1); color: var(--neutral-foreground-rest); + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: background 0.15s, transform 0.1s, box-shadow 0.15s, border-color 0.15s; +} + +.provider-row .provider-btn:hover { + background: var(--neutral-layer-2); + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); +} + +.provider-row .provider-microsoft:hover { border-color: #00a4ef; } +.provider-row .provider-google:hover { border-color: #4285f4; } +.provider-row .provider-linkedin:hover { border-color: #0a66c2; } +.provider-row .provider-apple:hover { border-color: #333333; } + +.provider-logo { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.provider-logo ::deep svg, +.provider-logo svg { + width: 32px; + height: 32px; +} + +.provider-name { + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.provider-btn-full { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 18px; + border-radius: 6px; + border: 1px solid var(--neutral-stroke-rest); background: var(--neutral-layer-1); + color: var(--neutral-foreground-rest); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + width: 100%; + transition: background 0.15s, transform 0.1s, box-shadow 0.15s; } -.provider-btn:hover { +.provider-btn-full:hover { background: var(--neutral-layer-2); transform: translateY(-1px); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); } -.provider-icon { +.provider-btn-full.provider-dev { border-left: 3px solid #f59e0b; } +.provider-btn-full.provider-linkedin-publish { border-left: 3px solid #0a66c2; } + +.provider-icon-text { font-size: 1.2rem; width: 24px; text-align: center; } -.provider-microsoft { border-left: 3px solid #00a4ef; } -.provider-google { border-left: 3px solid #4285f4; } -.provider-linkedin { border-left: 3px solid #0077b5; } -.provider-apple { border-left: 3px solid #333333; } -.provider-dev { border-left: 3px solid #f59e0b; } - .divider { display: flex; align-items: center; gap: 12px; - margin: 8px 0; + margin: 12px 0; color: var(--neutral-foreground-hint); font-size: 0.85rem; } diff --git a/memex/Memex.Portal.Shared/Pages/Onboarding.razor b/memex/Memex.Portal.Shared/Pages/Onboarding.razor index 8452a3629..0ca9c1d78 100644 --- a/memex/Memex.Portal.Shared/Pages/Onboarding.razor +++ b/memex/Memex.Portal.Shared/Pages/Onboarding.razor @@ -1,18 +1,25 @@ @page "/onboarding" @attribute [Microsoft.AspNetCore.Authorization.Authorize] @using MeshWeaver.Blazor.Infrastructure +@using MeshWeaver.Data +@using MeshWeaver.Graph @using MeshWeaver.Mesh @using MeshWeaver.Mesh.Security @using MeshWeaver.Mesh.Services @using MeshWeaver.Messaging @using MeshWeaver.Hosting.Blazor +@using Memex.Portal.Shared.Authentication @using Microsoft.Extensions.DependencyInjection +@using Microsoft.Extensions.Logging +@using System.Reactive.Disposables +@using System.Reactive.Linq @inject AccessService AccessService -@inject IMeshService NodeFactory -@inject IMeshService MeshQuery +@inject UserOnboardingService OnboardingService @inject PortalApplication PortalApplication @inject NavigationManager Navigation @inject CircuitAccessHandler CircuitAccessHandler +@inject ILogger Logger +@implements IDisposable Complete Your Profile - Memex Portal @@ -102,65 +109,83 @@ else private bool checkCompleted; private bool existingUserFound; - protected override async Task OnInitializedAsync() + // Holds the subscriptions from OnInitialized (existing-user lookup) and + // HandleSubmit (the dual-write pipeline). Disposed when the page tears + // down so an in-flight chain can't write back to a stale circuit. + private readonly CompositeDisposable subscriptions = new(); + + protected override void OnInitialized() { var context = AccessService?.Context ?? AccessService?.CircuitContext; - if (context == null) { checkCompleted = true; return; } + if (context is null) { checkCompleted = true; return; } model.Email = context.Email ?? ""; - - // Only lock the email field if we got a valid email from OAuth emailReadOnly = !string.IsNullOrEmpty(context.Email) && context.Email.Contains('@'); - // Pre-populate full name from OAuth claims if (!string.IsNullOrEmpty(context.Name) && context.Name != "Unknown") model.FullName = context.Name; - // Suggest username from email prefix (lowercase) if (!string.IsNullOrEmpty(context.Email) && context.Email.Contains('@')) model.Username = context.Email.Split('@')[0].ToLowerInvariant(); - // Check if a User node already exists for this email (e.g., created via another portal) - if (!string.IsNullOrEmpty(context.Email)) + if (string.IsNullOrEmpty(context.Email)) { - using (AccessService!.ImpersonateAsHub(PortalApplication!.Hub)) - { - var existing = await MeshQuery.QueryAsync( - $"nodeType:User namespace:User content.email:{context.Email} limit:1") - .FirstOrDefaultAsync(); + checkCompleted = true; + return; + } - if (existing is { State: MeshNodeState.Active }) - { - // Adopt existing identity and skip onboarding - var updated = (AccessService.Context ?? new AccessContext()) with + var email = context.Email; + var workspace = PortalApplication!.Hub.GetWorkspace(); + + // Synced query — bypasses RLS (runs as System) and is gated on Initial, + // so the first emission IS the authoritative snapshot. Take(1) completes + // the subscription after that single emission; no FirstAsync, no ToTask. + subscriptions.Add( + workspace.GetQuery( + $"onboarding:byEmail:{email}", + $"nodeType:User namespace:User content.email:{email} limit:1") + .Take(1) + .Subscribe( + snapshot => OnExistingUserCheckCompleted(email, snapshot.FirstOrDefault()), + ex => { - ObjectId = existing.Id, - Name = existing.Name ?? existing.Id, - Email = context.Email - }; - AccessService.SetContext(updated); - // Update the circuit-level context so subsequent client-side - // navigations (e.g., to Index.razor) see the resolved identity. - CircuitAccessHandler.UpdateUserContext(updated); - existingUserFound = true; - checkCompleted = true; - return; - } - } - } - checkCompleted = true; + Logger.LogWarning(ex, + "Onboarding: existing-user lookup failed for {Email}", email); + InvokeAsync(() => { checkCompleted = true; StateHasChanged(); }); + })); } - protected override Task OnAfterRenderAsync(bool firstRender) + private void OnExistingUserCheckCompleted(string email, MeshNode? existing) { - if (firstRender && existingUserFound) + if (existing is { State: MeshNodeState.Active }) + { + var updated = (AccessService!.Context ?? new AccessContext()) with + { + ObjectId = existing.Id, + Name = existing.Name ?? existing.Id, + Email = email + }; + AccessService.SetContext(updated); + // Update the circuit-level context so subsequent client-side + // navigations (e.g., to Index.razor) see the resolved identity. + CircuitAccessHandler.UpdateUserContext(updated); + + InvokeAsync(() => + { + existingUserFound = true; + checkCompleted = true; + StateHasChanged(); + // Client-side only, no forceLoad — preserves the resolved circuit. + Navigation.NavigateTo("/"); + }); + } + else { - Navigation.NavigateTo("/"); // Client-side only, no forceLoad + InvokeAsync(() => { checkCompleted = true; StateHasChanged(); }); } - return Task.CompletedTask; } - private async Task HandleSubmit() + private void HandleSubmit() { if (string.IsNullOrWhiteSpace(model.Username)) { @@ -168,111 +193,123 @@ else return; } - isSaving = true; - errorMessage = null; - - try + var callerId = AccessService?.Context?.ObjectId + ?? AccessService?.CircuitContext?.ObjectId; + if (string.IsNullOrEmpty(callerId)) { - var userId = AccessService?.Context?.ObjectId - ?? AccessService?.CircuitContext?.ObjectId; - if (string.IsNullOrEmpty(userId)) - { - errorMessage = "Not authenticated. Please sign in again."; - return; - } - - var username = model.Username.Trim().ToLowerInvariant(); - var fullName = model.FullName?.Trim(); - var userContent = new User - { - FullName = string.IsNullOrWhiteSpace(fullName) ? null : fullName, - Email = model.Email.Trim(), - Bio = string.IsNullOrWhiteSpace(model.Bio) ? null : model.Bio.Trim(), - Role = string.IsNullOrWhiteSpace(model.Role) ? null : model.Role.Trim(), - PinnedPaths = ["Doc"], - }; - - // Use ImpersonateAsHub so the portal hub identity is recognized - // by the portal create access rule (portal namespace = create/read/update User nodes) - using (AccessService!.ImpersonateAsHub(PortalApplication!.Hub)) - { - // Check if this is the first user (no existing User nodes = platform admin) - var isFirstUser = true; - await foreach (var _ in MeshQuery.QueryAsync(new MeshQueryRequest { Query = "namespace:User", Limit = 1 })) - { - isFirstUser = false; - break; - } - - // Check that the username is not already taken - var existingNode = await MeshQuery.QueryAsync( - $"path:User/{username} scope:self").FirstOrDefaultAsync(); - if (existingNode != null) - { - errorMessage = $"Username '{username}' is already taken. Please choose a different one."; - return; - } - - // Check that the email is not already assigned to another user - var emailValue = model.Email.Trim(); - var existingByEmail = await MeshQuery.QueryAsync( - $"nodeType:User content.email:{emailValue}").FirstOrDefaultAsync(); - if (existingByEmail != null && existingByEmail.State == MeshNodeState.Active) - { - errorMessage = $"This email is already assigned to user '{existingByEmail.Id}'. Please sign in with that account."; - return; - } - - var node = new MeshNode(username, "User") - { - Name = string.IsNullOrWhiteSpace(fullName) ? username : fullName, - NodeType = "User", - State = MeshNodeState.Active, - Icon = string.IsNullOrWhiteSpace(model.AvatarUrl) ? null : model.AvatarUrl.Trim(), - Content = userContent - }; + errorMessage = "Not authenticated. Please sign in again."; + return; + } - await NodeFactory.CreateNodeAsync(node); + isSaving = true; + errorMessage = null; - // First user becomes global Admin (stored in admin.access table) - if (isFirstUser) + var username = model.Username.Trim().ToLowerInvariant(); + var email = model.Email.Trim(); + var fullName = model.FullName?.Trim(); + var workspace = PortalApplication!.Hub.GetWorkspace(); + + var request = new UserOnboardingRequest( + Username: username, + Email: email, + FullName: fullName, + Bio: model.Bio, + Role: model.Role, + AvatarUrl: model.AvatarUrl); + + // Three synced queries — bypass RLS, gated on Initial, deduped on path. + // workspace.GetQuery is the canonical primitive for reading a set of + // MeshNodes (see Doc/Architecture/SyncedMeshNodeQueries.md). Distinct + // cache ids per check; the username/email ones live for the page's + // lifetime, which is fine for one-shot pre-checks. + var firstUserQuery = workspace.GetQuery( + "onboarding:firstUserCheck", + "namespace:User limit:1"); + var usernameQuery = workspace.GetQuery( + $"onboarding:username:{username}", + $"path:User/{username} scope:self"); + var emailQuery = workspace.GetQuery( + $"onboarding:email:{email}", + $"nodeType:User content.email:{email}"); + + // Observable.Using holds ImpersonateAsHub's IDisposable for the entire + // subscription lifetime so every meshService.CreateNode call inside the + // chain captures the portal-hub identity (the portal create-rule lets + // the portal-hub identity write User / AccessAssignment nodes). + var pipeline = Observable.Using( + () => AccessService!.ImpersonateAsHub(PortalApplication!.Hub), + _ => Observable + .CombineLatest(firstUserQuery, usernameQuery, emailQuery, + (all, byName, byMail) => (all, byName, byMail)) + .Take(1) + .SelectMany(checks => { - var securityService = PortalApplication.Hub.ServiceProvider - .GetService(); - if (securityService != null) - await securityService.AddUserRoleAsync(username, Role.Admin.Id, "Admin", username); - } - } - - // Update request-scoped context so the OnboardingMiddleware on the next request - // recognizes this user as already onboarded. The forceLoad navigation below - // triggers a fresh HTTP request + circuit, so CircuitAccessHandler will - // re-resolve the user from AuthenticationState. - var updatedContext = (AccessService.Context ?? new AccessContext()) with - { - ObjectId = username, - Name = username, - Email = model.Email.Trim() - }; - AccessService.SetContext(updatedContext); + if (checks.byName.Any()) + return Observable.Throw(new InvalidOperationException( + $"Username '{username}' is already taken. Please choose a different one.")); + + var existingByEmail = checks.byMail + .FirstOrDefault(n => n.State == MeshNodeState.Active); + if (existingByEmail is not null) + return Observable.Throw(new InvalidOperationException( + $"This email is already assigned to user '{existingByEmail.Id}'. " + + "Please sign in with that account.")); + + var isFirstUser = !checks.all.Any(); + + // Dual-write (3 rows) → self-AccessAssignment → optional + // platform-Admin grant. All four steps are sequenced via + // SelectMany so error / cancellation flows through one chain. + return OnboardingService.CreateUser(request) + .SelectMany(rootNode => OnboardingService.GrantSelfAdmin(username) + .Select(_ => rootNode)) + .SelectMany(rootNode => isFirstUser + ? OnboardingService.GrantPlatformAdmin(username).Select(_ => rootNode) + : Observable.Return(rootNode)); + })); + + subscriptions.Add(pipeline.Subscribe( + _ => OnOnboardingSucceeded(username, email), + ex => OnOnboardingFailed(username, ex))); + } - Navigation.NavigateTo("/", forceLoad: true); - } - catch (Microsoft.AspNetCore.Components.NavigationException) + private void OnOnboardingSucceeded(string username, string email) + { + // Update request-scoped context so the OnboardingMiddleware on the next + // request recognizes this user as already onboarded. The forceLoad + // navigation below triggers a fresh HTTP request + circuit, so + // CircuitAccessHandler will re-resolve the user from AuthenticationState. + var updatedContext = (AccessService!.Context ?? new AccessContext()) with { - // NavigateTo with forceLoad throws NavigationException — let it propagate - throw; - } - catch (Exception ex) + ObjectId = username, + Name = username, + Email = email + }; + AccessService.SetContext(updatedContext); + + InvokeAsync(() => { - errorMessage = $"Failed to save profile: {ex.Message}"; - } - finally + isSaving = false; + StateHasChanged(); + Navigation.NavigateTo("/", forceLoad: true); + }); + } + + private void OnOnboardingFailed(string username, Exception ex) + { + Logger.LogError(ex, "Onboarding submit failed for {Username}", username); + InvokeAsync(() => { + errorMessage = ex is InvalidOperationException + ? ex.Message + : $"Failed to save profile: {ex.Message}"; isSaving = false; - } + StateHasChanged(); + }); } + public void Dispose() => subscriptions.Dispose(); + private class OnboardingModel { public string? FullName { get; set; } diff --git a/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs index 56265a529..a3afb19fc 100644 --- a/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs +++ b/memex/Memex.Portal.Shared/Settings/ApiTokensSettingsTab.cs @@ -31,7 +31,7 @@ public static MessageHubConfiguration AddApiTokensSettingsTab( Group: "Security", Icon: FluentIcons.Key(), Order: 230, - RequiredPermission: Permission.Read)); + RequiredPermission: Permission.None)); } internal static UiControl BuildApiTokensContent( @@ -51,18 +51,22 @@ internal static UiControl BuildApiTokensContent( const string createDataId = "apiTokenCreate"; const string resultDataId = "apiTokenResult"; - const string tokenListRefreshId = "apiTokenListRefresh"; host.UpdateData(createDataId, new Dictionary { ["label"] = "", ["expiryDays"] = 365 }); - // NOTE: Do NOT initialize resultDataId here — CreateTokenAsync saves a MeshNode + // NOTE: Do NOT initialize resultDataId here — CreateToken saves a MeshNode // which triggers the workspace stream, causing the Settings page to rebuild. // If we set resultDataId="" here, the rebuild would overwrite the token display // that the click handler just set. Instead, the reactive view uses .StartWith(). - host.UpdateData(tokenListRefreshId, DateTimeOffset.UtcNow.Ticks); + // + // The token list below subscribes to `tokenService.GetTokensForUser(userId)` + // — a live synced query (workspace.GetQuery under the hood). New tokens + // appear on CreateNode commit, revokes flip rows to "Revoked" on + // workspace.GetMeshNodeStream(...).Update commit, deletes drop rows on + // DeleteNode commit. No refresh trigger needed. // Create token form var createSection = Controls.Stack.WithWidth("100%") @@ -157,7 +161,9 @@ internal static UiControl BuildApiTokensContent( ctx.Host.UpdateData(resultDataId, tokenHtml); ctx.Host.UpdateData(tokenRenderKey, DateTimeOffset.UtcNow.Ticks); - ctx.Host.UpdateData(tokenListRefreshId, DateTimeOffset.UtcNow.Ticks); + // No list refresh trigger — the synced query + // below re-emits automatically when the new + // node commits to the workspace. }, ex => ctx.Host.UpdateData(resultDataId, "

Your Tokens")); + // Live token list — bound directly to the synced query. The view + // re-renders whenever the underlying mesh-query collection changes + // (token created, revoked, deleted). No refresh trigger pattern; + // see Doc/Architecture/SyncedMeshNodeQueries.md for the canonical + // shape — every emission is a complete snapshot. stack = stack.WithView((h, _) => - h.Stream.GetDataStream(tokenListRefreshId) - .SelectMany(async _ => - { - if (string.IsNullOrEmpty(userId)) - return (UiControl?)Controls.Html( - "

No user identity found.

"); - - var tokens = await tokenService.GetTokensForUserAsync(userId); - - if (tokens.Count == 0) - return (UiControl?)Controls.Html( - "

No tokens yet. Create one above.

"); - - return (UiControl?)BuildTokenList(tokens, tokenService, tokenListRefreshId, resultDataId); - })); + string.IsNullOrEmpty(userId) + ? Observable.Return(Controls.Html( + "

No user identity found.

")) + : tokenService.GetTokensForUser(userId) + .Select(tokens => tokens.Count == 0 + ? (UiControl?)Controls.Html( + "

No tokens yet. Create one above.

") + : BuildTokenList(tokens, tokenService, resultDataId))); return stack; } private static UiControl BuildTokenList( - List tokens, + IReadOnlyList tokens, ApiTokenService tokenService, - string tokenListRefreshId, string resultDataId) { var container = Controls.Stack.WithWidth("100%").WithStyle("gap: 8px;"); @@ -247,15 +250,14 @@ private static UiControl BuildTokenList( "

Deleting '{Esc(capturedForDelete.Label)}'…

"); - // Reactive: Subscribe to the service observable (hub.Post + RegisterCallback under the hood). + // Reactive: Subscribe to the service observable + // (hub.Post + RegisterCallback under the hood). The + // list re-renders automatically when the synced + // query above sees the deletion. tokenService.DeleteToken(capturedForDelete.NodePath).Subscribe( - _ => - { - ctx.Host.UpdateData(resultDataId, - "

Token '{Esc(capturedForDelete.Label)}' deleted.

"); - ctx.Host.UpdateData(tokenListRefreshId, DateTimeOffset.UtcNow.Ticks); - }, + _ => ctx.Host.UpdateData(resultDataId, + "

Token '{Esc(capturedForDelete.Label)}' deleted.

"), ex => ctx.Host.UpdateData(resultDataId, "

Failed to delete: {Esc(ex.Message)}

")); @@ -270,24 +272,16 @@ private static UiControl BuildTokenList( .WithAppearance(Appearance.Outline) .WithClickAction(ctx => { - ctx.Host.UpdateData(resultDataId, - "

Revoking '{Esc(captured.Label)}'…

"); - - // Reactive: Subscribe to the service observable — no await, no Task.Run. - tokenService.RevokeToken(captured.NodePath).Subscribe( - success => - { - ctx.Host.UpdateData(resultDataId, success - ? "

Token '{Esc(captured.Label)}' revoked.

" - : "

Failed to revoke token.

"); - ctx.Host.UpdateData(tokenListRefreshId, DateTimeOffset.UtcNow.Ticks); - }, - ex => ctx.Host.UpdateData(resultDataId, - "

Failed to revoke: {Esc(ex.Message)}

")); + ctx.Host.UpdateData(resultDataId, BuildPendingHtml($"Revoking '{Esc(captured.Label)}'…")); + + // Reactive: subscribe to the factored-out observable. + // Revoke(...) bridges the service call to a single outcome + // record so the test can assert on the same composition + // the UI subscribes to — no await, no Task.Run. The + // list row flips to "Revoked" automatically when the + // synced query sees the IsRevoked change. + Revoke(tokenService, captured.NodePath, captured.Label).Subscribe( + outcome => ctx.Host.UpdateData(resultDataId, BuildOutcomeHtml(outcome))); return Task.CompletedTask; })); } @@ -299,4 +293,46 @@ private static UiControl BuildTokenList( } private static string Esc(string s) => System.Web.HttpUtility.HtmlEncode(s); + + /// + /// Outcome of a token revoke/delete invocation — surfaced to both the + /// click handler and the test. is the user-facing + /// pass/fail; carries the optional error detail to + /// embed in the result HTML. Kept internal so it stays a presentation- + /// layer concern, not an exported API. + /// + internal record TokenActionOutcome(bool Success, string Label, string? Message = null); + + /// + /// Factored-out revoke pipeline — single observable composition shared by + /// the click handler and the test. Bridges + /// to an outcome that includes + /// the label (so the message can be rendered without recapturing it) and + /// folds the OnError path into a successful emission of + /// (Success=false, Message=...) so the test never has to assert on + /// observable termination semantics. Subscribe once — Take(1)-equivalent + /// shape because the underlying service emits exactly one value. + /// + internal static IObservable Revoke( + ApiTokenService tokenService, string nodePath, string label) + => tokenService.RevokeToken(nodePath) + .Select(success => new TokenActionOutcome(success, label)) + .Catch(ex => + Observable.Return(new TokenActionOutcome(false, label, ex.Message))); + + private static string BuildPendingHtml(string message) => + "

{Esc(message)}

"; + + private static string BuildOutcomeHtml(TokenActionOutcome outcome) + { + if (outcome.Success) + return "

Token '{Esc(outcome.Label)}' revoked.

"; + var detail = string.IsNullOrEmpty(outcome.Message) + ? "Failed to revoke token." + : $"Failed to revoke: {Esc(outcome.Message)}"; + return "

{detail}

"; + } } diff --git a/memex/Memex.Portal.Shared/Settings/ModelsSettingsTab.cs b/memex/Memex.Portal.Shared/Settings/ModelsSettingsTab.cs new file mode 100644 index 000000000..bbe1ef9cf --- /dev/null +++ b/memex/Memex.Portal.Shared/Settings/ModelsSettingsTab.cs @@ -0,0 +1,259 @@ +using System.Reactive.Linq; +using MeshWeaver.AI; +using MeshWeaver.Application.Styles; +using MeshWeaver.Data; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Layout; +using MeshWeaver.Layout.Composition; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Messaging; +using Memex.Portal.Shared.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Memex.Portal.Shared.Settings; + +/// +/// Settings tab for managing AI ModelProvider credentials. +/// Mirrors 's reactive shape — entries are +/// stored as MeshNodes under the owner's namespace (the User's partition +/// when viewed from the user settings page, any node's namespace when +/// viewed from that node's settings). +/// +public static class ModelsSettingsTab +{ + public const string TabId = "Models"; + + public static MessageHubConfiguration AddModelsSettingsTab( + this MessageHubConfiguration config) + { + return config.AddSettingsMenuItems( + new SettingsMenuItemDefinition( + Id: TabId, + Label: "Models", + ContentBuilder: BuildModelsContent, + Group: "AI", + Icon: FluentIcons.Sparkle(), + Order: 220, + RequiredPermission: Permission.Api)); + } + + internal static UiControl BuildModelsContent( + LayoutAreaHost host, StackControl stack, MeshNode? node) + { + var providerService = host.Hub.ServiceProvider.GetRequiredService(); + var accessService = host.Hub.ServiceProvider.GetService(); + var userId = accessService?.Context?.ObjectId ?? ""; + + // Owner path: the MeshNode whose settings we're viewing, falling back + // to the user's partition when this tab is rendered from the user's + // own settings page (node==null). This matches the user's intent of + // "under user namespace OR any other node's namespace". + var ownerPath = !string.IsNullOrEmpty(node?.Path) ? node!.Path : userId; + + stack = stack.WithView(Controls.H2("Models").WithStyle("margin: 0 0 8px 0;")); + stack = stack.WithView(Controls.Html( + "

" + + "Enter your own AI provider credentials. Each provider auto-creates the standard model nodes you can pick in chat. " + + "Keys never leave your namespace.

")); + + if (string.IsNullOrEmpty(ownerPath)) + { + stack = stack.WithView(Controls.Html( + "

No owner identity available.

")); + return stack; + } + + const string createDataId = "modelProviderCreate"; + const string resultDataId = "modelProviderResult"; + + // Build dropdown options from the live LanguageModelCatalogOptions — + // each provider package self-registers via its AddXxxCatalog + // extension (no central registry). Keyless providers (Copilot / + // ClaudeCode use other auth) are filtered out for the BYO-key UX. + var catalogOptions = host.Hub.ServiceProvider.GetService(); + var providerOptions = catalogOptions?.Sources + .Where(s => s.RequiresApiKey) + .OrderBy(s => s.Order) + .ToList() + ?? new List(); + + host.UpdateData(createDataId, new Dictionary + { + ["provider"] = providerOptions.FirstOrDefault()?.ProviderName ?? "", + ["label"] = "", + ["apiKey"] = "", + ["endpoint"] = "" + }); + + var createSection = Controls.Stack.WithWidth("100%") + .WithStyle("padding: 16px; background: var(--neutral-layer-2); border-radius: 8px; gap: 12px; margin-bottom: 24px;"); + createSection = createSection.WithView( + Controls.Html("

Add Provider

")); + + var formRow1 = Controls.Stack + .WithOrientation(Orientation.Horizontal) + .WithStyle("gap: 12px; align-items: flex-end; flex-wrap: wrap;"); + + var providerOptionsArray = providerOptions + .Select(p => new Option(p.ProviderName, p.EffectiveLabel)) + .Cast