From 3eea204081205f7d101cde55e88269a02bce0ab5 Mon Sep 17 00:00:00 2001 From: Edi Asllani Date: Wed, 10 Jun 2026 13:02:13 +0200 Subject: [PATCH 01/12] fix(security): enforce project-member visibility on standups/commits + lock down /metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR-4.7 says project data is invited-only, enforced backend, but three project-scoped GET handlers only required authentication and filtered by ProjectId — letting any signed-in user read another project's private standups, standup CSV export, and commit history by guessing a project id: - GetStandupFeedQueryHandler - ExportStandupsCsvQueryHandler (previously only checked the project exists) - GetProjectCommitsQueryHandler Apply the same owner-or-accepted-member guard the sibling handlers (GetProjectDashboard/GetProjectSummaries) already use, throwing ForbiddenException (-> 403) for non-members. Document 403/404 on the endpoints. Adds ProjectVisibilityGuardTests covering outsider/owner/member for all three (6 tests). Also closes public exposure of the Prometheus /metrics endpoint. The prod nginx ingress has snippet directives disabled cluster-wide, so block by Host in-app: MapMetrics("/metrics").RequireHost(Metrics:AllowedHosts). In-cluster Prometheus scrapes the pod directly as 'loopless-backend:8080' (allow-listed) while the public api host is not, returning 404 to the internet without breaking scraping. Wired via Helm values config.backend.Metrics__AllowedHosts__0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Endpoints/ProjectEndpoints.cs | 1 + .../Endpoints/StandupEndpoints.cs | 3 + backend/src/Loopless.Api/Program.cs | 14 +- .../GetProjectCommitsQueryHandler.cs | 28 +++- .../ExportStandupsCsvQueryHandler.cs | 27 +++- .../GetFeed/GetStandupFeedQueryHandler.cs | 29 +++- .../Visibility/ProjectVisibilityGuardTests.cs | 146 ++++++++++++++++++ .../ProjectVisibilityTestDbContext.cs | 93 +++++++++++ devops/helm/loopless/values.yaml | 5 + 9 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs create mode 100644 backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityTestDbContext.cs diff --git a/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs b/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs index f605532..cd9fa4a 100644 --- a/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs @@ -186,6 +186,7 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild .WithName("GetProjectCommits") .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .ProducesValidationProblem(); diff --git a/backend/src/Loopless.Api/Endpoints/StandupEndpoints.cs b/backend/src/Loopless.Api/Endpoints/StandupEndpoints.cs index cefe22e..ec85147 100644 --- a/backend/src/Loopless.Api/Endpoints/StandupEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/StandupEndpoints.cs @@ -52,6 +52,8 @@ public static IEndpointRouteBuilder MapStandupEndpoints(this IEndpointRouteBuild .WithName("GetStandupFeed") .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) .ProducesValidationProblem(); group.MapGet("/analytics", async ( @@ -84,6 +86,7 @@ public static IEndpointRouteBuilder MapStandupEndpoints(this IEndpointRouteBuild .WithName("ExportStandupsCsv") .Produces(StatusCodes.Status200OK, contentType: "text/csv") .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); return app; diff --git a/backend/src/Loopless.Api/Program.cs b/backend/src/Loopless.Api/Program.cs index 0ef4a11..a1e858e 100644 --- a/backend/src/Loopless.Api/Program.cs +++ b/backend/src/Loopless.Api/Program.cs @@ -227,7 +227,19 @@ app.MapGet("/", () => Results.Redirect("/swagger")).ExcludeFromDescription(); } - app.MapMetrics("/metrics"); + // Prometheus scrape endpoint. Snippet-based path blocking is disabled on the prod + // ingress, so restrict /metrics by Host instead: the in-cluster scrape reaches the + // pod directly as "loopless-backend:8080" (allowed), while public traffic arrives as + // the ingress host (api.) which is not in the allowlist and gets 404 — keeping + // metrics off the public internet without breaking scraping. Unset (dev/local) = open. + var metricsEndpoint = app.MapMetrics("/metrics"); + var metricsAllowedHosts = app.Configuration + .GetSection("Metrics:AllowedHosts").Get(); + if (metricsAllowedHosts is { Length: > 0 }) + { + metricsEndpoint.RequireHost(metricsAllowedHosts); + } + app.MapHealthChecks("/health"); app.MapHealthChecks("/health/ready", new HealthCheckOptions { diff --git a/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs index db811e8..0e0cf12 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs @@ -1,21 +1,39 @@ using Loopless.Application.Common.Exceptions; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; +using Loopless.Domain.Enums; using MediatR; using Microsoft.EntityFrameworkCore; namespace Loopless.Application.Features.Projects.GetCommits; public sealed class GetProjectCommitsQueryHandler( - IAppDbContext db) : IRequestHandler> + IAppDbContext db, + ICurrentUser currentUser) : IRequestHandler> { public async Task> Handle(GetProjectCommitsQuery request, CancellationToken cancellationToken) { - var projectExists = await db.Projects - .AnyAsync(p => p.Id == request.ProjectId, cancellationToken); + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); - if (!projectExists) - throw new NotFoundException("Project not found."); + var user = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + var project = await db.Projects + .FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken) + ?? throw new NotFoundException("Project not found."); + + var isOwner = project.OwnerId == user.Id; + var isMember = await db.ProjectInvitations + .AnyAsync( + i => i.ProjectId == project.Id + && (i.EnterpriseId == user.Id || i.FreelancerId == user.Id) + && i.Status == InvitationStatus.Accepted, + cancellationToken); + + if (!isOwner && !isMember) + throw new ForbiddenException("Only project members can view commits."); var baseQuery = db.GitHubCommits.Where(c => c.ProjectId == request.ProjectId); var totalCount = await baseQuery.CountAsync(cancellationToken); diff --git a/backend/src/Loopless.Application/Features/Standups/GetAnalytics/ExportStandupsCsvQueryHandler.cs b/backend/src/Loopless.Application/Features/Standups/GetAnalytics/ExportStandupsCsvQueryHandler.cs index 744b69e..05f1ff3 100644 --- a/backend/src/Loopless.Application/Features/Standups/GetAnalytics/ExportStandupsCsvQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Standups/GetAnalytics/ExportStandupsCsvQueryHandler.cs @@ -1,18 +1,39 @@ using Loopless.Application.Common.Exceptions; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; +using Loopless.Domain.Enums; using MediatR; using Microsoft.EntityFrameworkCore; namespace Loopless.Application.Features.Standups.GetAnalytics; public sealed class ExportStandupsCsvQueryHandler( - IAppDbContext db) : IRequestHandler> + IAppDbContext db, + ICurrentUser currentUser) : IRequestHandler> { public async Task> Handle(ExportStandupsCsvQuery request, CancellationToken cancellationToken) { - var projectExists = await db.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken); - if (!projectExists) throw new NotFoundException("Project not found."); + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); + + var user = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + var project = await db.Projects + .FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken) + ?? throw new NotFoundException("Project not found."); + + var isOwner = project.OwnerId == user.Id; + var isMember = await db.ProjectInvitations + .AnyAsync( + i => i.ProjectId == project.Id + && (i.EnterpriseId == user.Id || i.FreelancerId == user.Id) + && i.Status == InvitationStatus.Accepted, + cancellationToken); + + if (!isOwner && !isMember) + throw new ForbiddenException("Only project members can export standups."); return await db.Standups .Where(s => s.ProjectId == request.ProjectId) diff --git a/backend/src/Loopless.Application/Features/Standups/GetFeed/GetStandupFeedQueryHandler.cs b/backend/src/Loopless.Application/Features/Standups/GetFeed/GetStandupFeedQueryHandler.cs index ad46e1c..9b84893 100644 --- a/backend/src/Loopless.Application/Features/Standups/GetFeed/GetStandupFeedQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Standups/GetFeed/GetStandupFeedQueryHandler.cs @@ -1,15 +1,40 @@ -using Loopless.Application.DTOs; +using Loopless.Application.Common.Exceptions; +using Loopless.Application.DTOs; using Loopless.Application.Interfaces; +using Loopless.Domain.Enums; using MediatR; using Microsoft.EntityFrameworkCore; namespace Loopless.Application.Features.Standups.GetFeed; public sealed class GetStandupFeedQueryHandler( - IAppDbContext db) : IRequestHandler> + IAppDbContext db, + ICurrentUser currentUser) : IRequestHandler> { public async Task> Handle(GetStandupFeedQuery request, CancellationToken cancellationToken) { + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); + + var user = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + var project = await db.Projects + .FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken) + ?? throw new NotFoundException("Project not found."); + + var isOwner = project.OwnerId == user.Id; + var isMember = await db.ProjectInvitations + .AnyAsync( + i => i.ProjectId == project.Id + && (i.EnterpriseId == user.Id || i.FreelancerId == user.Id) + && i.Status == InvitationStatus.Accepted, + cancellationToken); + + if (!isOwner && !isMember) + throw new ForbiddenException("Only project members can view the standup feed."); + var baseQuery = db.Standups.Where(s => s.ProjectId == request.ProjectId); var totalCount = await baseQuery.CountAsync(cancellationToken); diff --git a/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs b/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs new file mode 100644 index 0000000..b92bfe0 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs @@ -0,0 +1,146 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Features.Projects.GetCommits; +using Loopless.Application.Features.Standups.GetAnalytics; +using Loopless.Application.Features.Standups.GetFeed; +using Loopless.Domain.Entities; +using Loopless.Domain.Enums; +using Loopless.UnitTests.Messaging; +using Xunit; + +namespace Loopless.UnitTests.Visibility; + +// Regression tests for FR-4.7 "project visibility invited-only, enforced backend". +// The standup feed, CSV export, and commits endpoints previously returned another +// project's data to ANY authenticated user (no membership check). These assert the +// owner-or-accepted-member guard now blocks non-members and admits owner + members. +public class ProjectVisibilityGuardTests +{ + private static User MakeUser(string keycloakId, UserRole role) + => new() + { + Email = $"{keycloakId}@test.local", + Name = keycloakId, + Role = role, + KeycloakId = keycloakId, + }; + + private static Project MakeProject(Guid ownerId) + => new() { OwnerId = ownerId, Title = "Test Project", Status = ProjectStatus.Active }; + + private sealed record Fixture( + ProjectVisibilityTestDbContext Db, + Project Project, + User Owner, + User Member, + User Outsider); + + // Owner (enterprise) + an accepted freelancer member + an unrelated outsider, with one + // standup and one commit on the project. + private static async Task SeedAsync() + { + var db = ProjectVisibilityTestDbContext.CreateInMemory(); + + var owner = MakeUser("owner", UserRole.Enterprise); + var member = MakeUser("member", UserRole.Freelancer); + var outsider = MakeUser("outsider", UserRole.Freelancer); + db.Users.AddRange(owner, member, outsider); + + var project = MakeProject(owner.Id); + db.Projects.Add(project); + + db.ProjectInvitations.Add(new ProjectInvitation + { + ProjectId = project.Id, + EnterpriseId = owner.Id, + FreelancerId = member.Id, + Status = InvitationStatus.Accepted, + }); + + db.Standups.Add(new Standup + { + ProjectId = project.Id, + FreelancerId = member.Id, + WhatDid = "did", + WhatPlan = "plan", + Blockers = "none", + CreatedAt = DateTimeOffset.UtcNow, + }); + + db.GitHubCommits.Add(new GitHubCommit + { + ProjectId = project.Id, + Sha = "abc123", + Message = "init", + Author = "member", + CommittedAt = DateTimeOffset.UtcNow, + }); + + await db.SaveChangesAsync(); + return new Fixture(db, project, owner, member, outsider); + } + + // ---- Standup feed ------------------------------------------------------- + + [Fact] + public async Task StandupFeed_Outsider_IsForbidden() + { + var f = await SeedAsync(); + var handler = new GetStandupFeedQueryHandler(f.Db, new FakeCurrentUser(f.Outsider.KeycloakId)); + await Assert.ThrowsAsync(() => + handler.Handle(new GetStandupFeedQuery(f.Project.Id, 1, 20), CancellationToken.None)); + } + + [Fact] + public async Task StandupFeed_Owner_AndMember_SeeEntries() + { + var f = await SeedAsync(); + + var asOwner = new GetStandupFeedQueryHandler(f.Db, new FakeCurrentUser(f.Owner.KeycloakId)); + var ownerResult = await asOwner.Handle(new GetStandupFeedQuery(f.Project.Id, 1, 20), CancellationToken.None); + Assert.Equal(1, ownerResult.TotalCount); + + var asMember = new GetStandupFeedQueryHandler(f.Db, new FakeCurrentUser(f.Member.KeycloakId)); + var memberResult = await asMember.Handle(new GetStandupFeedQuery(f.Project.Id, 1, 20), CancellationToken.None); + Assert.Equal(1, memberResult.TotalCount); + } + + // ---- CSV export --------------------------------------------------------- + + [Fact] + public async Task StandupCsvExport_Outsider_IsForbidden() + { + var f = await SeedAsync(); + var handler = new ExportStandupsCsvQueryHandler(f.Db, new FakeCurrentUser(f.Outsider.KeycloakId)); + await Assert.ThrowsAsync(() => + handler.Handle(new ExportStandupsCsvQuery(f.Project.Id), CancellationToken.None)); + } + + [Fact] + public async Task StandupCsvExport_Member_GetsRows() + { + var f = await SeedAsync(); + var handler = new ExportStandupsCsvQueryHandler(f.Db, new FakeCurrentUser(f.Member.KeycloakId)); + var rows = await handler.Handle(new ExportStandupsCsvQuery(f.Project.Id), CancellationToken.None); + Assert.Single(rows); + } + + // ---- Commits ------------------------------------------------------------ + + [Fact] + public async Task Commits_Outsider_IsForbidden() + { + var f = await SeedAsync(); + var handler = new GetProjectCommitsQueryHandler(f.Db, new FakeCurrentUser(f.Outsider.KeycloakId)); + await Assert.ThrowsAsync(() => + handler.Handle(new GetProjectCommitsQuery(f.Project.Id, 1, 20), CancellationToken.None)); + } + + [Fact] + public async Task Commits_Owner_SeesCommits() + { + var f = await SeedAsync(); + var handler = new GetProjectCommitsQueryHandler(f.Db, new FakeCurrentUser(f.Owner.KeycloakId)); + var result = await handler.Handle(new GetProjectCommitsQuery(f.Project.Id, 1, 20), CancellationToken.None); + Assert.Equal(1, result.TotalCount); + } +} diff --git a/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityTestDbContext.cs b/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityTestDbContext.cs new file mode 100644 index 0000000..0ec6a18 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityTestDbContext.cs @@ -0,0 +1,93 @@ +using Loopless.Application.Interfaces; +using Loopless.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.UnitTests.Visibility; + +// In-memory IAppDbContext that maps the entities the project-visibility guards touch +// (Users, Projects, ProjectInvitations, Standups, GitHubCommits). The Standups-analytics +// test context ignores ProjectInvitation, so the membership guards need their own. +internal sealed class ProjectVisibilityTestDbContext(DbContextOptions options) + : DbContext(options), IAppDbContext +{ + public DbSet Users => Set(); + public DbSet FreelancerProfiles => Set(); + public DbSet EnterpriseProfiles => Set(); + public DbSet Skills => Set(); + public DbSet Embeddings => Set(); + public DbSet Standups => Set(); + public DbSet Conversations => Set(); + public DbSet Messages => Set(); + public DbSet Projects => Set(); + public DbSet ProjectInvitations => Set(); + public DbSet GitHubCommits => Set(); + public DbSet ProjectTasks => Set(); + public DbSet ProjectTaskAuditLogs => Set(); + public DbSet Notifications => Set(); + public DbSet ProjectSummaries => Set(); + public DbSet AuditTrails => Set(); + public DbSet ProjectFiles => Set(); + public DbSet ContentFlags => Set(); + public DbSet ProjectLinks => Set(); + public DbSet SupportTickets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + modelBuilder.Entity(b => + { + b.HasKey(u => u.Id); + b.Ignore(u => u.FreelancerProfile); + b.Ignore(u => u.EnterpriseProfile); + }); + modelBuilder.Entity(b => + { + b.HasKey(p => p.Id); + b.Ignore(p => p.Owner); + b.Ignore(p => p.Invitations); + }); + modelBuilder.Entity(b => + { + b.HasKey(i => i.Id); + b.Ignore(i => i.Project); + b.Ignore(i => i.Enterprise); + b.Ignore(i => i.Freelancer); + }); + modelBuilder.Entity(b => + { + b.HasKey(s => s.Id); + b.HasOne(s => s.Freelancer).WithMany().HasForeignKey(s => s.FreelancerId); + }); + modelBuilder.Entity(b => + { + b.HasKey(c => c.Id); + b.Ignore(c => c.Project); + }); + + base.OnModelCreating(modelBuilder); + } + + public static ProjectVisibilityTestDbContext CreateInMemory() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)) + .Options; + return new ProjectVisibilityTestDbContext(options); + } +} diff --git a/devops/helm/loopless/values.yaml b/devops/helm/loopless/values.yaml index de2722b..986629a 100644 --- a/devops/helm/loopless/values.yaml +++ b/devops/helm/loopless/values.yaml @@ -113,6 +113,11 @@ config: OpenAI__ChatEndpoint: "https://models.inference.ai.azure.com/chat/completions" OpenAI__ChatModel: "gpt-4o-mini" KeyVault__Uri: "" # no cloud vault on this cluster -> plain k8s Secret only + # Restrict /metrics to the in-cluster Prometheus scrape (Host: loopless-backend:8080). + # Snippet directives are disabled on this cluster's ingress, so path-blocking happens + # in-app by Host: the public api host is not allow-listed -> /metrics returns 404 to + # the internet while the in-cluster scrape still works. See Program.cs MapMetrics. + Metrics__AllowedHosts__0: "loopless-backend:8080" # Object storage (in-cluster MinIO). Access/secret keys come from the Secret. Storage__ServiceUrl: "http://minio:9000" Storage__BucketName: "loopless-uploads" From 0af43bd202b14e1bf50fd2583562c41e3bb8f48e Mon Sep 17 00:00:00 2001 From: Edi Asllani Date: Thu, 11 Jun 2026 14:56:01 +0200 Subject: [PATCH 02/12] docs(env): add frontend .env.example documenting runtime/buildtime config Documents every NEXT_PUBLIC_* variable the frontend reads, the window.__ENV runtime-injection mechanism (APP_* vars written to public/__env.js by the container entrypoint), and which deployed values live in devops/helm/loopless/values.yaml. Un-ignores .env.example so it can be tracked. Co-Authored-By: Claude Fable 5 --- frontend/client/.env.example | 41 ++++++++++++++++++++++++++++++++++++ frontend/client/.gitignore | 1 + 2 files changed, 42 insertions(+) create mode 100644 frontend/client/.env.example diff --git a/frontend/client/.env.example b/frontend/client/.env.example new file mode 100644 index 0000000..3343326 --- /dev/null +++ b/frontend/client/.env.example @@ -0,0 +1,41 @@ +# Loopless frontend — environment variables +# +# Local dev: copy this file to .env.local and adjust values, then `npm run dev`. +# cp .env.example .env.local +# +# HOW CONFIG RESOLUTION WORKS (see lib/clientEnv.ts): +# 1. window.__ENV.* — runtime-injected in containers. The Docker entrypoint +# (devops/docker/frontend/Dockerfile) writes public/__env.js from APP_* env vars +# at container start, so one immutable image runs in any environment. +# 2. NEXT_PUBLIC_* — build/dev-time fallback, inlined by Next.js at build. This is +# what `next dev` uses locally (from .env.local). +# 3. Hardcoded localhost defaults as a last resort. +# +# DEPLOYED ENVIRONMENTS do NOT use this file. They set the runtime APP_* equivalents on +# the frontend container (see devops/helm/loopless/values.yaml -> env.frontend): +# APP_API_BASE_URL, APP_KEYCLOAK_URL, APP_POSTHOG_KEY, APP_POSTHOG_HOST, APP_ENABLE_RBAC +# +# Never commit .env.local or any file containing real secrets. + +# Base URL of the .NET API (no trailing slash). Also used for SignalR hubs +# (/hubs/messaging, /hubs/notifications). +# - Docker Compose dev stack maps the backend to host port 8081. +# - `dotnet run --project src/Loopless.Api` listens on http://localhost:8080. +NEXT_PUBLIC_API_BASE_URL=http://localhost:8081 + +# Keycloak base URL (no trailing slash, no realm path). Used by the GitHub SSO button +# to build the /realms/loopless/protocol/openid-connect/auth redirect. +# Docker Compose maps Keycloak to host port 8090. +NEXT_PUBLIC_KEYCLOAK_URL=http://localhost:8090 + +# Host that serves the admin UI. Read by the Edge middleware (lib/host.ts), which cannot +# see window.__ENV — so this one is ALWAYS build-time, even in deployed images. +# Defaults to the production admin host; override for local/staging setups. +NEXT_PUBLIC_ADMIN_HOST=admin.project-01.gjirafa.dev + +# Feature flag: enforce role-based gating on the admin analytics page ("true"/"false"). +NEXT_PUBLIC_ENABLE_RBAC=true + +# PostHog product analytics (optional). Leave blank to skip client-side init entirely. +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com diff --git a/frontend/client/.gitignore b/frontend/client/.gitignore index 5ef6a52..7b8da95 100644 --- a/frontend/client/.gitignore +++ b/frontend/client/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel From 4e182dc22a6066a6c3486ec7cedf82d415c07a3e Mon Sep 17 00:00:00 2001 From: Edi Asllani Date: Thu, 11 Jun 2026 22:11:27 +0200 Subject: [PATCH 03/12] fix(messages): create bell notification for message recipient Message sends only broadcast over the MessagingHub, so the recipient never got a bell notification unless they refreshed. Now SendMessageCommandHandler also records an in-app notification (deduped to one unread entry per conversation), and the RealtimeProvider suppresses it as read when the recipient is already on the messages page. Issue 2 of docs/Issues_2026-06-11.md. Co-Authored-By: Claude Fable 5 --- .../Send/SendMessageCommandHandler.cs | 25 +- .../Messaging/MessagingHandlerTests.cs | 84 +++++- .../Messaging/RecordingNotificationService.cs | 14 + .../Messaging/TestAppDbContext.cs | 6 +- docs/Issues_2026-06-11.md | 278 ++++++++++++++++++ .../components/providers/RealtimeProvider.tsx | 22 +- 6 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs create mode 100644 docs/Issues_2026-06-11.md diff --git a/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs b/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs index 7a1bf3a..cf16cd4 100644 --- a/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs @@ -10,7 +10,8 @@ namespace Loopless.Application.Features.Messages.Send; public sealed class SendMessageCommandHandler( IAppDbContext db, ICurrentUser currentUser, - IMessagingNotifier notifier) : IRequestHandler + IMessagingNotifier notifier, + INotificationService notifications) : IRequestHandler { public async Task Handle(SendMessageCommand request, CancellationToken cancellationToken) { @@ -57,6 +58,28 @@ public async Task Handle(SendMessageCommand request, CancellationTok await notifier.NotifyMessageSentAsync(dto, recipientId, cancellationToken); + // Bell notification for the recipient. One unread entry per conversation is + // enough — a burst of messages must not flood the bell, so skip when an + // unread notification for this conversation already exists. + var actionUrl = $"/messages?c={conversation.Id}"; + var hasUnread = await db.Notifications.AnyAsync( + n => n.UserId == recipientId && n.ActionUrl == actionUrl && !n.IsRead, + cancellationToken); + + if (!hasUnread) + { + var preview = request.Content.Length > 100 + ? request.Content[..97] + "..." + : request.Content; + + await notifications.SendAsync( + recipientId, + $"New message from {me.Name}", + preview, + cancellationToken, + actionUrl); + } + return dto; } } diff --git a/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs b/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs index 99dcac0..da8ff9c 100644 --- a/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs +++ b/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs @@ -65,7 +65,8 @@ public async Task SendMessage_PersistsAndBroadcasts() await db.SaveChangesAsync(); var notifier = new RecordingMessagingNotifier(); - var handler = new SendMessageCommandHandler(db, new FakeCurrentUser("k-me"), notifier); + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler(db, new FakeCurrentUser("k-me"), notifier, notifications); var dto = await handler.Handle(new SendMessageCommand(conv.Id, "hello"), CancellationToken.None); @@ -76,6 +77,85 @@ public async Task SendMessage_PersistsAndBroadcasts() Assert.NotNull(db.Conversations.Single().LastMessageAt); } + [Fact] + public async Task SendMessage_NotifiesRecipientOnly() + { + await using var db = TestAppDbContext.CreateInMemory(); + var me = MakeUser("k-me", "Sender"); + var other = MakeUser("k-other"); + db.Users.AddRange(me, other); + var conv = new Conversation { Participant1Id = me.Id, Participant2Id = other.Id }; + db.Conversations.Add(conv); + await db.SaveChangesAsync(); + + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler( + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), notifications); + + await handler.Handle(new SendMessageCommand(conv.Id, "hello"), CancellationToken.None); + + var sent = Assert.Single(notifications.Sent); + Assert.Equal(other.Id, sent.UserId); + Assert.Equal("New message from Sender", sent.Title); + Assert.Equal($"/messages?c={conv.Id}", sent.ActionUrl); + } + + [Fact] + public async Task SendMessage_SkipsNotificationWhenUnreadOneExistsForConversation() + { + await using var db = TestAppDbContext.CreateInMemory(); + var me = MakeUser("k-me", "Sender"); + var other = MakeUser("k-other"); + db.Users.AddRange(me, other); + var conv = new Conversation { Participant1Id = me.Id, Participant2Id = other.Id }; + db.Conversations.Add(conv); + db.Notifications.Add(new Notification + { + UserId = other.Id, + Title = "New message from Sender", + Body = "earlier", + IsRead = false, + ActionUrl = $"/messages?c={conv.Id}", + }); + await db.SaveChangesAsync(); + + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler( + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), notifications); + + await handler.Handle(new SendMessageCommand(conv.Id, "again"), CancellationToken.None); + + Assert.Empty(notifications.Sent); + } + + [Fact] + public async Task SendMessage_NotifiesAgainAfterPreviousNotificationWasRead() + { + await using var db = TestAppDbContext.CreateInMemory(); + var me = MakeUser("k-me", "Sender"); + var other = MakeUser("k-other"); + db.Users.AddRange(me, other); + var conv = new Conversation { Participant1Id = me.Id, Participant2Id = other.Id }; + db.Conversations.Add(conv); + db.Notifications.Add(new Notification + { + UserId = other.Id, + Title = "New message from Sender", + Body = "earlier", + IsRead = true, + ActionUrl = $"/messages?c={conv.Id}", + }); + await db.SaveChangesAsync(); + + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler( + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), notifications); + + await handler.Handle(new SendMessageCommand(conv.Id, "again"), CancellationToken.None); + + Assert.Single(notifications.Sent); + } + [Fact] public async Task SendMessage_RejectsNonParticipant() { @@ -89,7 +169,7 @@ public async Task SendMessage_RejectsNonParticipant() await db.SaveChangesAsync(); var handler = new SendMessageCommandHandler( - db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier()); + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), new RecordingNotificationService()); await Assert.ThrowsAsync(() => handler.Handle(new SendMessageCommand(conv.Id, "x"), CancellationToken.None)); diff --git a/backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs b/backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs new file mode 100644 index 0000000..aaf6249 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs @@ -0,0 +1,14 @@ +using Loopless.Application.Interfaces; + +namespace Loopless.UnitTests.Messaging; + +internal sealed class RecordingNotificationService : INotificationService +{ + public List<(Guid UserId, string Title, string Body, string? ActionUrl)> Sent { get; } = []; + + public Task SendAsync(Guid userId, string title, string body, CancellationToken cancellationToken = default, string? actionUrl = null) + { + Sent.Add((userId, title, body, actionUrl)); + return Task.CompletedTask; + } +} diff --git a/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs b/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs index 5c88f53..4e3a0f5 100644 --- a/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs +++ b/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs @@ -39,7 +39,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); - modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); @@ -66,6 +65,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasKey(m => m.Id); b.HasOne(m => m.Sender).WithMany().HasForeignKey(m => m.SenderId); }); + modelBuilder.Entity(b => + { + b.HasKey(n => n.Id); + b.Ignore(n => n.User); + }); base.OnModelCreating(modelBuilder); } diff --git a/docs/Issues_2026-06-11.md b/docs/Issues_2026-06-11.md new file mode 100644 index 0000000..0ca2d90 --- /dev/null +++ b/docs/Issues_2026-06-11.md @@ -0,0 +1,278 @@ +# Issues — 2026-06-11 + +Triage, prioritization, and detailed work plan for the 8 issues reported on 2026-06-11. +All findings below were verified against the `develop` branch as of commit `2fb61c6`. + +--- + +## Priority Ranking (most → least important) + +| # | Priority | Issue | Severity rationale | +|---|----------|-------|--------------------| +| 1 | **P0 — Critical** | App breaks on page reload while logged in (projects/messages/profile never load) | Blocks every logged-in user on every page in the deployed environment. Also the hidden root cause behind parts of issues #2 and #3. | +| 2 | **P1 — High** | Message notifications not delivered in real time | Core product flow (messaging) appears broken; no notification record is ever created for new messages. | +| 3 | **P1 — High** | Admin doesn't receive help/support ticket notifications | Admin workflow broken; backend already sends them — delivery path is dead (same SignalR root cause as #1) + 30 s cache. | +| 4 | **P1 — High** | Flags page Dismiss button "doesn't work" | Admin moderation blocked. Wiring is complete in code; failure is silent (no error UI) — repro-first fix. | +| 5 | **P2 — Medium** | Forced password change at next login has no frontend flow | Security-relevant; admin feature exists but the user-facing half is missing, so a forced reset silently does nothing in our UI. | +| 6 | **P2 — Medium** | Suspension has no duration (admin must pick how long) | Feature gap; suspension today is a permanent boolean with no auto-expiry and no login enforcement check. | +| 7 | **P2 — Medium** | Profile URL exposes user GUID + Share button dead | Privacy/professionalism issue (ID enumeration surface); share button has no handler at all. | +| 8 | **P3 — Low** | .txt / .md (and other) files rejected at project file upload | Small allowlist gap in one validator + a frontend/backend max-size mismatch. | + +**Sequencing note:** Issue #1 must land first. Its root cause (API base URL resolved at build time instead of runtime) also kills the SignalR `NotificationsHub`/`MessagingHub` connections in the deployed environment, which is the delivery half of issues #2 and #3. Fixing #2/#3 before #1 would be unverifiable. + +--- + +## Issue 1 — P0: App breaks on reload while logged in + +### Symptom +Reloading any page while logged in leaves the app stuck: projects, messages, and profile never load. Only closing the tab and logging in again recovers. + +### Root cause (confirmed) +The API base URL is captured **at module-initialization time** from a **build-time** env var that is not set in the production image, so every HTTP client and SignalR connection falls back to `http://localhost:8080`: + +| File | Line(s) | Problem | +|------|---------|---------| +| `frontend/client/lib/api/client.ts` | 16–17 | `process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"` baked in at import | +| `frontend/client/lib/auth/token.ts` | 53 | same — token refresh calls go to the wrong host | +| `frontend/client/lib/signalr/messagingConnection.ts` | 13–14 | same — hub connects to wrong host | +| `frontend/client/lib/signalr/notificationsConnection.ts` | 13–14 | same — hub connects to wrong host | + +Auth state itself is fine: `stores/authStore.ts` persists tokens via Zustand `persist` (`loopless.auth` in localStorage) and rehydrates correctly; `AuthGuard` passes. But every subsequent request (including token refresh) targets the wrong origin and fails/hangs, so all queries spin forever. Re-login "fixes" it only because the login flow happens to work until the next reload. + +**The fix already exists** on branch `fix/frontend-api-url-env-config` (commits `3e9142e`, `1306d4a`, `0af43bd`, pushed to origin): it introduces `frontend/client/lib/clientEnv.ts` with an `apiBaseUrl()` function that resolves `window.__ENV` (runtime config injected by the Docker entrypoint via `/__env.js`) → server env → build-time fallback, and switches all four call sites to it. **It is not merged into `develop`** (verified: `clientEnv.ts` missing on develop). + +### Work plan +1. Open a PR merging `fix/frontend-api-url-env-config` → `develop`. Resolve conflicts in the four files above in favor of the `apiBaseUrl()` call pattern. +2. Verify after merge that **all** producers of the base URL use the runtime resolver — grep for `NEXT_PUBLIC_API_BASE_URL` and assert the only remaining references are inside `clientEnv.ts` (as build-time fallback) and `.env.example`. +3. Verify the Docker runtime-config chain end to end: + - frontend Docker entrypoint writes `__env.js` with `apiBaseUrl`; + - the app layout loads `