Skip to content
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
24 changes: 24 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# The backend build needs only these repository files. Keep frontend dependencies,
# test fixtures, local credentials and generated databases out of the build context.
**
!global.json
!backend/
!backend/Dockerfile
!backend/src/
!backend/src/ProCoderJi.Api/
!backend/src/ProCoderJi.Api/**
!shared/
!shared/practice/
!shared/practice/questions.json
!shared/portfolio/
!shared/portfolio/rules.json
**/bin/**
**/obj/**
**/.data/**
**/*.db
**/*.db-shm
**/*.db-wal
**/appsettings.Local.json
**/.env*
**/*.user
**/*.suo
129 changes: 129 additions & 0 deletions .github/workflows/backend-containers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Backend containers

on:
pull_request:
paths:
- 'backend/**'
- 'shared/**'
- 'global.json'
- '.dockerignore'
- 'compose*.yml'
- 'deploy/**'
- 'vercel.backend.json'
- '.github/workflows/backend-containers.yml'
push:
branches: [main]
paths:
- 'backend/**'
- 'shared/**'
- 'global.json'
- '.dockerignore'
- 'compose*.yml'
- 'deploy/**'
- 'vercel.backend.json'
- '.github/workflows/backend-containers.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: backend-containers-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
persistence:
name: Database contracts and container smoke
# mongo:8.0 rejects the current Ubuntu 24.04 runner kernel at startup.
# Keep this integration job on a compatible host until MongoDB fixes its check:
# https://jira.mongodb.org/browse/SERVER-136848
runs-on: ubuntu-22.04
timeout-minutes: 25
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: procoderji_ci
POSTGRES_DB: procoderji_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres -d procoderji_test"
--health-interval 5s --health-timeout 5s --health-retries 30
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
# Developer edition and fixed credentials are only for disposable CI databases.
ACCEPT_EULA: Y
MSSQL_PID: Developer
MSSQL_MEMORY_LIMIT_MB: 2048
MSSQL_SA_PASSWORD: Procoderji_ci_2026!
ports: ['1433:1433']
options: >-
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Procoderji_ci_2026!' -C -Q 'SELECT 1' -b"
--health-interval 5s --health-timeout 5s --health-retries 40
--health-start-period 20s
mongodb:
image: mongo:8.0
ports: ['27017:27017']
options: >-
--health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'"
--health-interval 5s --health-timeout 5s --health-retries 30
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- name: Restore locked backend dependencies
run: dotnet restore backend/ProCoderJi.slnx --locked-mode
- name: Build backend
run: dotnet build backend/ProCoderJi.slnx --configuration Release --no-restore
- name: Test PostgreSQL contracts
env:
PROCODERJI_TEST_POSTGRES: 'Host=localhost;Database=procoderji_test;Username=postgres;Password=procoderji_ci'
run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
- name: Test SQL Server contracts
env:
PROCODERJI_TEST_SQLSERVER: 'Server=127.0.0.1,1433;User Id=sa;Password=Procoderji_ci_2026!;Encrypt=True;TrustServerCertificate=True'
run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
- name: Test MongoDB contracts
env:
PROCODERJI_TEST_MONGODB: mongodb://localhost:27017
run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
- name: Validate Compose options
env:
POSTGRES_PASSWORD: Procoderji_ci_2026!
MSSQL_SA_PASSWORD: Procoderji_ci_2026!
MSSQL_ACCEPT_EULA: Y
MONGO_PASSWORD: Procoderji_ci_2026!
run: |
for provider in postgres sqlserver mongodb; do
docker compose --env-file deploy/container.env.example -f compose.yml -f compose.$provider.yml config --quiet
done
docker compose --env-file deploy/neon.env.example -f compose.yml -f compose.neon.yml config --quiet
- name: Build backend container
run: docker build --file backend/Dockerfile --tag procoderji-api:ci .
- name: Smoke test the packaged API
run: |
docker run --detach --name api-smoke --read-only --tmpfs /tmp \
--publish 127.0.0.1:5100:8080 \
--env ASPNETCORE_ENVIRONMENT=Development \
--env PublicOrigin=http://localhost:3000 \
--env Database__Provider=Sqlite \
--env Database__MigrateOnStartup=true \
--env 'ConnectionStrings__Platform=Data Source=/tmp/container-smoke.db' \
procoderji-api:ci
for attempt in {1..30}; do
if curl --fail --silent http://localhost:5100/api/platform/health; then break; fi
sleep 1
done
curl --fail --silent http://localhost:5100/api/platform/auth/session | grep '"configured":false'
docker exec api-smoke sh -c 'test "$(id -u)" != 0 && test -f Data/questions.json && test -f Data/portfolio-rules.json'
- name: Container logs
if: failure()
run: docker logs api-smoke || true
- name: Stop smoke container
if: always()
run: docker rm --force api-smoke || true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ yarn-error.log*
.env*
# The committed example documents the variables; it holds no secrets.
!.env.example
/deploy/container.env
/deploy/neon.env

# vercel
.vercel
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ site data removes that local history, and browser-only progress does not travel
The optional **ASP.NET Core 10** backend provides Google sign-in and account progress through
the frontend's same-origin API. Account progress is kept separate from guest progress. The
backend authenticates requests, validates attempts against the shared question bank, and stores
each student's own history with Entity Framework Core. See [Backend setup](backend/README.md)
each student's own history using PostgreSQL, SQL Server, MongoDB, or local-development SQLite.
See [Backend setup](backend/README.md) and [Docker setup](docs/containers.md)
for local persistence, Google credentials, and production integration checks.

The hosted product uses [Neon PostgreSQL](docs/neon.md), with separate runtime and migration
connections.

The free practice flow works without provider credentials. Paid plans, payment processing, and
commercial account entitlements require separate product and provider decisions; this repository
does not currently enable them.
Expand Down
20 changes: 20 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Build from the repository root: docker build -f backend/Dockerfile .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

COPY global.json ./
COPY backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj backend/src/ProCoderJi.Api/packages.lock.json backend/src/ProCoderJi.Api/
RUN dotnet restore backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj --locked-mode

COPY backend/src/ProCoderJi.Api/ backend/src/ProCoderJi.Api/
COPY shared/ shared/
RUN dotnet publish backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj \
--configuration Release --no-restore --output /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
COPY --from=build --chown=app:app /app/publish ./
USER $APP_UID
ENTRYPOINT ["dotnet", "ProCoderJi.Api.dll"]
56 changes: 51 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,25 +54,65 @@ Attempts store raw question IDs and selected answers, not trusted scores. The ba

Serializable transactions and a state concurrency token serialize sync/reset races. A reset rotates the generation, so old devices cannot silently restore deleted cloud history. Attempts are keyed by account plus session UUID, and the newest 100 are retained per account. Requests are limited to 100 attempts and 256 KiB.

## PostgreSQL and deployment
## Choose a database

The same EF Core model and initial migration support PostgreSQL through Npgsql. Supply backend-only configuration:
**Neon PostgreSQL is the selected hosted database.** Follow [Neon setup](../docs/neon.md) for
the runtime and migration connections. The other providers remain available for contributors.

Choose one provider per deployment. These are operator settings; students use the same account
and progress API with every provider. SQLite remains the default for development without Docker.

| `Database__Provider` | Storage | Required configuration |
| --- | --- | --- |
| `Sqlite` | Local SQLite file through EF Core | Optional `ConnectionStrings__Platform`; defaults to `Data/procoderji.db`. |
| `Postgres` | PostgreSQL through EF Core/Npgsql | `ConnectionStrings__Platform` with a PostgreSQL connection string. |
| `SqlServer` | Microsoft SQL Server through EF Core | `ConnectionStrings__Platform` with a SQL Server connection string. |
| `MongoDb` | MongoDB through the official .NET driver | MongoDB URI in `ConnectionStrings__Platform`, plus `Database__Name`. |

The API, migrations, and selected database can run together with Docker Compose, in separate
containers with persistent database volumes. Follow [Container setup](../docs/containers.md).
Vercel container deployments need an external durable database; Compose volumes are for a
Docker host. Changing providers does not copy existing data or sessions between databases.

Example backend-only PostgreSQL configuration:

```text
Database__Provider=Postgres
ConnectionStrings__Platform=Host=YOUR_HOST;Database=YOUR_DATABASE;Username=YOUR_USER;Password=YOUR_PASSWORD
PublicOrigin=https://YOUR_FRONTEND_ORIGIN
```

For SQL Server, use `Database__Provider=SqlServer` and a connection string such as
`Server=YOUR_HOST;Database=procoderji;User Id=YOUR_USER;Password=YOUR_PASSWORD;Encrypt=True;TrustServerCertificate=False`.
For MongoDB, use `Database__Provider=MongoDb`, `Database__Name=procoderji`, and
`ConnectionStrings__Platform=mongodb+srv://YOUR_USER:YOUR_PASSWORD@YOUR_HOST/`.
Keep actual credentials in user-secrets or the hosting secret store.

## Provision and deploy

Run migrations deliberately before starting production replicas:

```bash
dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --migrate
```

This command uses the current environment's database/provider settings and exits after migration. Development and Testing apply migrations automatically; production does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database before schema changes. Keep versioned migrations and the model snapshot together, and validate changes against both providers.
This command uses the current environment's database/provider settings and exits after
provisioning. It applies EF migrations for relational databases and creates MongoDB's unique
Google-subject index for MongoDB. Development and Testing provision automatically; production
does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database
before schema changes. Shared relational migrations contain provider-specific SQL where needed;
keep both the default and SQL Server model snapshots current and run the provider checks.

Data Protection keys are stored in the selected application database with a stable application
name, so replicas using the same database can decrypt sessions. Relational storage uses EF Core;
MongoDB uses a key collection and an XML repository. Protect database access, backups, and key
material at rest, and isolate key rings between deployments. Restrict direct network access to
the backend and use the frontend BFF as the public entry point. Configure infrastructure rate
limits, HTTPS, operational monitoring, and backups before public account launch.

Data Protection keys are stored in the application database with a stable application name, so replicas using the same database can decrypt sessions. Protect database access, backups, and key material at rest, and isolate key rings between deployments. Restrict direct network access to the backend and use the frontend BFF as the public entry point. Configure infrastructure rate limits, HTTPS, operational monitoring, and backups before public account launch.
MongoDB stores each account's progress generation, revision and 100 retained attempts in one
document. Conditional atomic writes preserve reset and immutable-attempt behavior without a
replica set or multi-document transactions. See [ADR 0003](../docs/decisions/0003-container-database-options.md).

Razorpay or Paytm integration is planned for a later billing slice. No payment, subscription activation, or live checkout is implemented in this API yet.

Expand All @@ -84,7 +124,13 @@ dotnet test backend/ProCoderJi.slnx --no-restore

The default suite creates isolated SQLite databases and uses test-only authentication registered by `WebApplicationFactory` in the test assembly. The API has no test-header login path. Tests cover anonymous behavior, Google challenge URL construction, CSRF/origin checks, account isolation, immutable sync, generation resets, stale requests, question validation, and retention.

To run the same API contract tests against a **disposable PostgreSQL instance**, set `PROCODERJI_TEST_POSTGRES` to its administrative test connection string before running the command. Each factory creates and drops only its own randomly named schema. Do not point test configuration at a production database. A real Google login still needs operator-provided credentials and an end-to-end callback check in the configured deployment.
To run the same API contract tests against a disposable server, set exactly one of
`PROCODERJI_TEST_POSTGRES`, `PROCODERJI_TEST_SQLSERVER`, or `PROCODERJI_TEST_MONGODB` to its
administrative test connection string. Each factory creates and drops only its randomly named
test schema/database. The MongoDB setting also enables its persistence and Data Protection
integration tests, which otherwise report skipped. Never use a production database for these
tests. The backend-container workflow exercises all three servers and the packaged API.
A real Google login still needs operator-provided credentials and a live callback check.

## References

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using ProCoderJi.Api.Data;
using ProCoderJi.Api.Migrations;

namespace ProCoderJi.Api.Configuration;

/// <summary>Relational storage for SQLite development, PostgreSQL, or SQL Server.</summary>
public static class RelationalDatabaseConfiguration
{
public static IServiceCollection AddRelationalDatabase(this IServiceCollection services)
{
services.AddDbContextFactory<PlatformDbContext>((provider, options) =>
{
var configuration = provider.GetRequiredService<IConfiguration>();
Configure(options, configuration, provider.GetRequiredService<IHostEnvironment>().ContentRootPath);
});
return services;
}

public static void Configure(DbContextOptionsBuilder options, IConfiguration configuration, string contentRoot)
{
var provider = configuration["Database:Provider"] ?? "Sqlite";
var connection = configuration.GetConnectionString("Platform");
if (provider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase))
{
var sqlite = new SqliteConnectionStringBuilder(connection ?? "Data Source=Data/procoderji.db");
if (sqlite.DataSource != ":memory:" && !Path.IsPathRooted(sqlite.DataSource))
sqlite.DataSource = Path.Combine(contentRoot, sqlite.DataSource);
if (sqlite.DataSource != ":memory:") Directory.CreateDirectory(Path.GetDirectoryName(sqlite.DataSource)!);
options.UseSqlite(sqlite.ConnectionString);
}
else if (provider.Equals("Postgres", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(connection))
options.UseNpgsql(connection);
else if (provider.Equals("SqlServer", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(connection))
// ProgressService retries its whole serializable transaction. A provider execution
// strategy would reject those explicit transactions or replay only part of the work.
options.UseSqlServer(connection).ReplaceService<IMigrationsAssembly, SqlServerMigrationsAssembly>();
else
throw new InvalidOperationException("Choose Sqlite, or configure Postgres/SqlServer with ConnectionStrings:Platform.");
}
}
8 changes: 8 additions & 0 deletions backend/src/ProCoderJi.Api/Data/IAccountStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ProCoderJi.Api.Data;

/// <summary>The authenticated account boundary, independent of the persistence provider.</summary>
public interface IAccountStore
{
Task<bool> Exists(Guid userId, CancellationToken cancellationToken);
Task<LearnerUser> SignIn(string subject, string email, string name, CancellationToken cancellationToken);
}
33 changes: 33 additions & 0 deletions backend/src/ProCoderJi.Api/Data/Mongo/MongoAccountStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using MongoDB.Driver;

namespace ProCoderJi.Api.Data.Mongo;

public sealed class MongoAccountStore(MongoPlatformDatabase database) : IAccountStore
{
public Task<bool> Exists(Guid userId, CancellationToken cancellationToken) =>
database.Users.Find(user => user.Id == userId.ToString("D")).AnyAsync(cancellationToken);

public async Task<LearnerUser> SignIn(string subject, string email, string name, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subject);
if (subject.Length > 255) throw new ArgumentException("The Google subject is too long.", nameof(subject));
var update = Builders<MongoUserDocument>.Update
.SetOnInsert(user => user.Id, Guid.NewGuid().ToString("D"))
.SetOnInsert(user => user.GoogleSubject, subject)
.SetOnInsert(user => user.CreatedAtUtc, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
.Set(user => user.Email, email[..Math.Min(email.Length, 320)])
.Set(user => user.Name, name[..Math.Min(name.Length, 200)]);
var options = new FindOneAndUpdateOptions<MongoUserDocument> { IsUpsert = true, ReturnDocument = ReturnDocument.After };
try
{
return (await database.Users.FindOneAndUpdateAsync(user => user.GoogleSubject == subject, update, options, cancellationToken)).ToUser();
}
catch (Exception error) when (error is MongoCommandException { Code: 11000 or 11001 }
or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey })
{
// Two first logins may race the unique subject index. The winner owns the identity.
options.IsUpsert = false;
return (await database.Users.FindOneAndUpdateAsync(user => user.GoogleSubject == subject, update, options, cancellationToken)).ToUser();
}
}
}
Loading