Skip to content

fix: critical issues — page-registry guard, deployment-security hardening, DbContext races/outbox, docs/CI reconciliation - #246

Merged
antosubash merged 7 commits into
mainfrom
worktree-fix-critical-issues
Jun 10, 2026
Merged

fix: critical issues — page-registry guard, deployment-security hardening, DbContext races/outbox, docs/CI reconciliation#246
antosubash merged 7 commits into
mainfrom
worktree-fix-critical-issues

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

Addresses the four critical issues from the framework review, the recurring bug classes behind them, and the doc/CI gaps — verified by automated CI, two adversarial code-review rounds, a browser QA pass, and the e2e suite.

1. Page-registry footgun guard — resurrected on both ends.

  • validate-pages.mjs scanned modules/<Name>/src/<Name> but the real layout is src/SimpleModule.<Name>, so it validated zero C# files and printed success in CI and npm run check. Fixed the path (scan every project dir under src/, skip obj/bin/wwwroot), resolve Inertia.Render calls that use same-file string consts, flag interpolated Render($"…"), and add a fail-on-zero self-check so path drift can never silently disable it again. Now validates 73 endpoints across 553 files.
  • The runtime fallback was equally dead: app.tsx called showErrorToast(...) (doesn't exist → ReferenceError). Root cause: ClientApp had no tsconfig.json and was excluded from typecheck.mjs. Added the tsconfig + wired ClientApp into typecheck, which then exposed two more latent bugs — router.on('exception') isn't an Inertia v3 event (now networkError), and the resolver returned a module wrapper where Inertia needs the component.

2. Default deployment hardened (was one request from an admin token).

  • UserSeedService fails host startup outside Development/Testing when Seed:AdminPassword is unset instead of seeding the published Admin123! default.
  • New OpenIddictProductionGuard refuses the ROPC password grant and ephemeral signing keys in real deployments. Both guards share HostEnvironmentExtensions.IsLocalOrTest so they never disagree.
  • Forwarded-header trust is now explicit (config-driven KnownProxies/KnownNetworks/TrustAllProxies) instead of trusting any spoofed X-Forwarded-For.
  • docker-compose.yml pins OpenIddict__AllowPasswordGrant=false, requires SEED_ADMIN_PASSWORD (api and worker), and documents proxy config.
  • UploadEndpoint now enforces FileStorageModuleOptions (413 over MaxFileSizeMb, 400 outside AllowedExtensions).

3. Remaining #242-class DbContext races + lost-event outbox gaps.

  • Sequentialized the genuinely-shared-context site (AdminService); reverted UsersEditEndpoint to parallel after verifying its 3 contracts use distinct DbContexts; fixed TenantFeatureHelper (threw with 2+ active flags).
  • Routed TenantService.CreateTenantAsync and FileStorageService upload/delete through IDbContextOutbox so events commit atomically with the DB write (the old SaveChanges-then-Publish lost events on crash; delete also dropped the event on a failed blob delete).

4. Docs reconciled with the tracked codebase.

  • CLAUDE.md / Constitution described phantom modules (Products/Orders/Marketplace/PageBuilder) and a dual-provider CI that doesn't exist. Rewrote the test/benchmark/load-test sections around the real 17-module system and the actual 6 NBomber scenarios / 5 benchmark classes; switched examples off the phantom Products module (whose wrong src/<Name> layout was the origin of the validate-pages bug).
  • Added the security scanning the Constitution claims: CodeQL (C#/JS), Dependabot (nuget/npm/actions), and a dotnet list package --vulnerable CI gate.

Verification

  • dotnet build -warnaserror: 0 warnings / 0 errors
  • dotnet test: 19/19 assemblies, 0 failures (incl. 24 new tests covering the security guards: HostEnvironmentExtensions, OpenIddictProductionGuard, UserSeedService.ResolveSeedPassword)
  • npm run check: biome + validate-pages + i18n + framework-scope + typecheck 14/14
  • npm run build: all workspaces
  • Browser QA (logged in as seeded admin): page hydration, file upload limits (.txt/.csv/.png→201, .exe→400), upload/delete outbox flows, admin user-edit parallel revert (all tabs render), 404 error page, zero JS console errors
  • e2e: filestorage flow 15/15 (incl. new upload-validation regression test) + smoke suite 66/66

The e2e gate caught a real regression: enforcing AllowedExtensions (default omitted plain .txt) broke the framework's own .txt-upload test. Fixed by adding .txt/.csv to the default allowlist (resolving the framework's self-contradiction) rather than weakening the test, plus a durable regression spec.

Deferred follow-ups (recorded in tasks/todo.md, not silently dropped): the SM0060 analyzer for the race/outbox bug classes, a shared outbox helper, a framework production-config validation abstraction, module-options↔Settings binding, the TenantFeatureHelper N+1, and a PostgreSQL CI leg.

Test plan

  • CI green (build, tests, lint, validators, CodeQL, vulnerable-package audit)
  • Reviewer confirms npm run validate-pages now reports a non-zero endpoint count
  • Reviewer confirms a Production-env boot without Seed:AdminPassword fails fast

validate-pages.mjs scanned modules/<Name>/src/<Name>, but the real layout is
src/SimpleModule.<Name> — it validated zero C# files and printed success in CI
and 'npm run check' since the layout change. Fix the path by scanning every
project dir under src/ (skipping obj/bin/wwwroot, whose stale artifacts contain
Render calls for deleted pages), resolve Inertia.Render arguments that are
same-file string consts (UnlockAccountEndpoint pattern), and add a self-check
that fails the run when zero C# files or zero endpoints are found repo-wide so
path drift can never silently disable the guard again.

The runtime fallback was equally dead: app.tsx called showErrorToast(), which
does not exist (ReferenceError instead of an error toast on failed page loads).
Root cause it survived: ClientApp had no tsconfig.json and was excluded from
scripts/typecheck.mjs. Add the tsconfig, wire ClientApp into typecheck (with a
hard failure if its tsconfig ever disappears), and fix the two latent bugs the
new checking exposed: router.on('exception') is not an Inertia v3 event (the
network-error toast never fired — the event is 'networkError'), and the page
resolver must return the component, not the module wrapper.
Sequentialize the three surviving Task.WhenAll-over-scoped-DbContext sites
(AdminService.GetAdminOverviewAsync, Admin UsersEditEndpoint,
TenantFeatureHelper.GetOverridesForTenantAsync — the last threw whenever 2+
active feature flags existed). EF Core forbids concurrent operations on one
context instance, so these surfaced as intermittent HTTP 500s under load,
exactly like the UsersEndpoint instance fixed in #244.

Route TenantService.CreateTenantAsync and FileStorageService UploadFileAsync /
DeleteFileAsync through IDbContextOutbox instead of SaveChangesAsync followed
by bus.PublishAsync — the old pattern lost the event when the process died
between the two calls, and DeleteFileAsync additionally skipped the event
whenever blob deletion failed after the DB commit. Create flows need the
database-generated id in the event payload, so they save inside an explicit
transaction and let SaveChangesAndFlushMessagesAsync persist the envelope,
commit, and only then flush — FakeDbContextOutbox now mirrors that
commit-current-transaction behavior.
A default deployment was one HTTP request away from an admin token: the
compiled-in admin@simplemodule.dev / Admin123! fallback was seeded in every
environment (with only a log warning), the shipped docker-compose ran in
Development which turns on the ROPC password grant, and the host cleared
KnownProxies/KnownIPNetworks so X-Forwarded-For spoofing bypassed the per-IP
rate limiter that would slow brute force. Closing all links in the chain:

- UserSeedService now fails host startup outside Development when
  Seed:AdminPassword is unset (SeedConfigurationException escapes the
  tolerate-DB-errors catch) instead of seeding the published default; the
  demo user is skipped entirely unless Seed:UserPassword is provided.
- New OpenIddictProductionGuard fails startup in Production when
  OpenIddict:AllowPasswordGrant is enabled or when signing/encryption
  certificates are missing (ephemeral keys regenerate per restart and
  invalidate every issued token).
- Forwarded-header trust is now explicit: loopback by default, with
  ForwardedHeaders:KnownProxies / KnownNetworks / TrustAllProxies config
  instead of unconditionally trusting any client-supplied header.
- docker-compose.yml pins OpenIddict__AllowPasswordGrant=false, requires
  SEED_ADMIN_PASSWORD via .env, and documents the Production switches.
- UploadEndpoint actually enforces FileStorageModuleOptions: 413 above
  MaxFileSizeMb, 400 for extensions outside AllowedExtensions (both were
  defined but never consulted).
CLAUDE.md and the Constitution described a system that is not on main: 11
load-test scenarios (mostly against Products/Orders/Marketplace/PageBuilder
modules that exist only as untracked artifacts), benchmarks 'for every module',
and a dual-provider CI that never existed. Rewrite the testing sections around
what is actually tracked (6 NBomber scenarios, 5 benchmark classes), switch the
page-registry examples from the phantom Products module to the real Tenants
module — the old example also showed the wrong src/<Name> layout, which is
exactly the path bug that silently disabled validate-pages — and state plainly
that the PostgreSQL CI leg is a known gap rather than claiming it runs.

CI gains the security scanning an auth framework published to NuGet should
have had: a CodeQL workflow (C# + JS/TS), Dependabot for nuget/npm/actions,
and a vulnerable-package audit job that fails on known-vulnerable NuGet
dependencies (including transitive).
…corded)

Correctness:
- ForwardedHeaders:KnownProxies/KnownNetworks now accept the comma-separated
  scalar form the docker-compose comment documents (Get<string[]>() returned
  null for a scalar env var, silently leaving the proxy untrusted), and parse
  via TryParse with a named error instead of an opaque FormatException.
- docker-compose worker gets the same Seed__AdminPassword/Seed__UserPassword as
  the api: the worker runs UserSeedService too, so without them it would win the
  startup race and seed the admin with the compiled-in default — defeating the
  api's required SEED_ADMIN_PASSWORD.
- FileStorageService.UploadFileAsync scopes blob rollback to the pre-commit
  window: a post-commit outbox-flush failure no longer deletes a blob whose
  StoredFile row is already durably committed.
- UserSeedService and OpenIddictProductionGuard now share
  HostEnvironmentExtensions.IsLocalOrTest (Development+Testing). This closes the
  inconsistency where the guard fired only in Production (Staging bypassed it)
  while the seeder hard-failed in every non-Development env (crashing Testing).
- UsersEditEndpoint reverted to Task.WhenAll: its three contracts resolve
  distinct DbContexts (UsersDbContext / PermissionsDbContext / OpenIddict token
  store), so there was never a shared-context race — only AdminService genuinely
  shares UsersDbContext. Corrected the misleading comment.

Hardening of the guards themselves:
- ConfigKeys.OpenIddictAllowPasswordGrant constant replaces three hardcoded key
  strings (a rename would otherwise silently disable the production guard).
- validate-pages reports interpolated Inertia.Render($"…") as unresolved
  instead of skipping it; validate-i18n gains the same fail-on-zero self-check.

Cleanup:
- UploadEndpoint resolves IOptions and parses the extension allowlist once at
  Map() time instead of allocating a HashSet per request.

Larger architectural findings (shared outbox helper, production-config
validation abstraction, module-options↔settings binding, the SM0060 analyzer,
TenantFeatureHelper N+1) are recorded in tasks/todo.md as deliberate follow-ups.
Round-2 review found no new bugs but flagged the env-branching seed/guard
logic as untested — and those paths (refuse default admin password in a real
deployment; refuse the password grant / ephemeral keys outside local) can't be
exercised by the browser QA run, only by unit tests.

- Extract UserSeedService.ResolveSeedPassword as a pure, internal decision
  function so the 'never seed a real deployment with the compiled-in default'
  branching is testable without an Identity stack; 9 cases cover configured /
  local-default / required-fail / optional-skip / empty-string.
- HostEnvironmentExtensions.IsLocalOrTest: 7 cases pin the Development/Testing →
  true, Production/Staging/QA/custom → false classification both guards depend on.
- OpenIddictProductionGuard: 8 cases assert it tolerates unsafe config locally,
  throws on password-grant or missing certs in Production/Staging/QA, and passes
  when fully configured.

Full suite: 19/19 assemblies, 0 failures; npm run check green.
…d validation e2e

Enforcing FileStorageModuleOptions.AllowedExtensions (added this branch) exposed
a contradiction in the framework: the default allowlist (.jpg…/.doc/.docx/.pdf/
.zip) omits plain text, yet filestorage-crud.spec.ts uploads a .txt file and
asserts 201. A file-storage module that rejects plain .txt and .csv by default is
an oversight, so add both common safe text formats to the default rather than
weaken the test. Also fixes a latent bug in the FileStorage.AllowedExtensions
setting default, which carried embedded literal quotes.

Adds a durable regression test (FileStorage upload validation) asserting a
disallowed extension (.exe) is rejected with 400 and an allowed one (.txt) is
accepted — locking in the enforcement so it can't silently regress.
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@antosubash
antosubash merged commit f5cfad7 into main Jun 10, 2026
10 checks passed
@antosubash
antosubash deleted the worktree-fix-critical-issues branch June 10, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants