Landing the user-facing surface for the BlogAcl work. The user
can now:
1. Open the 'Mes cercles' page (a new 'Mes cercles' button on
the main page) and create / edit / delete their own
circles. The page lists circles in an ObservableCollection
bound to a ListBox; per-row buttons drive StartEdit and
Delete; the bottom editor pushes new / edited circles via
the Save command.
2. With a post selected, click the new 'ACL' button to open a
modal 'PostAclDialog' for that post. The modal shows the
current ACL entries (filtered server-side by Allowed.OwnerId
== caller) and a dropdown of the caller's circles to add.
Each entry has a 'Revoke' button.
Both pages follow the same pattern:
- ViewModel uses [ObservableProperty] for state and
[RelayCommand] for verbs; IsBusy drives a ProgressBar
overlay; StatusMessage surfaces server feedback.
- View follows the XAML-Background/Foreground lesson (no
hard-coded colours), so dark mode works without
contrast surprises.
- Code-behind is minimal — just AvaloniaXamlLoader.Load —
because navigation is driven by RelayCommand + event
(ManageAclRequested, OpenCirclesRequested) that the
MainPage code-behind handles via its DataContextChanged
handler.
The 'complete' scope (c) of this commit was confirmed by
Paul. Three follow-up tracks are deliberately out of scope
and tracked in MEMORY.md (2026-08-18):
- i18n: no .resx / IStringLocalizer today; all visible text
is hard-coded French.
- Avalonia.Headless UI tests: only ViewModel-level coverage
is feasible today; full navigation tests are a separate
effort.
- XAML accessibility audit of pre-existing pages (Settings,
MainPage) that predate the Background/Foreground lesson.
Build + 51/51 tests green.
App.axaml.cs is the composition root for PostIt. It now also
builds and registers:
- CircleApiClient (singleton) — backed by the same YavscApiClient
and the same blogs base URL as BlogApiClient
- BlogAclApiClient (singleton) — same shape
- IYavscApiClient -> YavscApiClient mapping (singleton). The
concrete class is still resolvable as YavscApiClient; the new
registration makes the same instance available as
IYavscApiClient so future consumers (and unit tests) can take
the interface without coupling to the concrete type.
The 3 high-level clients are singletons: they hold no mutable
state of their own, just a reference to YavscApiClient and a
base URL. Reusing the same instance across requests is what the
HttpClient inside YavscApiClient was already designed for.
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
Yavsc.Api.Client is the new home for high-level HTTP clients
(BlogApiClient, CircleApiClient, BlogAclApiClient, etc.). It
depends on the host application's transport layer, but the host
(PostIt) is a UI app with OIDC, settings, and an ApplicationData
directory — none of which the abstract client library should
know about.
The IYavscApiClient interface captures just the transport
surface those clients need:
- HttpClient (so the client can configure BaseAddress)
- CallAsync<T> and CallAsync (the JSON over HTTP verb)
It deliberately leaves out LoginAsync / TrySilentLoginAsync /
CurrentAccessToken / HasValidSession / Settings — those are
authentication and configuration concerns, not transport. They
stay on the concrete YavscApiClient in PostIt.Services.
The concrete YavscApiClient now implements IYavscApiClient; the
existing public surface is unchanged (no breaking changes for
existing call sites in PostIt or the tests).
This commit only lays the foundation. The actual high-level
clients (Blog/Circle/BlogAcl) land in a follow-up commit that
re-uses this interface, so this one stays a small, reviewable
refactor.
BlogPost is shared between the server (Yavsc.Server/Models/Blog/
BlogPost.cs is the EF entity) and any client that talks to the
blogs API. Keeping the client-side DTO in PostIt.Models made
sense when there was only one consumer; now that the
Yavsc.Api.Client project is about to host BlogApiClient alongside
CircleApiClient and BlogAclApiClient, the DTO has to live in a
layer both the client project and PostIt can reference without
inverting the dependency.
Yavsc.Abstract is the existing home for cross-tier interfaces
and DTOs (IBlogPost, IBlogPostPayLoad, IApplicationUser).
Yavsc.Blogspot is the sub-namespace already used by the
matching interface, so the new concrete class follows.
Why not move Circle and CircleAuthorizationToBlogPost at the
same time? Both depend on the concrete ApplicationUser class
(via the Owner and Target/Allowed navigation properties) which
lives in Yavsc.Server. Moving them would mean either dragging
ApplicationUser into the abstract layer (huge blast radius —
auth, billing, chat, etc.) or weakening the navigation
properties (breaks EF Core shaping). They're staying where
they are; the new Yavsc.Api.Client will get DTO counterparts
instead.
Updated call sites:
- 4 .cs files: replace 'using PostIt.Models;' with
'using Yavsc.Blogspot;' where the file was actually using
BlogPost. Files that only used SignaturePadData keep their
'using PostIt.Models;' — that type stays put.
- 1 .axaml file: xmlns:models="using:PostIt.Models" ->
xmlns:models="using:Yavsc.Blogspot" (one DataTemplate for
the post list in MainPage).
Build + tests green (51/51).
Closes the data-leak holes that survived the move of these controllers
from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its
membership should never be visible, modifiable, or deletable by anyone
other than its owner.
BlogAclApiController:
- GetBlogACL() was returning the full table; now filters by
Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed)
so EF Core can push the filter into SQL instead of materialising
the whole table.
- Other endpoints (GetById, Put, Post, Delete) already enforced
ownership; left as is.
CircleApiController:
- GetCircle() (no id) now filters by OwnerId.
- GetCircle(id) now requires c.Id == id && c.OwnerId == uid;
returns 404 (not 403) on miss to avoid leaking the existence of
someone else's circle.
- PutCircle verifies the existing record is owned by the caller,
then forces circle.OwnerId = uid on the body (the client's value
is ignored). Returns ChallengeResult when the caller doesn't own
the record.
- PostCircle forces circle.OwnerId = uid (was trusting the body).
- DeleteCircle now filters by OwnerId; 404 on miss.
All checks use the same source of truth (User.FindFirstValue(
ClaimTypes.NameIdentifier)) that the existing BlogAclApiController
authz code already relies on.
These two controllers belong to the Blogs subsystem (their routes
/api/blogacl and /api/circle are blog-domain concerns, not the
generic Api surface). Moving them next to BlogApiController keeps
related code together and prepares the PostIt client to consume
them through the same BlogsApiUrl base address as the existing
BlogApiClient.
Mechanical changes only:
- Namespace Yavsc.Controllers -> Yavsc.Blogs.Controllers
- Drop unused 'using Yavsc.Helpers;' (no symbol in the new
compilation unit depends on it; the build confirms it was
dead since the controllers were first written)
- Fix typo in CircleApiController route: 'api/cirle' -> 'api/circle'
(any client trying to call the documented route was hitting 404)
No functional changes to authorization or query shape. The known
security gaps in these controllers (GetBlogACL and GetCircle
return unfiltered collections, DeleteCircle has no ownership
check) are deliberately left untouched in this commit and will
be addressed in a follow-up.
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.
Patch is even (6) and bare, so this is classified as 'stable' by
the validate-release job in .github/workflows/docker-publish-android.yml.
Move the Unreleased section up by inserting [1.0.6] below it, with
a list of changes that landed on this release:
- Self-hosted Forgejo Actions runner now drives CI on yavsc,
using pazof/yavsc-build-env:debian12-dotnet10-android36-v1
pulled from Docker Hub.
- .forgejo/workflows/buildAndTest.yml builds without
actions/checkout (image has no Node) and uses NuGet.config
for the isn.pschneider.fr feed.
- Dockerfile / Dockerfile.backend drop the redundant
'dotnet nuget add source' step that broke the APK build on
GitHub Actions.
The project-level NuGet.config (added in 94012c51) lists the isn feed
so 'dotnet restore' picks it up without an inline 'dotnet nuget add
source' step.
The inline add source was duplicating NuGet.config and causing build
failures in GitHub Actions:
- The --allow-insecure-connections flag did not match the actual
HTTPS deployment of isn.pschneider.fr (Letsencrypt-issued cert,
not self-signed), making the step fail with 'exit code 1'.
- docker build --target build-env (used by
.github/workflows/docker-publish-android.yml) hit this on every
run.
Both Dockerfile and Dockerfile.backend had the same redundant step;
both removed. 'dotnet restore' still finds the feed via NuGet.config
at /src/NuGet.config (copied in by 'COPY . .').
The project-level NuGet.config (added in 94012c51) lists the isn feed
so 'dotnet restore' picks it up without an inline 'dotnet nuget add
source' step.
The inline add source was duplicating NuGet.config and causing build
failures in GitHub Actions:
- The --allow-insecure-connections flag did not match the actual
HTTPS deployment of isn.pschneider.fr (Letsencrypt-issued cert,
not self-signed), making the step fail with 'exit code 1'.
- docker build --target build-env (used by
.github/workflows/docker-publish-android.yml) hit this on every
run.
Both Dockerfile and Dockerfile.backend had the same redundant step;
both removed. 'dotnet restore' still finds the feed via NuGet.config
at /src/NuGet.config (copied in by 'COPY . .').
GitVersion.MsBuild fails on shallow clones ('Repository is a shallow
clone. Git repositories must contain the full history.') because it
walks the git log to compute the SemVer version.
Drop --depth 1 from both the PR ref fetch and the submodule update
so the runner's working tree has full history. The repo is small
enough that the cost is negligible.
The yavsc solution depends on HigginsSoft.IdentityServer8.* 8.1.0-alpha.*,
which is only published on the internal feed https://isn.pschneider.fr.
Public nuget.org has 8.0.4 as the nearest version, so every project that
uses IdentityServer8 (Yavsc.Org, Yavsc.Api, Yavsc.Blogs, Yavsc.Server,
cli, Yavsc.Org.Tests, Yavsc.Blogs.Tests) fails with NU1102 on restore.
Both feeds are reachable anonymously, so listing isn first and
nuget.org second in a project-level config restores everything without
credentials. The CI runner on forgejo now sees the same sources as a
local clone.
forgejo-runner v13 does not interpolate ${{ runner.workspace }}
in working-directory: (or ignores the field entirely for docker
containers), so the container tried to chdir to '/_src' (literally)
which does not exist.
The image WORKDIR is /src, so clone directly into /src/_src and cd
into it at the start of each step. Adds an echo of the checkout
SHA + branch state for visibility in the log.
The runner container does not have an SSH client, and even if it
did, no key is configured for it. Forgejo Actions must reach the
submodule over HTTPS with anonymous read access (which is now
enabled on the Forgejo instance).
Use 'git submodule sync --recursive' on the developer side after
checkout to propagate the URL change to .git/modules/.
In pull_request context, GITHUB_REF_NAME is the PR number ('17'),
not the source branch. Cloning --branch 17 fails with
'Could not find remote branch 17 to clone'.
Use GITHUB_REF (refs/pull/N/head in PR context, refs/heads/<branch>
in push context) and fetch + checkout FETCH_HEAD. workflow_dispatch
falls back to the default branch.
The pazof/yavsc-build-env:debian12-dotnet10-android36-v1 image only
ships .NET 10 SDK + Android SDK + JDK 17, no Node. actions/checkout@v6
requires Node, so the job failed with 'exec: node not found'.
Replace actions/checkout with a direct git clone over HTTPS (Forgejo
anonymous is enabled), and init submodules recursively.
Also drop the bogus docker://image:tag runs-on: matcher, use just 'docker'
to match the runner's declared label name.
Le workflow buildAndTest tournait sur un runner nu debian-latest avec
setup-dotnet@v5 pour la SDK 10.0.x. Restore échouait car cet
environnement n'a ni la source NuGet interne (isn.pschneider.fr) ni
les workloads Android configurés, contrairement à l'image
pazof/yavsc-build-env utilisée par le Dockerfile.
Bascule le job sur un runner labelisé docker avec l'image
debian12-dotnet10-android36-v1 directement. Le step setup-dotnet
devient inutile (l'image a déjà la SDK 10.0), le restore partage
la même config que le Dockerfile.
Refs l'image cible par ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1.
Le repo cible net10.0 partout (csproj, TFM), mais le workflow CI
Forgejo buildAndTest installait une SDK 9.0.x. Aligne sur 10.0.x
pour que la CI build avec une SDK qui connaît le TFM net10.0.
Pas de global.json ajouté : la SDK est résolue à l'installation
de l'image runner, le repo reste agnostique de la version exacte.
Rend le job publish-release dépendant d'un nouveau job validate-release
qui :
- parse le tag (format MAJOR.MINOR.PATCH[-SUFFIX])
- classifie le canal : pair=stable, impair=preview, suffixe=instable
- fail-fast sur instable sauf opt-in explicite via workflow_dispatch
- vérifie que CHANGELOG.md contient une section ## [<tag>] - <canal>
- expose le body de la section via $GITHUB_ENV pour le job de publication
Le tag trigger passe de 'v*' à '*' (pas de préfixe sur les tags), et
le corps de release GitHub est désormais curé via CHANGELOG.md plutôt
que généré automatiquement.
Cette convention de parité est partagée avec le dépôt postit-debian
pour la production des paquets .deb (alignement à traiter dans une PR
séparée).
Rend le job publish-release dépendant d'un nouveau job validate-release
qui :
- parse le tag (format MAJOR.MINOR.PATCH[-SUFFIX])
- classifie le canal : pair=stable, impair=preview, suffixe=instable
- fail-fast sur instable sauf opt-in explicite via workflow_dispatch
- vérifie que CHANGELOG.md contient une section ## [<tag>] - <canal>
- expose le body de la section via $GITHUB_ENV pour le job de publication
Le tag trigger passe de 'v*' à '*' (pas de préfixe sur les tags), et
le corps de release GitHub est désormais curé via CHANGELOG.md plutôt
que généré automatiquement.
Cette convention de parité est partagée avec le dépôt postit-debian
pour la production des paquets .deb (alignement à traiter dans une PR
séparée).
Initialise le changelog du projet au format Keep a Changelog 1.1.0,
en français, avec une section [Unreleased] vide prête à être curée
au moment de la première release.
Le préambule documente la convention de parité du patch :
- pair → stable
- impair → preview
- suffixe → instable
Cette convention est partagée avec le dépôt postit-debian pour la
production des paquets .deb (alignement à traiter dans une PR séparée).
Adds a publish-release job that triggers only on tag pushes (refs/tags/v*).
It reuses the APK artifact uploaded by apk-deploy, publishes a GitHub
release via softprops/action-gh-release, and attaches the APK.
Result: a stable permalink to the latest APK at
https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk