Chapter 20: Phase 1 - Foundation Implementation

20.1 Overview

Phase 1 establishes the foundational infrastructure: multi-tenant isolation, authentication, and catalog management. This 5-week phase (extended from 4 weeks based on security research) creates the base upon which all other modules build.

Week 1-2: Multi-Tenant Infrastructure Week 3: Authentication (with security hardening) Week 4: Catalog Domain Week 5: Production Hardening (NEW)


20.2 Domain Configuration Strategy

Development Domain Setup

During development, we’ll use the existing nexusdenim.com domain with Cloudflare, allowing immediate development without waiting for a dedicated domain.

Subdomain Structure:

nexusdenim.com (existing infrastructure)
├── pos.nexusdenim.com              → Platform admin portal
├── api-pos.nexusdenim.com          → REST API gateway
├── {tenant}.pos.nexusdenim.com     → Tenant-specific access
│   ├── nexus.pos.nexusdenim.com    → Example: Nexus Clothing tenant
│   └── demo.pos.nexusdenim.com     → Example: Demo tenant
│
└── (existing services - unchanged)
    ├── tasks.nexusdenim.com
    └── orders-api.nexusdenim.com

Production Migration Path

When ready, migration to a dedicated domain (e.g., posplatform.com) requires:

ComponentChange RequiredEffort
DNS RecordsNew Cloudflare zone10 min
Environment VariablesUpdate PLATFORM_DOMAIN1 min
Tenant RecordsUPDATE query1 min
SSL CertificatesCloudflare auto-generatesAutomatic
Application CodeNone (domain-agnostic)0 min

Migration Script (run when switching domains):

-- Update all tenant domains from dev to production
UPDATE shared.tenants
SET domain = REPLACE(domain, 'nexusdenim.com', 'posplatform.com')
WHERE domain LIKE '%.nexusdenim.com';

Environment-Based Configuration

All domain references use environment variables for seamless migration:

// src/PosPlatform.Api/appsettings.json
{
  "Platform": {
    "Domain": "nexusdenim.com",           // Change this ONE place
    "AdminSubdomain": "pos",              // pos.{domain}
    "ApiSubdomain": "api-pos",            // api-pos.{domain}
    "TenantPattern": "{tenant}.pos"       // {tenant}.pos.{domain}
  },
  "Jwt": {
    "Issuer": "https://pos.nexusdenim.com",
    "Audience": "https://pos.nexusdenim.com"
  }
}
// src/PosPlatform.Core/Configuration/PlatformSettings.cs
namespace PosPlatform.Core.Configuration;

public class PlatformSettings
{
    public string Domain { get; set; } = "nexusdenim.com";
    public string AdminSubdomain { get; set; } = "pos";
    public string ApiSubdomain { get; set; } = "api-pos";
    public string TenantPattern { get; set; } = "{tenant}.pos";

    public string AdminUrl => $"https://{AdminSubdomain}.{Domain}";
    public string ApiUrl => $"https://{ApiSubdomain}.{Domain}";
    public string GetTenantUrl(string tenantCode)
        => $"https://{TenantPattern.Replace("{tenant}", tenantCode.ToLowerInvariant())}.{Domain}";
}

Cloudflare Tunnel Configuration

Development Setup (nexusdenim.com):

# cloudflared/config.yml
tunnel: pos-platform-dev
credentials-file: /etc/cloudflared/credentials.json

ingress:
  # Platform admin portal
  - hostname: pos.nexusdenim.com
    service: http://pos-web:8080

  # API gateway
  - hostname: api-pos.nexusdenim.com
    service: http://pos-api:5100

  # Wildcard for tenant subdomains
  - hostname: "*.pos.nexusdenim.com"
    service: http://pos-web:8080

  - service: http_status:404

Tenant Resolution Updates

The middleware now uses configured domain patterns:

// Updated subdomain parsing to use configured domain
private string? ResolveFromSubdomain(HttpContext context, PlatformSettings settings)
{
    var host = context.Request.Host.Host;

    // Extract tenant from pattern: {tenant}.pos.{domain}
    // e.g., nexus.pos.nexusdenim.com → nexus
    var suffix = $".{settings.AdminSubdomain}.{settings.Domain}";

    if (host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
    {
        var tenant = host[..^suffix.Length];
        if (!string.IsNullOrEmpty(tenant) && tenant != settings.AdminSubdomain)
            return tenant.ToUpperInvariant();
    }

    return null;
}

SSL Certificate Configuration

Important: The 3-level subdomain pattern ({tenant}.pos.nexusdenim.com) requires Cloudflare Advanced Certificate Manager ($10/month) for wildcard SSL support. Universal SSL only covers 2-level wildcards.

Setup Steps:

  1. Enable Advanced Certificate Manager in Cloudflare dashboard
  2. Request wildcard certificate for *.pos.nexusdenim.com
  3. Verify certificate covers all tenant subdomains

Key Design Decisions

  1. Domain-Agnostic Code: All application code uses configuration, never hardcoded domains
  2. Environment Variables: Single source of truth for domain configuration
  3. Cloudflare Integration: Leverages existing Cloudflare infrastructure with ACM
  4. Wildcard Subdomains: Supports dynamic tenant creation without DNS changes
  5. Zero-Downtime Migration: Can run both domains simultaneously during transition
  6. SSL Strategy: Advanced Certificate Manager for multi-level wildcard support

20.3 Multi-Tenancy Architecture Decision

Why Custom Implementation Over Finbuckle.MultiTenant

During architecture planning, we evaluated Finbuckle.MultiTenant (the most popular .NET multi-tenancy library) against a custom implementation.

Finbuckle.MultiTenant Overview:

// What Finbuckle looks like
services.AddMultiTenant<TenantInfo>()
    .WithHostStrategy()              // tenant.example.com
    .WithHeaderStrategy("X-Tenant")  // X-Tenant-Code header
    .WithEFCoreStore<AppDbContext, TenantInfo>();

Decision: Custom Implementation

FactorFinbuckleCustom (Chosen)
Multi-tenant patternDesigned for Row-Level Security (TenantId column)Native PostgreSQL schema-per-tenant (search_path)
ControlMagic inside libraryEvery line is our code
DebuggingStack traces through library internalsDirect, readable code path
POS-specific needsGeneric SaaS patternsTailored for retail (locations, registers, shifts)
DependenciesExternal NuGet packageZero external dependencies
Learning curveTeam learns library APITeam deeply understands multi-tenancy
Schema isolationRequires workaroundsFirst-class PostgreSQL schema support

When Finbuckle Would Make Sense

  • 100+ tenants sharing tables with Row-Level Security
  • Rapid prototyping where development speed > control
  • Team unfamiliar with multi-tenant architecture patterns

Why Custom Makes Sense for RapOS

  1. Schema-per-tenant is our architecture - Finbuckle is optimized for RLS, not PostgreSQL schemas
  2. 5-50 tenants expected - Not the scale where library abstractions pay off
  3. Compliance requirements - Complete data isolation per tenant (schema separation)
  4. POS-specific patterns - Locations, cash drawers, shifts aren’t generic SaaS concepts
  5. Already designed - Switching to Finbuckle would be rework, not simplification

Our Implementation (Clean & Understandable)

// TenantResolutionMiddleware.cs - Resolve tenant from request
var tenantCode = ResolveTenantCode(context);  // Header/Subdomain/JWT
var tenant = await _tenantRepository.GetByCodeAsync(tenantCode);
_tenantContext.SetTenant(tenant);

// TenantDbContext.cs - Dynamic schema binding
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var schema = $"tenant_{_tenantContext.TenantCode.ToLowerInvariant()}";
    modelBuilder.HasDefaultSchema(schema);
}

This is 60 lines of middleware that we fully understand vs. a library dependency we’d need to learn, debug, and work around for schema-per-tenant.


20.4 Week 1-2: Multi-Tenant Infrastructure

Day 1-2: Tenant Entity and Repository

Objective: Create the tenant domain model with repository pattern.

Claude Command:

/dev-team implement tenant entity with repository pattern

Implementation:

// src/PosPlatform.Core/Entities/Tenant.cs
using System;
using System.Collections.Generic;

namespace PosPlatform.Core.Entities;

public class Tenant
{
    public Guid Id { get; private set; }
    public string Code { get; private set; } = string.Empty;
    public string Name { get; private set; } = string.Empty;
    public string? Domain { get; private set; }
    public TenantStatus Status { get; private set; }
    public TenantSettings Settings { get; private set; } = new();
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private Tenant() { } // EF Core

    public static Tenant Create(string code, string name, string? domain = null)
    {
        if (string.IsNullOrWhiteSpace(code) || code.Length > 10)
            throw new ArgumentException("Code must be 1-10 characters", nameof(code));

        return new Tenant
        {
            Id = Guid.NewGuid(),
            Code = code.ToUpperInvariant(),
            Name = name,
            Domain = domain,
            Status = TenantStatus.Active,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void UpdateSettings(TenantSettings settings)
    {
        Settings = settings ?? throw new ArgumentNullException(nameof(settings));
        UpdatedAt = DateTime.UtcNow;
    }

    public void Suspend() => Status = TenantStatus.Suspended;
    public void Activate() => Status = TenantStatus.Active;
}

public enum TenantStatus
{
    Active,
    Suspended,
    Pending
}

public class TenantSettings
{
    public string Timezone { get; set; } = "UTC";
    public string Currency { get; set; } = "USD";
    public decimal TaxRate { get; set; } = 0.0m;
    public string? LogoUrl { get; set; }
    public Dictionary<string, string> Custom { get; set; } = new();
}
// src/PosPlatform.Core/Interfaces/ITenantRepository.cs
using PosPlatform.Core.Entities;

namespace PosPlatform.Core.Interfaces;

public interface ITenantRepository
{
    Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Tenant?> GetByCodeAsync(string code, CancellationToken ct = default);
    Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default);
    Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default);
    Task<Tenant> AddAsync(Tenant tenant, CancellationToken ct = default);
    Task UpdateAsync(Tenant tenant, CancellationToken ct = default);
    Task<bool> ExistsAsync(string code, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/Repositories/TenantRepository.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;

namespace PosPlatform.Infrastructure.Repositories;

public class TenantRepository : ITenantRepository
{
    private readonly PlatformDbContext _context;

    public TenantRepository(PlatformDbContext context)
    {
        _context = context;
    }

    public async Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _context.Tenants.FindAsync(new object[] { id }, ct);

    public async Task<Tenant?> GetByCodeAsync(string code, CancellationToken ct = default)
        => await _context.Tenants
            .FirstOrDefaultAsync(t => t.Code == code.ToUpperInvariant(), ct);

    public async Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default)
        => await _context.Tenants
            .FirstOrDefaultAsync(t => t.Domain == domain.ToLowerInvariant(), ct);

    public async Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default)
        => await _context.Tenants.ToListAsync(ct);

    public async Task<Tenant> AddAsync(Tenant tenant, CancellationToken ct = default)
    {
        await _context.Tenants.AddAsync(tenant, ct);
        await _context.SaveChangesAsync(ct);
        return tenant;
    }

    public async Task UpdateAsync(Tenant tenant, CancellationToken ct = default)
    {
        _context.Tenants.Update(tenant);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<bool> ExistsAsync(string code, CancellationToken ct = default)
        => await _context.Tenants.AnyAsync(t => t.Code == code.ToUpperInvariant(), ct);
}

Test Command:

# Run unit tests for tenant entity
dotnet test --filter "FullyQualifiedName~TenantTests"

Day 3-4: Schema Provisioning Service

Objective: Automatically create tenant-specific database schemas.

Claude Command:

/dev-team create tenant provisioning service with schema isolation

Implementation:

// src/PosPlatform.Core/Interfaces/ITenantProvisioningService.cs
namespace PosPlatform.Core.Interfaces;

public interface ITenantProvisioningService
{
    Task ProvisionTenantAsync(string tenantCode, CancellationToken ct = default);
    Task DeprovisionTenantAsync(string tenantCode, CancellationToken ct = default);
    Task<bool> IsProvisionedAsync(string tenantCode, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/MultiTenant/TenantProvisioningService.cs
using Microsoft.Extensions.Logging;
using Npgsql;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.MultiTenant;

public class TenantProvisioningService : ITenantProvisioningService
{
    private readonly string _connectionString;
    private readonly ILogger<TenantProvisioningService> _logger;

    public TenantProvisioningService(
        string connectionString,
        ILogger<TenantProvisioningService> logger)
    {
        _connectionString = connectionString;
        _logger = logger;
    }

    public async Task ProvisionTenantAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);
        _logger.LogInformation("Provisioning tenant schema: {Schema}", schemaName);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await using var transaction = await conn.BeginTransactionAsync(ct);

        try
        {
            // Create schema
            await ExecuteAsync(conn, $"CREATE SCHEMA IF NOT EXISTS {schemaName}", ct);

            // Create locations table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.locations (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    code VARCHAR(10) NOT NULL,
                    name VARCHAR(100) NOT NULL,
                    address JSONB,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_locations_code UNIQUE (code)
                )", ct);

            // Create users table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.users (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    employee_id VARCHAR(20),
                    full_name VARCHAR(100) NOT NULL,
                    email VARCHAR(255),
                    password_hash VARCHAR(255),
                    pin_hash VARCHAR(255),
                    role VARCHAR(50) NOT NULL,
                    location_id UUID REFERENCES {schemaName}.locations(id),
                    is_active BOOLEAN DEFAULT true,
                    last_login_at TIMESTAMPTZ,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_users_email UNIQUE (email),
                    CONSTRAINT uk_{schemaName}_users_employee_id UNIQUE (employee_id)
                )", ct);

            // Create categories table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.categories (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    name VARCHAR(100) NOT NULL,
                    parent_id UUID REFERENCES {schemaName}.categories(id),
                    sort_order INT DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW()
                )", ct);

            // Create products table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.products (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    sku VARCHAR(50) NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    description TEXT,
                    category_id UUID REFERENCES {schemaName}.categories(id),
                    base_price DECIMAL(10,2) NOT NULL,
                    cost DECIMAL(10,2),
                    tax_rate DECIMAL(5,4) DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    updated_at TIMESTAMPTZ,
                    CONSTRAINT uk_{schemaName}_products_sku UNIQUE (sku)
                )", ct);

            // Create product_variants table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.product_variants (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    product_id UUID NOT NULL REFERENCES {schemaName}.products(id),
                    sku VARCHAR(50) NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    attributes JSONB NOT NULL DEFAULT '{{}}',
                    price_adjustment DECIMAL(10,2) DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_variants_sku UNIQUE (sku)
                )", ct);

            // Create indexes
            await ExecuteAsync(conn, $@"
                CREATE INDEX IF NOT EXISTS idx_{schemaName}_products_category
                ON {schemaName}.products(category_id);

                CREATE INDEX IF NOT EXISTS idx_{schemaName}_products_name
                ON {schemaName}.products USING gin(name gin_trgm_ops);

                CREATE INDEX IF NOT EXISTS idx_{schemaName}_variants_product
                ON {schemaName}.product_variants(product_id);
            ", ct);

            await transaction.CommitAsync(ct);
            _logger.LogInformation("Tenant schema provisioned: {Schema}", schemaName);
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync(ct);
            _logger.LogError(ex, "Failed to provision tenant schema: {Schema}", schemaName);
            throw;
        }
    }

    public async Task DeprovisionTenantAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);
        _logger.LogWarning("Deprovisioning tenant schema: {Schema}", schemaName);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await ExecuteAsync(conn, $"DROP SCHEMA IF EXISTS {schemaName} CASCADE", ct);
    }

    public async Task<bool> IsProvisionedAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await using var cmd = new NpgsqlCommand(
            "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @schema)",
            conn);
        cmd.Parameters.AddWithValue("schema", schemaName);

        var result = await cmd.ExecuteScalarAsync(ct);
        return result is true;
    }

    private static string GetSchemaName(string tenantCode)
        => $"tenant_{tenantCode.ToLowerInvariant()}";

    private static async Task ExecuteAsync(NpgsqlConnection conn, string sql, CancellationToken ct)
    {
        await using var cmd = new NpgsqlCommand(sql, conn);
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

Test Command:

# Test schema provisioning
curl -X POST http://localhost:5100/api/admin/tenants \
  -H "Content-Type: application/json" \
  -d '{"code": "TEST", "name": "Test Store"}'

# Verify schema exists
docker exec -it pos-postgres psql -U pos_admin -d pos_platform -c "\dn"

Day 5: Tenant Resolution Middleware

Objective: Resolve tenant from request and establish context.

Claude Command:

/dev-team implement tenant resolution middleware with request context

Implementation:

// src/PosPlatform.Core/Interfaces/ITenantContext.cs
using PosPlatform.Core.Entities;

namespace PosPlatform.Core.Interfaces;

public interface ITenantContext
{
    Tenant? CurrentTenant { get; }
    string? TenantCode { get; }
    bool HasTenant { get; }
}

public interface ITenantContextSetter
{
    void SetTenant(Tenant tenant);
    void ClearTenant();
}
// src/PosPlatform.Infrastructure/MultiTenant/TenantContext.cs
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.MultiTenant;

public class TenantContext : ITenantContext, ITenantContextSetter
{
    private Tenant? _tenant;

    public Tenant? CurrentTenant => _tenant;
    public string? TenantCode => _tenant?.Code;
    public bool HasTenant => _tenant != null;

    public void SetTenant(Tenant tenant)
    {
        _tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
    }

    public void ClearTenant()
    {
        _tenant = null;
    }
}
// src/PosPlatform.Api/Middleware/TenantResolutionMiddleware.cs
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Api.Middleware;

public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TenantResolutionMiddleware> _logger;

    public TenantResolutionMiddleware(
        RequestDelegate next,
        ILogger<TenantResolutionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ITenantRepository tenantRepository,
        ITenantContextSetter tenantContext)
    {
        // Skip tenant resolution for platform endpoints
        if (context.Request.Path.StartsWithSegments("/api/admin") ||
            context.Request.Path.StartsWithSegments("/health"))
        {
            await _next(context);
            return;
        }

        var tenantCode = ResolveTenantCode(context);

        if (string.IsNullOrEmpty(tenantCode))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant not specified" });
            return;
        }

        var tenant = await tenantRepository.GetByCodeAsync(tenantCode);

        if (tenant == null)
        {
            context.Response.StatusCode = 404;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant not found" });
            return;
        }

        if (tenant.Status != TenantStatus.Active)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant is not active" });
            return;
        }

        tenantContext.SetTenant(tenant);
        _logger.LogDebug("Tenant resolved: {TenantCode}", tenant.Code);

        try
        {
            await _next(context);
        }
        finally
        {
            tenantContext.ClearTenant();
        }
    }

    private static string? ResolveTenantCode(HttpContext context)
    {
        // Priority 1: Header
        if (context.Request.Headers.TryGetValue("X-Tenant-Code", out var headerValue))
            return headerValue.ToString();

        // Priority 2: Query string
        if (context.Request.Query.TryGetValue("tenant", out var queryValue))
            return queryValue.ToString();

        // Priority 3: Subdomain (e.g., tenant1.posplatform.com)
        var host = context.Request.Host.Host;
        var parts = host.Split('.');
        if (parts.Length >= 3)
            return parts[0];

        // Priority 4: JWT claim (if authenticated)
        var tenantClaim = context.User?.FindFirst("tenant_code");
        if (tenantClaim != null)
            return tenantClaim.Value;

        return null;
    }
}

// Extension method for registration
public static class TenantMiddlewareExtensions
{
    public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
    {
        return app.UseMiddleware<TenantResolutionMiddleware>();
    }
}

Registration in Program.cs:

// Add to Program.cs
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<ITenantContext>(sp => sp.GetRequiredService<TenantContext>());
builder.Services.AddScoped<ITenantContextSetter>(sp => sp.GetRequiredService<TenantContext>());

// In middleware pipeline (after authentication, before controllers)
app.UseAuthentication();
app.UseTenantResolution();
app.UseAuthorization();

Day 6-7: Dynamic Connection Routing

Objective: Route database connections to tenant-specific schemas.

Claude Command:

/dev-team implement dynamic connection string routing per tenant

Implementation:

// src/PosPlatform.Infrastructure/Data/TenantDbContext.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.Data;

public class TenantDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;
    private readonly string _schemaName;

    public TenantDbContext(
        DbContextOptions<TenantDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
        _schemaName = tenantContext.HasTenant
            ? $"tenant_{tenantContext.TenantCode!.ToLowerInvariant()}"
            : "public";
    }

    public DbSet<Location> Locations => Set<Location>();
    public DbSet<User> Users => Set<User>();
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<ProductVariant> ProductVariants => Set<ProductVariant>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Set default schema for all entities
        modelBuilder.HasDefaultSchema(_schemaName);

        // Configure entities
        modelBuilder.Entity<Location>(entity =>
        {
            entity.ToTable("locations");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Code).HasMaxLength(10).IsRequired();
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.HasIndex(e => e.Code).IsUnique();
        });

        modelBuilder.Entity<User>(entity =>
        {
            entity.ToTable("users");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.FullName).HasMaxLength(100).IsRequired();
            entity.Property(e => e.Email).HasMaxLength(255);
            entity.HasIndex(e => e.Email).IsUnique();
            entity.HasIndex(e => e.EmployeeId).IsUnique();
            entity.HasOne(e => e.Location)
                  .WithMany()
                  .HasForeignKey(e => e.LocationId);
        });

        modelBuilder.Entity<Category>(entity =>
        {
            entity.ToTable("categories");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.HasOne(e => e.Parent)
                  .WithMany(e => e.Children)
                  .HasForeignKey(e => e.ParentId)
                  .OnDelete(DeleteBehavior.Restrict);
        });

        modelBuilder.Entity<Product>(entity =>
        {
            entity.ToTable("products");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Sku).HasMaxLength(50).IsRequired();
            entity.Property(e => e.Name).HasMaxLength(255).IsRequired();
            entity.Property(e => e.BasePrice).HasPrecision(10, 2);
            entity.Property(e => e.Cost).HasPrecision(10, 2);
            entity.HasIndex(e => e.Sku).IsUnique();
            entity.HasOne(e => e.Category)
                  .WithMany()
                  .HasForeignKey(e => e.CategoryId);
        });

        modelBuilder.Entity<ProductVariant>(entity =>
        {
            entity.ToTable("product_variants");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Sku).HasMaxLength(50).IsRequired();
            entity.HasIndex(e => e.Sku).IsUnique();
            entity.HasOne(e => e.Product)
                  .WithMany(p => p.Variants)
                  .HasForeignKey(e => e.ProductId);
        });
    }
}
// DI Registration in Program.cs
builder.Services.AddDbContext<TenantDbContext>((sp, options) =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    options.UseNpgsql(connectionString);
});

Day 8-9: Tenant Management API

Objective: Create REST API for tenant CRUD operations.

Claude Command:

/dev-team create tenant management API with CRUD endpoints

Implementation:

// src/PosPlatform.Api/Controllers/Admin/TenantsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Api.Controllers.Admin;

[ApiController]
[Route("api/admin/tenants")]
[Authorize(Roles = "super_admin")]
public class TenantsController : ControllerBase
{
    private readonly ITenantRepository _tenantRepository;
    private readonly ITenantProvisioningService _provisioningService;
    private readonly ILogger<TenantsController> _logger;

