Add Heimdall patron support intake

This commit is contained in:
Meta Vens 2026-06-09 17:16:27 +01:00
commit bb1b1056be
21 changed files with 625 additions and 13 deletions

View file

@ -18,6 +18,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
- member-facing motions with category thresholds aligned to the labor-platform doc
- health and readiness endpoints plus startup config validation
- GitHub App webhook ingestion for issue, pull request, and review sync
- Heimdall-signed external patron support intake for Patreon and PayPal support facts
- local bridge tooling for agent-owned GitHub draft PRs and Discord posts
- local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app
- CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion
@ -70,6 +71,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
- set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
- default connection string points at local PostgreSQL
- build with `dotnet build Bifrost.slnx`
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
@ -90,5 +92,6 @@ Before implementation work starts in a new session, read these files in order:
5. [docs/verse-service-contract.md](E:\Projects\Bifrost\docs\verse-service-contract.md)
6. [docs/agent-transport.md](E:\Projects\Bifrost\docs\agent-transport.md)
7. [docs/reddit.md](E:\Projects\Bifrost\docs\reddit.md)
8. [docs/full-implementation-strategy.md](E:\Projects\Bifrost\docs\full-implementation-strategy.md)
9. [docs/context.md](E:\Projects\Bifrost\docs\context.md)
8. [docs/paypal-patronage.md](E:\Projects\Bifrost\docs\paypal-patronage.md)
9. [docs/full-implementation-strategy.md](E:\Projects\Bifrost\docs\full-implementation-strategy.md)
10. [docs/context.md](E:\Projects\Bifrost\docs\context.md)

View file

@ -73,5 +73,7 @@ After bootstrap, the next session should start implementing the first vertical s
- a formal proposal that affects either project work or GameCult policy/structure
- `Ledger Entry`
- an immutable record of patron points, contributor points, nominal compensation, or adjustments
- `Patron Support Event`
- an auditable support fact recorded manually or through a Heimdall-verified external provider event; Bifrost derives patron points and voting tier snapshots from these events
- `Payout Proposal Batch`
- an admin-reviewed grouping of payout recommendations derived from approved ledger data

View file

@ -97,7 +97,10 @@ Implementation requirements:
- calculate patron points from current recurring support plus historical donations: implemented by `PatronageService`
- apply the concept-doc rule that historical donations are halved after one month and then continue decaying: implemented for effective patron point calculation
- expose both raw support history and current effective patron points: implemented on the ledger surface
- ingest Patreon/Heimdall provider events into the same service path: pending
- ingest Patreon/Heimdall provider events into the same service path: implemented through `/heimdall/patron-support/events`
- ingest PayPal/Heimdall checkout, subscription, refund, reversal, and chargeback facts into the same service path: implemented as provider metadata plus support adjustments
External provider support facts are not payment webhooks in Bifrost. Heimdall owns provider webhook verification and account linking. Bifrost accepts only Heimdall-signed support facts, resolves the linked `HeimdallAccountId`, deduplicates by provider event id, records support through `PatronageService`, and refreshes the derived patron tier.
### Contributor Points

View file

@ -85,6 +85,7 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
- canonical feature-request and governance topic threads
- topic comments, support, objections, questions, Persona approvals, dispatch promotion, and topic receipts
- user, patron, member, contributor, and agent transport events when they affect work, priority, reward, votes, claims, or receipts
- patron support meaning: support events, effective patron points, derived tier snapshots, voting weight, and audit trail
- mapping from internal request to public target surface
- GitHub proposal/comment/PR transport for agent and member work
- Discord-native Bifrost commands/interactions, dispatch receipts, and persona announcements for work crossings and swarm I/O
@ -96,6 +97,7 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
### Heimdall Owns
- OAuth provider flows
- PayPal and Patreon webhook receipt, provider signature verification, payment-status normalization, and account-link evidence
- account linking
- credential custody
- signed identity and capability claims
@ -103,6 +105,14 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed.
### Patron Collection Crossing
PayPal and Patreon money movement enters Bifrost only as Heimdall-verified support facts. Heimdall receives provider webhooks, verifies provider signatures, links the provider account to a Heimdall account, normalizes payment status, and POSTs a signed support fact to `/heimdall/patron-support/events`.
Bifrost verifies Heimdall's intake HMAC and resolves the linked `HeimdallAccountId` to a Bifrost account. Only then does `PatronageService` record a `PatronSupportEvent`, point transaction, tier refresh, and audit event. Duplicate provider events are idempotent by `(Provider, ProviderEventId)`.
PayPal one-time donation completions should arrive as `Provider = PayPal`, `Kind = OneTimeDonation`, and a positive amount after completed capture. PayPal subscription current-support snapshots should arrive as `Kind = RecurringSupportSnapshot` with `IsCurrentRecurringSupport = true` after Heimdall has verified active paid support. Refunds, reversals, and chargebacks should arrive as `Kind = SupportAdjustment` with a negative amount. Bifrost does not delete old support history to hide provider churn.
### VoidBot Owns
- Discord observation and room context

57
docs/paypal-patronage.md Normal file
View file

@ -0,0 +1,57 @@
# PayPal Patronage Intake
PayPal collection is a Heimdall-to-Bifrost crossing, not a Bifrost payment stack.
## Authority
- PayPal owns money movement, captures, subscription status, refunds, reversals, and chargebacks.
- Heimdall owns PayPal app credentials, webhook receipt, PayPal signature verification, provider account linking, and provider-event normalization.
- Bifrost owns patronage meaning: support events, point derivation, tier snapshots, voting weight, and audit records.
## Intake Endpoint
Heimdall POSTs verified support facts to:
`POST /heimdall/patron-support/events`
The raw request body must be signed with `HMACSHA256(Heimdall:PatronSupportIntakeSecret, body)` and sent as:
`X-Heimdall-Signature-256: sha256=<hex>`
Payload:
```json
{
"heimdallAccountId": "heimdall-account-id",
"provider": "PayPal",
"providerEventId": "paypal-webhook-event-id",
"kind": "OneTimeDonation",
"amount": 125.00,
"currencyCode": "USD",
"externalSupportId": "PAYMENT.CAPTURE.COMPLETED:CAPTURE-ID",
"supportedAtUtc": "2026-06-09T15:00:00Z",
"isCurrentRecurringSupport": false,
"providerPayerId": "PAYPAL-PAYER-ID",
"providerSubscriptionId": "",
"notes": "Verified PayPal checkout capture from Heimdall."
}
```
Valid `provider` values are `PayPal` and `Patreon`. `Manual` is rejected for this endpoint.
Valid `kind` values:
- `OneTimeDonation`: positive completed one-time support.
- `RecurringSupportSnapshot`: positive current recurring support; set `isCurrentRecurringSupport` to `true` when it is the current support amount.
- `SupportAdjustment`: negative refund, reversal, chargeback, or correction.
`providerEventId` is the idempotency key inside a provider. Bifrost stores one support event per `(provider, providerEventId)`.
## PayPal Event Mapping
- Checkout/order donations: Heimdall records `OneTimeDonation` only after PayPal reports a completed capture.
- Subscription activation/current paid support: Heimdall records `RecurringSupportSnapshot` after verified active paid support.
- Subscription cancellation, suspension, expiration, or failed payment: Heimdall should send a new recurring snapshot that clears or reduces current support when paid support is no longer current.
- Refunds, reversals, and chargebacks: Heimdall records `SupportAdjustment` with a negative amount.
Unlinked PayPal support must stay pending in Heimdall. Bifrost will not create voting power from a PayPal payer id alone.

View file

@ -14,6 +14,10 @@ public sealed class HeimdallOptions
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
public bool EnablePatronSupportIntake { get; init; } = true;
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(BaseUrl) &&
!string.IsNullOrWhiteSpace(AppSlug);
@ -26,4 +30,9 @@ public sealed class HeimdallOptions
public bool IsPatreonConfigured =>
IsConfigured &&
!string.IsNullOrWhiteSpace(PatreonTierTitle);
public bool IsPatronSupportIntakeConfigured =>
IsConfigured &&
EnablePatronSupportIntake &&
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
}

View file

@ -334,10 +334,17 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
modelBuilder.Entity<PatronSupportEvent>(entity =>
{
entity.Property(x => x.Kind).HasConversion<string>();
entity.Property(x => x.Provider).HasConversion<string>();
entity.Property(x => x.Amount).HasPrecision(18, 2);
entity.Property(x => x.ExternalSupportId).HasMaxLength(240);
entity.Property(x => x.ProviderEventId).HasMaxLength(240);
entity.Property(x => x.ProviderPayerId).HasMaxLength(240);
entity.Property(x => x.ProviderSubscriptionId).HasMaxLength(240);
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
entity.HasIndex(x => new { x.UserAccountId, x.Kind, x.IsCurrentRecurringSupport });
entity.HasIndex(x => new { x.Provider, x.ProviderEventId })
.IsUnique()
.HasFilter("\"ProviderEventId\" <> ''");
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.PatronSupportEvents)
.HasForeignKey(x => x.UserAccountId)

View file

@ -0,0 +1,75 @@
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
[Migration("20260609160000_AddExternalPatronProviderIntake")]
public partial class AddExternalPatronProviderIntake : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provider",
table: "PatronSupportEvents",
type: "text",
nullable: false,
defaultValue: nameof(ExternalPatronProvider.Manual));
migrationBuilder.AddColumn<string>(
name: "ProviderEventId",
table: "PatronSupportEvents",
type: "character varying(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ProviderPayerId",
table: "PatronSupportEvents",
type: "character varying(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ProviderSubscriptionId",
table: "PatronSupportEvents",
type: "character varying(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.CreateIndex(
name: "IX_PatronSupportEvents_Provider_ProviderEventId",
table: "PatronSupportEvents",
columns: new[] { "Provider", "ProviderEventId" },
unique: true,
filter: "\"ProviderEventId\" <> ''");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PatronSupportEvents_Provider_ProviderEventId",
table: "PatronSupportEvents");
migrationBuilder.DropColumn(
name: "Provider",
table: "PatronSupportEvents");
migrationBuilder.DropColumn(
name: "ProviderEventId",
table: "PatronSupportEvents");
migrationBuilder.DropColumn(
name: "ProviderPayerId",
table: "PatronSupportEvents");
migrationBuilder.DropColumn(
name: "ProviderSubscriptionId",
table: "PatronSupportEvents");
}
}
}

View file

@ -500,6 +500,25 @@ namespace Bifrost.Web.Data.Migrations
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProviderEventId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("ProviderPayerId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("ProviderSubscriptionId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("SupportedAtUtc")
.HasColumnType("timestamp with time zone");
@ -512,6 +531,10 @@ namespace Bifrost.Web.Data.Migrations
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
b.HasIndex("Provider", "ProviderEventId")
.IsUnique()
.HasFilter("\"ProviderEventId\" <> ''");
b.ToTable("PatronSupportEvents");
});

View file

@ -163,7 +163,15 @@ public enum PointTransactionType
public enum PatronSupportEventKind
{
OneTimeDonation,
RecurringSupportSnapshot
RecurringSupportSnapshot,
SupportAdjustment
}
public enum ExternalPatronProvider
{
Manual,
Patreon,
PayPal
}
public sealed class UserAccount
@ -717,6 +725,14 @@ public sealed class PatronSupportEvent
public string ExternalSupportId { get; set; } = string.Empty;
public ExternalPatronProvider Provider { get; set; } = ExternalPatronProvider.Manual;
public string ProviderEventId { get; set; } = string.Empty;
public string ProviderPayerId { get; set; } = string.Empty;
public string ProviderSubscriptionId { get; set; } = string.Empty;
public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation;
public decimal Amount { get; set; }

View file

@ -0,0 +1,166 @@
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 Bifrost.Web.Features.Patronage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Heimdall;
public sealed class HeimdallPatronSupportIntakeService(
BifrostDbContext dbContext,
PatronageService patronageService,
IOptions<HeimdallOptions> heimdallOptions)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
public async Task<HeimdallPatronSupportIntakeResult> ProcessAsync(
string signature,
string payload,
CancellationToken cancellationToken)
{
if (!heimdallOptions.Value.IsPatronSupportIntakeConfigured)
{
return HeimdallPatronSupportIntakeResult.NotConfigured("Heimdall patron support intake is not configured.");
}
if (!VerifySignature(heimdallOptions.Value.PatronSupportIntakeSecret, payload, signature))
{
return HeimdallPatronSupportIntakeResult.Unauthorized("Invalid Heimdall patron support intake signature.");
}
var request = JsonSerializer.Deserialize<HeimdallPatronSupportEventRequest>(payload, JsonOptions);
if (request is null)
{
return HeimdallPatronSupportIntakeResult.BadRequest("Patron support payload was empty.");
}
if (request.Provider is ExternalPatronProvider.Manual)
{
return HeimdallPatronSupportIntakeResult.BadRequest("Heimdall patron support intake requires an external provider.");
}
if (string.IsNullOrWhiteSpace(request.HeimdallAccountId))
{
return HeimdallPatronSupportIntakeResult.BadRequest("Patron support payload omitted Heimdall account id.");
}
if (string.IsNullOrWhiteSpace(request.ProviderEventId))
{
return HeimdallPatronSupportIntakeResult.BadRequest("Patron support payload omitted provider event id.");
}
var existingEvent = await dbContext.PatronSupportEvents
.AsNoTracking()
.SingleOrDefaultAsync(
x => x.Provider == request.Provider &&
x.ProviderEventId == request.ProviderEventId.Trim(),
cancellationToken);
if (existingEvent is not null)
{
return HeimdallPatronSupportIntakeResult.Processed(
$"Already processed {request.Provider} patron support event {request.ProviderEventId}.",
existingEvent.Id);
}
var user = await dbContext.UserAccounts
.AsNoTracking()
.SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken);
if (user is null)
{
return HeimdallPatronSupportIntakeResult.BadRequest(
$"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}.");
}
var supportEvent = await patronageService.RecordSupportEventAsync(
actorUserAccountId: null,
userAccountId: user.Id,
request.Kind,
request.Amount,
request.CurrencyCode,
request.ExternalSupportId,
request.Provider,
request.ProviderEventId,
request.ProviderPayerId,
request.ProviderSubscriptionId,
request.SupportedAtUtc,
request.IsCurrentRecurringSupport,
request.Notes,
cancellationToken);
return HeimdallPatronSupportIntakeResult.Processed(
$"Processed {request.Provider} patron support event {request.ProviderEventId}.",
supportEvent.Id);
}
private static bool VerifySignature(string secret, string payload, string signature)
{
if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(signature))
{
return false;
}
var normalizedSignature = signature.Trim();
if (normalizedSignature.StartsWith("sha256=", StringComparison.OrdinalIgnoreCase))
{
normalizedSignature = normalizedSignature["sha256=".Length..];
}
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expectedBytes = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload));
var expectedHex = Convert.ToHexString(expectedBytes).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expectedHex),
Encoding.ASCII.GetBytes(normalizedSignature.ToLowerInvariant()));
}
}
public sealed record HeimdallPatronSupportEventRequest(
string HeimdallAccountId,
ExternalPatronProvider Provider,
string ProviderEventId,
PatronSupportEventKind Kind,
decimal Amount,
string CurrencyCode,
string ExternalSupportId,
DateTimeOffset SupportedAtUtc,
bool IsCurrentRecurringSupport,
string ProviderPayerId = "",
string ProviderSubscriptionId = "",
string Notes = "");
public sealed record HeimdallPatronSupportIntakeResult(
HeimdallPatronSupportIntakeStatus Status,
string Message,
Guid? PatronSupportEventId)
{
public static HeimdallPatronSupportIntakeResult Processed(string message, Guid patronSupportEventId) =>
new(HeimdallPatronSupportIntakeStatus.Processed, message, patronSupportEventId);
public static HeimdallPatronSupportIntakeResult BadRequest(string message) =>
new(HeimdallPatronSupportIntakeStatus.BadRequest, message, null);
public static HeimdallPatronSupportIntakeResult Unauthorized(string message) =>
new(HeimdallPatronSupportIntakeStatus.Unauthorized, message, null);
public static HeimdallPatronSupportIntakeResult NotConfigured(string message) =>
new(HeimdallPatronSupportIntakeStatus.NotConfigured, message, null);
}
public enum HeimdallPatronSupportIntakeStatus
{
Processed,
BadRequest,
Unauthorized,
NotConfigured
}

View file

@ -35,6 +35,11 @@ public static class PatronTierPolicy
return Math.Max(0m, supportEvent.Amount);
}
if (supportEvent.Kind == PatronSupportEventKind.SupportAdjustment)
{
return supportEvent.Amount;
}
var amount = Math.Max(0m, supportEvent.Amount);
var age = nowUtc - supportEvent.SupportedAtUtc.ToUniversalTime();
if (age < TimeSpan.FromDays(30))

View file

@ -11,12 +11,16 @@ public sealed class PatronageService(
TimeProvider timeProvider)
{
public async Task<PatronSupportEvent> RecordSupportEventAsync(
Guid actorUserAccountId,
Guid? actorUserAccountId,
Guid userAccountId,
PatronSupportEventKind kind,
decimal amount,
string currencyCode,
string externalSupportId,
ExternalPatronProvider provider,
string providerEventId,
string providerPayerId,
string providerSubscriptionId,
DateTimeOffset supportedAtUtc,
bool isCurrentRecurringSupport,
string notes,
@ -31,11 +35,37 @@ public sealed class PatronageService(
throw new InvalidOperationException("Patron support can only be recorded for an active member.");
}
if (amount <= 0)
if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment)
{
throw new InvalidOperationException("Patron support amount must be greater than zero.");
}
if (amount == 0)
{
throw new InvalidOperationException("Patron support amount must not be zero.");
}
if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0)
{
throw new InvalidOperationException("Patron support adjustments must reduce support.");
}
var normalizedProviderEventId = providerEventId.Trim();
if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId))
{
throw new InvalidOperationException("External patron provider events require a provider event id.");
}
var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId)
? null
: await dbContext.PatronSupportEvents
.SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken);
if (existingProviderEvent is not null)
{
return existingProviderEvent;
}
var now = timeProvider.GetUtcNow();
var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode)
? "USD"
@ -60,6 +90,10 @@ public sealed class PatronageService(
UserAccountId = userAccountId,
Kind = kind,
ExternalSupportId = externalSupportId.Trim(),
Provider = provider,
ProviderEventId = normalizedProviderEventId,
ProviderPayerId = providerPayerId.Trim(),
ProviderSubscriptionId = providerSubscriptionId.Trim(),
Amount = amount,
CurrencyCode = normalizedCurrency,
IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport,
@ -74,9 +108,9 @@ public sealed class PatronageService(
UserAccountId = userAccountId,
Type = PointTransactionType.PatronSupport,
Amount = amount,
IsDecaying = kind != PatronSupportEventKind.RecurringSupportSnapshot,
IsDecaying = kind == PatronSupportEventKind.OneTimeDonation,
CreatedAtUtc = now,
Note = $"Recorded {kind} patron support event {supportEvent.ExternalSupportId}."
Note = $"Recorded {provider} {kind} patron support event {supportEvent.ExternalSupportId}."
});
await dbContext.SaveChangesAsync(cancellationToken);
@ -85,7 +119,7 @@ public sealed class PatronageService(
nameof(PatronSupportEvent),
supportEvent.Id,
"patron-support.recorded",
$"Recorded {amount:0.##} {normalizedCurrency} {kind} patron support for {user.DisplayName}.",
$"Recorded {amount:0.##} {normalizedCurrency} {provider} {kind} patron support for {user.DisplayName}.",
cancellationToken);
await RefreshPatronTierSnapshotAsync(actorUserAccountId, userAccountId, cancellationToken);
@ -93,7 +127,7 @@ public sealed class PatronageService(
}
public async Task<PatronPointSummary> RefreshPatronTierSnapshotAsync(
Guid actorUserAccountId,
Guid? actorUserAccountId,
Guid userAccountId,
CancellationToken cancellationToken)
{

View file

@ -8,6 +8,7 @@ public sealed class StartupConfigurationValidator(
IHostEnvironment hostEnvironment,
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<HeimdallOptions> heimdallOptions,
IOptions<BifrostHostOptions> hostOptions)
{
public void Validate()
@ -34,6 +35,12 @@ public sealed class StartupConfigurationValidator(
failures.Add("Host:PublicBaseUrl and Host:ExpectedHost must be configured.");
}
if (heimdallOptions.Value.EnablePatronSupportIntake &&
!heimdallOptions.Value.IsPatronSupportIntakeConfigured)
{
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
}
var allowedHosts = configuration["AllowedHosts"] ?? string.Empty;
if (hostOptions.Value.RequireStrictHostValidation &&
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*')))

View file

@ -206,6 +206,7 @@
<tr>
<td>@support.MemberName</td>
<td>
<span class="pill">@support.Provider</span>
<span class="pill">@support.Kind</span><br />
@support.Amount.ToString("0.##") @support.CurrencyCode
@if (support.IsCurrentRecurringSupport)

View file

@ -111,6 +111,10 @@ public sealed class IndexModel(
PatronSupport.Amount,
PatronSupport.CurrencyCode,
PatronSupport.ExternalSupportId ?? string.Empty,
ExternalPatronProvider.Manual,
string.Empty,
string.Empty,
string.Empty,
PatronSupport.SupportedAtUtc,
PatronSupport.IsCurrentRecurringSupport,
PatronSupport.Notes ?? string.Empty,
@ -181,6 +185,7 @@ public sealed class IndexModel(
return new PatronSupportListItem(
x.UserAccountId,
x.UserAccount.DisplayName,
x.Provider,
x.Kind,
x.Amount,
x.CurrencyCode,
@ -244,7 +249,7 @@ public sealed class IndexModel(
[Display(Name = "Support kind")]
public PatronSupportEventKind Kind { get; set; } = PatronSupportEventKind.OneTimeDonation;
[Range(0.01, 100000000)]
[Range(-100000000, 100000000)]
public decimal Amount { get; set; }
[Display(Name = "Currency")]
@ -270,6 +275,7 @@ public sealed class IndexModel(
public sealed record PatronSupportListItem(
Guid UserAccountId,
string MemberName,
ExternalPatronProvider Provider,
PatronSupportEventKind Kind,
decimal Amount,
string CurrencyCode,

View file

@ -70,6 +70,7 @@ builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();
@ -284,6 +285,29 @@ app.MapPost("/auth/heimdall/callback", (
}
}).AllowAnonymous();
app.MapPost("/heimdall/patron-support/events", async (
HttpRequest request,
HeimdallPatronSupportIntakeService intakeService,
CancellationToken cancellationToken) =>
{
using var reader = new StreamReader(request.Body);
var payload = await reader.ReadToEndAsync(cancellationToken);
var signature = request.Headers["X-Heimdall-Signature-256"].ToString();
var result = await intakeService.ProcessAsync(signature, payload, cancellationToken);
return result.Status switch
{
HeimdallPatronSupportIntakeStatus.Processed => Results.Text(
result.Message,
statusCode: StatusCodes.Status202Accepted),
HeimdallPatronSupportIntakeStatus.BadRequest => Results.BadRequest(result.Message),
HeimdallPatronSupportIntakeStatus.NotConfigured => Results.Text(
result.Message,
statusCode: StatusCodes.Status503ServiceUnavailable),
_ => Results.Text(result.Message, statusCode: StatusCodes.Status401Unauthorized)
};
}).AllowAnonymous();
app.MapGet("/auth/heimdall/wait", async (
HttpContext httpContext,
HeimdallAuthService heimdallAuthService,

View file

@ -29,7 +29,9 @@
"AppSlug": "bifrost",
"DiscordGuildId": "",
"DiscordAllowedRoleIds": [],
"PatreonTierTitle": "Inner Sanctum"
"PatreonTierTitle": "Inner Sanctum",
"PatronSupportIntakeSecret": "",
"EnablePatronSupportIntake": true
},
"Logging": {
"LogLevel": {

View file

@ -70,6 +70,10 @@ public sealed class PatronageServiceTests
125m,
"usd",
"patreon-member-1",
ExternalPatronProvider.Patreon,
"member-pledge-event-1",
"patreon-user-1",
"patreon-member-1",
now,
isCurrentRecurringSupport: true,
"Imported from Patreon current support.",

View file

@ -1,6 +1,8 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
@ -78,10 +80,165 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Equal("GameCult/Bifrost".ToLowerInvariant(), project.GitHubRepository);
}
[Fact]
public async Task Heimdall_paypal_support_event_records_patron_support_and_is_idempotent()
{
using var client = _factory.CreateClient();
var now = DateTimeOffset.UtcNow;
var heimdallAccountId = $"heimdall-paypal-{Guid.NewGuid():N}";
var providerEventId = $"WH-{Guid.NewGuid():N}";
Guid patronUserAccountId;
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var patron = new UserAccount
{
HeimdallAccountId = heimdallAccountId,
DisplayName = "PayPal Patron",
GitHubLogin = $"paypal-{Guid.NewGuid():N}",
NormalizedGitHubLogin = $"paypal-{Guid.NewGuid():N}",
CreatedAtUtc = now,
LastSeenAtUtc = now
};
patronUserAccountId = patron.Id;
patron.Membership = new Membership
{
UserAccountId = patron.Id,
Status = MembershipStatus.Active,
CreatedAtUtc = now
};
dbContext.UserAccounts.Add(patron);
await dbContext.SaveChangesAsync();
}
var payload = JsonSerializer.Serialize(new
{
heimdallAccountId,
provider = "PayPal",
providerEventId,
kind = "OneTimeDonation",
amount = 125m,
currencyCode = "USD",
externalSupportId = "PAYMENT.CAPTURE.COMPLETED:CAPTURE-1",
supportedAtUtc = now,
isCurrentRecurringSupport = false,
providerPayerId = "PAYER-1",
notes = "Verified PayPal checkout capture from Heimdall."
});
using var firstRequest = CreateSignedHeimdallRequest(payload);
using var firstResponse = await client.SendAsync(firstRequest);
using var secondRequest = CreateSignedHeimdallRequest(payload);
using var secondResponse = await client.SendAsync(secondRequest);
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.Accepted, secondResponse.StatusCode);
using var verificationScope = _factory.Services.CreateScope();
var verificationContext = verificationScope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var supportEvents = verificationContext.PatronSupportEvents
.Where(x => x.Provider == ExternalPatronProvider.PayPal && x.ProviderEventId == providerEventId)
.ToList();
var tierSnapshot = verificationContext.TierSnapshots
.Single(x => x.Kind == TierSnapshotKind.Patron &&
x.IsCurrent &&
x.Membership.UserAccountId == patronUserAccountId);
Assert.Single(supportEvents);
Assert.Equal("PAYMENT.CAPTURE.COMPLETED:CAPTURE-1", supportEvents.Single().ExternalSupportId);
Assert.Equal("Patron Silver", tierSnapshot.Label);
Assert.Equal(2m, tierSnapshot.Weight);
}
[Fact]
public async Task Heimdall_paypal_adjustment_reduces_derived_patron_tier()
{
using var client = _factory.CreateClient();
var now = DateTimeOffset.UtcNow;
var heimdallAccountId = $"heimdall-refund-{Guid.NewGuid():N}";
Guid patronUserAccountId;
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var patron = new UserAccount
{
HeimdallAccountId = heimdallAccountId,
DisplayName = "Refunded Patron",
GitHubLogin = $"refund-{Guid.NewGuid():N}",
NormalizedGitHubLogin = $"refund-{Guid.NewGuid():N}",
CreatedAtUtc = now,
LastSeenAtUtc = now
};
patronUserAccountId = patron.Id;
patron.Membership = new Membership
{
UserAccountId = patron.Id,
Status = MembershipStatus.Active,
CreatedAtUtc = now
};
dbContext.UserAccounts.Add(patron);
await dbContext.SaveChangesAsync();
}
var donationPayload = JsonSerializer.Serialize(new
{
heimdallAccountId,
provider = "PayPal",
providerEventId = $"WH-{Guid.NewGuid():N}",
kind = "OneTimeDonation",
amount = 125m,
currencyCode = "USD",
externalSupportId = "PAYMENT.CAPTURE.COMPLETED:CAPTURE-2",
supportedAtUtc = now,
isCurrentRecurringSupport = false
});
var refundPayload = JsonSerializer.Serialize(new
{
heimdallAccountId,
provider = "PayPal",
providerEventId = $"WH-{Guid.NewGuid():N}",
kind = "SupportAdjustment",
amount = -125m,
currencyCode = "USD",
externalSupportId = "PAYMENT.CAPTURE.REFUNDED:CAPTURE-2",
supportedAtUtc = now.AddMinutes(1),
isCurrentRecurringSupport = false,
notes = "Verified PayPal refund from Heimdall."
});
using var donationRequest = CreateSignedHeimdallRequest(donationPayload);
using var donationResponse = await client.SendAsync(donationRequest);
using var refundRequest = CreateSignedHeimdallRequest(refundPayload);
using var refundResponse = await client.SendAsync(refundRequest);
Assert.Equal(HttpStatusCode.Accepted, donationResponse.StatusCode);
Assert.Equal(HttpStatusCode.Accepted, refundResponse.StatusCode);
using var verificationScope = _factory.Services.CreateScope();
var verificationContext = verificationScope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var currentPatronTiers = verificationContext.TierSnapshots
.Where(x => x.Kind == TierSnapshotKind.Patron &&
x.IsCurrent &&
x.Membership.UserAccountId == patronUserAccountId)
.ToList();
Assert.Empty(currentPatronTiers);
}
private static string ComputeSignature(string secret, string payload)
{
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload));
return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static HttpRequestMessage CreateSignedHeimdallRequest(string payload)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events");
request.Headers.Add("X-Heimdall-Signature-256", ComputeSignature("test-heimdall-intake-secret", payload));
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
return request;
}
}

View file

@ -32,7 +32,8 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
["GitHubApp:AppId"] = "1",
["Host:PublicBaseUrl"] = "https://localhost",
["Host:ExpectedHost"] = "localhost",
["Host:RequireStrictHostValidation"] = "false"
["Host:RequireStrictHostValidation"] = "false",
["Heimdall:PatronSupportIntakeSecret"] = "test-heimdall-intake-secret"
});
});