Skip to content

Feat/portfolio owned position events - #31

Open
JacobChwastek wants to merge 47 commits into
masterfrom
feat/portfolio-owned-position-events
Open

Feat/portfolio owned position events#31
JacobChwastek wants to merge 47 commits into
masterfrom
feat/portfolio-owned-position-events

Conversation

@JacobChwastek

Copy link
Copy Markdown
Collaborator

No description provided.

Delete tests/WiSave.Portal.IntegrationTests and its solution entry.
The WebApplicationFactory-based suite covered auth endpoints, admin
access management, the YARP user-header transform, SignalR hubs, the
Redis backplane, and Wolverine consumer fan-out, but its maintenance
cost outweighed the signal it produced.

Portal test coverage is now WiSave.Portal.UnitTests only. Behavior
worth keeping is re-covered at unit level as it comes up; the header
transform's stripping of client-supplied X-User-* headers is the most
notable gap left behind.
Add .config/dotnet-tools.json pinning dotnet-ef to 10.0.9 with
rollForward disabled, matching Microsoft.EntityFrameworkCore.Design in
Directory.Packages.props. Generating a script with a mismatched tool
writes the wrong ProductVersion into __EFMigrationsHistory, which is how
the tree ended up with a 10.0.7-generated script against 10.0.9 packages.

Delete scripts/generate-dbup-script.sh. It only wrapped a single
dotnet ef invocation, and its --startup-project pointed at
WiSave.Portal.EfTools, which no longer exists. The Portal project's
DesignTimeDbContextFactory makes the direct command sufficient.

Run `dotnet tool restore` once per clone before generating scripts.
Make AGENTS.md the single source of truth and reduce CLAUDE.md to an
@AGENTS.md import, so Claude Code inlines the same content other agent
tooling reads. The two files had drifted into contradicting each other.

Correct the guidance against the current tree while merging:
- drop WiSave.Portal.EfTools and tests/WiSave.Portal.Tests, both gone
- document WiSave.Portal.Console and WiSave.Portal.Contracts
- replace the plan-based permission model with the role-claim model that
  RolePermissionResolver actually implements; there are no Plans,
  Permissions, or PlanPermissions tables
- record that identity keys are UUIDv7 Guids
- add the expenses proxy route, per-route authorization policies, and the
  /api prefix strip; correct the stocks port from 5086 to 5300
- state that the integration suite is not to be recreated

Surface AGENTS.md and Directory.Packages.props in solution items.
Record the design for two requested changes: extracting code from the
WiSave.Portal web project into class libraries, and replacing the
hand-written Dockerfile plus docker-compose with .NET Aspire.

The investigation recommends extracting zero class libraries. The reuse
driver is structurally blocked — WiSave.Portal.Contracts has had no
consumer for 3.6 months because the publish feed is not configured in
this repo or any sibling — and the testability driver is a code-shape
problem, not a project-boundary one. The untestable paths are untestable
because logic sits in YARP transform closures, non-virtual methods on
concrete Identity-manager dependencies, and private statics inside
399-line endpoint files. Refactoring in place fixes all of it.

Aspire is adopted last and additively. Its two headline arguments do not
survive scrutiny: the private-feed fix is dotnet publish -t:PublishContainer,
an SDK capability the csproj is already configured for, and startup
ordering is depends_on: service_completed_successfully. What remains is a
dashboard, a one-command dev loop, and a home for OTel tracing across the
YARP hop. RabbitMQ never moves; it is the ecosystem's only broker and
Aspire 13.4.6 cannot join a pre-existing Docker network in run mode.

The design also documents three live defects found while investigating:
db-rebuild-identity throws at runtime, ApplyPortalMigrations has no
callers so Migrations__AutoApplyOnStartup is dead config, and the stocks
proxy target points at the incomes service port.

User decisions folded in: drop the stash line of work and delete the
identity-rebuild path; nothing deploys Portal at 192.168.1.50:5100, so
the Dockerfile can go and Phase 8 is dropped. Package reuse is treated as
aspirational by default and is flagged as the one decision not explicitly
confirmed.
Delete docs/superpowers and ignore it, along with .superpowers. The 22
specs and plans tracked here now live in the Obsidian vault at
Development/superpowers/wisave-portal/, with YAML frontmatter carrying
project, type, date and nested Obsidian tags.

Design churn does not belong in the code history, and the documents are
more useful somewhere they can be searched and cross-linked. Migrated
content was verified byte-identical before deletion.

Note this stops tracking going forward; earlier commits still contain
these files.
Split src/WiSave.Portal into three projects with an enforced dependency
direction:

  Core.Application      -> nothing at all
  Core.Infrastructure   -> Contracts, Core.Application
  WebApi                -> Contracts, Core.Application, Core.Infrastructure,
                           Migrations

Core.Application holds pure policy — permission vocabulary, access-management
predicates, downstream-service options, realtime envelopes — and declares zero
ProjectReference, PackageReference and FrameworkReference. Core.Infrastructure
holds the Identity entities, PortalDbContext and its EF migrations, permission
resolution and the Redis ticket store. The web project is renamed to
WiSave.Portal.WebApi and keeps only HTTP, SignalR, YARP and Wolverine wiring;
RootNamespace is pinned to WiSave.Portal so no C# namespace changed.

No Core.Domain project: ApplicationUser and ApplicationRole are EF Identity
persistence entities and would drag Microsoft.Extensions.Identity.Stores into
any "domain" project holding them.

Behaviour pinned by tests before it moved. The YARP request-transform closure
is now an internal static method, so the stripping of client-supplied X-User-*
headers is asserted directly — the gap 49eaa0c named when the integration
suite was deleted. Permission resolution moved behind IPermissionResolver and
the admin access-management predicates into their own class, both with tests.
ProjectDependencyDirectionTests asserts the csproj reference sets, so a future
violation fails the build rather than passing review.

The three EF files moved as a byte-preserving text edit — namespace, using and
five ApplicationUser entity-name strings, verified as 1/7/7 changed lines with
no table.Column, HasColumnType or [Migration] change. Regenerating them here
would have smuggled a text->uuid schema conversion into a refactor; that
belongs to the migration-authority plan.

Also removes the db-rebuild-identity console command and its DbMigrator path,
which threw at runtime after 49eaa0c deleted the maintenance SQL it loaded,
and corrects the downstream proxy ports — incomes was pointed at the retired
5114 and stocks at the incomes port 5300 instead of 5301.

Tests: 62 (1 failing) -> 90 passing.

BREAKING: the built assembly is now WiSave.Portal.WebApi.dll and the project
lives at src/WiSave.Portal.WebApi. The Dockerfile and docker-compose are
updated; the published container repository name is unchanged. Anything
outside this repo that builds src/WiSave.Portal by path needs updating.
Move IPermissionResolver from Core.Infrastructure into Core.Application and
change its signature from GetPermissionsAsync(ApplicationUser) to
GetPermissionsAsync(Guid userId, CancellationToken). The port was declared
beside its own implementation and named an EF entity, so the web project
depended on the infrastructure layer purely to obtain an abstraction.

The resolver now looks the user up itself and returns an empty set when there
is none. PermissionResolutionMiddleware drops its UserManager dependency and
resolves straight from the name-identifier claim. Behaviour is unchanged:
PermissionHandler already treated an absent and an empty permission set
identically, and a test now pins the unknown-user path.
Introduce IRealtimeNotifier in Core.Application, implemented by
SignalRRealtimeNotifier in the web project. The event handlers translate an
inbound integration event into a RealtimeEnvelope and hand it to the port; the
hub, its group naming and its Redis backplane stay behind the boundary.

Their tests lose a three-level IHubContext/IHubClients/IClientProxy substitute
in favour of one fake notifier.

Adds RealtimePayloadContractTests, which pins the payload fields wisave-ui
declares in src/app/core/signalr/stocks-signalr.types.ts. The portal never
reads these payloads — it relays downstream integration events verbatim — so a
rename in WiSave.Stock.Contracts would otherwise reach the browser with
nothing failing in either build. Presence is asserted rather than equality, so
additive downstream changes stay compatible.
Microsoft.OpenApi 2.0.0 has a known high-severity vulnerability
(GHSA-v5pm-xwqc-g5wc) and was reaching the build transitively through
Microsoft.AspNetCore.OpenApi, which still pins exactly 2.0.0 even in its
latest 10.0.10 release — so bumping the ASP.NET package does not help.

Enable CentralPackageTransitivePinningEnabled and pin Microsoft.OpenApi to
2.11.0, the newest release in the same major, so the ASP.NET package keeps the
API surface it compiled against.

The advisory fired NU1903 on every restore. The build is now warning-free,
which also unblocks adopting TreatWarningsAsErrors later.
Move the Microsoft.* packages from 10.0.9 to 10.0.10, Npgsql.EntityFrameworkCore
.PostgreSQL to 10.0.3 and NSubstitute to 6.0.0.

NSubstitute 6 annotates Arg.Is<T> for nullability, which surfaced CS8604 in the
two event-handler test helpers added with the realtime port: the predicate can
be invoked with null, so the helpers now take RealtimeEnvelope? and assert
non-null. Build is warning-free again and all 98 tests pass on the new majors.
The notification handlers and the Wolverine listen definitions were never the
web project's responsibility. Move both, so WiSave.Portal.WebApi is HTTP
surface plus composition and nothing else.

Handlers move to Core.Application, which gains the three integration-contract
packages — the only references it is allowed. Their duplicated envelope
construction becomes a shared RealtimeNotificationHandler base: build the
envelope, drop events that carry no user, hand it to IRealtimeNotifier. The
domain discriminators move into a RealtimeDomain constant class; they are wire
contract, since the Angular client filters on them before dispatching to a
feature service.

The broker and listen configuration moves to Core.Infrastructure as
MessagingHostBuilderExtensions, taking WolverineFx.RabbitMQ,
WiSave.Framework.Messaging.Wolverine and the contracts packages with it. The
web project's package list drops from 12 to 7.

ProjectDependencyDirectionTests is retuned rather than relaxed: Core.Application
still bans project and framework references outright, and its packages must
match an explicit three-entry allowlist, so a fourth is a deliberate edit rather
than drift.
Both handlers did the same thing to different upstream event families, so
splitting them by source service only duplicated the translation. Merge them
into RealtimeNotificationsEventHandler covering all sixteen events.

It is named for what it produces rather than how the notification travels: the
handler pushes a RealtimeEnvelope at IRealtimeNotifier and does not know SignalR
exists. Putting the transport back in the name would undo that.

Also restores the legacy RabbitMQ queue name, which the previous commit's
namespace rewrite silently changed. It reads like a .NET namespace because
Wolverine derived it from one, but it is a durable queue that already exists on
the broker. Renaming it would have made the portal bind a different queue and
stop consuming the legacy income, category and expense notifications, with
nothing failing at build time. A test now pins the literal.
Produce the image with
`dotnet publish src/WiSave.Portal.WebApi -c Release -t:PublishContainer`, using
the ContainerRepository, ContainerPort and ContainerEnvironmentVariable settings
the project already carried, and point Compose at wisave-portal:latest with
pull_policy: never.

This removes the hand-written Dockerfile in full, including the BuildKit secret
that fed GitHub Packages credentials into the build, the throwaway
NuGet.Build.Config and its trap-based cleanup, and the two additional_contexts
that bind-mounted the developer's NuGet cache into the build. That last one was
a real fragility: the container build only succeeded when the host cache already
held the exact pinned WiSave.Expenses.Contracts and WiSave.Incomes.Contracts
versions. The SDK restores through the repository's own NuGet.Config and
local-packages instead.