    public TenantsController(
        ITenantRepository tenantRepository,
        ITenantProvisioningService provisioningService,
        ILogger<TenantsController> logger)
    {
        _tenantRepository = tenantRepository;
        _provisioningService = provisioningService;
        _logger = logger;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<TenantDto>>> GetAll(CancellationToken ct)
    {
        var tenants = await _tenantRepository.GetAllAsync(ct);
        return Ok(tenants.Select(TenantDto.FromEntity));
    }

    [HttpGet("{code}")]
    public async Task<ActionResult<TenantDto>> GetByCode(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        return Ok(TenantDto.FromEntity(tenant));
    }

    [HttpPost]
    public async Task<ActionResult<TenantDto>> Create(
        [FromBody] CreateTenantRequest request,
        CancellationToken ct)
    {
        if (await _tenantRepository.ExistsAsync(request.Code, ct))
            return Conflict(new { error = "Tenant code already exists" });

        var tenant = Tenant.Create(request.Code, request.Name, request.Domain);

        if (request.Settings != null)
            tenant.UpdateSettings(request.Settings);

        await _tenantRepository.AddAsync(tenant, ct);

        // Provision database schema
        await _provisioningService.ProvisionTenantAsync(tenant.Code, ct);

        _logger.LogInformation("Tenant created: {Code}", tenant.Code);

        return CreatedAtAction(
            nameof(GetByCode),
            new { code = tenant.Code },
            TenantDto.FromEntity(tenant));
    }

    [HttpPut("{code}/settings")]
    public async Task<IActionResult> UpdateSettings(
        string code,
        [FromBody] TenantSettings settings,
        CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.UpdateSettings(settings);
        await _tenantRepository.UpdateAsync(tenant, ct);

        return NoContent();
    }

    [HttpPost("{code}/suspend")]
    public async Task<IActionResult> Suspend(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.Suspend();
        await _tenantRepository.UpdateAsync(tenant, ct);

        _logger.LogWarning("Tenant suspended: {Code}", code);
        return NoContent();
    }

    [HttpPost("{code}/activate")]
    public async Task<IActionResult> Activate(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.Activate();
        await _tenantRepository.UpdateAsync(tenant, ct);

        return NoContent();
    }
}

// DTOs
public record CreateTenantRequest(
    string Code,
    string Name,
    string? Domain,
    TenantSettings? Settings);

public record TenantDto(
    Guid Id,
    string Code,
    string Name,
    string? Domain,
    string Status,
    TenantSettings Settings,
    DateTime CreatedAt)
{
    public static TenantDto FromEntity(Tenant tenant) => new(
        tenant.Id,
        tenant.Code,
        tenant.Name,
        tenant.Domain,
        tenant.Status.ToString(),
        tenant.Settings,
        tenant.CreatedAt);
}

Day 10: Integration Tests

Objective: Verify tenant isolation through integration tests.

Claude Command:

/qa-team write tenant isolation integration tests

Implementation:

// tests/PosPlatform.Api.Tests/TenantIsolationTests.cs
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

namespace PosPlatform.Api.Tests;

public class TenantIsolationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TenantIsolationTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task Request_WithoutTenant_Returns400()
    {
        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    [Fact]
    public async Task Request_WithInvalidTenant_Returns404()
    {
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "INVALID");

        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task Products_FromDifferentTenants_AreIsolated()
    {
        // Create product in Tenant A
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_A");

        var productA = new { Sku = "SKU-A", Name = "Product A", BasePrice = 10.00m };
        await _client.PostAsJsonAsync("/api/products", productA);

        // Create product in Tenant B
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_B");

        var productB = new { Sku = "SKU-B", Name = "Product B", BasePrice = 20.00m };
        await _client.PostAsJsonAsync("/api/products", productB);

        // Verify Tenant A only sees their product
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_A");

        var responseA = await _client.GetFromJsonAsync<ProductListResponse>("/api/products");
        Assert.Single(responseA!.Items);
        Assert.Equal("SKU-A", responseA.Items[0].Sku);

        // Verify Tenant B only sees their product
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_B");

        var responseB = await _client.GetFromJsonAsync<ProductListResponse>("/api/products");
        Assert.Single(responseB!.Items);
        Assert.Equal("SKU-B", responseB.Items[0].Sku);
    }
}

public record ProductListResponse(List<ProductItem> Items);
public record ProductItem(string Sku, string Name, decimal BasePrice);

Test Command:

# Run integration tests
dotnet test tests/PosPlatform.Api.Tests --filter "FullyQualifiedName~TenantIsolation"

20.5 Week 2-3: Authentication System

Day 1-2: User Entity with Password Hashing

Claude Command:

/dev-team implement user entity with bcrypt password hashing

Implementation:

// src/PosPlatform.Core/Entities/User.cs
using System.Security.Cryptography;
using BCrypt.Net;

namespace PosPlatform.Core.Entities;

public class User
{
    public Guid Id { get; private set; }
    public string? EmployeeId { get; private set; }
    public string FullName { get; private set; } = string.Empty;
    public string? Email { get; private set; }
    public string? PasswordHash { get; private set; }
    public string? PinHash { get; private set; }
    public UserRole Role { get; private set; }
    public Guid? LocationId { get; private set; }
    public Location? Location { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime? LastLoginAt { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private User() { }

    public static User Create(
        string fullName,
        UserRole role,
        string? email = null,
        string? employeeId = null)
    {
        return new User
        {
            Id = Guid.NewGuid(),
            FullName = fullName,
            Email = email?.ToLowerInvariant(),
            EmployeeId = employeeId,
            Role = role,
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void SetPassword(string password)
    {
        if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters");

        PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, 12);
    }

    public bool VerifyPassword(string password)
    {
        if (string.IsNullOrEmpty(PasswordHash))
            return false;

        return BCrypt.Net.BCrypt.Verify(password, PasswordHash);
    }

    public void SetPin(string pin)
    {
        if (string.IsNullOrWhiteSpace(pin) || !pin.All(char.IsDigit) || pin.Length != 6)
            throw new ArgumentException("PIN must be exactly 6 digits");

        // Block common weak PINs
        var weakPins = new[] { "000000", "111111", "123456", "654321", "012345" };
        if (weakPins.Contains(pin))
            throw new ArgumentException("PIN is too weak. Choose a more secure combination.");

        PinHash = BCrypt.Net.BCrypt.HashPassword(pin, 10);
    }

    public bool VerifyPin(string pin)
    {
        if (string.IsNullOrEmpty(PinHash))
            return false;

        return BCrypt.Net.BCrypt.Verify(pin, PinHash);
    }

    public void AssignLocation(Guid locationId) => LocationId = locationId;
    public void RecordLogin() => LastLoginAt = DateTime.UtcNow;
    public void Deactivate() => IsActive = false;
    public void Activate() => IsActive = true;
}

public enum UserRole
{
    Cashier,
    Supervisor,
    Manager,
    Admin
}

Day 3-4: JWT Token Service

Claude Command:

/dev-team create JWT token service with refresh token support

Implementation:

// src/PosPlatform.Infrastructure/Services/JwtTokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using PosPlatform.Core.Entities;

namespace PosPlatform.Infrastructure.Services;

public interface IJwtTokenService
{
    TokenResult GenerateTokens(User user, string tenantCode);
    ClaimsPrincipal? ValidateToken(string token);
    string GenerateRefreshToken();
}

public record TokenResult(
    string AccessToken,
    string RefreshToken,
    DateTime ExpiresAt);

public class JwtTokenService : IJwtTokenService
{
    private readonly JwtSettings _settings;
    private readonly byte[] _key;

    public JwtTokenService(IOptions<JwtSettings> settings)
    {
        _settings = settings.Value;
        _key = Encoding.UTF8.GetBytes(_settings.SecretKey);
    }

    public TokenResult GenerateTokens(User user, string tenantCode)
    {
        var expiresAt = DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpirationMinutes);

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Name, user.FullName),
            new(ClaimTypes.Role, user.Role.ToString()),
            new("tenant_code", tenantCode),
            new("jti", Guid.NewGuid().ToString())
        };

        if (!string.IsNullOrEmpty(user.Email))
            claims.Add(new Claim(ClaimTypes.Email, user.Email));

        if (user.LocationId.HasValue)
            claims.Add(new Claim("location_id", user.LocationId.Value.ToString()));

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = expiresAt,
            Issuer = _settings.Issuer,
            Audience = _settings.Audience,
            SigningCredentials = new SigningCredentials(
                new SymmetricSecurityKey(_key),
                SecurityAlgorithms.HmacSha256Signature)
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);

        return new TokenResult(
            tokenHandler.WriteToken(token),
            GenerateRefreshToken(),
            expiresAt);
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();

        try
        {
            var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(_key),
                ValidateIssuer = true,
                ValidIssuer = _settings.Issuer,
                ValidateAudience = true,
                ValidAudience = _settings.Audience,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            }, out _);

            return principal;
        }
        catch
        {
            return null;
        }
    }

    public string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}

public class JwtSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string Issuer { get; set; } = "PosPlatform";
    public string Audience { get; set; } = "PosPlatform";
    public int AccessTokenExpirationMinutes { get; set; } = 60;
    public int RefreshTokenExpirationDays { get; set; } = 7;
}

Day 5-6: PIN Authentication

Claude Command:

/dev-team implement PIN authentication for POS terminals

Implementation:

// src/PosPlatform.Api/Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Services;

namespace PosPlatform.Api.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IUserRepository _userRepository;
    private readonly IJwtTokenService _tokenService;
    private readonly ITenantContext _tenantContext;

    public AuthController(
        IUserRepository userRepository,
        IJwtTokenService tokenService,
        ITenantContext tenantContext)
    {
        _userRepository = userRepository;
        _tokenService = tokenService;
        _tenantContext = tenantContext;
    }

    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        var user = await _userRepository.GetByEmailAsync(request.Email, ct);

        if (user == null || !user.IsActive)
            return Unauthorized(new { error = "Invalid credentials" });

        if (!user.VerifyPassword(request.Password))
            return Unauthorized(new { error = "Invalid credentials" });

        user.RecordLogin();
        await _userRepository.UpdateAsync(user, ct);

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new LoginResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt,
            UserDto.FromEntity(user)));
    }

    [HttpPost("login/pin")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> LoginWithPin(
        [FromBody] PinLoginRequest request,
        CancellationToken ct)
    {
        var user = await _userRepository.GetByEmployeeIdAsync(request.EmployeeId, ct);

        if (user == null || !user.IsActive)
            return Unauthorized(new { error = "Invalid credentials" });

        if (!user.VerifyPin(request.Pin))
            return Unauthorized(new { error = "Invalid PIN" });

        user.RecordLogin();
        await _userRepository.UpdateAsync(user, ct);

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new LoginResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt,
            UserDto.FromEntity(user)));
    }

    [HttpPost("refresh")]
    [AllowAnonymous]
    public async Task<ActionResult<TokenResponse>> Refresh(
        [FromBody] RefreshRequest request,
        CancellationToken ct)
    {
        // In production, validate refresh token from database
        var principal = _tokenService.ValidateToken(request.AccessToken);
        if (principal == null)
            return Unauthorized();

        var userId = Guid.Parse(principal.FindFirst(ClaimTypes.NameIdentifier)!.Value);
        var user = await _userRepository.GetByIdAsync(userId, ct);

        if (user == null || !user.IsActive)
            return Unauthorized();

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new TokenResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt));
    }

    [HttpPost("logout")]
    [Authorize]
    public IActionResult Logout()
    {
        // In production, invalidate refresh token in database
        return NoContent();
    }
}

// DTOs
public record LoginRequest(string Email, string Password);
public record PinLoginRequest(string EmployeeId, string Pin);
public record RefreshRequest(string AccessToken, string RefreshToken);
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, UserDto User);
public record TokenResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);

public record UserDto(Guid Id, string FullName, string? Email, string Role, Guid? LocationId)
{
    public static UserDto FromEntity(User user) => new(
        user.Id, user.FullName, user.Email, user.Role.ToString(), user.LocationId);
}

20.6 Week 3-4: Catalog Domain

Day 1-2: Product and Category Entities

Claude Command:

/dev-team create product entity with variant support

See entities defined in TenantDbContext above.

Day 5-6: Pricing Rules Engine

Claude Command:

/dev-team create pricing rules engine

Implementation:

// src/PosPlatform.Core/Services/PricingService.cs
namespace PosPlatform.Core.Services;

public interface IPricingService
{
    decimal CalculatePrice(Product product, ProductVariant? variant, PricingContext context);
}

public class PricingService : IPricingService
{
    public decimal CalculatePrice(Product product, ProductVariant? variant, PricingContext context)
    {
        var basePrice = product.BasePrice;

        // Apply variant adjustment
        if (variant != null)
            basePrice += variant.PriceAdjustment;

        // Apply promotions
        foreach (var promo in context.ActivePromotions)
        {
            if (promo.AppliesTo(product))
                basePrice = promo.Apply(basePrice);
        }

        // Apply customer discount
        if (context.CustomerDiscount > 0)
            basePrice *= (1 - context.CustomerDiscount);

        return Math.Round(basePrice, 2);
    }
}

public class PricingContext
{
    public List<Promotion> ActivePromotions { get; set; } = new();
    public decimal CustomerDiscount { get; set; }
    public DateTime PriceDate { get; set; } = DateTime.UtcNow;
}

public abstract class Promotion
{
    public abstract bool AppliesTo(Product product);
    public abstract decimal Apply(decimal price);
}

public class PercentagePromotion : Promotion
{
    public decimal DiscountPercent { get; set; }
    public Guid? CategoryId { get; set; }

    public override bool AppliesTo(Product product)
        => !CategoryId.HasValue || product.CategoryId == CategoryId;

    public override decimal Apply(decimal price)
        => price * (1 - DiscountPercent / 100);
}

PIN Rate Limiting & Lockout (Security Enhancement)

Objective: Prevent brute-force attacks on 6-digit PINs.

Research Finding: Even with 6-digit PINs (1M combinations), without rate limiting an attacker could test all combinations in hours. Add account lockout after failed attempts.

Claude Command:

/dev-team implement PIN rate limiting with lockout

Implementation:

// src/PosPlatform.Core/Services/PinAttemptTracker.cs
using Microsoft.Extensions.Caching.Distributed;

namespace PosPlatform.Core.Services;

public interface IPinAttemptTracker
{
    Task<bool> IsLockedOutAsync(string employeeId, CancellationToken ct = default);
    Task RecordFailedAttemptAsync(string employeeId, CancellationToken ct = default);
    Task ResetAttemptsAsync(string employeeId, CancellationToken ct = default);
    Task<int> GetFailedAttemptsAsync(string employeeId, CancellationToken ct = default);
}

public class PinAttemptTracker : IPinAttemptTracker
{
    private readonly IDistributedCache _cache;
    private readonly ILogger<PinAttemptTracker> _logger;

    private const int MaxAttempts = 5;
    private const int LockoutMinutes = 15;
    private const int ManagerResetThreshold = 10;

    public PinAttemptTracker(IDistributedCache cache, ILogger<PinAttemptTracker> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<bool> IsLockedOutAsync(string employeeId, CancellationToken ct = default)
    {
        var lockoutKey = $"pin_lockout:{employeeId}";
        var lockout = await _cache.GetStringAsync(lockoutKey, ct);
        return lockout != null;
    }

    public async Task RecordFailedAttemptAsync(string employeeId, CancellationToken ct = default)
    {
        var attemptsKey = $"pin_attempts:{employeeId}";
        var lockoutKey = $"pin_lockout:{employeeId}";

        // Get current attempts
        var currentStr = await _cache.GetStringAsync(attemptsKey, ct);
        var current = string.IsNullOrEmpty(currentStr) ? 0 : int.Parse(currentStr);
        current++;

        // Store updated attempts (expires in 1 hour)
        await _cache.SetStringAsync(attemptsKey, current.ToString(),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
            }, ct);

        _logger.LogWarning("PIN attempt {Attempt} for employee {EmployeeId}", current, employeeId);

        // Lock out after MaxAttempts
        if (current >= MaxAttempts)
        {
            await _cache.SetStringAsync(lockoutKey, DateTime.UtcNow.ToString("O"),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(LockoutMinutes)
                }, ct);

            _logger.LogError("Employee {EmployeeId} locked out after {Attempts} failed PIN attempts",
                employeeId, current);
        }

        // Alert for potential security incident
        if (current >= ManagerResetThreshold)
        {
            _logger.LogCritical("Employee {EmployeeId} exceeded {Threshold} failed PIN attempts - requires manager reset",
                employeeId, ManagerResetThreshold);
        }
    }

    public async Task ResetAttemptsAsync(string employeeId, CancellationToken ct = default)
    {
        await _cache.RemoveAsync($"pin_attempts:{employeeId}", ct);
        await _cache.RemoveAsync($"pin_lockout:{employeeId}", ct);
    }

    public async Task<int> GetFailedAttemptsAsync(string employeeId, CancellationToken ct = default)
    {
        var attemptsKey = $"pin_attempts:{employeeId}";
        var currentStr = await _cache.GetStringAsync(attemptsKey, ct);
        return string.IsNullOrEmpty(currentStr) ? 0 : int.Parse(currentStr);
    }
}

Updated PIN Login Endpoint:

[HttpPost("login/pin")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> LoginWithPin(
    [FromBody] PinLoginRequest request,
    CancellationToken ct)
{
    // Check lockout first
    if (await _pinTracker.IsLockedOutAsync(request.EmployeeId, ct))
    {
        _logger.LogWarning("PIN login attempt for locked-out employee {EmployeeId}", request.EmployeeId);
        return Unauthorized(new { error = "Account temporarily locked. Try again in 15 minutes." });
    }

    var user = await _userRepository.GetByEmployeeIdAsync(request.EmployeeId, ct);

    if (user == null || !user.IsActive)
    {
        await _pinTracker.RecordFailedAttemptAsync(request.EmployeeId, ct);
        return Unauthorized(new { error = "Invalid credentials" });
    }

    if (!user.VerifyPin(request.Pin))
    {
        await _pinTracker.RecordFailedAttemptAsync(request.EmployeeId, ct);
        var remaining = 5 - await _pinTracker.GetFailedAttemptsAsync(request.EmployeeId, ct);
        return Unauthorized(new { error = $"Invalid PIN. {Math.Max(0, remaining)} attempts remaining." });
    }

    // Success - reset attempts
    await _pinTracker.ResetAttemptsAsync(request.EmployeeId, ct);
    user.RecordLogin();
    await _userRepository.UpdateAsync(user, ct);

    var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

    return Ok(new LoginResponse(
        tokens.AccessToken,
        tokens.RefreshToken,
        tokens.ExpiresAt,
        UserDto.FromEntity(user)));
}

Refresh Token Rotation (Security Enhancement)

Objective: Prevent token theft by implementing single-use refresh tokens with family tracking.

Research Finding: Without rotation, a stolen refresh token can be reused indefinitely within its validity period.

Claude Command:

/dev-team implement refresh token rotation with reuse detection

Implementation:

// src/PosPlatform.Core/Entities/RefreshToken.cs
namespace PosPlatform.Core.Entities;

public class RefreshToken
{
    public Guid Id { get; private set; }
    public string Token { get; private set; } = string.Empty;
    public Guid UserId { get; private set; }
    public string FamilyId { get; private set; } = string.Empty;  // Groups related tokens
    public bool IsRevoked { get; private set; }
    public bool IsUsed { get; private set; }
    public DateTime ExpiresAt { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public Guid? ReplacedByTokenId { get; private set; }  // Chain tracking

    private RefreshToken() { }

    public static RefreshToken Create(Guid userId, int expirationDays = 7, string? familyId = null)
    {
        return new RefreshToken
        {
            Id = Guid.NewGuid(),
            Token = GenerateSecureToken(),
            UserId = userId,
            FamilyId = familyId ?? Guid.NewGuid().ToString(),
            ExpiresAt = DateTime.UtcNow.AddDays(expirationDays),
            CreatedAt = DateTime.UtcNow
        };
    }

    public RefreshToken Rotate()
    {
        if (IsRevoked || IsUsed)
            throw new InvalidOperationException("Cannot rotate a revoked or used token");

        IsUsed = true;
        var newToken = Create(UserId, 7, FamilyId);
        ReplacedByTokenId = newToken.Id;
        return newToken;
    }

    public void Revoke() => IsRevoked = true;

    public bool IsValid => !IsRevoked && !IsUsed && ExpiresAt > DateTime.UtcNow;

    private static string GenerateSecureToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}
// Updated TokenService with rotation
public async Task<TokenResult> RefreshTokenAsync(string accessToken, string refreshToken, CancellationToken ct)
{
    var storedToken = await _tokenRepository.GetByTokenAsync(refreshToken, ct);

    if (storedToken == null)
        throw new SecurityException("Invalid refresh token");

    // CRITICAL: Detect token reuse (indicates possible theft)
    if (storedToken.IsUsed || storedToken.IsRevoked)
    {
        // Revoke entire family - security breach detected
        await _tokenRepository.RevokeTokenFamilyAsync(storedToken.FamilyId, ct);
        _logger.LogCritical("Refresh token reuse detected for family {FamilyId}. All tokens revoked.",
            storedToken.FamilyId);
        throw new SecurityException("Token reuse detected. All sessions terminated.");
    }

    if (!storedToken.IsValid)
        throw new SecurityException("Refresh token expired");

    // Rotate the token
    var newRefreshToken = storedToken.Rotate();
    await _tokenRepository.UpdateAsync(storedToken, ct);
    await _tokenRepository.AddAsync(newRefreshToken, ct);

    // Get user and generate new access token
    var user = await _userRepository.GetByIdAsync(storedToken.UserId, ct);
    if (user == null || !user.IsActive)
        throw new SecurityException("User not found or inactive");

    var accessTokenResult = GenerateAccessToken(user, _tenantContext.TenantCode!);

    return new TokenResult(
        accessTokenResult.Token,
        newRefreshToken.Token,
        accessTokenResult.ExpiresAt);
}

20.7 Week 5: Production Hardening (NEW)

This week was added based on security research findings. It addresses critical production-readiness gaps.

Day 1-2: Global Exception Handling & Logging

Claude Command:

/dev-team implement global exception handler with Serilog

Implementation:

// src/PosPlatform.Api/Middleware/GlobalExceptionMiddleware.cs
using Microsoft.AspNetCore.Mvc;

namespace PosPlatform.Api.Middleware;

public class GlobalExceptionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionMiddleware> _logger;

    public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            _logger.LogWarning(ex, "Validation error");
            await WriteErrorResponse(context, 400, "Validation Error", ex.Message);
        }
        catch (NotFoundException ex)
        {
            _logger.LogInformation(ex, "Resource not found");
            await WriteErrorResponse(context, 404, "Not Found", ex.Message);
        }
        catch (UnauthorizedAccessException ex)
        {
            _logger.LogWarning(ex, "Unauthorized access");
            await WriteErrorResponse(context, 403, "Forbidden", ex.Message);
        }
        catch (SecurityException ex)
        {
            _logger.LogError(ex, "Security exception");
            await WriteErrorResponse(context, 401, "Security Error", ex.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception");
            var correlationId = context.Items["CorrelationId"]?.ToString() ?? "unknown";
            await WriteErrorResponse(context, 500, "Internal Server Error",
                $"An unexpected error occurred. Reference: {correlationId}");
        }
    }

    private static async Task WriteErrorResponse(HttpContext context, int statusCode, string title, string detail)
    {
        context.Response.StatusCode = statusCode;
        context.Response.ContentType = "application/problem+json";

        var problem = new ProblemDetails
        {
            Status = statusCode,
            Title = title,
            Detail = detail,
            Instance = context.Request.Path
        };

        await context.Response.WriteAsJsonAsync(problem);
    }
}
// src/PosPlatform.Api/Middleware/CorrelationIdMiddleware.cs
namespace PosPlatform.Api.Middleware;

