The bool Comment on CircleAuthorizationToBlogPost was dead code:
never read or written by any caller in src/, no UI exposure, no
behavioural semantics. The wire DTO (CircleAuthorization in
Yavsc.Abstract) doesn't carry it, no reader consumes it, and the
PostIt client builds its payload without it.
What changes:
- src/Yavsc.Server/Models/Access/CircleAuthorizationToBlogPost.cs:
remove the property.
- src/Yavsc.Blogs.Tests/BlogAclApiTests.cs: drop 'Comment = true'
from the existing test payload and trim the now-inaccurate XML
doc comment ('CircleId + BlogPostId + Comment' -> 'CircleId +
BlogPostId'). Also adds a new [Fact] pinning the prod bug
reported on 2026-08-21 (HTTP 500 'BlogPostId is unknown' when
PostIt POSTs the bare { circleId } shape). That test stays red:
the real fix for the 500 is in PostIt (payload needs blogPostId)
+ on the wire DTO + server-side validation, and lives in a
follow-up commit.
Migration:
- src/Yavsc.Org/Migrations/20260820232152_DropCommentFromCircleAuthorizationToBlogPost
drops the boolean 'Comment' column on CircleAuthorizationToBlogPost.
The generated scaffold also wanted to drop three 'ClientId1'
shadow FK columns on ClientScopes / ClientRedirectUris /
ClientGrantTypes (from leftover HasOne<Client>() overrides in
ApplicationDbContext.OnModelCreating); those were removed from
the .cs to keep the migration scoped to this fix. Cleaning up the
shadow property declarations themselves is left as a separate
task.
The ModelSnapshot still reflects the shadow 'ClientId1' columns
intentionally: they exist in the prod database today (all NULL),
and EF will rescaffold a drop migration for them on the next
'migrations add' regardless. No data loss.
System.Text.Json cannot materialise an interface without a
polymorphic converter. Until this commit, BlogPostDto.Author
was typed as the abstract interface IApplicationUser, which
crashed the "load posts" call in PostIt whenever the server
returned a post with a populated Author object (the common
case — GET /api/BlogApi).
Fix:
* Introduce a minimum-viable wire DTO BlogPostAuthorDto in
Yavsc.Abstract.Blogspot (record: Id, UserName, Avatar).
These are the only fields the client UI actually needs;
the server-side ApplicationUser navigation is preserved
for permission checks and authorisation.
* Change IBlogPost.Author and BlogPostDto.Author from
IApplicationUser to BlogPostAuthorDto? (interface change,
breaking). The EF entity BlogPost keeps its full
ApplicationUser navigation property and exposes
IBlogPost.Author via an explicit interface implementation
that projects to BlogPostAuthorDto on demand (so EF can
still lazy-load the navigation without forcing an eager
join on every read).
* Restore the using directive that was accidentally removed
when the BlogPostDto property was rewritten (needed for
ICircleAuthorization in GetACL()).
Regression coverage (the missing test Paul flagged):
* Add BlogPostAuthorDtoTests in PostIt.Tests with four
scenarios that exercise the wire shape on the client side:
- A BlogPostDto JSON with a populated Author round-trips
through JsonSerializer without throwing and the three
fields (Id, UserName, Avatar) survive intact.
- A BlogPostDto JSON with explicit "author": null
deserialises with Author == null.
- A BlogPostDto JSON without any Author field at all
deserialises with Author == null (forward compat).
- The serialised shape of BlogPostAuthorDto uses camelCase
property names (matching the server's Web defaults), so
the field names on the wire don't drift without a test
catching it.
Tests: 55/55 PostIt.Tests (+4 new), 24/24 Yavsc.Blogs.Tests,
44/44 Yavsc.Org.Tests. No regressions.
Side note: yavsc.sln picks up Yavsc.Api.Client (added by
'feat/postit-acl' in 1.0.7 but never registered in the
solution file until now — probably auto-added by a recent
'dotnet build' that discovered the .csproj).
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.
The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).
Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish body { publish: bool }
Returns 204 on success, 404 when the post doesn't exist,
Challenge() (401) when the caller is not the author
(EditPermission gate). Idempotent: PUT because the
resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
factored out of the existing
Modify(BlogPostEditViewModel) inline toggle, so the new
endpoint reuses the same BlogSpotPublication row logic
(add row if missing on publish=true, remove row if
present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
service after each Index/Details fetch — a single bulk
lookup, not N+1 — and surfaces through the wire JSON
so PostIt can show the current state without a follow-up
request.
- ApplicationUser nav properties (Posts, Book,
DeviceDeclaration, Connections, Circles, BlackList,
Rooms, RoomAccess, Membership, BlogComments) now carry
BOTH [JsonIgnore] (Newtonsoft) and
[System.Text.Json.Serialization.JsonIgnore] so the
Yavsc.Blogs test fixture (System.Text.Json) stops
exploding on object cycles when serialising
BlogPost.Author.Posts.Author.Posts. Production
(Yavsc.Org, NewtonsoftJson) was already safe via the
Newtonsoft-only attribute; this commit just makes the
Yavsc.Blogs side consistent.
Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
new endpoint.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
field; serialised as a plain bool in JSON.
UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
mirrors the existing DraftTitle/DraftArticle pattern;
hydrated from SelectedPost.IsPublished on selection
change. TogglePublish command pushes the new state to
SetPublishAsync and updates both the buffer and the
selected post locally so the UI reflects the change
without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
bound to DraftIsPublished TwoWay and wired to
TogglePublishCommand. The toggle is its own action
(not part of Save), matching the wire contract.
Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
* PUT publish=true returns 204 and IsPublished is true
in the next GET
* PUT publish=false clears IsPublished
* PUT on an unknown post returns 404
* PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
app.UseDeveloperExceptionPage() so 500s in tests
surface a real stack trace instead of an empty
InternalServerError body — much easier to diagnose
future regressions.
Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
in the admin web Yavsc (the Org UI already edits Publish
inline; no work needed there).
Replace the implicit 'ACL empty = private' convention with an
explicit two-axis model: Visibility is the master switch, the
ACL is the exception list.
Semantics (matches what BlogSpotService.Index / Details enforce):
Visibility.Public + empty ACL : every caller sees it
Visibility.Public + non-empty : only author + ACL circles + admin
Visibility.Private + any ACL : only author + admin (ACL ignored)
ACL is preserved across
Private/Public flips so
reopening is lossless
The Public+non-empty shape is the 'restrict by exception' case:
open by default, narrowed by the ACL. This is intentionally
different from the previous behaviour, where a Public post
with a non-empty ACL was effectively ACL-restricted anyway —
the new model makes that explicit and removes ambiguity.
Server (Yavsc.Blogs / Yavsc.Server)
- New enum Visibility { Private, Public } in
Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity
share the same type). Stored as int via .HasConversion<int>()
on BlogPost.Visibility. Default Private on construction;
the column default in the migration is 0 so existing rows
land Private without any data migration.
- BlogSpotService.Index: filter rewritten to honour the two-
axis model. Authenticated and anonymous callers now share
the same shape (Public+emptyACL visible to all, otherwise
scoped). Admin reads still go through PermissionHandler.
- PermissionHandler.IsPublic: dropped the blogSpotPublications
lookup, replaced with the Visibility + empty-ACL check that
matches the new model. PermissionHandler.IsSponsor and
IsOwner unchanged.
- UserHelpers.UserPosts (the per-author feed for
/CircleMembers/Details and similar): mirror of the
Index filter, so the two code paths can't silently diverge.
- BlogPostEditViewModel.Publish untouched on this commit. It
still controls whether a row exists in BlogSpotPublication;
the two systems coexist (Publish = 'is this draft published',
Visibility = 'who can read it'). Follow-up to consolidate.
EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility)
- Scaffolded by 'dotnet ef migrations add', not hand-edited,
per the repo preference for generated migrations.
- Adds the new Visibility column (int, NOT NULL, default 0).
- Also drops three shadow-state ClientId1 foreign keys and
their indexes/columns on ClientScopes, ClientRedirectUris,
ClientGrantTypes. These shadow FKs were created by EF from
HasOne<Client>().HasForeignKey(e => e.ClientId) mappings
that have long since been removed from
ApplicationDbContext.OnModelCreating, but the snapshot was
never regenerated against the current model. The columns
are nullable ints with no production data, so the drop is
lossless. Without this, EF Core would keep emitting
warnings on every migration add and the model would drift
further from reality.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- Visibility property added to BlogPostDto. System.Text.Json
serialises the enum as its underlying int, so the JSON
shape is a plain number, no JsonConverter needed.
Client UI (PostIt)
- MainPageViewModel: DraftVisibility ObservableProperty
mirroring the existing DraftTitle/DraftArticle pattern.
Initialised to Private so a fresh draft is private by
default. Save command writes the chosen value into the
BlogPostDto payload for both CreatePostAsync and
UpdatePostAsync. OnSelectedPostChanged hydrates the buffer
from the server-supplied value.
- AllVisibilities property on the VM exposes [Private, Public]
in that order, bound by the ComboBox in MainPage.axaml.
- VisibilityLabelConverter (PostIt.Views) maps the enum to
French user-facing labels ('Privé' / 'Public'); registered
in App.axaml as a static resource.
- MainPage.axaml: a new ComboBox row in the editor pane
between Title and Article. Uses the existing 'no hardcoded
Background without Foreground' lesson so dark mode works.
Tests (Yavsc.Blogs.Tests)
- BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with
Visibility fixtures seeded directly in the in-memory DB:
* Private + ACL: only the author sees it
* Public + empty ACL: any authenticated caller sees it
* Public + non-empty ACL: caller without ACL membership
does NOT see it
* Private + ACL: ACL is ignored, only the author sees it
* Visibility round-trips through the JSON wire (int 1)
- UserHelpersVisibilityTests (4 [Fact]): exercise the helper
directly so the two code paths (Index filter vs per-author
feed) can't diverge silently. Same fixture, no HTTP.
Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility
+4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Visibilité :' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ Visibility consolidation
(which system wins when both are set on the same post?).
- Org-side UI for editing Visibility (the admin web Yavsc
still edits posts without a visibility field).
ConfigureBillingService() walks AppDomain.CurrentDomain.GetAssemblies()
and calls Assembly.GetTypes() on each. If any of the loaded assemblies
has a type that fails to resolve (a flaky dependency, an AddOn with a
broken reference, a test dependency that's been rewritten after compile),
GetTypes() throws ReflectionTypeLoadException (or, less commonly,
FileNotFoundException / TypeLoadException for the assembly itself).
In CI on the forgejo-runner (and especially in test discovery under
xunit v3), one such assembly is loaded somewhere between test runs and
silently throws. The exception is not handled, so:
1. Collections are Cleared at the top of ConfigureBillingService().
2. The reflection loop throws before reaching the
RegisterBilling<HairCutQuery/HairMultiCutQuery/RdvQuery> calls.
3. BillingService.Billing ends up empty (Count = 0).
4. The second ConfigureBillingService() call sees the same assembly
loaded (xunit v3 keeps the AppDomain warm for the whole suite),
throws identically, and the test
Yavsc.BillingServiceTests.ConfigureBillingService_CanBeCalledTwiceWithoutThrowing
fails with 'Assert.Equal() Failure: Expected 3, Actual 0'.
Fix: catch ReflectionTypeLoadException and use the partial
.Types() list (the successfully-resolved subset), and use a
broader catch (with continue) for any other assembly-level
load failure. The lost user-settings types are not material;
they are derived from ApplicationDbContext in a separate loop
right after, and the RegisterBilling<>() calls that populate
BillingService.Billing run last, after both reflective phases
have completed best-effort.
The test still passes locally because the local test environment
loads a clean set of assemblies; only the CI runner (with its
extra test-time tooling) hits this path.
LoadClientAsync(id) on ClientController used to trip an
IndexOutOfRangeException at the InMemory shaper for any
.Include() of one of three Client navs: RedirectUris,
AllowedScopes, AllowedGrantTypes. Five other Client navs (with
the same EF shape and the same application-level config) worked
fine.
Bisection pointed at the InMemory provider; that hypothesis was
wrong. The real cause is in ApplicationDbContext.OnModelCreating:
yavsc was redeclaring the HasOne<Client>().WithMany(...).
HasForeignKey(e => e.ClientId) for all eight Client* navs. The
same relation is already declared (more completely, with
.IsRequired().OnDelete(DeleteBehavior.Cascade)) by
IdentityServer8's ConfigureClientStore via ModelBuilderExtensions.
The redundant mapping on three specific entities — ClientScope,
ClientRedirectUri, ClientGrantType — interacts with the InMemory
provider's shaper in a way that throws IndexOutOfRange. Removing
the redundancy fixes it.
This commit also walks back b12c272d:
- Drops .AsSplitQuery() from LoadClientAsync (no longer needed
for the InMemory shaper, and the Postgres path it was a hedge
against was a false alarm — there is no Postgres production
bug here, only an InMemory shaper quirk surfaced by the
redundant mapping).
- Removes the 9 Bisect_*_alone tests that were the artefact of
the provider-hypothesis phase. They pointed at the right
entities but for the wrong reason.
- Keeps EditRedirectUris_GET_after_add_lists_both_uris as the
end-to-end regression sentinel: with the fix in place, it
loads a Client with two RedirectUris and asserts both are
rendered. Without the fix, it fails with IndexOutOfRange.
Tout billet a un auteur, tout commentaire a un auteur. On aligne la
base sur ce contrat (Postgres) en droppant les orphelins existants
puis en remplaçant les FK en cascade par des FK Restrict.
- ApplicationDbContext: fluent pour BlogPost.Author et Comment.Author
en DeleteBehavior.Restrict.
- ApplicationUser: ajoute la nav inverse BlogComments (manquait,
EF aurait sinon créé une shadow FK).
- Migration 20260711173717_EnforceBlogAuthorFKs: Up purge les
Comment/BlogSpot dont l'AuthorId n'existe plus, log le volume,
puis drop+add des FK. Down laisse la cascade (état pré-migration).
Le code applicatif (BlogSpotService.Details) s'appuiera sur cette
contrainte dans un commit séparé.
Adds a new JSON-bodied signature endpoint as a sibling of the
legacy PNG-based prosign/clisign routes. The legacy flow stays
intact: the TeX invoice templates (Bill_tex.cshtml,
Estimate_tex.cshtml) still consume the sign-{billingCode}-{id}.png
files the old endpoints write, and the new endpoint writes to a
distinct /signatures/ tree under UserFilesDirName. A future
migration commit will regenerate PNGs from the JSON payload and
decommission the PNG flow.
Scope
- New Signature entity (Yavsc.Server/Models/Billing/Signature.cs)
with FK to Estimate, FK to ApplicationUser (Signer), Type
(Pro/Client) enum, CoordinateMax (default 10_000), int[] Strokes
(native Npgsql mapping), CapturedAtUtc, FilePath. Multiple
versions per (EstimateId, Type) are allowed; the controller
reads the most recent.
- New Estimate.Signatures nav collection (InverseProperty) so the
composite index covers both sides of the relation.
- New DbSet<Signature> Signatures + composite index
(EstimateId, Type, CapturedAtUtc DESC) in ApplicationDbContext
OnModelCreating. DeleteBehavior.Cascade on Estimate deletion
cleans up signatures automatically.
- New EstimateSignatureFileHelper (Server/Helpers) with
ReceiveEstimateSignatureAsync(user, estimateId, type, payload).
Writes a yavsc.signature/v1 JSON envelope to
UserFilesDirName/{user}/signatures/sign-{type}-{estimateId}-{ticks}.json.
Quota update lives in the controller, not the helper, because
the helper has no DbContext access.
- New endpoint POST /api/bill/estimate/{id:long}/sign on
BillingController. Authz is body-driven (the bearer token is the
PostIt OAuth client, not the end user, so signerUserId is in
the JSON body, validated against Estimate.OwnerId/ClientId).
Returns 201 Created with the new Signature's metadata.
Plumbing
- SignatureSubmission (body type) lives next to BillingController
in the same file — small enough to keep colocated.
- The legacy prosign/clisign routes are untouched. They keep
the IFormFile PNG contract; the new endpoint is the JSON
counterpart.
Tests
- New EstimateSignatureFileHelperTests in Yavsc.Org.Tests
(8 tests, all green): filename format incl. lowercase type and
ticks, envelope v1 round-trip (parsed via JsonDocument, not
text matching), null payload rejected, non-positive
estimateId rejected. Disk side effects are isolated to a
per-test temp root via AbstractFileSystemHelpers.UserFilesDirName.
- Yavsc.Org.Tests full suite: 29/29 green.
- PostIt.Tests: 57/57 green (untouched by this commit).
- Builds: Yavsc.Server, Yavsc.Api, Yavsc.Org, Yavsc.Org.Tests
all compile clean.
Out of scope
- EF migration: the Signatures table doesn't exist in the
database yet. The migration is intentionally a separate
commit so the generated SQL can be reviewed against the
composite index and the int[] column type before it touches
any prod database. Until the migration lands, the new
endpoint will 500 on SaveChanges; the [DEV] button in
PostIt is the only call site, so this is acceptable.
- SignalR handler that opens the signature page on a
'devis received' push — commit 4.
Narrow ISmtpClient to the four operations MailSender actually uses,
behind a Yavsc.Interfaces.ISmtpClientFactory. Production wires
MailKitSmtpClient (SmtpClientFactory); tests wire a recording fake
(RecordingSmtpClientFactory). The fake is pre-registered in
WebServerFixture so SMTP calls are short-circuited; the EMailling
test now asserts the Connect -> Authenticate -> Send -> Disconnect
sequence. Yavsc.Abstract stays free of MailKit/MimeKit.
Move product-local package versions out of the root Directory.Packages.props
into per-product props files under src/<Product>/. The root file now only
contains versions for packages declared by two or more top-level products,
which is the actual shared set.
Each per-product Directory.Packages.props imports the root via
GetPathOfFileAbove so that the shared versions are inherited; this is
necessary because the .NET SDK picks the closest Directory.Packages.props
in the hierarchy and does not merge multiple ones.
Per-product file contents:
- src/cli/ Microsoft.AspNetCore.Razor.Language,
Microsoft.Extensions.{CommandLineUtils,Configuration,Hosting}
- src/PostIt/ Avalonia* and CommunityToolkit.Mvvm
- src/PostIt.Tests/ Avalonia.Headless{,XUnit}
- src/Yavsc.Org/ AsciiDocSharp*, Google.Apis.Compute.v1,
HigginsSoft.IdentityServer8.AspNetIdentity,
IdentityServer8.EntityFramework.Storage,
IdentityServer8.Security, IdentityServer8.Storage,
Microsoft.AspNetCore.Antiforgery, Authentication.Google,
Diagnostics.EntityFrameworkCore, Mvc.NewtonsoftJson,
SignalR, EntityFrameworkCore.Tools, Swashbuckle,
System.Security.Cryptography.Pkcs, YamlDotNet
- src/Yavsc.Org.Tests/ Microsoft.AspNetCore.Hosting,
Extensions.Caching.Memory, Options,
Options.ConfigurationExtensions,
Selenium.WebDriver, xunit.v3.{common,extensibility.core}
- src/Yavsc.Server/ Anthropic.SDK, Google.Apis.Calendar.v3,
Magick.NET-Q8-AnyCPU, MailKit, MimeKit,
Microsoft.AspNetCore.Http.Features, StaticFiles,
EntityFrameworkCore.SqlServer,
Npgsql.EntityFrameworkCore.PostgreSQL,
PayPalMerchantSDK, pazof.rules, RazorEngine.NetCore
- src/Yavsc.Web/ IdentityModel.AspNetCore
No per-product file is created for Yavsc.Api, Yavsc.Blogs, Yavsc.Abstract,
or templateWeb: Api and Blogs only declare the shared JwtBearer, Abstract
and templateWeb declare no package references at all.
Also includes a minor cosmetic update to FirstUIStript.cs (Firefox -> Chrome
driver, dedent, comment header). Tests previously failing on DataProtection
keyset / SMTP were unrelated environment issues (resolved by fixing the
SMTP password locally).
The Site:Audience setting was conflating two distinct concepts: an OAuth
JWT audience (a single resource identifier) and a CORS allow-list (an
array of origins). Collapsing them caused several latent bugs:
- OAuth/JWT validation expected a single string while CORS WithOrigins
accepts an array.
- Password-reset callback URLs and OAuth client RedirectUri/Origin were
being built from what was meant to be an audience identifier, not a
base URL.
- Yavsc.Org's main CORS policy was hardcoded to '*', with no way to
restrict it without code changes.
Changes:
- SiteSettings.Audience (string) replaced with CorsAllowedOrigins
(IList<string>).
- OAuth JWT Authority still reads Site:Authority; Audience now reads
Site:ExternalUrl (Org only; Api/Blogs use ValidateAudience=false).
- MailSender and AccountController build reset-callback URLs from
Site:ExternalUrl.
- ClientController uses Site:ExternalUrl for OAuth RedirectUri/Origin
defaults on newly created clients.
- Yavsc.Api and Yavsc.Blogs now read CORS origins from
Site:CorsAllowedOrigins instead of hardcoded URLs.
Add shared AddYavscCors / AddYavscJwtBearer extension methods in
Yavsc.Server/Helpers/ServiceExtensions.cs to enforce a single
configuration contract across all runtime services (Api, Blogs, Org).
Fails closed when CorsAllowedOrigins is empty; fails fast at startup
when Site:Authority is missing.
Remove obsolete ConfigurationHelpers.GetAudience (no remaining callers).
Local appsettings-*.json files (which carry deployment-specific values
and are gitignored) must be updated to add Site:CorsAllowedOrigins.