IContactService used to be the catch-all for "people you can reach
from PostIt": on mobile it read the device-local address book, on
desktop it queried the central /api/user-search endpoint and merged
both worlds into a single ContactDto (a flat Email field, an
ObservableCollection cache, a SearchAsync method). Two unrelated
flows under the same name, with a wire shape (Email) silently
flattening the mobile provider's multi-email list.
Split into two interfaces, each with a single responsibility:
- IContactService: device-local address book only. Mobile provider
reads MAUI Essentials Contacts.Default and carries the full email
list per contact. Desktop provider is an honest stub returning an
empty list — the desktop has no local address book, and inviting
external people from desktop is a separate flow (manual email
entry + invitation endpoint) that doesn't belong here.
- IUserDirectory: central Yavsc user directory, the only consumer
of /api/user-search. Both Desktop and Mobile providers delegate
to UserSearchClient; the platform split exists so future
platform-specific sources (offline cache, directory-scoped
providers) can plug in without disturbing consumers.
ContactDto restores IReadOnlyList<string> Emails (the flat Email
from d0e0f4c1 was a regression that matched the wire shape of
/api/user-search at the cost of the mobile provider's per-contact
list). UserSummary is a separate platform-neutral record that
mirrors the server's UserSearchResultDto without leaking transport
concerns.
App.axaml.cs registers both interfaces as singletons.
Build + 51/51 PostIt.Tests green. No UI consumer yet — these
interfaces are still plomberie; the ViewModel that joins them for
the "add to a circle" / "invite someone" flows is a follow-up.
Replaces the empty ContactService.Desktop stub with a real
implementation backed by UserSearchClient. Closes the loop
between the server-side /api/user-search endpoint (b3056f1c),
the client wrapper (6e7e0414), and the platform abstraction.
IContactService gains:
- SearchAsync(string query, CancellationToken): on desktop,
hits /api/user-search and appends results to an in-memory
cache. On mobile, throws PlatformNotSupportedException —
mobile providers use the device-local address book
(GetDeviceContactsAsync) and don't talk to a network search.
- Contacts (ObservableCollection<ContactDto>): live view of
the cache; UI binds directly to it. Mobile populates it
inside GetDeviceContactsAsync (eager load); desktop populates
it via SearchAsync (lazy, on-demand).
ContactDto shape changes:
- Emails (IReadOnlyList<string>) -> Email (string?). The
/api/user-search endpoint returns one email per user. The
use case ('invite / add to a circle') only needs one.
- Mobile provider flattens its per-contact email list down
to the first non-empty entry (a small functional loss that
matches the wire shape).
App.axaml.cs constructs a ContactService from the
UserSearchClient singleton and registers it as
IContactService so future ViewModels can take the interface
by constructor injection.
Build + 51/51 tests green. The mobile provider is still
gated by #if ANDROID || IOS and not exercised by the
Desktop test target — runtime behaviour on Android will
need a smoke test on device when PostIt.Android lands.
Adds the client-side half of the user-search endpoint landed
on the server in b3056f1c (commit 6 on this branch). The
client mirrors the server's filter contract:
- query: substring match on FullName or UserName
- email: exact match on Email
- take: 1..100, default 25
Empty (query + email) short-circuits to an empty list
client-side rather than letting the server return the first
`take` users alphabetically — the address-book UX is
"type to search", not "show me a directory".
The DTO (Yavsc.Api.Client.Dtos.UserSearchResultDto) is a flat
shape (Id, UserName, FullName, Avatar, Email) with no
navigation properties; field names match the JSON the server
emits so deserialisation is a no-op.
PostIt wiring:
- App.axaml.cs constructs a UserSearchClient singleton and
registers it alongside CircleApiClient and BlogAclApiClient.
- The PostIt.csproj ProjectReference to Yavsc.Api.Client was
in place before this commit on feat/postit-acl; the rebase
of feat/app-invite on top of feat/postit-acl dropped it.
This commit re-adds it.
Splits the single ContactService class (which threw
PlatformNotSupportedException on non-Android/iOS targets) into a
platform-conditional structure:
- IContactService + ContactDto: shared abstraction in
src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend
on this; concrete providers map their native shapes to ContactDto.
- ContactService.Mobile.cs: MAUI Essentials implementation, compiled
only when ANDROID or IOS is defined. Wraps
Contacts.Default.GetAllAsync() with permission handling and a
NotImplementedInReferenceAssemblyException safety net.
- ContactService.Desktop.cs: stub returning an empty list, compiled
when neither ANDROID nor IOS is defined. Replaces the
'throw PlatformNotSupportedException' path so desktop targets
(PostIt.Desktop, PostIt.Browser) build and run cleanly.
The Microsoft.Maui.Essentials portable facade is referenced from
PostIt.csproj, but it only becomes functional when the host
application project (PostIt.Android, future PostIt.iOS) also
references the platform-specific implementation.
No tests added: per AGENTS.md, a 'stub returns empty list' test on
PostIt.Tests (net10.0 desktop target) would be cosmetic and not
detect the real failure mode. Android-side tests require a working
PostIt.Android project, which doesn't exist yet.
Future providers (Google Contacts API, Exchange, CardDAV) plug in
as additional IContactService implementations selected by DI
configuration.
Adds Microsoft.Maui.Essentials package and <UseMaui>true</UseMaui> to
src/PostIt/PostIt/PostIt.csproj so the shared project can compile code
that calls MAUI Essentials APIs (Microsoft.Maui.ApplicationModel.*).
Also adds a draft ContactService that wraps Contacts.Default.GetAllAsync()
behind a runtime platform check and permission request.
WIP caveats:
- The portable MAUI Essentials facade compiles on net10.0 but throws
NotImplementedInReferenceAssemblyException at runtime when no
platform-specific MAUI Essentials binary is loaded. A PostIt.Android
project (or equivalent) must reference the Android MAUI Essentials
implementation for Contacts.Default.GetAllAsync() to actually work.
- On desktop (Linux/macOS/Windows) the API is unsupported by design;
ContactService currently throws PlatformNotSupportedException. A
desktop stub returning Array.Empty<Contact>() is the likely next step.
- No tests yet. The scaffold is unverified at runtime; build passes.
Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2)
a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus
de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name'
parameter".
Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...`
était passé en argument positionnel entre `--data-binary @file` et
l'URL. curl l'interprète comme un second fichier d'input (un fichier
nommé '?name=...'), pas comme un query param, et l'API Forgejo ne
voit jamais le name.
Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement.
L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets.
La section [1.0.6] - stable du CHANGELOG mentionnait encore
debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH
release qui tombait en 404 à cause du sed greedy + JSON minifié.
Mets à jour avant de relancer la publication de la release
1.0.6 (workflow_dispatch), pour que le body publié reflète l'état
réel de l'infra (image v2 avec jq) et du workflow.
L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir
de debian12-dotnet10-android36-v2 (Dockerfile du repo
dotnet-android-build-image, commit e06f096 "adds jq"). On en
profite pour supprimer json_escape et json_field à base de sed,
qui étaient fragiles :
* sed est greedy par défaut : sur du JSON minifié d'une seule
ligne (ce que renvoie l'API Forgejo de cette instance pour
/releases/tags/<tag>), la regex s/.*"id".../\1/p attrape la
DERNIÈRE occurrence de "id":<digits> sur la ligne, qui est
l'id de l'auteur de la release (1, premier user du repo),
pas l'id de la release (10706).
* Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié :
il n'isole rien et le sed greedy continue à capturer
l'id de l'auteur.
* PATCH /releases/1 tombait alors en 404 "The target couldn't
be found" (cf. run échoué du 2026-08-17 04:05 sur le tag
1.0.6).
jq résout les deux problèmes en une fois :
* jq -r '.id' retourne le champ id racine, pas l'id imbriqué
dans author.
* jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease:
$prerelease}' construit un body JSON proprement échappé
(backslashes, guillemets, newlines, caractères de contrôle
Unicode) sans avoir à le reproduire à la main.
Effet de bord : les bodies PATCH et POST sont écrits dans
/tmp/patch.json et /tmp/post.json puis passés à curl via
--data-binary @<file> au lieu d'une variable shell. Plus de
problème de quoting en chaîne shell, plus de collision avec
les espaces ou les caractères spéciaux du body.
Pré-requis côté runner : image pazof/yavsc-build-env:debian12-
dotnet10-android36-v2 (avec jq) + maj du label correspondant
dans la config du runner Forgejo.
L'API Forgejo renvoie pour /releases/tags/<tag> un objet JSON
pretty-printed où l'id racine (release.id, ex. 10706) est sur la
première ligne, mais l'objet author contient aussi un id (souvent 1
pour le premier user du repo). L'ancienne regex sed matchait la
première occurrence globale de "id" dans le fichier, donc elle
retombait sur author.id=1 et le PATCH /releases/1 tombait en 404
'The target couldn't be found'.
Fix : on pipe le fichier dans 'head -3' pour ne matcher que les
premières lignes (couvre largement le préambule de l'objet release).
Si Forgejo renvoie du JSON minifié (une seule ligne), head -3
renvoie toute la ligne et la regex matche le premier id (la racine,
parce que les champs auteur sont après les champs racine).
L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni
node). Le step de publication Forgejo utilisait python3 pour générer
les bodies JSON (POST /releases, PATCH /releases/{id}) et pour
extraire le 'id' de la réponse.
Fix : deux fonctions bash :
- json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t)
- json_field : extraction d'un champ scalaire d'un fichier JSON via sed
Suffisant pour les bodies qu'on envoie (tag_name, name, body,
prerelease) et les champs qu'on lit (id).
L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload
Android, mais PAS le binaire 'docker' ni de daemon Docker. Le
'Build de l'image Docker' du workflow plantait avec 'docker: command
not found'.
Fix : on exécute directement les commandes dotnet du Dockerfile
(restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r
android-arm64), puis on copie l'APK depuis le chemin de sortie
standard bin/Release/net10.0-android/android-arm64/.
Note : le Dockerfile reste la voie canonique pour les builds en
local et via GitHub Actions (qui a docker). Ce fix concerne
uniquement le workflow Forgejo Actions où le runner n'a pas Docker.
The previous awk extracted the section body but excluded the title
line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never
matched. Fix: include the title line in the extracted body, verify
the channel suffix on the title, then strip the title before passing
the body to the release API.
The runner's docker label points at pazof/yavsc-build-env, a Debian
image without Node.js. Any action like actions/checkout@v7,
actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc.
fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'.
This workflow is rewritten in pure bash:
- replace actions/checkout with explicit git clone + checkout (full
history + tags so GitVersion.MsBuild is happy);
- merge the two jobs into one (no inter-job artifacts needed since
everything shares the runner's filesystem);
- replace rasterstate/forgejo-release-action with direct calls to the
Forgejo REST API (/api/v1/repos/.../releases, .../assets), with
python3 used to build and parse JSON bodies (jq not guaranteed in
the runner image).
Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The
rasterstate action or any other Node-based action can be reinstated
later if the runner image is swapped for one with Node installed.
Repo-level secrets creation is broken on this Forgejo instance
(InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely
a text-vs-bytea column type on the secret table). The fix is in
upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto-
provided by the runner, scoped to contents: write for the current
repo) keeps the release workflow operational without any UI setup.
When the instance is upgraded and the secret table is migrated,
revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }}
for least-privilege.
Adds .forgejo/workflows/release.yml: triggered by tag push or
workflow_dispatch, it validates the tag/CHANGELOG parity (stable /
preview / unstable), builds the PostIt Android APK via the existing
Dockerfile (--target build-env), and publishes a Forgejo release with
the APK as an asset via rasterstate/forgejo-release-action@v1.
Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml
so the two channels (Forgejo source-of-truth + GitHub mirror) stay
consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo
PAT scoped to write:repository configured in the repository's Actions
secrets.
Lives in Yavsc.Blogs (not Yavsc.Api) because Yavsc.Api is not
yet enabled in production; future migration to Yavsc.Api is a
single namespace + route prefix change.
Endpoint: GET /api/user-search?q=<name>&e=<email>&take=<n>
- Authorisation: [Authorize] (any authenticated caller).
- q: case-insensitive substring match on FullName OR UserName.
- e: case-insensitive exact match on Email.
- take: 1..100, default 25.
Returns a flat UserSearchResultDto (Id, UserName, FullName,
Avatar, Email) — no navigation properties, so the payload
stays small even if the user table grows.
The Email field is included because the address-book use case
(composing circle membership, sending invites) needs it.
On Yavsc's single-tenant deployments the user table is a
closed community; multi-tenant deployments should gate this
controller behind a tenant-scoped policy before exposing it.
The trade-off is documented in the controller's class-level
XML doc.
When commit 0e95e283 moved BlogPost from PostIt.Models to
Yavsc.Blogspot, it created an unfortunate collision with the
server-side EF entity Yavsc.Models.Blog.BlogPost. The two
classes have nothing in common beyond the name; the DTO is
the wire shape PostIt exchanges with the Blogs API, the EF
entity is the persistence model. Server code that imports both
namespaces (BlogSpotService.cs, etc.) ended up with 'BlogPost
is an ambiguous reference between X and Y' errors.
Renaming the client DTO to BlogPostDto (matching the
naming convention of the other DTOs in Yavsc.Api.Client.Dtos
— CircleDto, CircleAuthorizationDto, UserSearchResultDto)
disambiguates without renaming the EF entity on the server.
The namespace stays Yavsc.Blogspot; only the class name
changes. All call sites (client code, tests, XAML DataTemplates,
XML doc comments) are updated mechanically.
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.
Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2)
a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus
de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name'
parameter".
Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...`
était passé en argument positionnel entre `--data-binary @file` et
l'URL. curl l'interprète comme un second fichier d'input (un fichier
nommé '?name=...'), pas comme un query param, et l'API Forgejo ne
voit jamais le name.
Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement.
L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets.
La section [1.0.6] - stable du CHANGELOG mentionnait encore
debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH
release qui tombait en 404 à cause du sed greedy + JSON minifié.
Mets à jour avant de relancer la publication de la release
1.0.6 (workflow_dispatch), pour que le body publié reflète l'état
réel de l'infra (image v2 avec jq) et du workflow.
L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir
de debian12-dotnet10-android36-v2 (Dockerfile du repo
dotnet-android-build-image, commit e06f096 "adds jq"). On en
profite pour supprimer json_escape et json_field à base de sed,
qui étaient fragiles :
* sed est greedy par défaut : sur du JSON minifié d'une seule
ligne (ce que renvoie l'API Forgejo de cette instance pour
/releases/tags/<tag>), la regex s/.*"id".../\1/p attrape la
DERNIÈRE occurrence de "id":<digits> sur la ligne, qui est
l'id de l'auteur de la release (1, premier user du repo),
pas l'id de la release (10706).
* Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié :
il n'isole rien et le sed greedy continue à capturer
l'id de l'auteur.
* PATCH /releases/1 tombait alors en 404 "The target couldn't
be found" (cf. run échoué du 2026-08-17 04:05 sur le tag
1.0.6).
jq résout les deux problèmes en une fois :
* jq -r '.id' retourne le champ id racine, pas l'id imbriqué
dans author.
* jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease:
$prerelease}' construit un body JSON proprement échappé
(backslashes, guillemets, newlines, caractères de contrôle
Unicode) sans avoir à le reproduire à la main.
Effet de bord : les bodies PATCH et POST sont écrits dans
/tmp/patch.json et /tmp/post.json puis passés à curl via
--data-binary @<file> au lieu d'une variable shell. Plus de
problème de quoting en chaîne shell, plus de collision avec
les espaces ou les caractères spéciaux du body.
Pré-requis côté runner : image pazof/yavsc-build-env:debian12-
dotnet10-android36-v2 (avec jq) + maj du label correspondant
dans la config du runner Forgejo.
L'API Forgejo renvoie pour /releases/tags/<tag> un objet JSON
pretty-printed où l'id racine (release.id, ex. 10706) est sur la
première ligne, mais l'objet author contient aussi un id (souvent 1
pour le premier user du repo). L'ancienne regex sed matchait la
première occurrence globale de "id" dans le fichier, donc elle
retombait sur author.id=1 et le PATCH /releases/1 tombait en 404
'The target couldn't be found'.
Fix : on pipe le fichier dans 'head -3' pour ne matcher que les
premières lignes (couvre largement le préambule de l'objet release).
Si Forgejo renvoie du JSON minifié (une seule ligne), head -3
renvoie toute la ligne et la regex matche le premier id (la racine,
parce que les champs auteur sont après les champs racine).
L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni
node). Le step de publication Forgejo utilisait python3 pour générer
les bodies JSON (POST /releases, PATCH /releases/{id}) et pour
extraire le 'id' de la réponse.
Fix : deux fonctions bash :
- json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t)
- json_field : extraction d'un champ scalaire d'un fichier JSON via sed
Suffisant pour les bodies qu'on envoie (tag_name, name, body,
prerelease) et les champs qu'on lit (id).
L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload
Android, mais PAS le binaire 'docker' ni de daemon Docker. Le
'Build de l'image Docker' du workflow plantait avec 'docker: command
not found'.
Fix : on exécute directement les commandes dotnet du Dockerfile
(restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r
android-arm64), puis on copie l'APK depuis le chemin de sortie
standard bin/Release/net10.0-android/android-arm64/.
Note : le Dockerfile reste la voie canonique pour les builds en
local et via GitHub Actions (qui a docker). Ce fix concerne
uniquement le workflow Forgejo Actions où le runner n'a pas Docker.
The previous awk extracted the section body but excluded the title
line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never
matched. Fix: include the title line in the extracted body, verify
the channel suffix on the title, then strip the title before passing
the body to the release API.
The runner's docker label points at pazof/yavsc-build-env, a Debian
image without Node.js. Any action like actions/checkout@v7,
actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc.
fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'.
This workflow is rewritten in pure bash:
- replace actions/checkout with explicit git clone + checkout (full
history + tags so GitVersion.MsBuild is happy);
- merge the two jobs into one (no inter-job artifacts needed since
everything shares the runner's filesystem);
- replace rasterstate/forgejo-release-action with direct calls to the
Forgejo REST API (/api/v1/repos/.../releases, .../assets), with
python3 used to build and parse JSON bodies (jq not guaranteed in
the runner image).
Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The
rasterstate action or any other Node-based action can be reinstated
later if the runner image is swapped for one with Node installed.
Repo-level secrets creation is broken on this Forgejo instance
(InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely
a text-vs-bytea column type on the secret table). The fix is in
upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto-
provided by the runner, scoped to contents: write for the current
repo) keeps the release workflow operational without any UI setup.
When the instance is upgraded and the secret table is migrated,
revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }}
for least-privilege.
Adds .forgejo/workflows/release.yml: triggered by tag push or
workflow_dispatch, it validates the tag/CHANGELOG parity (stable /
preview / unstable), builds the PostIt Android APK via the existing
Dockerfile (--target build-env), and publishes a Forgejo release with
the APK as an asset via rasterstate/forgejo-release-action@v1.
Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml
so the two channels (Forgejo source-of-truth + GitHub mirror) stay
consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo
PAT scoped to write:repository configured in the repository's Actions
secrets.
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
The Yavsc.Org integration tests were failing 'Internal Server Error' on
the OIDC discovery document when run as part of the full test suite.
Root cause: WebHostFixture bound Kestrel to IPAddress.Loopback on a
dynamically-allocated port and exposed it via IServerAddressesFeature.
But the OIDC issuer URLs (and the issuer claim) come from Site:Authority,
which was left at the production value (mercure.pschneider.fr). So
IdentityServer8's discovery document advertised URLs unreachable from
the test process, and the discovery call returned a 500.
Fix:
- WebServerFixture now overrides Site:Authority and Site:ExternalUrl
in AddInMemoryCollection to 'https://localhost:44300' (the ASP.NET
Core dev HTTPS convention).
- WebHostFixture reads Site:Authority from configuration and binds
Kestrel to that fixed URL. The exposed Addresses list is sourced
from the same configuration value instead of the
IServerAddressesFeature, so the listen URL and the OIDC issuer
URLs always match.
Remoting.cs (Mandatory/Remoting.cs): add 'using
Microsoft.Extensions.DependencyInjection;' so the existing OIDC/DB
diagnostic block (capture raw HTTP response + dump OIDC-related DB
state on discovery failure) compiles. The diagnostic itself is left
in place — it's what surfaced the 500 in the first place.
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.
LoadClientAsync chains 9 .Include() calls on dbContext.Clients.
On Postgres (and InMemory for some IdentityServer8 nav types), the
resulting cartesian product trips the query shaper with
IndexOutOfRangeException at IncludeCollection materialisation time.
Bug reproduces in production on the Blog admin pages that load a
Client by id.
AsSplitQuery() rewrites the load as 9 separate SELECTs joined by
client id, which sidesteps the cartesian explosion and any shaper
ambiguity between Claims/Properties/ClientSecrets (which share
Type/Value column names across some IdentityServer8 versions).
Tests:
- EditRedirectUris_GET_after_add_lists_both_uris: end-to-end
reproducer that adds a second RedirectUri via POST then re-GETs
the editor. Guards the fix on the integration path.
- Bisect_*_alone: nine unit tests that exercise the same
.SingleOrDefaultAsync(c => c.Id == id).Include(nav) on the
InMemory provider, one nav at a time. Pinpointed three
problematic navs (RedirectUris, AllowedScopes, AllowedGrantTypes)
on InMemory; kept as a regression net for any future shaper
regressions on the InMemory provider (not the Postgres path).
TestWebApplicationFactory used ASPNETCORE_ENVIRONMENT=Development, which
caused Program.Main's AddConfiguration("org") to load the tracked
appsettings-org.json (the reference file with the
'*** via dotnet user-secrets ou variable d'environnement ***'
placeholder connection string). Npgsql then failed to parse that
placeholder during host startup, failing six integration tests
(observed 2026-07-11: System.ArgumentException on
NpgsqlConnectionStringBuilder.set_Item).
Switching the test host to a dedicated Testing environment makes
AddConfiguration("org") pick up the new optional
appsettings-org.Testing.json file as the last source in the chain
(JSON → env vars), which overrides YavscConnection with the
InMemory marker and the Smtp section with the test stub values.
The .gitignore exception whitelists this file explicitly: it is a
configuration source for the test host, not a secrets file.
The WebServerFixture path is unchanged — it owns its
WebApplicationBuilder and adds the same in-memory override via its
BuildApp hook.
Le commit 2 a fixé la NPE du /BlogSpot/Details/{id} en passant
le display template par UserDisplayHelpers.AvatarSrc, qui
défend contre un UserName null. Ce commit complète le filet
de non-régression et pose la doc d'architecture des tests.
- ApplicationUserDisplayTemplateTests : assert que le cshtml ne
concatène plus directement Model.UserName (ancien code fautif)
et qu'il utilise bien le helper. Si quelqu'un revert la ligne
4 du cshtml, les tests cassent. Les autres usages de
Model.UserName (alt, title, asp-route-id) sont autorisés : ils
ne sont pas la cause du 500, juste laids si null.
- doc/testing.md : vue d'ensemble de la stratégie de test
(conventions NonRegression/Mandatory/Smoke/Controllers, EF
in-memory via InMemoryDatabaseRoot partagé, auth stubs,
quand ne pas écrire de test).
- src/Yavsc.Tests.Shared/README.md : détails du scaffold partagé
(WebHostFixture + son cycle de vie et ses hooks,
TestAuthPolicyProvider, TestTokenIssuer) et des deux
spécialisations dans le repo
(Yavsc.Org.Tests.WebServerFixture et
Yavsc.Blogs.Tests.BlogsWebServerFixture).
- doc/README.md : entrée vers testing.md dans l'index.
GET /BlogSpot/Details/1 retournait 500 (avec un 500-sur-500 sur
la page d'erreur elle-même) parce que DisplayTemplates
/ApplicationUser.cshtml faisait `var avuri = "/Avatars/" +
Model.UserName + ".s.png"` : avec <Nullable>enable</Nullable>,
Razor émet un null-check implicite sur Model.UserName et lève
NullReferenceException quand l'auteur n'a pas de UserName
posé (donnée héritée, user partiellement initialisé).
- UserDisplayHelpers.AvatarSrc : helper statique pur dans
Yavsc.Abstract.Identity qui retourne
YavscConstants.DefaultAvatar pour user null / UserName vide
ou whitespace, et un path /avatars/<name>.s.png sinon.
- ApplicationUser.cshtml : utilise le helper.
- Tests : 4 cas (null, vide, whitespace, valide) dans
Yavsc.Org.Tests/NonRegression.
Bonus : le path d'avatar passe de "/Avatars/" (S majuscule,
ne résolvait pas dans le middleware de fichiers statiques) à
YavscConstants.AvatarsPath ("/avatars" minuscule), pour fermer
l'autre trou que centraliser le calcul permettait de fixer
proprement.
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é.
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.
Three pieces here make the regression diagnosable and pin a
test for the fix:
1. YavscApiClient: replace EnsureSuccessStatusCode() at both
call sites with a small helper that reads the response
body and embeds it in the thrown HttpRequestException. The
VM's existing catch (Exception) in ExecuteAsync forwards
ex.Message to the status bar, so the next 'click Save'
tells the user exactly which field the server rejected.
2. Yavsc.Blogs.Tests: two integration tests on the real
controller (no HTTP mock) — one pins that a well-formed
PostIt-shaped payload (Title + Article + AuthorId + dates,
Id=0) is accepted with 201, the other pins that a payload
with Title=string.Empty is rejected with 400. Together they
pin the contract the VM has to honour.
3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
MainPage inside a headless Window, types a title into the
TextBox without first selecting a post in the list, taps
Save, and asserts the body of the first POST contains
the typed title. Today this test fails with Title='',
reproducing the production 400. The matching fix (a
Title/Article buffer on MainPageViewModel that the XAML
binds to, and that Save uses to build the outgoing
BlogPost) is the next commit; the test is the safety net.
Three related changes that close the loop on the DarkMode
field and lay the first stone of a UI test scaffold for
PostIt.
1. Settings.DarkMode was previously a dead field. It round-
tripped through postit-settings.json and the SettingsPage
CheckBox, OnDarkModeChanged flipped IsDirty, and that was
it — no consumer ever read the value, so toggling the
CheckBox had no visible effect. The fix is in
App.OnFrameworkInitializationCompleted: read the value
Load() just populated and set
Application.Current.RequestedThemeVariant accordingly
(so a dark-mode user lands on a dark window on first
launch, not on a default-light window that flips after
the user touches the toggle), then subscribe to
settings.PropertyChanged and update the theme on every
DarkMode change. The consumer lives in App.axaml.cs, not
in Settings, so the Settings model stays free of any
Avalonia.Application dependency and the SettingsLoadTests
(which construct Settings outside an Avalonia host)
still pass unchanged.
2. MainPageViewModel had a vestigial [ObservableProperty]
ThemeVariant themeVariant = ThemeVariant.Default that no
XAML, no code, and no test ever read. It was the start of
a half-finished attempt to expose the theme variant on
the page VM. The dark-mode wiring above makes it
irrelevant: the theme is now driven by Application, not
by a VM property. The field is removed, along with the
using Avalonia.Styling; it pulled in (now unused).
3. SessionStatusBannerTests adds the first set of UI tests
for PostIt. They mount a real MainWindow via the headless
Avalonia host declared in TestApp.cs, attach a
SessionStatusViewModel as the banner's DataContext, and
assert the actual visual tree contents: three buttons
render (Se déconnecter, Se connecter, Paramètres), the
Login button is visible when logged out, the Logout
button is hidden when logged out, the Paramètres button
is visible regardless of session, and the session label
text reflects the VM. The pattern follows what
UnitTest1.MainPage_Should_Load already established:
[AvaloniaFact] (from Avalonia.Headless.XUnit) plus
new MainWindow() / window.Show(). A plain [Fact] cannot
drive Window..ctor() because the headless platform's
PlatformManager.CreateWindow() has no service registered
outside a dispatcher-aware test context; the
AvaloniaFact attribute provides that context. The
DataContext is set on the banner directly because
App.OnFrameworkInitializationCompleted is not called in
a unit test (production wiring is exercised by the
manual launch, not here).
Build: 0 errors. Tests: 5/5 SessionStatusBannerTests,
3/3 SettingsLoadTests, 1/1 MainPageTests (the existing
scaffold test, unchanged). The other PostIt.Tests suites
depend on the OIDC stub WebApplicationFactory and time out
on this network-restricted host.
Two related changes that close the loop on the SettingsPage
push semantics.
1. The SettingsPage used to be registered as Transient. Each
click on the Paramètres button resolved a fresh instance,
re-bound it to the Settings singleton, and pushed it onto
the navigation stack. Repeated clicks accumulated stacked
instances, each fully bound, and the user had to tap Back
N times to leave. The fix is to register the page as a
Singleton in the DI container. There is now one and only
one SettingsPage ContentPage for the lifetime of the app:
- its DataContext is wired once, at composition time
(just after the ViewLocator is added to DataTemplates),
not on every push;
- the OpenSettingsRequested handler is a pure navigation
concern, with no DI resolution and no rebinding;
- the in-memory Settings state is preserved across visits
(any in-flight edit stays in the same instance).
2. The OpenSettingsRequested handler is guarded so that if the
SettingsPage is already at the top of NavigationStack, the
push is a no-op. NavigationPage.PushAsync does not
deduplicate; without the guard, calling it twice with the
same instance pushes it a second time, and the user has to
tap Back twice to leave. The guard is a reference comparison
on NavigationStack[Count - 1] against the singleton
instance, which is correct precisely because the page is
a singleton.
doc/architecture/postit.md is updated to match: the DI table
reflects the new lifetime, and the 'Garde anti-empilement'
section is rewritten from 'to be implemented' to the actual
implementation, including the rationale for reference
comparison and the cross-dependency between the singleton
lifetime and the guard.
The Settings-singleton invariant (in the same doc) is
unchanged: Settings is still a singleton, and adding a
transient override would still be the bug it always was.
The new SettingsPage singleton sits alongside it cleanly.
Build: 0 errors. Tests: 3/3 SettingsLoadTests green.
Two long-standing gaps in the architecture documentation are
filled in this commit:
1. doc/architecture/postit.md is new. It covers everything the
existing postit-oidc.md does not: the one-codebase /
three-frontends topology (PostIt lib + PostIt.Desktop +
PostIt.Android + PostIt.Browser), the custom ViewLocator
that resolves ViewModel -> View through the DI provider
(and why we don't use the Avalonia.Mvvm default), the
composition root in App.OnFrameworkInitializationCompleted
with the full DI registration table, the navigation flow
driven by SessionStatusViewModel events, the ViewModel
lifetime conventions (singleton vs transient), the
[RelayCommand] XAML binding conventions (referenced to
AGENTS.md for the canonical version), and the per-page
DataContext / role table. The Settings-singleton invariant
is called out as a guard rail, and the SettingsPage
anti-empilement invariant is documented as the TODO the
code still owes us.
2. doc/architecture/decoupage-organisation.md is brought up to
date. Its project table listed 7 .csproj; the repo has 14
(the four PostIt projects, the tests satellites, the cli
tool). The table is extended, the ASCII diagram picks up
the PostIt block, and an Outils et tests section lists
the test / CLI satellites that were missing.
doc/README.md is updated to index the new postit.md. No code
changes in this commit, no behaviour change.
Two changes to the PostIt settings surface, both in service of
the same observation: opening the Settings page did not reflect
the loaded state, and edits to Authority / ClientId did not
persist.
1. Settings was registered twice in the DI container: once as
a singleton (the already-Load()'d instance) and again as a
transient, with the transient registration winning. The
Settings page's DataContext was therefore a brand-new,
empty Settings instance on every push — Authority and
ClientId bound to null, and even if the user typed into the
fields, the edits landed on the throwaway instance and were
silently lost. The fix is the obvious one: keep Settings as
a singleton and drop the transient override.
2. The Scopes field of AuthenticationSettings is a string[],
which doesn't bind to a TextBox without a converter. The
Settings page already shows the other auth fields as plain
TextBoxes, so the same treatment is given to scopes via a
new space-separated view property:
- AuthenticationSettings.ScopeListText (string,
[ObservableProperty], [JsonIgnore]) is the view.
- OnScopeListTextChanged splits on any whitespace and
re-assigns Scopes, skipping the write when the parsed
array is element-wise equal to the current one to avoid
a PropertyChanged loop with OnScopesChanged.
- OnScopesChanged keeps ScopeListText in sync when
Scopes is reassigned from outside (JSON hydration,
MergeScopes, programmatic updates), again short-
circuiting when the textual representation hasn't
changed so the TextBox caret doesn't flicker on load.
- RefreshScopeListText is the explicit re-sync entry
point; Settings.ApplyJson calls it after a successful
hydration to normalise any whitespace the JSON might
have introduced.
SettingsPage.axaml gets a new Scopes row between ClientId
and the Blogs API URL; the Grid.RowDefinitions are bumped
to 13 to match. Scopes remains the on-disk format — only
ScopeListText is presentation.
The shape of the on-disk postit-settings.json is
unchanged: [JsonIgnore] on ScopeListText, and the
serialization path in Settings still round-trips Scopes
directly. MergeScopes in Settings.GetOidcClientOptions is
untouched.
Tests: 3/3 SettingsLoadTests passing (PostIt.Tests);
PostIt.csproj builds clean (0 errors). The other PostIt.Tests
suites depend on the OIDC stub WebApplicationFactory and
time out on this network-restricted host, so we trust the
unit-level coverage and the build.
Extract the kid calculation out of LoadSigningCredentialsInner
into a new internal static HostingExtensions.ComputeKid(string),
and cover it with five focused unit tests in
Yavsc.Org.Tests.ComputeKidTests.
The kid is the bit of signing-credential metadata that ties a
JWT to the right key in the JWKS. Without it, resource servers
(Yavsc.Blogs, Yavsc.Api) fail signature validation with IDX10500
'The signature key was not found', as fixed in 2c6d1157. That fix
inlined three lines of thumbprint-truncation logic at the top of
LoadSigningCredentialsInner, but left the calculation untested.
The tests in this commit pin its shape, value, stability, and
uniqueness, so a future refactor (e.g. switching from SHA-1 to
SHA-256, or moving to X509CertificateLoader for SYSLIB0057) has
to update them deliberately instead of silently changing the
JWKS key id.
Concretely:
- InternalsVisibleTo("Yavsc.Org.Tests") in AssemblyInfo.cs
gives the test project access to the new internal method
without forcing LoadSigningCredentialsInner to leak
further.
- ComputeKid(string) is the single source of truth for the
16-hex truncation; the production call site in
LoadSigningCredentialsInner now reads
'var kid = ComputeKid(certPath);'.
- The inline comment block is updated to say SHA-1 (which is
what X509Certificate2.GetCertHash() actually returns) instead
of the previous SHA-256 claim. The behaviour is unchanged.
- ComputeKid uses X509CertificateLoader.LoadCertificateFromFile
rather than the obsolete 'new X509Certificate2(string)' ctor
(SYSLIB0057); same on-disk behaviour, no obsolete warning.
Tests cover:
- 16-char upper-case hex output matching the first 16 hex
chars of the cert's GetCertHash();
- stability across repeated reads of the same cert;
- distinctness between two independently generated certs;
- the SHA-1 size of the underlying thumbprint (20 bytes), so
a future switch to SHA-256 forces a test update;
- CryptographicException propagation for a missing cert file
(Assert.ThrowsAny to stay portable across the Linux OpenSSL
and Windows leaf exception types).
IdentityServer8 was emitting JWTs without a 'kid' header and
serving the JWKS without per-key identifiers, because
LoadSigningCredentialsInner constructed RsaSecurityKey /
ECDsaSecurityKey objects without an explicit KeyId. Resource
servers (Yavsc.Blogs, Yavsc.Api) cannot match a token to a key
in the JWKS without one, so every signature validation failed
with 'The signature key was not found' (Microsoft.IdentityModel
IDX10500).
Root cause: SigningCredentials were built directly from the
BC-parsed key parameters, bypassing the X509Certificate2 path
IdentityServer normally derives the kid from. The fix derives
a stable KeyId from the certificate's SHA-256 thumbprint
(truncated to 16 hex chars) and sets it on both SecurityKey
variants before constructing SigningCredentials.
The thumbprint-based kid is stable across process restarts as
long as the cert doesn't change, and changes naturally on
LetsEncrypt renewal (~90 days), which is the right behaviour:
old tokens age out, resource servers refresh their JWKS cache
to discover the new kid.
Production rollout: redeploy Yavsc.Org and re-login (or let
the refresh-token path rotate) so newly issued tokens carry
the kid. Pre-restart tokens will continue to be rejected with
IDX10500 until they expire or are refreshed.
SettingsPage.axaml had TextBox / CheckBox TwoWay bindings to the
Settings singleton, but no Save button — user edits mutated the
in-memory instance and were lost on the next launch. This commit
addes the missing save path:
- Settings.Save() writes the current instance to
~/.config/PostIt/postit-settings.json (symmetrical to Load),
with 0600 POSIX permissions matching TokenStore.Save.
- Settings.IsDirty ObservableProperty flips to true on every
setter that flows through the four top-level
[ObservableProperty] fields (DarkMode, BlogsApiUrl,
BusinessApiUrl, plus the OnAuthenticationChanged partial for
the Authentication sub-property). Sub-property edits
(Authentication.Authority / ClientId / RedirectUri / Scopes)
are caught by a PropertyChanged subscription wired up in
OnAuthenticationChanged and re-wired on each Authentication
reassignment.
- [RelayCommand(CanExecute = nameof(CanSave))] on Save itself
emits the SaveCommand ICommand that the XAML binds to.
OnIsDirtyChanged calls SaveCommand.NotifyCanExecuteChanged()
so the button auto-enables / auto-disables. The Avalonia
binding is 'SaveCommand' without a suffix — the source
generator emits that property name from the Save method.
- ApplyJson resets IsDirty = false at the end so disk / embedded
loads don't leave the page stuck in dirty state.
- SettingsPage.axaml: fixed the RowDefinition count (4 rows
declared, 10 used — controls at rows 4..9 were rendering
outside the grid), and added a Sauver button at row 10 bound
to SaveCommand with IsEnabled driven by !IsDirty.
Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Tests: 45 / 45 passing.
Two intertwined jobs here:
1. Diagnostic test for the production 401 we see when PostIt
talks to Yavsc.Blogs. The hypothesis this test isolates:
the access token sent on the wire is missing the 'blogs'
scope that Yavsc.Blogs' BlogScope policy requires (see
Yavsc.Blogs/Program.cs: RequireClaim(JwtClaimTypes.Scope,
"blogs")). The test fakes a single HttpMessageHandler,
captures the outbound bearer, decodes the JWT, and asserts
the 'scope' claim contains 'blogs'. It does not stand up a
server, an OIDC stub, or any network listener. Result: the
scope is present in the access_token we construct, so the
401 is not on the client side — most likely the IdP at
Yavsc.Org is not issuing 'blogs' as a recognised scope.
2. Mechanical fix of the three test files that broke during
the Settings model refactor (PostIt.Settings ->
PostIt.ViewModels.Settings; ApiUrl -> BusinessApiUrl;
Scopes/RedirectUri moved under Authentication;
DefaultDesktopRedirectUri is on AuthenticationSettings in
the global namespace). Also restored the BaseAddress
setup that BlogApiClient does in production in
LoginAndPersistAsync / the reloaded-client path of
YavscApiClientTests, so the two integration tests that
call CallAsync("posts") directly don't trip on
'request URI must be absolute or BaseAddress must be set'.
Test status: 45 / 45 passing in PostIt.Tests.
The "Paramètres" button on SessionStatusBanner was wired to a stub
OpenSettingsCommand with a TODO. With the Settings model refactor
(VM consolidated to ViewModels/Settings.cs, SettingsViewModel.cs
dropped, App.axaml.cs registering Settings instead of the old VM),
the navigation is now plumbed end to end:
- SessionStatusViewModel gains an OpenSettingsRequested event
alongside LogoutCompleted / LoginSucceeded, and the
[RelayCommand] body just raises it. VM stays decoupled from
NavigationPage and window lifetime, same pattern as the
existing banner events.
- App.axaml.cs handles the event in the desktop branch: resolves
SettingsPage (transient) and the canonical Settings singleton
(the one we Load()'d at startup and bound via
Settings.BindToServiceProvider) from DI, then PushAsync the
page on top of the current NavRoot stack. Two-way bindings on
SettingsPage mutate the singleton in place.
Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Existing CS8602 / NU1507 / CS8632 warnings unchanged.
The endpoint was a copy-paste from the IdentityServer template
documentation. It serialised the entire HttpContext.User claim set
to anonymous JSON, with no auth gate. In a public-facing deployment
that's exactly the kind of surface scrapers and botnets love
(it tells them whether their token is valid and what shape the
issuer uses), and it served no production purpose.
Side benefit: removes the ASP0004 analyser warning ("IActionResult
should not be returned from a MapGet Delegate") that came with
this line.
All [ApiController] classes (BlogApi, BlogTags, PostTags, FileSystem,
FileSystemStream, Comments, TagsApi) returned 404 on every route,
even though Kestrel was up. The pipeline in Program.cs was missing
MapControllers(), so the controllers were never attached to the
endpoint data source. MapIdentityApi and MapGet("/identity")
worked because they're explicit minimal-API routes; the attribute-
routed controllers didn't.
Confirmed end-to-end: GET /api/v1/blog now returns 401 (auth
required by [Authorize("BlogScope")]) instead of 404.
Two leftover bits of dead code that were just compiler noise:
- Settings.folder (IStorageFolder?, never read) plus the three
Avalonia / Avalonia.Platform.Storage usings that only existed
to type it. The picker-based flow was replaced by a direct
file-path read in Settings.Load, so the field has been a
CS0414 for a while. Just delete it.
- ViewLocator.Build / Match took 'object data' while the
IDataTemplate interface expects 'object? data', which is why
the compiler was complaining with CS8767 about nullability
mismatch on every implementation. Add an explicit null arm
in the switch so the default branch doesn't have to
dereference a possibly-null data either.
Login flow no longer needs a dedicated page. The OIDC interactive
login now lives on the persistent SessionStatusBanner, alongside
'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync
command. On success the VM raises LoginSucceeded and App.axaml.cs
pushes MainPage on top of HomePage — same path BootAsync already
takes when the silent refresh succeeds at boot, so the two flows
can't drift apart (PushMainPageAsync helper, single source of
truth).
MainPage no longer shows an editable AuthorId field: the server
infers the author from the bearer token, so the client-side
control was misleading at best. The detail grid drops from 5 rows
to 4.
Removed:
- Views/LoginPage.axaml + .axaml.cs
- ViewModels/LoginPageViewModel.cs
- HomePage Login button + OnLoginClick code-behind
- DI registrations for LoginPage / LoginPageViewModel
- ViewLocator mapping
- dangling <c>LoginPage*</c> cref / comments in Platform.cs,
PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml,
YavscApiClient.cs
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
BlogApiController.PostBlog called Request.Form.Files
unconditionally, which throws on a plain JSON body — the
exception is "This request does not have a Content-Type header.
Forms are available from requests with bodies like POSTs and a
form Content-Type of either application/x-www-form-urlencoded or
multipart/form-data."
That broke PostIt's first-bill-of-blog flow: the client posts a
BlogPost as JSON and has no files to attach. The endpoint
contract is [FromBody] BlogPost, so the JSON body is deserialised
into 'blog' as expected — only the IFormFileCollection argument
to BlogSpotService.Create needs a real-or-empty value.
Branch on Request.HasFormContentType: pass the form files when
present, pass an empty FormFileCollection otherwise. BlogSpotService
already short-circuits on a null/empty file collection, so the
JSON-only path is now a clean code path.
Tests: add PostBlog_creates_a_post_and_Get_returns_it_in_the_list
(POST a draft, assert 201 + server-assigned Id, GET the index,
assert exactly one entry with that Id). Add a per-test
ResetDatabase helper because the in-memory store is shared across
the lifetime of the BlogsWebServerFixture instance.
First behavioural test for the Yavsc.Blogs API surface. Sends a
GET on the blog index with the X-Test-Role auth bypass and asserts
the response is 200 with an empty JSON array — the in-memory
ApplicationDbContext has no rows, and BlogSpotService.Index returns
an empty enumeration.
While here, fix a routing miss: AddControllers() in the test
fixture was only scanning the test assembly, so BlogApiController
was never registered. Add the Yavsc.Blogs application part
explicitly. Without this, every request to /api/v1/blog came back
as 404 — the same symptom PostIt was seeing in production.
The POST flow lands in the next commit, once BlogApiController is
made to accept JSON (it currently requires multipart/form-data
because of Request.Form.Files).
Adds the test project that the next commit will use to assert the
blog API endpoints. The fixture inherits from the shared
WebHostFixture (commit "refactor: extract WebHostFixture…") and
wires up only the bits the blog API needs:
* In-memory ApplicationDbContext — BlogSpotService is used as-is,
no mock. The first tests will exercise the real service against
an empty table.
* Trivial IFileSystemAuthManager stub (the GET index path never
reads the file system).
* TestAuthPolicyProvider swapped in, so X-Test-Role satisfies
[Authorize("BlogScope")].
Two smoke tests verify the fixture boots and the controller
pipeline is reachable. The first behavioural test
(GET /api/v1/blog → 200) lands in the next commit.
Also promotes two xunit.v3.* package versions to the root
Directory.Packages.props so future test projects can share them.
Yavsc.Blogs.Tests will need a fixture too. Lifting the cross-cutting
plumbing (Kestrel + self-signed cert + address discovery + lazy init)
into a new Yavsc.Tests.Shared project lets the next fixture inherit
from it without copying 200+ lines of setup boilerplate, and keeps
the Org.Tests fixture focused on its IdentityServer + SMTP seed.
* New project src/Yavsc.Tests.Shared with WebHostFixture (abstract)
and TestAuthPolicyProvider (test auth bypass via X-Test-Role).
* WebServerFixture in Org.Tests now inherits from WebHostFixture;
BuildApp + ConfigurePipelineAsync hold only Org-specific work.
* Two shared package versions promoted to the root Directory.Packages.props.
* Tests still 30/30 green.
The previous "PostIt: fix blog API double-prefix" commit changed
DefaultPathPrefix from "api/blog" to "blog" without spelling out
the convention. Future-me (or anyone else touching ApiUrl) needs
to know that BaseAddress already terminates in /api/v1/ and that
pathPrefix is relative to that.
* BlogApiClient: add a <para> in the class summary that names the
convention, points at the matching controller route, and
cross-references the fix commit.
* postit-oidc.md: add a row in the "Composants partagés" table
with the same warning, in the architectural-doc voice.
BlogApiClient's "api/blog" path combined with the BaseAddress's
"api/v1/" prefix to produce 404s on every call. Drop the redundant
"api/" segment, let Save handle the create case (no selection) and
remove the now-redundant New button + command.
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.
Builds on b0495514 (SignaturePadControl + SignaturePadData) with a
full Avalonia page that captures signatures, renders them as Polylines,
and persists the wire-format payload to ~/.local/share/PostIt/signatures
as JSON v1.
Scope
- New SignaturePage (axaml + code-behind) hosts the render-agnostic
control: a fixed-size Border is the hit-test surface, an overlaid
Canvas is rebuilt on every RedrawRequested from the Strokes buffer.
- SignaturePageViewModel wraps the control: exposes StrokeCount /
PointCount / StatusMessage, Clear and CaptureAsync commands, and
Attach/Detach for view-lifetime ownership.
- CaptureAsync writes a JSON envelope { format, coordinateMax,
capturedAtUtc, strokes, strokeCount } to
LocalApplicationData/PostIt/signatures/signature-yyyyMMdd-HHmmssfff.json.
This is a stop-gap; the production transport will be
POST /api/signature/{devisId} on Yavsc.Api (commit 3+).
- Entry point is a [DEV] button on MainPage that pushes the page
onto the NavigationPage. The production trigger is a SignalR push
from Yavsc.Org ("devis received, sign here") landing on a hub
handler — the button and its Click handler are explicitly marked
dev-only and tracked for removal in the same commit that wires
the SignalR handler.
Plumbing
- App.axaml.cs: SignaturePage and SignaturePageViewModel registered
as Transient in the DI container.
- ViewLocator: routes SignaturePageViewModel to SignaturePage.
- SignaturePadData: adds PointCount (sum of pairs across strokes),
used by the VM status bar and the test surface.
Tests (57/57 green, 9 new in this commit)
- SignaturePageViewModelTests: constructors and dimension validation,
Attach/Detach idempotence, StrokeCompleted and Clear propagate to
the VM, CaptureAsync on empty buffer is a no-op, CaptureAsync on a
non-empty buffer writes a v1 envelope with the expected
structure (parsed back via JsonDocument, not text matching), and
creates the destination directory if missing.
- All previously-green tests (48) remain green.
Out of scope
- POST /api/signature endpoint on Yavsc.Api (commit 3).
- SignalR handler that opens the page on a "devis received" push.
- Rasterization: this commit only proves capture and persistence;
the visible ink is a Polyline reconstruction, not a PNG, by
design (per the wire-format decision in commit 1).
Note on SignaturePadData
- The PointCount property was added after b0495514 landed. It is
folded into this commit rather than amending b0495514 to keep
the existing history readable; the change is mechanical and
tested by the new SignaturePageViewModelTests.
The postit://callback re-launch crashed Avalonia inside
DataValidationErrors.SetErrors with 'The calling thread cannot
access this object because a different thread owns it'. Two
Settings instances raced on PropertyChanged: one was the DI
singleton registered by App.OnFrameworkInitializationCompleted,
the other was a freshly-constructed fallback in
LoginPageViewModel() and LoginPage.axaml.cs's DataContext-null
branch. Avalonia's binding sink caught the cross-thread
notification and crashed before the LoginPage could render.
Fix at three layers:
1. Settings: lock the mutation gate so concurrent Load() /
ApplyJson() callers cannot tear reads; override
OnPropertyChanged to marshal every notification onto the
Avalonia UI thread via a new UiDispatcher helper (no more
cross-thread SetErrors). Add BindToServiceProvider /
RequireCurrent so production code paths cannot silently
allocate a second instance.
2. LoginPageViewModel(): resolve the canonical Settings from
the DI container (Settings.RequireCurrent) instead of
new Settings(). The cross-thread crash is now caught loudly
with a clear 'Settings.Current is not bound' error if
something instantiates the VM outside a bound App.
3. HomePage.axaml.cs and LoginPage.axaml.cs: resolve the
next view-model and BlogApiClient through App.Services
instead of constructing them with 'new'. Same instance
tree as the rest of the app; the postit://callback race
disappears by construction.
MainPageViewModel and HomePageViewModel keep their existing
'?? new Settings()' fallback for test friendliness, but the
fallback is now harmless because Settings itself is
thread-safe.
Adds two regression tests in SettingsLoadTests covering
concurrent Load+mutate and concurrent idempotent Load.
- ContentPage now stretches to fill the window
- root StackPanel replaced by a 3-row Grid (Auto, *, Auto)
so the post list absorbs the middle band and the detail
panel sits below
- detail panel inner StackPanel replaced by a Grid (Auto,
Auto, Auto, *, Auto) so the AvaloniaEdit TextEditor fills
all remaining width and height; MinHeight=320 keeps a
sane floor on tiny windows
HomePage.OnLoginClick does Navigation.PushAsync(new MainPage
{ ... }). NavigationPage.PushAsync only accepts a Page (or
Page subclass), not a MultiPage. MainPage was declared as
public partial class MainPage : NavigationPage
in MainPage.axaml.cs and the root of MainPage.axaml was
<NavigationPage ...>
which means 'new MainPage()' produced a MultiPage, not a
Page. PushAsync against a MultiPage argument does not route
through the standard Page push path; the visible result is
that the login succeeds, LoginSucceeded fires, but the UI
stays on LoginPage — the user is left looking at the
post-login state without any navigation.
The XAML content of MainPage (a StackPanel with the post
CRUD UI, a ListBox, a TextEditor) does not need the
multi-page container semantics — it's a single screen.
Switch both the code-behind base class and the XAML root
element to ContentPage so that MainPage is what
PushAsync expects.
LoginAsync uses ConfigureAwait(false) on the await of
LoginInteractiveCoreAsync. Since the surrounding code is
already executing on the UI thread (it was reached via a
RelayCommand that the UI dispatcher dispatched), the
ConfigureAwait drops the SynchronizationContext, and the
subsequent setters — IsBusy, AccessToken, StatusMessage,
LoginSuccess, and the LoginSucceeded?.Invoke() — run on a
thread-pool worker.
The downstream effects are all UI-bound: PropertyChanged
events fire, the BindingEngine republishes them as
AvaloniaObject.SetValue calls, and SetValue calls
Dispatcher.VerifyAccess. VerifyAccess throws because the
AvaloniaObject was created on the UI thread (owned by it)
and the SetValue is being attempted from a thread-pool
worker. Avalonia 11.12 throws SynchronousException through
DispatcherOperation.InvokeCore instead of dispatching back,
so the X11 message loop crashes the process with
System.InvalidOperationException: 'The calling thread cannot
access this object because a different thread owns it.'
Reproduced with the freshly installed postit_1.0.0-1_amd64.deb
package on a Debian 13 host — the .NET runtime loaded the
app, Avalonia started the X11 message loop, the operator
clicked 'Se connecter', the OIDC flow reached the post-login
phase, and the post-await setter chain crashed the process.
Drop ConfigureAwait(false) so the await captures the UI
thread SynchronizationContext and the setters resume on the
UI thread. The inner LoginInteractiveCoreAsync still uses
ConfigureAwait(false) for its own await, which is fine —
the inner method does not touch observables, only mutates
Platform.CreateBrowser and awaits the OIDC roundtrip, so it
can run anywhere.
The EMaillingTests.SendEMailSynchrone smoke test asserts the
recording fake observed this exact call sequence on a successful
send:
Connect, Authenticate, Send, Disconnect
MailSender.SendEmailAsync only calls Authenticate when
smtpSettings.UserName is non-null (src/Yavsc.Server/Services/
MailSender.cs line 89). WebServerFixture built the host without
a Smtp config — so UserName resolved to null, Authenticate was
skipped, and the recording captured only:
Connect, Send, Disconnect
Pre-existing breakage, not introduced by recent work; the
fixture had been loading from .env indirectly (probably never,
or before a refactor that stopped doing so).
Feed the test host a fake SMTP config via the same
AddInMemoryCollection the fixture already uses for
ConnectionStrings:
Smtp:Host = smtp.test.local
Smtp:Port = 465
Smtp:UserName = test-user
Smtp:Password = test-pass
UserName non-null means MailSender now exercises the Authenticate
branch, which the recording captures. Tests in the Yavsc.Org.Tests
suite: 21/21 green (was 20/21 with SendEMailSynchrone failing).
- CONTRIBUTING.md 'Tests' section now describes the smoke
pattern: per-BC, in-memory TestServer, EF InMemory, asserts
2xx/3xx or 401/403 on a representative GET.
- ROADMAP.md 'Tests d'integration smoke par BC' flips from
open to ticked off (Yavsc.Org coverage), with a note that
Yavsc.Api and Yavsc.Blogs smoke coverage is left for a
future session (separate WebApplicationFactory<Program>
targets).
With this commit, Jalon 0 'Fondations techniques' is fully
ticked off. The release criterion
'dotnet build + dotnet test + docker compose up verts sur
une machine vierge (apres procedure d'install)'
is met end-to-end for Yavsc.Org; the docker-compose criterion
documents the cert/HTTPS requirement for web explicitly.
Two tests, two bounded contexts (BCs as enumerated in
doc/ddd-exploration-2026-06-14.md):
- AccountSmokeTests : GET /signin
YavscConstants.SigninPath = "~/signin"
Routing + Razor + IdentityServer + EF + DI all wired.
- BlogSmokeTests : GET /BlogSpot/Index
BlogSpotController (note the capital S) under
Controllers/Communicating/. No class-level [Route], so
conventional /{controller}/{action} applies.
Both use TestWebApplicationFactory<Program> + the EF InMemory
provider wired by WebServerFixture.SetupHost, so they boot the
production HTTP pipeline without sockets, certs or a real DB.
Closes the 'Tests d'integration smoke par BC' item of Jalon 0
in ROADMAP.md (Yavsc.Api / Yavsc.Blogs coverage to come).
Smoke tests for the Jalon 0 'Tests d'integration smoke par BC'
item need a small helper to:
- issue a GET on an in-memory test server (HttpClient built by
TestWebApplicationFactory<Program>);
- assert that the response is 2xx (page served), 3xx (redirect
to login) or 401/403 (anonymous rejected). Anything else —
404 route missing, 5xx server crash, connection refused —
fails the test.
This commit only introduces the base class. Subsequent commits
add the per-BC smoke tests (Account, Blog, etc.).
The work was actually 'centraliser les versions communes'
(shared packages), not 'centraliser toutes les versions'. That
work is done:
- Directory.Packages.props at the repo root declares shared
package versions (ManagePackageVersionsCentrally=true,
see e.g. coverlet.collector, HigginsSoft.IdentityServer8,
IdentityModel.OidcClient, Microsoft.AspNetCore.*,
xunit.v3, …);
- each product directory imports it via GetPathOfFileAbove
and adds only product-specific versions.
No remaining work justifies the bullet. Removing it from
Jalon 0 leaves one open item: 'Tests d'integration smoke par BC'.
The shorthand 'secrets: - yavsc_appsettings' relies on Compose
v2 to derive both source and target from the same name. In
some BuildKit integrations this is not enough — the secret id
seen inside the Dockerfile (yavsc_appsettings) and the source
defined at top-level (yavsc_appsettings, file: ...) end up not
being mapped correctly, leading to:
cp: cannot stat '/run/secrets/yavsc_appsettings':
No such file or directory
at the blogs-runtime / api-runtime / web-runtime stages.
Use the explicit long form:
secrets:
- source: yavsc_appsettings
target: yavsc_appsettings
in all three runtime services. source is the top-level secret
name (file: ./src/Yavsc.Org/appsettings-org.json); target is
the id BuildKit exposes inside the container at
/run/secrets/yavsc_appsettings, matching --mount=type=secret,
id=yavsc_appsettings in the Dockerfile.
After the Dockerfile refactor into multi-stage (commit 6f975f87),
'docker build .' without --target selects the LAST stage of the
Dockerfile — which is blogs-runtime, an ASP.NET image with no
APK to extract. The subsequent docker cp command then fails
with:
Error: No such container:path: …PostIt.Android/bin/Release/
net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk
Add --target build-env to scope the build to the build-env stage
(the one that produces both the .apk and the publish artifacts,
and which still ends with CMD ["bash"]).
The 14 docker commits landed across this session achieve the
critère de sortie: 'docker compose up' starts db/api/blogs on
a bare host, and web fails with a documented IdentityServer
signing-certificate error that points at the installation
procedure (volume mount + Kestrel:Endpoints:Https in
appsettings-org.json).
Update the checkbox status and link to CONTRIBUTING.md so a
reader of ROADMAP.md can navigate to the install procedure.
Two Jalon 0 items remain open:
- ◐ NuGet centralisation (partial)
- ☐ Tests d'intégration smoke par BC