Verified rather than assumed: the published image was run under Compose against
Postgres, Redis and RabbitMQ, and /api/capabilities returned 200 with a real
payload. All three private contract packages resolve into /app.

Replaces the five tests that asserted Dockerfile contents with three that assert
the image-based Compose setup, and drops the two helpers only they used.
…ution in CI

/health runs the ready-tagged Postgres and Redis checks; /alive runs none. They
answer different questions: /alive says the process is up, so a restart would
help nothing, while /health can report that the process is fine but a dependency
is not — route traffic away rather than restart. Both are anonymous so an
orchestrator can reach them before anyone authenticates. Aspire's
WithHttpHealthCheck will poll /health.

The checks themselves live in Core.Infrastructure, not the web project: they open
sockets to Postgres and Redis, which is infrastructure, and only the endpoint
mapping is HTTP surface. Registration performs no I/O — each check connects
lazily on first probe — so an unreachable dependency delays readiness rather than
preventing startup, and dependencies that are not configured are not registered
at all, which keeps the in-memory test host legitimately ready.

CI now runs dotnet build on the solution between restore and test. It previously
compiled only what the test project referenced, so a project outside that graph
could break unnoticed — which will matter once the AppHost exists.

Verified against live dependencies rather than asserted: /alive returned 200 and
/health returned 200 Healthy with Postgres and Redis actually probed.
Additive: docker-compose --profile portal still works and remains the supported
stack until the remaining verification gates pass. Aspire owns Postgres and Redis;
RabbitMQ stays in Compose permanently, because it is the ecosystem's only broker,
three sibling stacks resolve it by the hostname "rabbitmq" over the external
wisave-net network, and Aspire 13.4.6 run mode cannot join a pre-existing Docker
network. Portal runs as a host process, so localhost reaches it.

Migrations are not modelled, by policy: they are run manually and never automated.
AppHost_NeverAutomatesMigrations enforces that no migrator, seeder or
WaitForCompletion appears here, and AGENTS.md now carries the rule. Beyond the
policy, gating the portal on a one-shot resource turns any migration failure into
a silent hang rather than a visible error.

The tests encode failures reproduced while designing this, each of which throws at
AppHost startup rather than at build: resource names are globally unique across
types, so the project is "portal-api" and not "portal" alongside the database;
13.4.6 defaults to postgres:18.3 and redis:8.6, so both tags are pinned; AddProject
has no args: parameter, and passing one silently binds launchProfileName;
WithHttpHealthCheck throws for non-http-scheme resources.

Verified by running it: postgres:17 and redis:7-alpine came up on the pinned tags,
the portal bound the fixed port 5100 with IsProxied disabled so the Angular proxy
still matches, /alive returned 200 and /health returned 200 Healthy with Postgres
and Redis actually probed.
Bring AGENTS.md in line with the code. The layout section still described the web
project as owning messaging and event handling, both of which moved out, and did
not mention the AppHost or the health checks at all.

Adds a "Running the stack" section covering the two supported paths, why RabbitMQ
stays in Compose under both — it is the ecosystem's only broker and three sibling
stacks resolve it by hostname over the external wisave-net network, which Aspire
13.4.6 run mode cannot join — and why applying the schema is a manual step under
either path. Adds a health-endpoints section distinguishing liveness from
readiness, since the difference decides whether an orchestrator restarts a pod or
routes around it.

The plan's version of this task described an end state that does not exist: it
referenced the one-shot migrator and seeder, which are not modelled by policy,
and db-rebuild-identity, which was deleted. Documented what is actually here.
…finitions

Model the broker as an Aspire resource and import
infrastructure/rabbitmq/definitions.json into it, so `aspire run` is
self-contained instead of requiring `docker compose up -d` first.

AddRabbitMQ cannot load a definitions file itself, so the file is bind-mounted and
imported through the management plugin via RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS —
the same mechanism docker-compose.yml uses. That file is load-bearing: Wolverine
auto-provisions exchanges and queues but not vhosts, so without it the portal's
connections to the incomes, expenses and stocks vhosts would fail.

Credentials are pinned to guest/guest rather than generated. The definitions file
provisions exactly one user, guest, with permissions on all five vhosts, so a
generated password would authenticate against a user that is never created.

Verified by running it: all five vhosts (/, portal, expenses, incomes, stocks)
were created with guest permissioned on each, the portal opened live connections
on all three named-broker vhosts, and Wolverine provisioned 20 queues across them.

Caveat for the sibling stacks: incomes, expenses and stock resolve the broker as
the literal hostname "rabbitmq" over the external wisave-net network, which Aspire
run mode cannot join. While the broker runs here rather than in Compose they need
RABBITMQ_HOST=host.docker.internal, a variable they already read. Compose still
declares its own rabbitmq service, so do not run both at once — they collide on
5672.
Replace the stale 20260426212559_Initial with 20260801231030_Initial, generated
against the current Guid-keyed model, and regenerate the matching idempotent DbUp
script.

The previous baseline described text Identity keys while the model has used Guid
since 535aa47. The model snapshot had to be deleted before regenerating: EF diffs
against the snapshot rather than the database, so with a stale snapshot in place
`migrations add` produced an empty migration and the generated script contained no
Identity tables at all — `has-pending-model-changes` reported "no changes" while
`migrations list` reported "no migrations".

The new baseline creates all seven Identity tables with uuid keys. It is a reset
baseline, not an upgrade path: existing databases must be recreated.
Two omissions kept Rider from producing a working run configuration for the
AppHost, both invisible from the CLI.

Add Properties/launchSettings.json. The scaffold never created one because
`aspire run` supplies its own defaults, so nothing surfaced the gap until the IDE
needed a launch profile. Dashboard and OTLP ports are chosen clear of 5100, 5432,
6379 and 5672.

Declare the Aspire MSBuild SDK as Sdk="Microsoft.NET.Sdk;Aspire.AppHost.Sdk/13.4.6"
instead of a nested <Sdk> element. Both are valid MSBuild and `dotnet msbuild
-getProperty:TargetFramework` resolved net10.0 either way, but Rider could not
evaluate the element form and left Target framework empty and unselectable.
Verified the SDK still applies: the Projects.* type handles are still generated.

This unblocks debugging the portal under `aspire run`, which is what the Compose
profile was previously needed for.
"Enabled": truew is not valid JSON, so the configuration provider threw a
FormatException on startup and the portal could not boot at all — the debugger
broke inside FileConfigurationProvider before any application code ran.

Correct it to true, which also enables the incomes downstream service. It was
previously false, which left the incomes proxy route inert regardless of the
address configured for it, so the assertion pinning that value moves with the
config rather than against it.

The test parses the file, so it now fails on a syntax error in it before the
portal refuses to start.
Both verification gates passed: the Angular dev server reaches the portal and
opens a SignalR WebSocket over the fixed port 5100, and a debugger attaches to
portal-api under `aspire run`. The Compose profile existed for that debugging
workflow, so it has no remaining purpose.

Delete docker-compose.yml entirely rather than reducing it to the broker. Every
service it hosted — portal, Postgres, Redis and RabbitMQ — is now modelled in the
AppHost, and two orchestrators declaring the same broker collide on 5672. The
wisave-net network was declared external, so removing this file does not affect
the sibling stacks that share it.

Replaces the six tests that asserted Compose file contents with AppHost
equivalents, keeping the assertions that still carry meaning: the downstream
addresses and the portal RabbitMQ vhost, both of which moved into the AppHost's
environment variables.

Cross-repo consequence, documented in AGENTS.md: incomes, expenses and stock
resolve the broker as the hostname "rabbitmq" over wisave-net, which Aspire run
mode cannot join, so they need RABBITMQ_HOST=host.docker.internal while it runs
here. They already read that variable.
The portal had no OpenTelemetry wiring at all, so the Aspire dashboard could only
scrape stdout: the trace and metric views were empty and the "structured" logs had
no fields to filter on. Aspire was already injecting OTEL_EXPORTER_OTLP_ENDPOINT;
nothing was exporting to it.

AddPortalObservability registers ASP.NET Core, HttpClient and runtime
instrumentation plus the OTLP exporter. The HTTP client instrumentation is the one
that matters most here: the portal is a gateway, and without it there is no way to
tell whether latency came from the portal or from the service it proxied to. With
it, the inbound request and the outbound call share one trace.

Wolverine is wired in per its docs. Its meter is named Wolverine:{ApplicationName},
so AddMeter("Wolverine*") needs the wildcard — a bare "Wolverine" matches nothing
and the messaging metrics never arrive.

Automatic instrumentation stops at the edges, so the portal also gets its own
ActivitySource and Meter. Both types are in the shared framework, so they live in
Core.Application without costing it its zero-package guarantee, and the layer that
owns the behaviour owns its instrumentation. Publishing a realtime notification and
resolving a user's permissions are now spans; permission resolution runs on every
authenticated request and issues several identity-store round trips, which is the
most likely hidden cost inside a slow request.

Events that carry no user are counted rather than silently dropped: a rising
notifications_dropped count means an upstream service is emitting events the portal
cannot route to anyone.

Health probes are filtered out of tracing, since Aspire polls /health continuously
and would otherwise dominate the trace list.
The automatic ASP.NET Core instrumentation already spans every endpoint, so
timing and status are covered. What it cannot show is why a request failed: login
returns 401 for an unknown email, a locked-out account, a disallowed sign-in and a
wrong password alike, and those four mean very different things operationally.

Count each outcome instead. A rising LOCKED_OUT or USER_NOT_FOUND rate is what
credential stuffing looks like from the outside, and neither is distinguishable
from a legitimate typo in the request span.

Registration gets the same treatment, including ROLE_ASSIGNMENT_FAILED — a user
created without its plan role resolves to an empty permission set and is refused
by every proxied route, which is worth separating from a rejected password.

Tags carry the operation and outcome only, never the email or user id: those are
personal data and would give the metric unbounded cardinality.
Containers now stop with the AppHost. ContainerLifetime.Persistent kept them
running after shutdown, holding 5432, 6379 and 5672 and colliding with the next
run. Storage is independent of lifetime, so every stateful resource keeps a named
volume and stopping the stack costs no data.

Redis gains a volume it did not have. It holds the data-protection key ring as
well as session tickets, so starting on a fresh one silently signs every user out.

Also trims the comments added over this work to the ones that carry a constraint
you cannot see from the code — the Wolverine meter wildcard, the resource-name
collision, the definitions file being load-bearing for vhosts — and drops the ones
that narrated what the call already says.
WolverineFx and WolverineFx.RabbitMQ 6.16.0 to 6.24.2, MessagePack 3.1.8,
Microsoft.NET.Test.Sdk 18.8.1 and Scalar.AspNetCore 2.16.17.

Build is warning-free and all 108 tests pass on the new versions, including the
messaging configuration and realtime handler coverage that exercise Wolverine.
Move the ActivitySource and meter names out of PortalObservability into
WolverineTelemetry. They describe what a third-party framework publishes under
rather than how the portal configures itself, and the meter wildcard in particular
is a constraint worth finding on its own rather than buried in a private field.
Core.Application had accumulated two unrelated jobs: it held the portal's
shared vocabulary — the ports other layers implement, the wire contracts the
Angular client consumes, and the names that appear in claims, config and
telemetry — alongside the policy and event handlers that actually use them.
Every outer layer had to depend on the whole thing to name an interface.

Split the vocabulary into a new innermost project, WiSave.Portal.Core.Abstractions:

  Authorization/  IPermissionResolver, PortalRoles, PortalClaimTypes
  Realtime/       IRealtimeNotifier, RealtimeEnvelope, RealtimeEventType, RealtimeDomain
  Gateway/        DownstreamServicesOptions, DownstreamServiceOptions, DownstreamServiceNames
  Observability/  PortalTelemetry

It names only BCL types and declares no references of any kind — no projects,
no packages, no shared framework — which is what makes it safe for every other
layer to depend on. Core.Application keeps the three types that are policy
rather than vocabulary: AccessManagementPolicy, the RealtimeNotificationHandler
base, and RealtimeNotificationsEventHandler. Core.Abstractions becomes its only
project reference; the three integration-contract packages stay, since the
handlers necessarily name those event types.

Behaviour is unchanged. Every moved file differs from its previous content only
in its namespace declaration, so no claim type, role name, event type, domain
discriminator or metric name changed value. RealtimeNotificationsEventHandler
deliberately stays in WiSave.Portal.Core.Application.EventHandlers: the shared
Wolverine helper derives the durable RabbitMQ queue name from the handler's
full type name, so moving it would orphan a live queue.

ProjectDependencyDirectionTests gains two facts pinning the new layer -
CoreAbstractions_DeclaresNoReferencesOfAnyKind checks the csproj declares no
project, framework or package reference, and CoreAbstractionsAssembly_-
ReferencesNoOtherWiSaveAssembly checks the compiled output agrees. The three
existing reference-shape assertions were updated to expect the new project.
c2de517 extracted Wolverine's telemetry identifiers into a WolverineTelemetry
class, but PortalObservability kept its own private copies of the same two
strings, so the extracted class was never read. Two definitions of an identifier
that must match what Wolverine actually publishes is exactly the kind of pair
that drifts silently — a stale copy produces no error, just missing metrics.

Point the registrations at WolverineTelemetry and delete the private consts.
The registered values are unchanged: the meter is still the wildcard
"Wolverine*", which is required because Wolverine names its meter
Wolverine:{ApplicationName} and a bare "Wolverine" matches nothing.
…n tests

The AppHost had accumulated more comment than code: a running commentary on
every resource, most of it restating the call it sat above. Drop the narration
and unwrap the three ReverseProxy environment variables onto single lines.

ProxyConfigurationTests.AppHost_TargetsTheCurrentLocalDownstreamPorts asserted on
the wrapped form — an embedded newline plus eight spaces of indentation — so
reformatting broke it even though the configuration is identical. It now
collapses whitespace before matching, pinning the key/value pairing rather than
the line wrapping. That is the property worth protecting: environment variables
win over appsettings, so those three addresses are what the running portal
actually proxies to.

Also restores the trailing newline the reformat dropped.
The portal owns the only RabbitMQ in the ecosystem, but wisave-incomes,
wisave-expenses and wisave-stock run in their own Compose stacks and resolve it
by the literal hostname "rabbitmq" over the external wisave-net network. Since
the broker moved into the AppHost it has been unreachable by that name, and the
three stacks have been carrying a local RABBITMQ_HOST=host.docker.internal
override that is not in any of their commits.

Attach the broker to wisave-net once its container is ready, aliased "rabbitmq".
The sibling stacks then need no override and no code change — the broker answers
to the name their committed Compose files already use.

This has to be a lifecycle hook rather than a builder call. Aspire gives a
container exactly one network and exposes no API to add a second:
WithContainerRuntimeArgs("--network", "wisave-net") compiles but collides with
DCP's own "--network bridge" on the docker create line, and Docker refuses to
combine a non-user-defined network mode with a user-defined one, so the container
dies at create time with exit 125. WithContainerNetworkAlias only registers DNS
names inside Aspire's own network. DCP itself attaches with docker network
connect after create, so WithSharedNetworkAlias does the same one step later.

The container name is pinned to wisave-rabbitmq because DCP otherwise appends a
random suffix, leaving no handle to attach to — or to docker exec against, which
the stock runbook needs. A failed attach warns rather than throws: only
cross-stack messaging degrades, and failing the resource would take the whole
local stack down with it.

