diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 087c44a..682efb2 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -71,6 +71,33 @@ Bifrost publishes under these CultMesh namespaces: Odin discovers Bifrost through the service namespace, then indexes the schema catalog and surface namespaces. Odin does not scrape Razor pages as truth. +## Codex MCP Boundary + +Codex and similar external agents are xeno clients at the Verse boundary. The +MCP surface that gives those clients access to Verse state belongs in Bifrost, +not in an individual daemon package. Bifrost hosts the MCP because it owns the +governed crossing: capability shaping, consent, policy, and the external +protocol contract. + +The MCP should expose Verse-level tools rather than daemon-specific shortcuts. +Those tools ask Odin for overview and discovery, then route to known CultMesh +addresses in the local Verse. Odin supplies the current map of daemons, +surfaces, schemas, capabilities, and routes; Bifrost decides what a xeno client +is allowed to see or command across that map. + +Brokkr is one daemon that may be present in the local Verse. Its Unity editor +TUI/Eve surface and CultCache mirror are discovered through Odin and addressed +through CultMesh like any other resident service. Brokkr should not host the +Codex MCP merely because it happens to be the daemon of interest for a given +session. + +Useful MCP tool shapes are therefore conceptual Verse tools such as +`verse.overview`, `verse.daemons`, `verse.surfaces`, `verse.read`, +`verse.write_intent`, and `verse.poll_events`. Their implementations may call +Odin for discovery and then use CultLib/CultMesh/CultCache primitives to read or +write the addressed service state. Local scripts may still inspect a Brokkr +cache directly for debugging and development, but that is not the xeno boundary. + ## Provider Advertisement Export Bifrost has a read-only first-cut provider advertisement command: diff --git a/src/Bifrost.Web/Configuration/StripeOptions.cs b/src/Bifrost.Web/Configuration/StripeOptions.cs new file mode 100644 index 0000000..d43a515 --- /dev/null +++ b/src/Bifrost.Web/Configuration/StripeOptions.cs @@ -0,0 +1,28 @@ +namespace Bifrost.Web.Configuration; + +public sealed class StripeOptions +{ + public const string SectionName = "Stripe"; + + public bool EnableCheckout { get; set; } + + public string SecretKey { get; set; } = string.Empty; + + public string WebhookSecret { get; set; } = string.Empty; + + public string GeneralPatronageGitHubLogin { get; set; } = string.Empty; + + public string SuccessUrl { get; set; } = "https://velvet.gamecult.org/?patronage=success"; + + public string CancelUrl { get; set; } = "https://velvet.gamecult.org/?patronage=cancelled"; + + public bool IsCheckoutConfigured => + EnableCheckout && + !string.IsNullOrWhiteSpace(SecretKey) && + !string.IsNullOrWhiteSpace(SuccessUrl) && + !string.IsNullOrWhiteSpace(CancelUrl); + + public bool IsWebhookConfigured => + EnableCheckout && + !string.IsNullOrWhiteSpace(WebhookSecret); +} diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 5942474..13483fc 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -171,7 +171,8 @@ public enum ExternalPatronProvider { Manual, Patreon, - PayPal + PayPal, + Stripe } public enum BridgeActorKind diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 80d06f3..bf406c0 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -24,13 +24,15 @@ public sealed class HeimdallAuthService( private static readonly HashSet AllowedProviders = new(StringComparer.OrdinalIgnoreCase) { "discord", - "patreon" + "patreon", + "twitch" }; public async Task StartAsync( string provider, string callbackBaseUrl, string returnUrl, + bool requireMemberAccess, CancellationToken cancellationToken) { if (!AllowedProviders.Contains(provider)) @@ -44,9 +46,9 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall is not configured."); } - var entitlementPolicy = provider.Equals("discord", StringComparison.OrdinalIgnoreCase) - ? BuildDiscordPolicy(options) - : BuildPatreonPolicy(options); + var entitlementPolicy = requireMemberAccess + ? BuildMemberAccessPolicy(provider, options) + : null; var attemptId = Guid.NewGuid().ToString("N"); var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback"); @@ -75,6 +77,7 @@ public sealed class HeimdallAuthService( } attemptStore.Create(attemptId, returnUrl); + attemptStore.SetRequiresMemberAccess(attemptId, requireMemberAccess); return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId); } @@ -107,12 +110,12 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed."); } - if (!payload.HasMemberAccess) + if (attempt.RequiresMemberAccess && !payload.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); } - var userAccount = await UpsertUserAccountAsync(payload, cancellationToken); + var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken); var claims = new List { new(ClaimTypes.NameIdentifier, userAccount.GitHubUserId?.ToString() ?? userAccount.HeimdallAccountId), @@ -137,6 +140,7 @@ public sealed class HeimdallAuthService( private async Task UpsertUserAccountAsync( HeimdallBackendCallbackPayload payload, + bool grantMemberAccess, CancellationToken cancellationToken) { var now = timeProvider.GetUtcNow(); @@ -167,10 +171,12 @@ public sealed class HeimdallAuthService( }, Membership = new Bifrost.Web.Domain.Membership { - Status = MembershipStatus.Active, + Status = grantMemberAccess ? MembershipStatus.Active : MembershipStatus.Authenticated, CreatedAtUtc = now, - ApprovedAtUtc = now, - Notes = "Activated by Heimdall member access claim" + ApprovedAtUtc = grantMemberAccess ? now : null, + Notes = grantMemberAccess + ? "Activated by Heimdall member access claim" + : $"Identity verified through Heimdall {payload.Provider}" } }; dbContext.UserAccounts.Add(userAccount); @@ -191,18 +197,24 @@ public sealed class HeimdallAuthService( UserAccountId = userAccount.Id, CreatedAtUtc = now }; - userAccount.Membership.Status = MembershipStatus.Active; - userAccount.Membership.ApprovedAtUtc ??= now; + if (grantMemberAccess) + { + userAccount.Membership.Status = MembershipStatus.Active; + userAccount.Membership.ApprovedAtUtc ??= now; + } } userAccount.MemberProfile!.Nickname = displayName; userAccount.MemberProfile.UpdatedAtUtc = now; - ApplicationBootstrapper.EnsureRole( - userAccount.Membership!, - MemberRole.StandardMember, - null, - now, - "Heimdall member access"); + if (grantMemberAccess) + { + ApplicationBootstrapper.EnsureRole( + userAccount.Membership!, + MemberRole.StandardMember, + null, + now, + "Heimdall member access"); + } await dbContext.SaveChangesAsync(cancellationToken); return userAccount; @@ -236,6 +248,21 @@ public sealed class HeimdallAuthService( options.PatreonTierTitle); } + private static HeimdallEntitlementPolicy BuildMemberAccessPolicy(string provider, HeimdallOptions options) + { + if (provider.Equals("discord", StringComparison.OrdinalIgnoreCase)) + { + return BuildDiscordPolicy(options); + } + + if (provider.Equals("patreon", StringComparison.OrdinalIgnoreCase)) + { + return BuildPatreonPolicy(options); + } + + throw new HeimdallAuthException($"Heimdall provider '{provider}' does not support Bifrost member access."); + } + private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl) { var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute); @@ -262,6 +289,14 @@ public sealed class HeimdallAuthAttemptStore _attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null); } + public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess) + { + _attempts.AddOrUpdate( + attemptId, + _ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess), + (_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess }); + } + public void Complete(string attemptId, HeimdallBackendCallbackPayload payload) { _attempts.AddOrUpdate( @@ -276,14 +311,18 @@ public sealed class HeimdallAuthAttemptStore } } -public sealed record HeimdallAuthAttempt(string ReturnUrl, HeimdallBackendCallbackPayload? Payload); +public sealed record HeimdallAuthAttempt( + string ReturnUrl, + HeimdallBackendCallbackPayload? Payload, + bool RequiresMemberAccess = false); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, [property: JsonPropertyName("mode")] string Mode, [property: JsonPropertyName("returnTo")] string ReturnTo, [property: JsonPropertyName("handoff")] HeimdallHandoff Handoff, - [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy EntitlementPolicy); + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy); public sealed record HeimdallHandoff( [property: JsonPropertyName("kind")] string Kind, diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index 06d2b89..fe5e09f 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -30,9 +30,9 @@ public sealed class PatronageService( .Include(x => x.Membership) .SingleAsync(x => x.Id == userAccountId, cancellationToken); - if (user.Membership is null || user.Membership.Status != MembershipStatus.Active) + if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended) { - throw new InvalidOperationException("Patron support can only be recorded for an active member."); + throw new InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account."); } if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment) diff --git a/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs b/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs new file mode 100644 index 0000000..16efb5a --- /dev/null +++ b/src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs @@ -0,0 +1,144 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Domain; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Patronage; + +public sealed class StripeCheckoutService( + HttpClient httpClient, + IOptions options) +{ + private static readonly IReadOnlyDictionary VelvetTiers = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["velvet-room"] = new( + "velvet-room", + "GameCult Patronage - Velvet Room", + "General GameCult patronage with Deru supporter access.", + 1900, + "usd"), + ["mirror-test"] = new( + "mirror-test", + "GameCult Patronage - Mirror Test", + "General GameCult patronage with Deru supporter access.", + 1200, + "usd"), + ["after-hours-bundle"] = new( + "after-hours-bundle", + "GameCult Patronage - After Hours Bundle", + "General GameCult patronage with Deru supporter access.", + 3900, + "usd") + }; + + public async Task CreateVelvetCheckoutAsync( + string tierSlug, + UserAccount patronAccount, + CancellationToken cancellationToken) + { + var stripeOptions = options.Value; + if (!stripeOptions.IsCheckoutConfigured) + { + return StripeCheckoutResult.NotConfigured("Stripe checkout is not configured."); + } + + if (!VelvetTiers.TryGetValue(tierSlug.Trim(), out var tier)) + { + return StripeCheckoutResult.UnknownTier($"Unknown Velvet patronage tier '{tierSlug}'."); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, "v1/checkout/sessions"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", stripeOptions.SecretKey); + request.Content = new FormUrlEncodedContent(BuildCheckoutForm(stripeOptions, tier, patronAccount)); + + using var response = await httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return StripeCheckoutResult.ProviderError( + $"Stripe checkout session creation failed with {(int)response.StatusCode}."); + } + + using var json = JsonDocument.Parse(body); + var url = json.RootElement.TryGetProperty("url", out var urlElement) + ? urlElement.GetString() + : null; + + if (string.IsNullOrWhiteSpace(url)) + { + return StripeCheckoutResult.ProviderError("Stripe checkout session response did not include a URL."); + } + + return StripeCheckoutResult.Created(new Uri(url)); + } + + private static IEnumerable> BuildCheckoutForm( + StripeOptions options, + StripePatronageTier tier, + UserAccount patronAccount) + { + var referenceId = $"velvet:{tier.Slug}:{patronAccount.Id:N}:{Guid.NewGuid():N}"; + var metadata = new Dictionary + { + ["source"] = "velvet.gamecult.org", + ["umbrella"] = "GameCult", + ["ledger"] = "bifrost", + ["purpose"] = "general_patronage", + ["model"] = "deru", + ["tier"] = tier.Slug, + ["bifrost_user_account_id"] = patronAccount.Id.ToString("N"), + ["bifrost_github_login"] = patronAccount.GitHubLogin + }; + + yield return new("mode", "payment"); + yield return new("success_url", options.SuccessUrl); + yield return new("cancel_url", options.CancelUrl); + yield return new("client_reference_id", referenceId); + yield return new("line_items[0][quantity]", "1"); + yield return new("line_items[0][price_data][currency]", tier.Currency); + yield return new("line_items[0][price_data][unit_amount]", tier.AmountInMinorUnits.ToString()); + yield return new("line_items[0][price_data][product_data][name]", tier.Name); + yield return new("line_items[0][price_data][product_data][description]", tier.Description); + + foreach (var (key, value) in metadata) + { + yield return new($"metadata[{key}]", value); + yield return new($"payment_intent_data[metadata][{key}]", value); + } + } +} + +public sealed record StripePatronageTier( + string Slug, + string Name, + string Description, + int AmountInMinorUnits, + string Currency); + +public sealed record StripeCheckoutResult( + StripeCheckoutStatus Status, + Uri? CheckoutUrl, + string Message) +{ + public static StripeCheckoutResult Created(Uri checkoutUrl) => + new(StripeCheckoutStatus.Created, checkoutUrl, "Stripe checkout session created."); + + public static StripeCheckoutResult NotConfigured(string message) => + new(StripeCheckoutStatus.NotConfigured, null, message); + + public static StripeCheckoutResult UnknownTier(string message) => + new(StripeCheckoutStatus.UnknownTier, null, message); + + public static StripeCheckoutResult ProviderError(string message) => + new(StripeCheckoutStatus.ProviderError, null, message); +} + +public enum StripeCheckoutStatus +{ + Created, + NotConfigured, + UnknownTier, + ProviderError +} diff --git a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs new file mode 100644 index 0000000..2859e40 --- /dev/null +++ b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs @@ -0,0 +1,216 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Patronage; + +public sealed class StripeWebhookService( + BifrostDbContext dbContext, + PatronageService patronageService, + IOptions options) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task ProcessAsync( + string signatureHeader, + string payload, + CancellationToken cancellationToken) + { + var stripeOptions = options.Value; + if (!stripeOptions.IsWebhookConfigured) + { + return StripeWebhookResult.NotConfigured("Stripe webhook processing is not configured."); + } + + if (!HasValidSignature(signatureHeader, payload, stripeOptions.WebhookSecret)) + { + return StripeWebhookResult.Unauthorized("Invalid Stripe webhook signature."); + } + + StripeWebhookEvent? stripeEvent; + try + { + stripeEvent = JsonSerializer.Deserialize(payload, JsonOptions); + } + catch (JsonException) + { + return StripeWebhookResult.BadRequest("Stripe webhook payload is not valid JSON."); + } + + if (stripeEvent is null || string.IsNullOrWhiteSpace(stripeEvent.Id)) + { + return StripeWebhookResult.BadRequest("Stripe webhook payload is missing an event id."); + } + + if (!string.Equals(stripeEvent.Type, "checkout.session.completed", StringComparison.OrdinalIgnoreCase)) + { + return StripeWebhookResult.Ignored($"Ignored Stripe event type '{stripeEvent.Type}'."); + } + + var session = stripeEvent.Data.Object; + if (!string.Equals(session.PaymentStatus, "paid", StringComparison.OrdinalIgnoreCase)) + { + return StripeWebhookResult.Ignored($"Ignored Stripe checkout session with payment status '{session.PaymentStatus}'."); + } + + if (session.AmountTotal is null or <= 0) + { + return StripeWebhookResult.BadRequest("Stripe checkout session is missing a positive amount_total."); + } + + var patronageAccount = await ResolvePatronageAccountAsync(session, stripeOptions, cancellationToken); + + if (patronageAccount?.Membership is null || patronageAccount.Membership.Status == MembershipStatus.Suspended) + { + return StripeWebhookResult.NotConfigured( + "Stripe checkout session is not linked to an unsuspended Bifrost patron account."); + } + + var amount = session.AmountTotal.Value / 100m; + var currencyCode = string.IsNullOrWhiteSpace(session.Currency) + ? "USD" + : session.Currency.ToUpperInvariant(); + + await patronageService.RecordSupportEventAsync( + actorUserAccountId: null, + patronageAccount.Id, + PatronSupportEventKind.OneTimeDonation, + amount, + currencyCode, + session.Id, + ExternalPatronProvider.Stripe, + stripeEvent.Id, + session.Customer, + string.Empty, + DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created), + isCurrentRecurringSupport: false, + BuildNotes(session), + cancellationToken); + + return StripeWebhookResult.Processed("Stripe patronage support event recorded."); + } + + private async Task ResolvePatronageAccountAsync( + StripeCheckoutSession session, + StripeOptions stripeOptions, + CancellationToken cancellationToken) + { + if (session.Metadata.TryGetValue("bifrost_user_account_id", out var userAccountIdText) && + Guid.TryParse(userAccountIdText, out var userAccountId)) + { + return await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + } + + if (!string.IsNullOrWhiteSpace(stripeOptions.GeneralPatronageGitHubLogin)) + { + var normalizedLogin = stripeOptions.GeneralPatronageGitHubLogin.Trim().ToUpperInvariant(); + return await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.NormalizedGitHubLogin == normalizedLogin, cancellationToken); + } + + return null; + } + + private static string BuildNotes(StripeCheckoutSession session) + { + session.Metadata.TryGetValue("tier", out var tier); + session.Metadata.TryGetValue("source", out var source); + return $"Verified Stripe checkout session for {source ?? "unknown source"} tier {tier ?? "unknown tier"}."; + } + + private static bool HasValidSignature(string signatureHeader, string payload, string webhookSecret) + { + var pieces = signatureHeader + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(piece => piece.Split('=', 2)) + .Where(parts => parts.Length == 2) + .GroupBy(parts => parts[0], parts => parts[1]) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); + + if (!pieces.TryGetValue("t", out var timestamps) || + !pieces.TryGetValue("v1", out var signatures) || + timestamps.Length == 0) + { + return false; + } + + var signedPayload = $"{timestamps[0]}.{payload}"; + using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret)); + var expected = Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(signedPayload))).ToLowerInvariant(); + return signatures.Any(signature => FixedTimeEquals(signature, expected)); + } + + private static bool FixedTimeEquals(string actual, string expected) + { + var actualBytes = Encoding.UTF8.GetBytes(actual); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + return actualBytes.Length == expectedBytes.Length && + CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes); + } +} + +public sealed record StripeWebhookResult( + StripeWebhookStatus Status, + string Message) +{ + public static StripeWebhookResult Processed(string message) => new(StripeWebhookStatus.Processed, message); + + public static StripeWebhookResult Ignored(string message) => new(StripeWebhookStatus.Ignored, message); + + public static StripeWebhookResult BadRequest(string message) => new(StripeWebhookStatus.BadRequest, message); + + public static StripeWebhookResult Unauthorized(string message) => new(StripeWebhookStatus.Unauthorized, message); + + public static StripeWebhookResult NotConfigured(string message) => new(StripeWebhookStatus.NotConfigured, message); +} + +public enum StripeWebhookStatus +{ + Processed, + Ignored, + BadRequest, + Unauthorized, + NotConfigured +} + +public sealed class StripeWebhookEvent +{ + public string Id { get; set; } = string.Empty; + + public string Type { get; set; } = string.Empty; + + public long Created { get; set; } + + public StripeWebhookData Data { get; set; } = new(); +} + +public sealed class StripeWebhookData +{ + public StripeCheckoutSession Object { get; set; } = new(); +} + +public sealed class StripeCheckoutSession +{ + public string Id { get; set; } = string.Empty; + + public string Customer { get; set; } = string.Empty; + + public string Currency { get; set; } = "usd"; + + [JsonPropertyName("amount_total")] + public long? AmountTotal { get; set; } + + [JsonPropertyName("payment_status")] + public string PaymentStatus { get; set; } = string.Empty; + + public Dictionary Metadata { get; set; } = []; +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index af028a4..41b1147 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -9,6 +9,7 @@ public sealed class StartupConfigurationValidator( IOptions gitHubOAuthOptions, IOptions gitHubAppOptions, IOptions heimdallOptions, + IOptions stripeOptions, IOptions hostOptions) { public void Validate() @@ -41,6 +42,17 @@ public sealed class StartupConfigurationValidator( failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled."); } + if (stripeOptions.Value.EnableCheckout) + { + if (!stripeOptions.Value.IsCheckoutConfigured) + { + failures.Add("Stripe:SecretKey, Stripe:SuccessUrl, and Stripe:CancelUrl are required when Stripe checkout is enabled."); + } + + // Stripe:GeneralPatronageGitHubLogin is only a legacy fallback for old sessions + // that predate account-bound checkout metadata. + } + var allowedHosts = configuration["AllowedHosts"] ?? string.Empty; if (hostOptions.Value.RequireStrictHostValidation && (string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*'))) diff --git a/src/Bifrost.Web/Pages/Index.cshtml b/src/Bifrost.Web/Pages/Index.cshtml index 26e2538..76dd401 100644 --- a/src/Bifrost.Web/Pages/Index.cshtml +++ b/src/Bifrost.Web/Pages/Index.cshtml @@ -38,9 +38,9 @@ } else { - Sign in with GitHub - Sign in with Discord - Sign in with Patreon + Sign in or create account + Member access through Discord + Member access through Patreon } View repository diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 49070a2..b893539 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -43,6 +43,10 @@ builder.Services .AddOptions() .BindConfiguration(HeimdallOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(StripeOptions.SectionName); + builder.Services .AddOptions() .BindConfiguration(BridgeOptions.SectionName); @@ -88,6 +92,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -99,6 +104,10 @@ builder.Services.AddHttpClient((serviceProvider, client) => var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); }); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.stripe.com/"); +}); var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"]; if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath)) @@ -243,21 +252,34 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { + var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = "/App"; + } + + var provider = httpContext.Request.Query["provider"].ToString(); + if (string.IsNullOrWhiteSpace(provider)) + { + await WriteSignInChooserAsync(httpContext, returnUrl); + return; + } + + if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) + { + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); + return; + } + if (!gitHubOptions.Value.IsConfigured) { httpContext.Response.Redirect("/?auth=github-not-configured"); return; } - var redirectUri = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(redirectUri)) - { - redirectUri = "/App"; - } - await httpContext.ChallengeAsync( GitHubAuthenticationDefaults.AuthenticationScheme, - new() { RedirectUri = redirectUri }); + new() { RedirectUri = returnUrl }); }).AllowAnonymous(); app.MapGet("/auth/heimdall/{provider}", async ( @@ -275,10 +297,16 @@ app.MapGet("/auth/heimdall/{provider}", async ( returnUrl = "/App"; } + var requireMemberAccess = string.Equals( + httpContext.Request.Query["access"].ToString(), + "member", + StringComparison.OrdinalIgnoreCase); + var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, returnUrl, + requireMemberAccess, cancellationToken); return Results.Redirect(start.AuthorizationUrl.ToString()); @@ -327,6 +355,66 @@ app.MapPost("/heimdall/patron-support/events", async ( }; }).AllowAnonymous(); +app.MapGet("/patronage/velvet/checkout", async ( + HttpRequest request, + ICurrentBifrostActorAccessor actorAccessor, + StripeCheckoutService stripeCheckoutService, + CancellationToken cancellationToken) => +{ + var tier = request.Query["tier"].ToString(); + if (string.IsNullOrWhiteSpace(tier)) + { + return Results.BadRequest("Missing required tier query parameter."); + } + + var actor = await actorAccessor.GetAsync(cancellationToken); + if (!actor.IsAuthenticated || actor.UserAccount is null) + { + var returnUrl = Uri.EscapeDataString($"{request.Path}{request.QueryString}"); + return Results.Redirect($"/auth/sign-in?returnUrl={returnUrl}"); + } + + var result = await stripeCheckoutService.CreateVelvetCheckoutAsync( + tier, + actor.UserAccount, + cancellationToken); + return result.Status switch + { + StripeCheckoutStatus.Created => Results.Redirect(result.CheckoutUrl!.ToString()), + StripeCheckoutStatus.UnknownTier => Results.NotFound(result.Message), + StripeCheckoutStatus.NotConfigured => Results.Text( + result.Message, + statusCode: StatusCodes.Status503ServiceUnavailable), + _ => Results.Text( + result.Message, + statusCode: StatusCodes.Status502BadGateway) + }; +}).AllowAnonymous(); + +app.MapPost("/patronage/stripe/webhook", async ( + HttpRequest request, + StripeWebhookService webhookService, + CancellationToken cancellationToken) => +{ + using var reader = new StreamReader(request.Body); + var payload = await reader.ReadToEndAsync(cancellationToken); + var signature = request.Headers["Stripe-Signature"].ToString(); + + var result = await webhookService.ProcessAsync(signature, payload, cancellationToken); + return result.Status switch + { + StripeWebhookStatus.Processed => Results.Text( + result.Message, + statusCode: StatusCodes.Status202Accepted), + StripeWebhookStatus.Ignored => Results.Text(result.Message), + StripeWebhookStatus.BadRequest => Results.BadRequest(result.Message), + StripeWebhookStatus.NotConfigured => Results.Text( + result.Message, + statusCode: StatusCodes.Status503ServiceUnavailable), + _ => Results.Text(result.Message, statusCode: StatusCodes.Status401Unauthorized) + }; +}).AllowAnonymous(); + var bridgeActions = app.MapGroup("/bridge/actions"); bridgeActions.MapPost("/request", async ( @@ -735,6 +823,47 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) +{ + httpContext.Response.ContentType = "text/html; charset=utf-8"; + var encodedReturnUrl = Uri.EscapeDataString(returnUrl); + var escapedReturnUrl = System.Net.WebUtility.HtmlEncode(returnUrl); + await httpContext.Response.WriteAsync($$""" + + + + + + Sign in - Bifrost + + + +
+ Transport identity +

Create or use a Bifrost patron account

+

Choose a provider to attach this checkout to a Bifrost account. Patron accounts do not automatically grant active member access.

+ +

After sign-in, Bifrost returns you to {{escapedReturnUrl}}.

+
+ + + """); +} + static JsonSerializerOptions CreateAppJsonSerializerOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index 4041e7d..b24dd83 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -33,6 +33,14 @@ "PatronSupportIntakeSecret": "", "EnablePatronSupportIntake": true }, + "Stripe": { + "EnableCheckout": false, + "SecretKey": "", + "WebhookSecret": "", + "GeneralPatronageGitHubLogin": "", + "SuccessUrl": "https://velvet.gamecult.org/?patronage=success", + "CancelUrl": "https://velvet.gamecult.org/?patronage=cancelled" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index 042cfbf..3e2bfd8 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -7,6 +7,7 @@ using Bifrost.Web.Features.Patronage; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; namespace Bifrost.Web.Tests; @@ -228,6 +229,116 @@ public sealed class PlatformEndpointsTests : IClassFixture(); + patronageAccountId = setupContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN").Id; + } + + var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var payload = JsonSerializer.Serialize(new + { + id = $"evt_{Guid.NewGuid():N}", + type = "checkout.session.completed", + created, + data = new + { + @object = new + { + id = "cs_test_velvet_paid", + customer = "cus_velvet_1", + currency = "usd", + amount_total = 3900, + payment_status = "paid", + metadata = new Dictionary + { + ["source"] = "velvet.gamecult.org", + ["ledger"] = "bifrost", + ["purpose"] = "general_patronage", + ["model"] = "deru", + ["tier"] = "after-hours-bundle", + ["bifrost_user_account_id"] = patronageAccountId.ToString("N"), + ["bifrost_github_login"] = "test-admin" + } + } + } + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/patronage/stripe/webhook"); + request.Headers.Add("Stripe-Signature", ComputeStripeSignature("whsec_test_webhook_secret", payload)); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var patronageAccount = dbContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + var supportEvent = dbContext.PatronSupportEvents.Single(x => x.Provider == ExternalPatronProvider.Stripe); + + Assert.Equal(patronageAccount.Id, supportEvent.UserAccountId); + Assert.Equal("cs_test_velvet_paid", supportEvent.ExternalSupportId); + Assert.Equal(39m, supportEvent.Amount); + Assert.Equal("USD", supportEvent.CurrencyCode); + Assert.Contains("after-hours-bundle", supportEvent.Notes); + Assert.Contains(dbContext.AuditEvents, x => x.Action == "patron-support.recorded"); + } + [Fact] public async Task Eve_governance_surface_returns_motion_verse() { @@ -1021,6 +1132,16 @@ public sealed class PlatformEndpointsTests : IClassFixture ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", ["Host:RequireStrictHostValidation"] = "false", - ["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret" + ["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret", + ["Stripe:EnableCheckout"] = "true", + ["Stripe:SecretKey"] = "sk_test_configured", + ["Stripe:WebhookSecret"] = "whsec_test_webhook_secret", + ["Stripe:GeneralPatronageGitHubLogin"] = "test-admin", + ["Stripe:SuccessUrl"] = "https://velvet.gamecult.org/?patronage=success", + ["Stripe:CancelUrl"] = "https://velvet.gamecult.org/?patronage=cancelled" }); }); @@ -64,6 +73,52 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory options.LoginPath = "/auth/sign-in"; options.AccessDeniedPath = "/Membership/Status"; }); + + services.RemoveAll(); + services.AddTransient(serviceProvider => + { + var client = new HttpClient(new TestStripeCheckoutHandler()) + { + BaseAddress = new Uri("https://api.stripe.test/") + }; + + return new StripeCheckoutService( + client, + serviceProvider.GetRequiredService>()); + }); }); } + + private sealed class TestStripeCheckoutHandler : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var form = await request.Content!.ReadAsStringAsync(cancellationToken); + var hasExpectedMetadata = + form.Contains("metadata%5Bpurpose%5D=general_patronage", StringComparison.Ordinal) && + form.Contains("metadata%5Bledger%5D=bifrost", StringComparison.Ordinal) && + form.Contains("metadata%5Bbifrost_user_account_id%5D=", StringComparison.Ordinal); + + if (request.RequestUri?.PathAndQuery != "/v1/checkout/sessions" || !hasExpectedMetadata) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("{}") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "id": "cs_test_velvet", + "url": "https://checkout.stripe.com/c/pay/cs_test_velvet" + } + """) + }; + } + } }