Skip to content

fix(framework): correctness & perf findings from framework review#278

Open
antosubash wants to merge 2 commits into
mainfrom
worktree-framework-review-fixes
Open

fix(framework): correctness & perf findings from framework review#278
antosubash wants to merge 2 commits into
mainfrom
worktree-framework-review-fixes

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

Addresses the findings from a five-area review of the framework layer (Core, Generator, Hosting, Database/Storage, frontend build). Scoped to the framework; no module behavior changes except where a module consumed a broken framework primitive.

Two strategic rewrites are intentionally deferred (noted in tasks/todo.md): splitting the generator pipeline onto MetadataReferencesProvider, and de-duplicating @simplemodule/ui across module bundles.

High severity

  • FeatureFlags gate + audit interceptor read ClaimTypes.NameIdentifier only. Under Keycloak/JWT (MapInboundClaims=false, id in sub), per-user feature overrides were silently ignored and CreatedBy/UpdatedBy went null. Now use the sub-aware GetUserId() / new GetRoles().
  • ?v= cache poisoning. The static-file cache middleware applied a 1-year public, immutable header to any request with a v query key — including authenticated HTML (/dashboard?v=1), publicly cacheable across users behind a shared cache/CDN. Now gated on static-asset path prefixes.
  • LocalStorageProvider path traversal. ListAsync bypassed the traversal guard entirely; GetFullPath's StartsWith(_basePath) lacked a separator boundary (/app/storage-backup passed an /app/storage check) and used OrdinalIgnoreCase on case-sensitive filesystems. Routed ListAsync through the guard; require a separator boundary; OS-appropriate case sensitivity.
  • Multi-tenant filter froze the first request's tenant into the cached EF model (cross-tenant leak). The filter now references the executing DbContext via new ITenantScopedDbContext.CurrentTenantId, which EF re-evaluates per query. Zero production callers today, so the API change is safe.
  • Source generator re-scanned every referenced assembly (BCL, ASP.NET) on each compile. Filtered System./Microsoft./framework assemblies out of the discovery walks.

Medium

  • InertiaResult double-serialized props on every shared-data render (JsonElement DOM + full re-serialize). Collapsed to a single Utf8JsonWriter pass; props still win on collision, no duplicate keys.
  • Layout middleware ran antiforgery crypto + menu filtering for every request including static assets and health probes. Gated to Inertia (X-Inertia) or HTML (Accept: text/html) requests.
  • Interceptors only covered the async SaveChanges path — a sync SaveChanges() would hard-delete soft-delete entities and skip audit/events. Sync overrides added to both.
  • Generated JSON resolver was uncompilable for init-only props / positional records (CS8852/CS7036). Init-only setters treated as non-settable; CreateObject guarded on an accessible parameterless ctor.
  • Vite vendor plugin never rebuilt after a version bump. Now keys a manifest on resolved package versions.

Low

PermissionMatcher span-based wildcard; ArgumentNullException → 500 not 400; single-file-publish version-init guard; namespace-boundary match; ViewPages FQN hint name; CoerceId handles Guid/value-object keys; maintenance-gate comment + static-asset exemption; O(1) permission dedup; in-page hash anchors scroll natively; add-component.mjs path resolution; capped tsc concurrency.

Tests & verification

New regression tests: cross-tenant isolation across a shared cached model (fails under the old code), sub-aware GetUserId/GetRoles, wildcard module-boundary, ViewPages same-name collision.

  • dotnet build — 0 errors (warnings-as-errors)
  • dotnet test — Core 280, Database 94, Generator 220, DevTools 35 all pass; CLI exit 0
  • npm — biome clean, validate-pages (74 endpoints), framework-scope, i18n, typecheck 15/15

Notes

https://claude.ai/code/session_01GACvG1C7EZGSDDGTaBUFjg

…view

Fixes from a five-area review of the framework layer (Core, Generator, Hosting,
Database/Storage, frontend build). Scoped to the framework; no module behavior
changed except where a module consumed a broken framework primitive.

High
- FeatureFlags gate + audit interceptor read ClaimTypes.NameIdentifier only, so
  under Keycloak/JWT (MapInboundClaims=false, id in "sub") per-user overrides were
  ignored and CreatedBy/UpdatedBy went null. Use sub-aware GetUserId()/GetRoles().
- Static-file cache middleware applied a 1yr public,immutable header to ANY request
  with a "?v=" query key, incl. authenticated HTML (e.g. /dashboard?v=1). Gate the
  immutable header on static-asset path prefixes.
- LocalStorageProvider: ListAsync bypassed the traversal guard entirely; GetFullPath's
  boundary check lacked a separator (so /app/storage-backup passed an /app/storage
  check) and used OrdinalIgnoreCase on case-sensitive filesystems. Route ListAsync
  through the guard; require a separator boundary; OS-appropriate case sensitivity.
- Multi-tenant query filter froze the first request's scoped ITenantContext into the
  cached EF model (cross-tenant leak). Reference the executing DbContext instance via
  new ITenantScopedDbContext.CurrentTenantId so EF re-evaluates per query.
- Source generator re-scanned every referenced assembly (BCL, ASP.NET) on each
  compile. Filter out System./Microsoft./framework assemblies from the discovery walks.

Medium
- InertiaResult merged shared data by round-tripping props through a JsonElement DOM
  then re-serializing the whole dict — props encoded twice per render. Collapse to one
  Utf8JsonWriter pass (props win on collision, no duplicate keys).
- InertiaLayoutDataMiddleware ran antiforgery crypto + menu filtering for every request
  incl. static assets and health probes. Gate to Inertia (X-Inertia) or HTML
  (Accept: text/html) requests.
- EntityInterceptor/EntityChangeInterceptor only overrode the async SaveChanges path,
  so a sync SaveChanges() would hard-delete soft-delete entities and skip audit/events.
  Override the sync path on both.
- Generated JSON resolver was uncompilable for init-only props / positional records
  (CS8852/CS7036). Treat init-only setters as non-settable; guard CreateObject on an
  accessible parameterless ctor.
- Vite vendor plugin skipped rebuild whenever outputs existed, shipping stale React
  after a version bump. Key a manifest on resolved package versions.

Low
- PermissionMatcher wildcard match: span-based, no per-check substring allocation.
- GlobalExceptionHandler: ArgumentNullException -> 500 (internal bug) not client 400.
- InertiaMiddleware.Version: guard empty Assembly.Location (single-file publish crash).
- SymbolHelpers: module-namespace prefix match now requires a dot boundary.
- ViewPagesEmitter: hint name from module FQN (no collision for same-named classes).
- SoftDeleteService.CoerceId: handle Guid / value-object keys instead of throwing.
- Maintenance gate: correct the static-asset comment and exempt static-asset paths.
- PermissionRegistryBuilder: O(1) HashSet dedup instead of O(n^2) list scan.
- app.tsx: in-page hash anchors scroll natively instead of re-requesting via Inertia.
- add-component.mjs: resolve packages/ or src/ UI dir; typecheck.mjs: cap tsc concurrency.

Regression tests: cross-tenant isolation across a shared cached model; sub-aware
GetUserId/GetRoles; wildcard module-boundary; ViewPages same-name collision.

Deferred (documented in tasks/todo.md): generator pipeline decomposition onto
MetadataReferencesProvider; @simplemodule/ui shared-chunk vendoring.

Claude-Session: https://claude.ai/code/session_01GACvG1C7EZGSDDGTaBUFjg
@antosubash
antosubash marked this pull request as ready for review July 16, 2026 18:33
…`docker run` works

The runtime image runs as a non-root appuser, but the app's built-in default
connection string ("Data Source=app.db") resolves to the root-owned /app
workdir, so a standalone `docker run` of the image crashed at startup with
"SQLite Error 14: unable to open database file". The Dockerfile already creates
a writable /app/data directory for the database but never pointed the app at it.

Set Database__DefaultConnection to Data Source=/app/data/app.db in the runtime
stage. docker-compose still overrides this with its PostgreSQL configuration, so
the Postgres deployment path is unchanged; only the standalone-SQLite quickstart
is fixed.
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.

1 participant