public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<CorrelationIdMiddleware> _logger;

    public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        context.Items["CorrelationId"] = correlationId;
        context.Response.Headers.Append("X-Correlation-ID", correlationId);

        using (_logger.BeginScope(new Dictionary<string, object>
        {
            ["CorrelationId"] = correlationId,
            ["TenantCode"] = context.Items["TenantCode"]?.ToString() ?? "unknown"
        }))
        {
            await _next(context);
        }
    }
}

Day 3: Rate Limiting Per Tenant

Claude Command:

/dev-team implement per-tenant rate limiting

Implementation:

// Program.cs - Rate limiting configuration
builder.Services.AddRateLimiter(options =>
{
    // Per-tenant rate limit
    options.AddPolicy("per-tenant", context =>
    {
        var tenantCode = context.Request.Headers["X-Tenant-Code"].ToString();
        if (string.IsNullOrEmpty(tenantCode))
            tenantCode = "anonymous";

        return RateLimitPartition.GetFixedWindowLimiter(tenantCode, _ =>
            new FixedWindowRateLimiterOptions
            {
                PermitLimit = 1000,
                Window = TimeSpan.FromMinutes(1),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit = 10
            });
    });

    // Stricter limit for auth endpoints
    options.AddPolicy("auth-limit", context =>
    {
        var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter(ipAddress, _ =>
            new FixedWindowRateLimiterOptions
            {
                PermitLimit = 20,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0  // No queuing for auth
            });
    });

    options.OnRejected = async (context, _) =>
    {
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        await context.HttpContext.Response.WriteAsJsonAsync(new
        {
            error = "Too many requests. Please slow down.",
            retryAfter = 60
        });
    };
});

Day 4: Health Checks

Claude Command:

/dev-team implement health check endpoints

Implementation:

// Program.cs - Health checks configuration
builder.Services.AddHealthChecks()
    .AddNpgSql(
        builder.Configuration.GetConnectionString("DefaultConnection")!,
        name: "database",
        tags: new[] { "ready" })
    .AddCheck<TenantProvisioningHealthCheck>("tenant_provisioning", tags: new[] { "ready" })
    .AddCheck("memory", () =>
    {
        var allocated = GC.GetTotalMemory(false);
        var maxMemory = 500 * 1024 * 1024; // 500MB threshold
        return allocated < maxMemory
            ? HealthCheckResult.Healthy($"Memory: {allocated / 1024 / 1024}MB")
            : HealthCheckResult.Degraded($"High memory: {allocated / 1024 / 1024}MB");
    }, tags: new[] { "live" });

// Endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Day 5: Input Validation & CORS

Claude Command:

/dev-team implement FluentValidation and CORS configuration

Implementation:

// Program.cs - FluentValidation and CORS
builder.Services.AddValidatorsFromAssemblyContaining<CreateTenantRequestValidator>();
builder.Services.AddFluentValidationAutoValidation();

builder.Services.AddCors(options =>
{
    options.AddPolicy("PosPolicy", policy =>
    {
        var allowedOrigins = builder.Configuration
            .GetSection("Cors:AllowedOrigins")
            .Get<string[]>() ?? Array.Empty<string>();

        policy.WithOrigins(allowedOrigins)
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials()
              .SetPreflightMaxAge(TimeSpan.FromMinutes(10));
    });
});

// Middleware order
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<GlobalExceptionMiddleware>();
app.UseCors("PosPolicy");
app.UseRateLimiter();
app.UseTenantResolution();
app.UseAuthentication();
app.UseAuthorization();
// src/PosPlatform.Api/Validators/CreateTenantRequestValidator.cs
using FluentValidation;

namespace PosPlatform.Api.Validators;

public class CreateTenantRequestValidator : AbstractValidator<CreateTenantRequest>
{
    public CreateTenantRequestValidator()
    {
        RuleFor(x => x.Code)
            .NotEmpty()
            .Length(1, 10)
            .Matches("^[A-Z0-9]+$")
            .WithMessage("Code must be 1-10 uppercase alphanumeric characters");

        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Domain)
            .MaximumLength(255)
            .Matches(@"^[a-z0-9.-]+$")
            .When(x => !string.IsNullOrEmpty(x.Domain));
    }
}

20.8 Week 1-4 Review Checkpoint

Claude Command:

/architect-review multi-tenant isolation, authentication, and security implementation

Checklist:

  • Tenant CRUD API functional
  • Schema provisioning creates tables correctly
  • Tenant middleware resolves from header/subdomain
  • JWT authentication working
  • 6-digit PIN login functional with rate limiting
  • PIN lockout after 5 failed attempts
  • Refresh token rotation implemented
  • Integration tests pass

20.9 Week 5 Review Checkpoint

Checklist:

  • Global exception handler returns consistent error format
  • Correlation IDs in all log entries
  • Per-tenant rate limiting active
  • Auth endpoint rate limiting (20/min)
  • Health check endpoints responding
  • FluentValidation on all DTOs
  • CORS properly configured

20.10 Implementation Roadmap Overview

The complete POS platform implementation spans 4 phases over 18 weeks:

Phase 1: Foundation         (5 weeks)  - THIS CHAPTER
         Multi-tenant infrastructure, Auth, Catalog

Phase 2: Core Operations    (4 weeks)  - Chapter 21
         Inventory, Sales, Payments, Cash APIs

Phase 3: Supporting Systems (3 weeks)  - Chapter 22
         RFID/Raptag, Reports, Integrations

Phase 4: Production Ready   (6 weeks)  - Chapter 23
         POS Client, Monitoring, Security, Go-live
─────────────────────────────────────────────────────────
Total: 18 weeks

Key Insight: POS Client as Standalone Phase

“The Web Portal is the main portal for platform operations. The POS Client is the main portal for client operations.”

Both deserve equal architectural attention. Phase 4 dedicates 6 full weeks to the revenue-generating POS terminal application.


20.11 Next Steps

Proceed to Chapter 21: Phase 2 - Core Implementation for:

  • Inventory domain with stock tracking
  • Sales domain with event sourcing
  • Payment processing
  • Cash drawer operations


Document Information

AttributeValue
Version5.0.0
Created2025-12-29
Updated2026-02-25
AuthorClaude Code
StatusActive
PartVI - Implementation Guide
Chapter20 of 32

This chapter is part of the POS Blueprint Book. All content is self-contained.