Verified on a running stack: the container is dual-homed on both networks, a
container on wisave-net resolves rabbitmq and completes an AMQP handshake, guest
authenticates from that non-loopback source, and all five vhosts still load.
`aspire run` fails immediately with an OptionsValidationException: "The
'applicationUrl' setting must be an https address unless the
ASPIRE_ALLOW_UNSECURED_TRANSPORT environment variable is set to true." No
resource starts.

The http profile is plain HTTP throughout — dashboard on 15100, OTLP on 19100,
resource service on 20100 — and `aspire run` takes the first profile. The
profile was added so the AppHost could be launched from an IDE with a debugger
attached, and until now only the IDE exercised it; the https profile starts
fine, which is why this stayed hidden.

Declare the opt-in in the profile itself, which is where the error message
points. HttpLaunchProfile_OptsInToUnsecuredTransport asserts the URL is HTTP and
the opt-in is present, so the two cannot drift apart.

Verified with `env -u ASPIRE_ALLOW_UNSECURED_TRANSPORT aspire run` — the
variable explicitly stripped so a stray shell export could not fake a pass. The
dashboard comes up, all three containers start and the portal reports healthy.
Found by checking every factual claim in the file against the repository.

Repository Layout listed a `docs` directory that does not exist.

The `aspire run` comment contradicted the Running the stack section 30 lines
below it: it said Aspire starts "Postgres and Redis" and that the RabbitMQ
broker "must already be up" as a precondition. Both are left over from before
the broker moved into the AppHost, and an agent reading top-down would have gone
looking for a broker to start.

The port list claimed the RabbitMQ management UI is on 15672. It is not
published: the -management-alpine image carries the plugin, but only
WithManagementPlugin() maps a host port and the AppHost does not call it.
Verified on a running stack — lsof shows dcp listening on 5432, 5672 and 6379
and nothing on 15672, and the port is closed from the host while the same UI
answers at rabbitmq:15672 from a container on wisave-net.

Everything else in the file checked out: PermissionHandlerTests, Migrations/Seeds
and Scripts, .config/dotnet-tools.json, the absent docker-compose.yml,
/scalar/v1, HttpContext.Items["UserPermissions"], all three YARP route names,
AppHost_NeverAutomatesMigrations, and port 5672.
…ndored nupkg

WiSave.Stock.Contracts was pinned to 2.0.0-marketmic.1, a build that exists on no
feed. Its cache metadata records where it came from:

  "source": "/Users/.../.config/superpowers/worktrees/wisave-portal/position-market/local-packages"

so the only way any machine could restore it was the committed nupkg plus the
local-packages source mapped to that exact package id. Move to 2.0.4, which is
published — its metadata records source https://nuget.pkg.github.com/WiSave/index.json
— and delete both the source and the vendored artifacts.

That mapping was also actively harmful. An exact-id pattern outranks a prefix, so
restore resolved WiSave.Stock.Contracts only from the local folder, while Renovate
ignored the source entirely because it drops non-http registries. Renovate was
therefore free to propose feed versions that the mapping forbade restore from
fetching; the two tools disagreed about where the package came from.

WiSave.Expenses.Contracts.0.1.0.nupkg goes with it. Nothing could consume it: the
id matched WiSave.Expenses.* on the wisave feed rather than the local source, and
Directory.Packages.props pins 1.0.9 regardless.

Verified: build clean, 113 tests pass. The realtime payload contract tests pass
unchanged, so the Stock event shapes did not drift across the bump.
fe67bec retired the Compose stack but left the file listed under Solution Items.
MSBuild never resolves <File> entries so builds were unaffected, but the IDE
showed a dangling entry.
Translate YARP forwarding failures into structured 503 and 504 Problem
Details responses using the downstream service metadata.

Replace expected connection stack traces with concise structured warnings
while preserving exception diagnostics for unexpected proxy errors. Cover
error mapping, successful response pass-through, response-start safety,
middleware ordering, and YARP log filtering.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant