Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,23 +232,23 @@ jobs:
output: trivy-results.sarif
severity: CRITICAL,HIGH

- name: Enforce no CRITICAL vulnerabilities (backend image)
- name: Enforce no CRITICAL/HIGH vulnerabilities (backend image)
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: image
image-ref: loopless-backend:security
format: table
severity: CRITICAL
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"

- name: Enforce no CRITICAL vulnerabilities (frontend image)
- name: Enforce no CRITICAL/HIGH vulnerabilities (frontend image)
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: image
image-ref: loopless-frontend:security
format: table
severity: CRITICAL
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"

Expand Down Expand Up @@ -310,9 +310,8 @@ jobs:
context: frontend/client
file: devops/docker/frontend/Dockerfile
push: true
build-args: |
NEXT_PUBLIC_API_BASE_URL=https://api.project-01.gjirafa.dev
NEXT_PUBLIC_KEYCLOAK_URL=https://api.project-01.gjirafa.dev/auth
# No build-args: public config (API/Keycloak URLs) is injected at RUNTIME via
# window.__ENV (Helm sets APP_* on the frontend pod), so the image is env-agnostic.
tags: |
${{ env.REGISTRY }}/${{ steps.owner.outputs.value }}/loopless-frontend:sha-${{ github.sha }}
${{ env.REGISTRY }}/${{ steps.owner.outputs.value }}/loopless-frontend:staging
Expand Down
81 changes: 0 additions & 81 deletions .github/workflows/terraform.yml

This file was deleted.

5 changes: 1 addition & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
- **Completed:** M1, M2, M3, M4, M5 · Sprints 1–5
- **Frontend:** Next.js 15 — 11 route groups implemented; most pages production-ready with TanStack Query, RHF+Zod, Framer Motion, Zustand. Authed routes live under `app/(protected)/` route group — wrapped in `AuthGuard` (token-presence redirect to `/login`); admin layout additionally wrapped in `AdminGuard` (non-admin → `/discover`).
- **Backend:** .NET 10 Clean Architecture — fully scaffolded and functional. 18 domain entities, 12 EF Core migrations (Apr 22–29), 10 endpoint groups, SignalR MessagingHub + NotificationsHub (CP-105 ✅), 4 Hangfire jobs, RabbitMQ publisher + 2 consumers, OpenAI embeddings + summary, S3 storage, Keycloak JWT + RBAC. Rate limiting added (BE-15). Correlation ID middleware added (BE-16). Integration tests scaffolded with Testcontainers (BE-17).
- **DevOps:** Docker Compose dev stack active (15 services: added Elasticsearch, Kibana, Uptime Kuma, Nginx). K8s manifests + Kustomize overlays + Helm chart done. Terraform 4 modules done. CI/CD (GitHub Actions) done + release-please semantic versioning workflow. Prometheus + Grafana + Loki + ELK active in compose. Nginx configured with reverse proxy rules.
- **DevOps:** Docker Compose dev stack active (15 services: PostgreSQL, Redis, MinIO, Elasticsearch, Kibana, Uptime Kuma, Nginx). K8s manifests + Kustomize overlays + Helm chart done (database, cache, storage all via Helm). CI/CD (GitHub Actions) done + release-please semantic versioning workflow. Prometheus + Grafana + Loki + ELK active in compose. Nginx configured with reverse proxy rules.

---

Expand Down Expand Up @@ -70,9 +70,6 @@ LIFE-Group1/
│ ├── k8s/overlays/production/ Same
│ ├── monitoring/prometheus/ prometheus.yml (5 jobs), alert-rules.yml (4 rules)
│ ├── monitoring/grafana/ 3 dashboards + provisioning datasources
│ ├── terraform/ versions.tf, backend.tf (S3+DynamoDB state lock)
│ │ ├── modules/ cache, database (RDS PG16+pgvector), network, storage
│ │ └── envs/staging,production Per-env tfvars
│ ├── keycloak/loopless-realm.json Keycloak realm export (auto-imported via docker compose)
│ └── loadtest/ k6 load test scripts
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ flowchart LR
| AI | OpenAI embeddings (1536-dim) + chat completions for project summaries / triage |
| Observability | Prometheus 2.54 + Alertmanager, Grafana 11, Loki 3.1, Elasticsearch + Logstash + Kibana 8.14, Uptime Kuma |
| Automation | aiops-triage (Node.js) — Alertmanager webhook → OpenAI → Slack/Discord; n8n for workflow automation |
| Infrastructure | Docker Compose (dev), Kubernetes + Kustomize + Helm (prod), Terraform 1.7+ (AWS), Nginx + Let's Encrypt |
| Infrastructure | Docker Compose (dev), Kubernetes + Kustomize + Helm (prod — all infra via Helm), Nginx + Let's Encrypt |
| CI/CD | GitHub Actions — lint/test/Trivy → push GHCR → kustomize deploy; release-please semantic versioning |

---
Expand Down Expand Up @@ -195,17 +195,15 @@ sudo -E SSH_PUBLIC_KEY='ssh-ed25519 ...' bash devops/scripts/server/bootstrap-ub

| What | Where | How to apply |
| -------------- | ------------------------------------------------------------- | ------------------------------------------------------------ |
| AWS resources | [`devops/terraform/`](./devops/terraform/) | See [`RUNBOOK.md`](./devops/terraform/RUNBOOK.md) |
| Helm charts | [`devops/helm/`](./devops/helm/) | All infrastructure deployed via Helm |
| Kubernetes | [`devops/k8s/`](./devops/k8s/) | `kubectl apply -k devops/k8s/overlays/staging` |
| Server bootstrap | [`devops/scripts/server/`](./devops/scripts/server/) | See [`devops/scripts/README.md`](./devops/scripts/README.md) |
| TLS certs | [`devops/scripts/init-letsencrypt.sh`](./devops/scripts/init-letsencrypt.sh) | `DOMAIN=... EMAIL=... sudo bash ...` |
| Secrets | Azure Key Vault (`KEYVAULT_URI` env var) | Backend auto-loads when URI is set |

**CI/CD workflows** (`.github/workflows/`):

- `ci.yml` — lint, test, build, Trivy scan, push images to GHCR
- `cd.yml` — deploy to staging then production (manual approval gate)
- `terraform.yml` — `fmt + validate + tflint` on every Terraform PR
- `release.yml` — release-please semantic versioning

---
Expand Down
79 changes: 77 additions & 2 deletions backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Loopless.Application.Features.Projects.GetProjectInvitations;
using Loopless.Application.Features.Projects.GetProjectLinks;
using Loopless.Application.Features.Projects.GetSummaries;
using Loopless.Application.Features.Projects.GetSummaryStatus;
using Loopless.Application.Features.Projects.InviteFreelancer;
using Loopless.Application.Features.Projects.Leave;
using Loopless.Application.Features.Projects.RejectApplicant;
Expand All @@ -27,10 +28,14 @@
using Loopless.Application.Features.Tasks.DeleteTask;
using Loopless.Application.Features.Tasks.GetProjectTasks;
using Loopless.Application.Features.Tasks.UpdateTaskStatus;
using Loopless.Application.Interfaces;
using Loopless.Domain.Enums;
using Loopless.Infrastructure.Identity;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;

namespace Loopless.Api.Endpoints;

Expand Down Expand Up @@ -186,6 +191,7 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild
.WithName("GetProjectCommits")
.Produces<PagedResult<GitHubCommitDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

Expand Down Expand Up @@ -296,24 +302,79 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild
.Produces(StatusCodes.Status409Conflict)
.ProducesValidationProblem();

group.MapPost("/{projectId:guid}/github/webhook", async (
[FromRoute] Guid projectId,
HttpRequest request,
IAppDbContext db,
ICommitSyncEnqueuer enqueuer,
CancellationToken ct) =>
{
request.EnableBuffering();
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms, ct);
var rawBody = ms.ToArray();

var project = await db.Projects
.FirstOrDefaultAsync(p => p.Id == projectId, ct);

if (project is null)
return Results.NotFound();

if (string.IsNullOrWhiteSpace(project.GitHubWebhookSecret))
return Results.Problem("Webhook secret not configured for this project.", statusCode: StatusCodes.Status400BadRequest);

var signatureHeader = request.Headers["X-Hub-Signature-256"].FirstOrDefault();
if (!IsValidGitHubSignature(rawBody, signatureHeader, project.GitHubWebhookSecret))
return Results.Unauthorized();

var eventType = request.Headers["X-GitHub-Event"].FirstOrDefault();
if (eventType is not "push")
return Results.Ok();

enqueuer.EnqueueProjectSync(projectId);
return Results.Accepted($"/api/v1/projects/{projectId}/commits");
})
.AllowAnonymous()
.WithName("GitHubWebhook")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);

group.MapPost("/{projectId:guid}/summary", async (
[FromRoute] Guid projectId,
ISender sender,
CancellationToken ct) =>
{
var dto = await sender.Send(new GenerateProjectSummaryCommand(projectId), ct);
return Results.Ok(dto);
return Results.Accepted(value: dto);
})
.RequireAuthorization()
.RequireRateLimiting("ai")
.WithName("GenerateProjectSummary")
.Produces<ProjectSummaryDto>(StatusCodes.Status200OK)
.Produces<SummaryJobStatusDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status429TooManyRequests)
.ProducesValidationProblem();

group.MapGet("/{projectId:guid}/summary/status", async (
[FromRoute] Guid projectId,
ISender sender,
CancellationToken ct) =>
{
var result = await sender.Send(new GetProjectSummaryStatusQuery(projectId), ct);
return Results.Ok(result);
})
.RequireAuthorization()
.WithName("GetProjectSummaryStatus")
.Produces<SummaryJobStatusDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);

group.MapGet("/{projectId:guid}/summaries", async (
[FromRoute] Guid projectId,
[FromQuery] int? page,
Expand Down Expand Up @@ -569,4 +630,18 @@ private sealed record CreateTaskBody(
DateTimeOffset? DueDate);

private sealed record UpdateTaskStatusBody(ProjectTaskStatus Status);

private static bool IsValidGitHubSignature(byte[] body, string? signatureHeader, string secret)
{
if (string.IsNullOrEmpty(signatureHeader) ||
!signatureHeader.StartsWith("sha256=", StringComparison.Ordinal))
return false;

byte[] expectedBytes;
try { expectedBytes = Convert.FromHexString(signatureHeader["sha256=".Length..]); }
catch { return false; }

var actualHash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedBytes);
}
}
3 changes: 3 additions & 0 deletions backend/src/Loopless.Api/Endpoints/StandupEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public static IEndpointRouteBuilder MapStandupEndpoints(this IEndpointRouteBuild
.WithName("GetStandupFeed")
.Produces<PagedResult<StandupDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

group.MapGet("/analytics", async (
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading