diff --git a/AGENTS.md b/AGENTS.md index c06e133..0b17617 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ ## Locked Technical Defaults - stack: ASP.NET Core 8 LTS -- UI: Razor Pages + HTMX +- UI: Razor Pages - database: PostgreSQL - hosting target: `bifrost.gamecult.org` on Yggdrasil - deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops` diff --git a/README.md b/README.md index 33302e1..4994f86 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m ## Chosen Stack - ASP.NET Core 8 LTS -- Razor Pages + HTMX +- Razor Pages - PostgreSQL - Bifrost-native identity registration - GitHub OAuth for linked sign-in and repository integration diff --git a/docs/bifrost-mvp-plan.md b/docs/bifrost-mvp-plan.md index fa1bd10..29fd545 100644 --- a/docs/bifrost-mvp-plan.md +++ b/docs/bifrost-mvp-plan.md @@ -4,7 +4,7 @@ - Create Bifrost as a separate GameCult app repo and deploy it on Yggdrasil. - Build a member alpha, not a public launch. -- Use ASP.NET Core 8 with Razor Pages + HTMX and PostgreSQL. +- Use ASP.NET Core 8 with Razor Pages and PostgreSQL. - Support invite-only GitHub-linked members, GitHub-backed and internal work items, app-native governance, Reddit organizing threads, and internal ledgers. - Preserve the Labor Platform loop: user/member/patron demand raises work priority and reward pressure, contributors pick up valuable work, maintainers accept completion artifacts, and Bifrost credits the contributor plus any bounty/reward allocation. - Do not include Ethereum, wallets, DAO mechanics, or payout execution in v1. @@ -13,7 +13,7 @@ ## System Shape - One ASP.NET web app process for v1. -- Server-rendered UI with HTMX for progressive interactions. +- Server-rendered UI; no client-side framework or third-party script. - PostgreSQL as the system of record. - Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity. - GitHub App plus webhooks for issue and pull request lifecycle sync. diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index c20525d..30a523e 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -53,7 +53,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only 21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key. 22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`). 23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`). -24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`). +24. The shared layout loads no third-party script or stylesheet (HTMX was dropped; `NoThirdPartyAssetTests` fails on any cross-origin ` @await RenderSectionAsync("Scripts", required: false) diff --git a/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs new file mode 100644 index 0000000..5deb1a8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text.RegularExpressions; +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; + +// The site serves every script and stylesheet itself: a page that pulls code or styling from another origin hands +// that origin the visitor's session. Fails on any cross-origin ", true)] + [InlineData("", true)] + [InlineData("html.Append(\"", false)] + [InlineData("repo", false)] + public void The_source_pattern_catches_cross_origin_assets_and_allows_same_origin_and_fonts(string markup, bool flagged) => + Assert.Equal(flagged, CrossOriginSource().IsMatch(markup)); + + [Theory] + [InlineData("
", true)] + [InlineData("
", true)] + [InlineData("token=ab-hx-9f3", false)] + public void Htmx_is_matched_only_as_an_attribute(string markup, bool flagged) => + Assert.Equal(flagged, HtmxAttribute().IsMatch(markup)); + + [Fact] + public async Task Real_pages_load_no_third_party_asset() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + var identity = $"assets-{Guid.NewGuid():N}"; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow }); + db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") }); + var pages = new (string Path, string? Identity, bool Anonymous)[] + { + ("/", null, true), + ($"/mill/{collection}/alpha", null, true), + ($"/mill/{collection}/alpha", null, false), + ("/App", null, false), + ("/auth/sign-in", null, true), + ("/auth/bifrost/register", null, true), + ("/auth/bifrost/consent", identity, false), + }; + + foreach (var (path, who, anonymous) in pages) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + else if (who is not null) + { + request.Headers.Add("X-Test-Bifrost-Identity", who); + } + + using var response = await client.SendAsync(request); + Assert.True(response.StatusCode == HttpStatusCode.OK, $"{path} returned {response.StatusCode}"); + var html = await response.Content.ReadAsStringAsync(); + Assert.False(string.IsNullOrWhiteSpace(html), path); + Assert.DoesNotContain("unpkg.com", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("htmx", html, StringComparison.OrdinalIgnoreCase); + Assert.False(HtmxAttribute().IsMatch(html), $"{path} carries an hx- attribute"); + Assert.False(CrossOriginAsset().IsMatch(html), $"{path} loads a cross-origin asset"); + } + } +}