Add Stripe patronage checkout integration

This commit is contained in:
Meta Vens 2026-06-22 14:08:35 +01:00
commit 861dff3ed7
13 changed files with 812 additions and 32 deletions

View file

@ -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:

View file

@ -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);
}

View file

@ -171,7 +171,8 @@ public enum ExternalPatronProvider
{
Manual,
Patreon,
PayPal
PayPal,
Stripe
}
public enum BridgeActorKind

View file

@ -24,13 +24,15 @@ public sealed class HeimdallAuthService(
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
{
"discord",
"patreon"
"patreon",
"twitch"
};
public async Task<HeimdallAuthStartResult> 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<Claim>
{
new(ClaimTypes.NameIdentifier, userAccount.GitHubUserId?.ToString() ?? userAccount.HeimdallAccountId),
@ -137,6 +140,7 @@ public sealed class HeimdallAuthService(
private async Task<UserAccount> 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,

View file

@ -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)

View file

@ -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<StripeOptions> options)
{
private static readonly IReadOnlyDictionary<string, StripePatronageTier> VelvetTiers =
new Dictionary<string, StripePatronageTier>(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<StripeCheckoutResult> 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<KeyValuePair<string, string>> BuildCheckoutForm(
StripeOptions options,
StripePatronageTier tier,
UserAccount patronAccount)
{
var referenceId = $"velvet:{tier.Slug}:{patronAccount.Id:N}:{Guid.NewGuid():N}";
var metadata = new Dictionary<string, string>
{
["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
}

View file

@ -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<StripeOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<StripeWebhookResult> 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<StripeWebhookEvent>(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<UserAccount?> 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<string, string> Metadata { get; set; } = [];
}

View file

@ -9,6 +9,7 @@ public sealed class StartupConfigurationValidator(
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> 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('*')))

View file

@ -38,9 +38,9 @@
}
else
{
<a class="button" href="/auth/sign-in">Sign in with GitHub</a>
<a class="button secondary" href="/auth/heimdall/discord">Sign in with Discord</a>
<a class="button secondary" href="/auth/heimdall/patreon">Sign in with Patreon</a>
<a class="button" href="/auth/sign-in">Sign in or create account</a>
<a class="button secondary" href="/auth/heimdall/discord?access=member">Member access through Discord</a>
<a class="button secondary" href="/auth/heimdall/patreon?access=member">Member access through Patreon</a>
}
<a class="button secondary" href="https://github.com/GameCult/Bifrost">View repository</a>

View file

@ -43,6 +43,10 @@ builder.Services
.AddOptions<HeimdallOptions>()
.BindConfiguration(HeimdallOptions.SectionName);
builder.Services
.AddOptions<StripeOptions>()
.BindConfiguration(StripeOptions.SectionName);
builder.Services
.AddOptions<BridgeOptions>()
.BindConfiguration(BridgeOptions.SectionName);
@ -88,6 +92,7 @@ builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
@ -99,6 +104,10 @@ builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
var options = serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
});
builder.Services.AddHttpClient<StripeCheckoutService>(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<GitHubOAuthOptions> 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>(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($$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign in - Bifrost</title>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; }
main { width: min(92vw, 520px); padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1; }
p { color: #c7b8ad; }
.providers { display: grid; gap: 10px; margin-top: 22px; }
a { display: flex; justify-content: space-between; align-items: center; min-height: 46px; padding: 0 14px; border-radius: 6px; background: #c43e4f; color: white; text-decoration: none; font-weight: 750; }
a.secondary { background: rgba(255,248,239,.08); border: 1px solid rgba(255,248,239,.22); }
small { color: #d9ad66; }
</style>
</head>
<body>
<main>
<small>Transport identity</small>
<h1>Create or use a Bifrost patron account</h1>
<p>Choose a provider to attach this checkout to a Bifrost account. Patron accounts do not automatically grant active member access.</p>
<div class="providers">
<a href="/auth/sign-in?provider=github&returnUrl={{encodedReturnUrl}}">GitHub <span>OAuth</span></a>
<a class="secondary" href="/auth/heimdall/discord?returnUrl={{encodedReturnUrl}}">Discord <span>Heimdall</span></a>
<a class="secondary" href="/auth/heimdall/patreon?returnUrl={{encodedReturnUrl}}">Patreon <span>Heimdall</span></a>
<a class="secondary" href="/auth/heimdall/twitch?returnUrl={{encodedReturnUrl}}">Twitch <span>Heimdall</span></a>
</div>
<p><small>After sign-in, Bifrost returns you to {{escapedReturnUrl}}.</small></p>
</main>
</body>
</html>
""");
}
static JsonSerializerOptions CreateAppJsonSerializerOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

View file

@ -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",

View file

@ -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<TestWebApplicationFac
Assert.Empty(currentPatronTiers);
}
[Fact]
public async Task Velvet_patronage_checkout_redirects_to_stripe_checkout()
{
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
using var response = await client.GetAsync("/patronage/velvet/checkout?tier=velvet-room");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal("https://checkout.stripe.com/c/pay/cs_test_velvet", response.Headers.Location?.ToString());
}
[Fact]
public async Task Velvet_patronage_checkout_requires_bifrost_account()
{
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
using var request = new HttpRequestMessage(HttpMethod.Get, "/patronage/velvet/checkout?tier=velvet-room");
request.Headers.Add("X-Test-Anonymous", "1");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(
"/auth/sign-in?returnUrl=%2Fpatronage%2Fvelvet%2Fcheckout%3Ftier%3Dvelvet-room",
response.Headers.Location?.ToString());
}
[Fact]
public async Task Sign_in_chooser_lists_supported_transport_providers()
{
using var client = _factory.CreateClient();
using var response = await client.GetAsync("/auth/sign-in?returnUrl=%2Fpatronage%2Fvelvet%2Fcheckout%3Ftier%3Dvelvet-room");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("GitHub", html);
Assert.Contains("/auth/heimdall/discord", html);
Assert.Contains("/auth/heimdall/patreon", html);
Assert.Contains("/auth/heimdall/twitch", html);
}
[Fact]
public async Task Stripe_checkout_completed_webhook_records_general_patronage()
{
using var client = _factory.CreateClient();
_ = await client.GetAsync("/App");
Guid patronageAccountId;
using (var setupScope = _factory.Services.CreateScope())
{
var setupContext = setupScope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<string, string>
{
["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<BifrostDbContext>();
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<TestWebApplicationFac
return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string ComputeStripeSignature(string secret, string payload)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signature = Convert.ToHexString(
hasher.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{payload}"))).ToLowerInvariant();
return $"t={timestamp},v1={signature}";
}
private static HttpRequestMessage CreateSignedHeimdallRequest(string payload)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events");

View file

@ -1,4 +1,5 @@
using Bifrost.Web.Data;
using Bifrost.Web.Features.Patronage;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting;
@ -8,6 +9,8 @@ using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using System.Net;
namespace Bifrost.Web.Tests.Support;
@ -34,7 +37,13 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
["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<Program>
options.LoginPath = "/auth/sign-in";
options.AccessDeniedPath = "/Membership/Status";
});
services.RemoveAll<StripeCheckoutService>();
services.AddTransient(serviceProvider =>
{
var client = new HttpClient(new TestStripeCheckoutHandler())
{
BaseAddress = new Uri("https://api.stripe.test/")
};
return new StripeCheckoutService(
client,
serviceProvider.GetRequiredService<IOptions<Bifrost.Web.Configuration.StripeOptions>>());
});
});
}
private sealed class TestStripeCheckoutHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> 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"
}
""")
};
}
}
}