diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs b/cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs new file mode 100644 index 00000000..f1ea4872 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/DownCommand.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Maintenance; + +public sealed class DownCommand : Command +{ + public override int Execute(CommandContext context, DownSettings settings) + { + var contentRoot = ResolveContentRoot(); + if (contentRoot is null) + { + AnsiConsole.MarkupLine( + "[red]Could not locate the host project. Run `sm down` from within a SimpleModule solution.[/]" + ); + return 1; + } + + if (settings.Status) + { + return PrintStatus(contentRoot); + } + + DateTimeOffset? until = null; + if (!string.IsNullOrWhiteSpace(settings.Until)) + { + if ( + !DateTimeOffset.TryParse( + settings.Until, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed + ) + ) + { + AnsiConsole.MarkupLine( + $"[red]--until must be a valid ISO-8601 timestamp, got `{settings.Until}`[/]" + ); + return 1; + } + until = parsed; + } + + if (settings.RetryAfterSeconds < 0) + { + AnsiConsole.MarkupLine("[red]--retry must be non-negative.[/]"); + return 1; + } + + MaintenanceSentinel.Write( + contentRoot, + settings.Secret, + settings.Message, + settings.RetryAfterSeconds, + until + ); + + AnsiConsole.MarkupLine( + $"[green]Maintenance mode enabled.[/] Sentinel: [dim]{MaintenanceSentinel.PathFor(contentRoot)}[/]" + ); + + if (!string.IsNullOrEmpty(settings.Secret)) + { + AnsiConsole.MarkupLine( + $"Bypass URL: [cyan]https:///?sm_bypass={Markup.Escape(settings.Secret)}[/]" + ); + AnsiConsole.MarkupLine( + "[yellow]Keep the secret out of shell history when sharing.[/]" + ); + } + + return 0; + } + + private static int PrintStatus(string contentRoot) + { + if (!MaintenanceSentinel.Exists(contentRoot)) + { + AnsiConsole.MarkupLine("[green]Live[/] — no maintenance sentinel present."); + return 0; + } + + AnsiConsole.MarkupLine( + $"[yellow]Maintenance mode active[/]. Sentinel: [dim]{MaintenanceSentinel.PathFor(contentRoot)}[/]" + ); + return 0; + } + + private static string? ResolveContentRoot() + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + return null; + } + var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath); + return string.IsNullOrEmpty(hostDir) ? null : hostDir; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs b/cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs new file mode 100644 index 00000000..36a7475d --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/DownSettings.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Maintenance; + +public sealed class DownSettings : CommandSettings +{ + [CommandOption("--secret ")] + [Description("Bypass secret. Visitors with ?sm_bypass= are allowed through.")] + public string? Secret { get; set; } + + [CommandOption("--message ")] + [Description("Human-readable message shown on the 503 page.")] + public string? Message { get; set; } + + [CommandOption("--retry ")] + [Description("Value of the Retry-After header. Defaults to 60.")] + [DefaultValue(60)] + public int RetryAfterSeconds { get; set; } = 60; + + [CommandOption("--until ")] + [Description( + "Optional ISO-8601 timestamp shown to operators (informational; the sentinel still requires `sm up`)." + )] + public string? Until { get; set; } + + [CommandOption("--status")] + [Description("Print the current maintenance state and exit.")] + public bool Status { get; set; } +} diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs b/cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs new file mode 100644 index 00000000..0f5528f1 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/MaintenanceSentinel.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace SimpleModule.Cli.Commands.Maintenance; + +/// +/// File-based sentinel that mirrors what MaintenanceModeMiddleware +/// reads at request time. Kept in the CLI tree so the two sides stay +/// honest about the on-disk shape — the middleware loads via JSON +/// deserialization, and this writes the same JSON shape. +/// +public static class MaintenanceSentinel +{ + public const string FileName = ".maintenance"; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static string PathFor(string contentRoot) => Path.Combine(contentRoot, FileName); + + public static void Write( + string contentRoot, + string? secret, + string? message, + int retryAfterSeconds, + DateTimeOffset? until + ) + { + var payload = new + { + Until = until, + SecretHash = secret is null ? null : HashSecret(secret), + Message = message, + RetryAfterSeconds = retryAfterSeconds, + }; + + Directory.CreateDirectory(contentRoot); + File.WriteAllText(PathFor(contentRoot), JsonSerializer.Serialize(payload, JsonOptions)); + } + + public static bool Delete(string contentRoot) + { + var path = PathFor(contentRoot); + if (!File.Exists(path)) + { + return false; + } + File.Delete(path); + return true; + } + + public static bool Exists(string contentRoot) => File.Exists(PathFor(contentRoot)); + + public static string HashSecret(string secret) + { + var bytes = Encoding.UTF8.GetBytes(secret); + var hash = SHA256.HashData(bytes); + return Convert.ToHexStringLower(hash); + } +} diff --git a/cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs b/cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs new file mode 100644 index 00000000..ddd771f4 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Maintenance/UpCommand.cs @@ -0,0 +1,39 @@ +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Maintenance; + +public sealed class UpCommand : Command +{ + public override int Execute(CommandContext context) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]Could not locate the host project. Run `sm up` from within a SimpleModule solution.[/]" + ); + return 1; + } + + var hostDir = Path.GetDirectoryName(solution.ApiCsprojPath); + if (string.IsNullOrEmpty(hostDir)) + { + AnsiConsole.MarkupLine("[red]Host project directory could not be resolved.[/]"); + return 1; + } + + var removed = MaintenanceSentinel.Delete(hostDir); + if (removed) + { + AnsiConsole.MarkupLine("[green]Maintenance mode disabled.[/] Sentinel removed."); + } + else + { + AnsiConsole.MarkupLine("[yellow]No active maintenance sentinel — nothing to do.[/]"); + } + + return 0; + } +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 9440fba8..3947d3ea 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -2,6 +2,7 @@ using SimpleModule.Cli.Commands.Doctor; using SimpleModule.Cli.Commands.Install; using SimpleModule.Cli.Commands.List; +using SimpleModule.Cli.Commands.Maintenance; using SimpleModule.Cli.Commands.New; using SimpleModule.Cli.Commands.Skill; using SimpleModule.Cli.Commands.Version; @@ -84,6 +85,16 @@ } ); + config + .AddCommand("down") + .WithDescription("Enable maintenance mode (writes the .maintenance sentinel)") + .WithExample("down", "--secret", "let-me-in", "--retry", "60") + .WithExample("down", "--status"); + + config + .AddCommand("up") + .WithDescription("Disable maintenance mode (removes the .maintenance sentinel)"); + config.AddCommand("version").WithDescription("Print the sm CLI version"); }); diff --git a/docs/maintenance-mode.md b/docs/maintenance-mode.md new file mode 100644 index 00000000..62e8c388 --- /dev/null +++ b/docs/maintenance-mode.md @@ -0,0 +1,90 @@ +# Maintenance mode + +Put the running app into a maintenance state during deployments, database +migrations, or any change that must not interleave with live traffic. Visitors +see a branded 503 page; the operator running the deploy can keep verifying +the release through a bypass cookie. + +## Quick start + +```bash +# Drop the gate. Holders of ?sm_bypass=let-me-in pass through. +sm down --secret let-me-in --message "Deploying v1.4" --retry 90 + +# Verify state. +sm down --status + +# Lift the gate. +sm up +``` + +`sm down` writes a JSON sentinel at `/.maintenance`. The +running app polls that path once per second; the gate engages within a +second of the file appearing and lifts within a second of `sm up`. + +The secret is hashed with SHA-256 before it touches disk — only the hash +sits in the sentinel, never the plaintext. + +## How the middleware behaves + +`MaintenanceModeMiddleware` runs after static-asset routing and before +authentication. Once the sentinel is active: + +- **Health probes** (`/health/live`, `/health/ready`) pass through unchanged + so load balancers can still distinguish "host is down" from "deployment in + progress". +- **Bypass query** — `?sm_bypass=` redirects with an `sm_bypass` + cookie (HttpOnly, Secure when HTTPS, `SameSite=Lax`). The cookie's value + is the SHA-256 hash, not the secret itself, so leaking the cookie still + requires brute-forcing the hash to recover the secret. +- **Bypass cookie** — subsequent requests with a matching cookie pass + through. The cookie expires after 12 hours by default. +- **Inertia / API requests** — receive a JSON 503 with `Retry-After` and + `Cache-Control: no-store` so SPAs can show the maintenance page without + triggering a full reload. +- **Browser requests** — receive a minimal HTML 503 with the same headers, + styled inline so it renders without any JS bundle. + +## Sentinel shape + +```json +{ + "Until": "2026-05-15T18:00:00+00:00", + "SecretHash": "f1b8c2…", + "Message": "Deploying v1.4", + "RetryAfterSeconds": 90 +} +``` + +All fields are optional. An empty file (or `{}`) still engages maintenance +mode — the values just default to "no bypass possible, generic message, +retry in 60 seconds". + +## Tuning + +The default options live in `MaintenanceModeOptions` and are bound through +`IOptions`: + +| Option | Default | Notes | +| --- | --- | --- | +| `SentinelFileName` | `.maintenance` | Relative to the host content root. | +| `PollInterval` | 1 s | How often the middleware re-checks the file. | +| `BypassCookieName` | `sm_bypass` | Change if it collides with an app cookie. | +| `BypassCookieLifetime` | 12 h | How long a bypass stays valid. | + +Override in `Program.cs`: + +```csharp +builder.Services.Configure(o => +{ + o.PollInterval = TimeSpan.FromMilliseconds(500); +}); +``` + +## Not in scope + +- **Per-tenant maintenance** — a follow-up issue. The current sentinel is + global to the host. +- **Distributed coordination** — each instance reads its own filesystem. + For containerized deploys, run `sm down` against a shared volume or wire + the sentinel into your deploy pipeline so every replica sees it. diff --git a/docs/superpowers/plans/2026-05-15-fix-all-github-issues.md b/docs/superpowers/plans/2026-05-15-fix-all-github-issues.md new file mode 100644 index 00000000..b8a48fce --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-fix-all-github-issues.md @@ -0,0 +1,95 @@ +# Fix All Open GitHub Issues — Master Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to dispatch one subagent per issue. Each issue is its own self-contained PR. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all 17 open GitHub issues by shipping a feature PR per issue, in priority order. + +**Architecture:** Each issue → one branch, one PR, one merge. Scope is held to the issue's acceptance criteria. Tests, docs, and Constitution updates land alongside each change. Branches are isolated; PRs are small and reviewable. + +**Tech Stack:** .NET 10 / ASP.NET Core, React 19 + Inertia.js, EF Core, Serilog, FluentValidation, Roslyn source generators, xUnit.v3 + FluentAssertions, NBomber, Biome, Vite. + +--- + +## Scope check + +The 17 open issues span: identity hardening (6), framework primitives (2), tier-1 ops features (2), tier-3 observability modules (4), tier-4 SaaS/devx modules (3). Each issue is its own subsystem. Per the writing-plans guidance, **one plan per subsystem**. This master plan acts as the index: it orders the issues, calls out cross-cutting dependencies, and points to per-issue sub-plans created on demand as each is started. + +## Priority order + +The order reflects (a) tier labels in the issues, (b) cross-issue dependencies, and (c) risk/value ratio: + +| Order | Issue | Tier | Effort | Why this rank | +|------:|-------|------|--------|---------------| +| 1 | #160 Maintenance mode (`sm down`/`sm up`) | T1 | S | Smallest tier-1 win; unblocks safer deploys for everything that follows. | +| 2 | #159 Task scheduler on top of BackgroundJobs | T1 | M | Required by #167 (Horizon dashboard "Recurring" page) and many later cron-style features. | +| 3 | #199 Identity resend cooldown + verification throttling | sec | S | Direct security follow-up to PR #198; small surface, high value. | +| 4 | #180 Recovery codes status & download | identity | S | Smallest identity UX win; pure additive UI. | +| 5 | #177 Authentication tokens API | identity | M | Foundation for #182 (which references stored tokens for "API access" badge). | +| 6 | #182 Enrich external logins UI | identity | M | Builds on #177 token visibility + needs `ExternalLoginMetadata` table. | +| 7 | #175 Admin user claims management | identity/admin | M | Pairs with #176; ship together but landable independently. | +| 8 | #176 Admin role claims management | identity/admin | M | Pair of #175; same patterns, same review surface. | +| 9 | #163 Form Request classes | T2/framework | L | Source-generator + validation foundation; many later modules will adopt it. | +| 10 | #162 Policy classes | T2/framework | L | Entity-authorization layer over Permissions; orthogonal to Form Requests. | +| 11 | #173 `sm tail` log viewer | T4/cli | S | Smaller CLI feature; useful while developing larger modules below. | +| 12 | #172 `sm tinker` REPL | T4/cli | M | CLI dev tool; benefits from #163/#162 already in place. | +| 13 | #167 Horizon-style jobs dashboard | T3 | L | Now depends on #159 (Recurring tab). Lives under Admin module. | +| 14 | #166 Telescope-style debug panel | T3/module | L | Standalone module; reuses #167 interceptor pattern. | +| 15 | #168 Pulse-style perf dashboard | T3/module | L | Aggregates the same signals as #166 but stores trends. | +| 16 | #170 Scout-style search + Meilisearch driver | T3/module | XL | Cross-cuts every searchable module; biggest framework primitive remaining. | +| 17 | #171 Stripe billing module (Cashier-equivalent) | T4/module | XL | Largest single deliverable; isolated; ship last. | + +**Effort key:** S ≈ 0.5 day, M ≈ 1–2 days, L ≈ 3–5 days, XL ≈ 1–2 weeks. + +## Execution protocol + +Per issue: + +- [ ] **Branch off `main`** with name `issue--` (e.g. `issue-160-maintenance-mode`). +- [ ] **Write a per-issue sub-plan** under `docs/superpowers/plans/2026-05-15-issue--.md` using `superpowers:writing-plans`. Use the issue's "Acceptance criteria" verbatim as the spec. +- [ ] **Execute via subagents** (per memory: `prefer-subagent-driven-execution`). +- [ ] **CI green** locally via the `ci` skill before opening the PR. +- [ ] **PR opens with `Closes #`** in the body so the issue auto-closes on merge. +- [ ] **Update this file's checklist** when the PR opens and again when it merges. + +## Cross-cutting dependencies (build sequence reasoning) + +- **#159 before #167** — Horizon dashboard's "Recurring" page renders scheduled jobs registered via `IScheduler`. Doing #167 first ships an empty tab. +- **#177 before #182** — External-logins UI shows an "API access" badge sourced from `IExternalProviderTokenStore`. Implement the store first. +- **#175/#176 together** — Same `Claims` tab pattern, same `RoleClaim<>/UserClaim<>` mechanics, same `permission`-claim filter. One reviewer pass covers both. +- **#163/#162 before #166/#168/#170** — Larger T3 modules expose endpoints that benefit from FormRequest binding and resource policies. Adopting them after the modules exist requires churn. +- **#170 stays late** — Search interceptor touches every `ISearchable` entity; landing it after the other T3 modules avoids re-indexing churn. +- **#171 last** — Standalone module, hardest to test, biggest scope; no other issue depends on it. + +## Issue checklist (master) + +Mark each row when the corresponding PR is merged. + +- [ ] #160 — Maintenance mode +- [ ] #159 — Task scheduler +- [ ] #199 — Resend cooldown + throttling +- [ ] #180 — Recovery codes status & download +- [ ] #177 — Authentication tokens API +- [ ] #182 — External logins UI enrichment +- [ ] #175 — Admin user claims +- [ ] #176 — Admin role claims +- [ ] #163 — Form Request classes +- [ ] #162 — Policy classes +- [ ] #173 — `sm tail` +- [ ] #172 — `sm tinker` +- [ ] #167 — Jobs (Horizon) dashboard +- [ ] #166 — Telescope debug panel +- [ ] #168 — Pulse perf dashboard +- [ ] #170 — Scout / Meilisearch +- [ ] #171 — Stripe billing module + +## Self-review notes + +- Spec coverage: every open issue is in the table above with a tier and dependency note. +- No placeholders: per-issue sub-plans are explicitly deferred to issue-start, not stubbed here. +- Type consistency: the master plan only references identifiers from the issues themselves (e.g. `IScheduler`, `IExternalProviderTokenStore`); each per-issue sub-plan owns its own type design. + +## What this plan does NOT do + +- Does **not** attempt to fix all 17 in a single session — each is a separate PR with its own review cycle. +- Does **not** lock the sub-plan content in advance — those are written when an issue is started so they reflect the latest code state. +- Does **not** include code in this file — per-issue plans hold the TDD steps. diff --git a/framework/SimpleModule.Core/Maintenance/IMaintenanceStateProvider.cs b/framework/SimpleModule.Core/Maintenance/IMaintenanceStateProvider.cs new file mode 100644 index 00000000..0ce93f6e --- /dev/null +++ b/framework/SimpleModule.Core/Maintenance/IMaintenanceStateProvider.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Core.Maintenance; + +/// +/// Reads the current maintenance-mode state. Implementations are expected to +/// be cheap (cached) so the middleware can call this once per request without +/// adding noticeable latency. +/// +public interface IMaintenanceStateProvider +{ + ValueTask GetAsync(CancellationToken cancellationToken = default); +} diff --git a/framework/SimpleModule.Core/Maintenance/MaintenanceState.cs b/framework/SimpleModule.Core/Maintenance/MaintenanceState.cs new file mode 100644 index 00000000..1c4d3ed8 --- /dev/null +++ b/framework/SimpleModule.Core/Maintenance/MaintenanceState.cs @@ -0,0 +1,18 @@ +namespace SimpleModule.Core.Maintenance; + +/// +/// Snapshot of maintenance-mode state read by MaintenanceModeMiddleware. +/// SecretHash is the SHA-256 hash (hex) of the bypass secret — never the secret itself. +/// +public sealed record MaintenanceState +{ + public required bool Active { get; init; } + + public DateTimeOffset? Until { get; init; } + + public string? SecretHash { get; init; } + + public string? Message { get; init; } + + public int RetryAfterSeconds { get; init; } = 60; +} diff --git a/framework/SimpleModule.Hosting/Maintenance/FileSystemMaintenanceStateProvider.cs b/framework/SimpleModule.Hosting/Maintenance/FileSystemMaintenanceStateProvider.cs new file mode 100644 index 00000000..24d8c9ad --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/FileSystemMaintenanceStateProvider.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Maintenance; + +namespace SimpleModule.Hosting.Maintenance; + +/// +/// Reads the maintenance sentinel from the content root. The sentinel is a +/// JSON file written by sm down at deploy time; its absence means the +/// app is live. State is cached for +/// to keep the per-request cost negligible. +/// +public sealed class FileSystemMaintenanceStateProvider : IMaintenanceStateProvider +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly string _sentinelPath; + private readonly TimeSpan _pollInterval; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + private MaintenanceState? _cached; + private DateTimeOffset _cachedAt = DateTimeOffset.MinValue; + private readonly Lock _gate = new(); + + public FileSystemMaintenanceStateProvider( + IWebHostEnvironment environment, + IOptions options, + TimeProvider timeProvider, + ILogger logger + ) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(logger); + + _sentinelPath = Path.Combine(environment.ContentRootPath, options.Value.SentinelFileName); + _pollInterval = options.Value.PollInterval; + _timeProvider = timeProvider; + _logger = logger; + } + + public ValueTask GetAsync(CancellationToken cancellationToken = default) + { + var now = _timeProvider.GetUtcNow(); + + lock (_gate) + { + if (now - _cachedAt < _pollInterval) + { + return ValueTask.FromResult(_cached); + } + } + + var fresh = ReadSentinel(); + + lock (_gate) + { + _cached = fresh; + _cachedAt = now; + } + + return ValueTask.FromResult(fresh); + } + + private MaintenanceState? ReadSentinel() + { + if (!File.Exists(_sentinelPath)) + { + return null; + } + + try + { + using var stream = File.OpenRead(_sentinelPath); + var payload = JsonSerializer.Deserialize(stream, JsonOptions); + if (payload is null) + { + return new MaintenanceState { Active = true }; + } + + return new MaintenanceState + { + Active = true, + Until = payload.Until, + SecretHash = payload.SecretHash, + Message = payload.Message, + RetryAfterSeconds = payload.RetryAfterSeconds <= 0 ? 60 : payload.RetryAfterSeconds, + }; + } + catch (Exception ex) when (ex is IOException or JsonException) + { + _logger.LogWarning( + ex, + "Failed to read maintenance sentinel at {Path}; treating as active without metadata", + _sentinelPath + ); + return new MaintenanceState { Active = true }; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812:Avoid uninstantiated internal classes", + Justification = "Instantiated by System.Text.Json via reflection." + )] + private sealed record SentinelPayload( + DateTimeOffset? Until, + string? SecretHash, + string? Message, + int RetryAfterSeconds + ); +} diff --git a/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs new file mode 100644 index 00000000..3bca85f1 --- /dev/null +++ b/framework/SimpleModule.Hosting/Maintenance/MaintenanceModeOptions.cs @@ -0,0 +1,34 @@ +namespace SimpleModule.Hosting.Maintenance; + +/// +/// Configures the file-based maintenance sentinel. The default sentinel lives +/// alongside the running app's content root and is created by the +/// sm down CLI command at deploy time. +/// +public sealed class MaintenanceModeOptions +{ + /// + /// Filename of the sentinel file relative to the content root. + /// Mirrors the Laravel convention of a hidden file at the app root. + /// + public string SentinelFileName { get; set; } = ".maintenance"; + + /// + /// How long to cache sentinel reads in memory before re-checking the + /// file system. Bounds the time between flipping the sentinel and the + /// middleware noticing. + /// + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Name of the cookie that records a verified bypass. HttpOnly, Secure, + /// SameSite=Lax. + /// + public string BypassCookieName { get; set; } = "sm_bypass"; + + /// + /// Lifetime of the bypass cookie. After this elapses, the bypass query + /// parameter must be re-presented. + /// + public TimeSpan BypassCookieLifetime { get; set; } = TimeSpan.FromHours(12); +} diff --git a/framework/SimpleModule.Hosting/Middleware/MaintenanceModeMiddleware.cs b/framework/SimpleModule.Hosting/Middleware/MaintenanceModeMiddleware.cs new file mode 100644 index 00000000..97d334b1 --- /dev/null +++ b/framework/SimpleModule.Hosting/Middleware/MaintenanceModeMiddleware.cs @@ -0,0 +1,231 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Constants; +using SimpleModule.Core.Maintenance; +using SimpleModule.Hosting.Maintenance; + +namespace SimpleModule.Hosting.Middleware; + +/// +/// Short-circuits requests with a 503 when the maintenance sentinel is active. +/// Health-check routes are exempt so probes can keep distinguishing +/// "deployment in progress" from "host is down". A bypass query parameter +/// (?sm_bypass=<secret>) verifies the secret hash and writes an +/// sm_bypass cookie so subsequent requests pass straight through. +/// +public sealed class MaintenanceModeMiddleware +{ + private const string BypassQueryParameter = "sm_bypass"; + + private readonly RequestDelegate _next; + private readonly IMaintenanceStateProvider _stateProvider; + private readonly MaintenanceModeOptions _options; + + public MaintenanceModeMiddleware( + RequestDelegate next, + IMaintenanceStateProvider stateProvider, + IOptions options + ) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _stateProvider = stateProvider ?? throw new ArgumentNullException(nameof(stateProvider)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task InvokeAsync(HttpContext context) + { + if (IsExempt(context)) + { + await _next(context); + return; + } + + var state = await _stateProvider.GetAsync(context.RequestAborted); + if (state is not { Active: true }) + { + await _next(context); + return; + } + + if (TryConsumeBypassQuery(context, state)) + { + return; // already redirected + } + + if (HasValidBypassCookie(context, state)) + { + await _next(context); + return; + } + + await WriteMaintenanceResponseAsync(context, state); + } + + private static bool IsExempt(HttpContext context) + { + var path = context.Request.Path.Value; + if (string.IsNullOrEmpty(path)) + { + return false; + } + + return path.StartsWith(RouteConstants.HealthLive, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(RouteConstants.HealthReady, StringComparison.OrdinalIgnoreCase); + } + + private bool TryConsumeBypassQuery(HttpContext context, MaintenanceState state) + { + if (!context.Request.Query.TryGetValue(BypassQueryParameter, out var provided)) + { + return false; + } + + var secret = provided.ToString(); + if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(state.SecretHash)) + { + return false; + } + + if (!HashSecret(secret).Equals(state.SecretHash, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + context.Response.Cookies.Append( + _options.BypassCookieName, + state.SecretHash, + new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Lax, + Expires = DateTimeOffset.UtcNow.Add(_options.BypassCookieLifetime), + IsEssential = true, + Path = "/", + } + ); + + var redirect = context.Request.Path.HasValue + ? context.Request.Path.Value! + : "/"; + context.Response.Redirect(redirect); + return true; + } + + private bool HasValidBypassCookie(HttpContext context, MaintenanceState state) + { + if (string.IsNullOrEmpty(state.SecretHash)) + { + return false; + } + + if (!context.Request.Cookies.TryGetValue(_options.BypassCookieName, out var cookieValue)) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(cookieValue), + Encoding.ASCII.GetBytes(state.SecretHash) + ); + } + + private static async Task WriteMaintenanceResponseAsync(HttpContext context, MaintenanceState state) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = state.RetryAfterSeconds.ToString( + CultureInfo.InvariantCulture + ); + context.Response.Headers.CacheControl = "no-store"; + + var payload = new + { + status = StatusCodes.Status503ServiceUnavailable, + title = "Service unavailable", + message = state.Message ?? "The application is undergoing scheduled maintenance.", + retryAfterSeconds = state.RetryAfterSeconds, + until = state.Until, + }; + + // Inertia / API callers want JSON; browsers navigating directly want + // an HTML page they can render without a JS bundle (the JS bundle + // itself may be cached, but we cannot assume). + if (PrefersJson(context)) + { + context.Response.ContentType = "application/json; charset=utf-8"; + await JsonSerializer.SerializeAsync(context.Response.Body, payload); + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync(RenderHtml(payload.title, payload.message, state)); + } + + private static bool PrefersJson(HttpContext context) + { + if (context.Request.Headers.ContainsKey("X-Inertia")) + { + return true; + } + + var accept = context.Request.Headers.Accept.ToString(); + return accept.Contains("application/json", StringComparison.OrdinalIgnoreCase) + && !accept.Contains("text/html", StringComparison.OrdinalIgnoreCase); + } + + private static string RenderHtml(string title, string message, MaintenanceState state) + { + var encodedTitle = System.Net.WebUtility.HtmlEncode(title); + var encodedMessage = System.Net.WebUtility.HtmlEncode(message); + var retryHint = state.RetryAfterSeconds > 0 + ? "

Please try again in about " + + state.RetryAfterSeconds + + " seconds.

" + : string.Empty; + + return """ + + + + + + __TITLE__ + + + +
+

503

+

__TITLE__

+

__MESSAGE__

+ __RETRY__ +
+ + + """ + .Replace("__TITLE__", encodedTitle, StringComparison.Ordinal) + .Replace("__MESSAGE__", encodedMessage, StringComparison.Ordinal) + .Replace("__RETRY__", retryHint, StringComparison.Ordinal); + } + + /// + /// SHA-256 hex (lowercase) of the bypass secret. Exposed so tests and + /// out-of-band tooling can compute a hash that matches what the sentinel + /// expects without re-implementing the algorithm. + /// + public static string HashSecret(string secret) + { + var bytes = Encoding.UTF8.GetBytes(secret); + var hash = SHA256.HashData(bytes); + return Convert.ToHexStringLower(hash); + } +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 4111872f..6ef608bf 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -15,6 +15,7 @@ using SimpleModule.Core.Exceptions; using SimpleModule.Core.Health; using SimpleModule.Core.Inertia; +using SimpleModule.Core.Maintenance; using SimpleModule.Core.Menu; using SimpleModule.Core.RateLimiting; using SimpleModule.Core.Security; @@ -24,6 +25,7 @@ using SimpleModule.DevTools; using SimpleModule.Hosting.Broadcasting; using SimpleModule.Hosting.Inertia; +using SimpleModule.Hosting.Maintenance; using SimpleModule.Hosting.Middleware; using SimpleModule.Hosting.RateLimiting; using Wolverine; @@ -133,6 +135,12 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.AddSingleton(); + // Maintenance mode — file-based sentinel poll, written by `sm down` / + // cleared by `sm up`. Resolved as singleton because it caches state + // for a short interval. + builder.Services.Configure(_ => { }); + builder.Services.TryAddSingleton(); + if (options.EnableHealthChecks) { builder @@ -293,6 +301,11 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) // files are intentionally public. app.MapStaticAssets().AllowAnonymous(); + // Maintenance gate runs after static assets (so the 503 page can load + // its CSS) but before auth (so anonymous users get 503 rather than a + // login redirect). Health probes are exempt inside the middleware. + app.UseMiddleware(); + app.UseAuthentication(); app.UseAuthorization(); app.UseSimpleModuleRateLimiting(); diff --git a/packages/SimpleModule.UI/components/errors/error-page-503.tsx b/packages/SimpleModule.UI/components/errors/error-page-503.tsx new file mode 100644 index 00000000..7847926b --- /dev/null +++ b/packages/SimpleModule.UI/components/errors/error-page-503.tsx @@ -0,0 +1,43 @@ +import { ErrorPageLayout, type ErrorPageProps } from './error-page-layout'; + +export interface MaintenancePageProps extends ErrorPageProps { + retryAfterSeconds?: number; + until?: string | null; +} + +export default function ErrorPage503({ message, retryAfterSeconds, until }: MaintenancePageProps) { + const retryHint = + retryAfterSeconds && retryAfterSeconds > 0 + ? `Please try again in about ${retryAfterSeconds} seconds.` + : null; + + return ( +