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 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.
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.
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
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.
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.
- OidcLoginPhase enum + IProgress<OidcLoginPhase> on
YavscApiClient.LoginInteractiveAsync, surfaced in the UI as
PhaseLabel (FR). Lets operators see where the flow actually
stalls, in particular whether the postit://callback ever arrives
on the running instance.
- YavscApiClient.TrySilentLoginAsync: silent refresh at boot.
Returns false (and purges the store) when the refresh token is
rejected by the OP.
- App.OnFrameworkInitializationCompleted auto-routes: HomePage is
the navigation root; on Opened the app calls TrySilentLoginAsync
and pushes MainPage if a session is restored. Logout pops back
to HomePage via the new persistent SessionStatusBanner (Connecté
/ Déconnecté + Logout button).
- PostIt.Desktop.Program.Main now detects the postit://callback
URL BEFORE Avalonia boots, hands it off via SingleInstance, and
exits. Stops the 2nd PostIt instance from flashing its own
MainWindow while the 1st instance is still waiting on the named
pipe. The check in App.OnFrameworkInitializationCompleted is
kept as belt-and-braces defence-in-depth.
- SchemeUrlDetector: pure platform-independent detector extracted
for unit testing.
- Tests: 7 new SchemeUrlDetectorTests + 5 new phase / silent
refresh tests in YavscApiClientTests.
The custom URI scheme (postit://callback) is now the only production
redirect on desktop. The loopback listener at 127.0.0.1:7890 is dead
since the previous scheme-handler refactor; this commit removes it
from every place that could pick it as a default.
Changes:
- Settings.RedirectUri now defaults to DefaultDesktopRedirectUri
(postit://callback) instead of DefaultLoopbackRedirectUri.
- Settings.ApplyJson falls back to DefaultDesktopRedirectUri when
the user settings file omits the RedirectUri field (was
DefaultLoopbackRedirectUri before).
- postit-settings sample.json: RedirectUri flipped to postit://callback
so anyone copying the sample gets a working config.
- DefaultLoopbackRedirectUri kept as a legacy constant (now
documented as test-only); OidcStubAuthority / FakeAuthorizingBrowser
continue to use it as a test fixture.
- The doc on RedirectUri now describes the custom-scheme path and
references AndroidRedirectUri for mobile.
Reminder for the operator: also remove the matching
http://127.0.0.1:7890/ entry from the Yavsc.Org server's allowed
redirect URIs (see src/Yavsc.Org/Extensions/HostingExtensions.cs)
since no client uses it any more.
Tests: 21/21 still green. No code path now sends anything to port 7890
in production.
BlogApiClient is a thin DTO↔path mapper on top of YavscApiClient:
- No more HttpClient, no more accessToken constructor argument.
- Single responsibility: turn Yavsc.Blogs endpoint paths into typed
BlogPost payloads and back, while YavscApiClient owns auth +
refresh + JSON shape.
- Default path prefix is 'api/blog', overridable for tests.
MainPageViewModel and App.axaml.cs are now free of any direct
OidcClient / IBrowser / BearerToken plumbing. The MainPage receives
a fully-configured BlogApiClient (which holds a YavscApiClient, which
holds a TokenStore) at construction. The duplicate LoginAsync method
on MainPageViewModel is gone; the LoginPage is the single entry point
for the interactive PKCE flow.
PlatformBootstrap.Desktop no longer overrides the redirect URI to
loopback. PostIt runs the postit://callback custom scheme
(SingleInstance hand-off) as the production path on desktop; the
loopback constant stays for tests and for platforms that cannot
register a custom scheme.
Settings.cs: DefaultLoopbackRedirectUri is now documented as a
fallback; DefaultDesktopRedirectUri ('postit://callback') is
introduced as the canonical desktop default.
TokenStore.Load() tolerates an empty file (returns null) so
first-launch races and stubbed test fixtures don't blow up the
constructor.
YavscApiClient:
- HasValidSession is exposed for warm-start UI logic.
- CurrentAccessToken / CurrentIdToken are exposed so the LoginPage
ViewModel can mirror the result onto its observable properties.
- CallAsync<T> is now virtual (and the class is no longer sealed)
to allow stubbing in PostItViewModelTests.
Tests (PostIt.Tests):
- YavscApiClientTests covers the silent refresh path (cache the
token, mark it expired, observe a new Bearer in the API server),
the 401 → refresh → retry path (forceFirstRequest on the stub),
HasValidSession after login, and the throw-when-no-token guard.
- OidcStubAuthority now mints a refresh_token in the token response
so YavscApiClient.RefreshTokenAsync can hit /connect/token in
tests.
- PostItViewModelTests uses a ThrowingYavscApiClient / StubYavscApiClient
pair instead of the old HttpClient injection point, matching the
new constructor shape.
dotnet test: 21/21 green. dotnet build: 0 errors.
Move TokenSource.cs and YavscApiClient.cs into PostIt/Services/ under
the PostIt.Services namespace so they sit next to CustomSchemeBrowser
and SingleInstance.
YavscApiClient:
- Take Settings + TokenStore in the constructor; BaseAddress now comes
from settings.ApiUrl (defaults to https://blogs.pschneider.fr/api/v1/)
instead of being hard-coded to yavsc.org.
- Compute access-token expiry from IdentityModel's AccessTokenExpiration
(TimeSpan / DateTimeOffset / int) with a JWT 'exp' claim fallback,
then a 23h default. Fixes the original .AccessTokenExpiration.Second
bug that made tokens expire immediately.
- Use IdentityModel.OidcClient 6.0's RefreshTokenAsync(refreshToken,
cancellationToken:) — the named parameter is 'cancellationToken',
not 'ct' as previously written.
- Wrap HttpClient in a BearerTokenHandler that injects the access
token on every outbound request; serialise the refresh path with a
SemaphoreSlim so concurrent callers don't all rotate the same
refresh token (which Auth0 invalidates on first use).
- 401 from the server triggers a single forced refresh + retry.
- RefreshFailedException is permanent when Auth0 rejects the refresh
token (revoked / rotation-theft detected / expired): TokenStore is
purged and the caller must re-run LoginInteractiveAsync.
- Expose HasValidSession for warm-start skip of the LoginPage.
TokenStore writes the JSON bundle with 0600 on POSIX. Future hardening
will route it through libsecret / DPAPI.
LoginPageViewModel becomes a thin coordinator: it builds (or reuses)
a YavscApiClient and delegates to LoginInteractiveAsync. The legacy
Password / UserEmail / RememberMe fields stay but are marked
[Obsolete] since the PKCE flow is interactive and IdP-collected.
Test hooks preserved: BrowserFactoryOverride, SettingsLoadOverride,
ApiClientOverride.
Builds clean (0 errors). No Auth0Avalonia package reference needed:
Platform.CreateBrowser + CustomSchemeBrowser is the right shape for
PostIt's per-platform redirection.
Narrow ISmtpClient to the four operations MailSender actually uses,
behind a Yavsc.Interfaces.ISmtpClientFactory. Production wires
MailKitSmtpClient (SmtpClientFactory); tests wire a recording fake
(RecordingSmtpClientFactory). The fake is pre-registered in
WebServerFixture so SMTP calls are short-circuited; the EMailling
test now asserts the Connect -> Authenticate -> Send -> Disconnect
sequence. Yavsc.Abstract stays free of MailKit/MimeKit.
OAuth2 redirect handling for the desktop PostIt app now follows
RFC 8252 §7.1: the redirect URI is a custom scheme
(postit://callback) that the OS routes back to PostIt instead of
a 127.0.0.1 HTTP listener. The browser hits the scheme, the OS
launches a fresh PostIt process, that process hands the URL to
the running instance over a named pipe, then exits.
Architecture:
- SingleInstance: cross-platform named-pipe helper. TryHandOffAsync
is what the 2nd instance calls to forward its command-line URL;
StartServerAsync runs on the 1st instance and pumps URLs into
a callback (the running CustomSchemeBrowser).
- CustomSchemeBrowser: IBrowser that opens the system browser on
the authorize URL and blocks until the named pipe yields the
callback URL. No HTTP listener, no port to bind or release,
no HttpListener lifecycle to babysit.
- Platform.DefaultRedirectUri is now postit://callback. The
CustomScheme property exposes the prefix for the redirect
validator.
- App.OnFrameworkInitializationCompleted detects a 2nd-instance
launch by scanning command-line args for the scheme prefix,
hands the URL off, and exits before opening a window. The
first instance starts normally and only the browser is
replaced.
What still has to happen on the user's machine:
- Registering the postit:// scheme with the OS (a one-time
setup step: .desktop file on Linux, registry key on Windows,
Info.plist / LSSetDefaultHandlerForURLScheme on macOS). The
code already validates EndUrl starts with the configured
scheme, so a missing registration surfaces as a clear error
from CustomSchemeBrowser rather than a silent hang.
Removed:
- LoopbackBrowser and LoopbackBrowserTests — the listener,
the timeout, the double Stop/Close dance. The whole class of
'port already bound' / 'next launch fails' issues goes away.
- The /tests/LoopbackBrowserTests.cs regression coverage is
obsolete: there is no listener to release anymore. The single-
instance hand-off is covered by the existing tests on the
OidcClient flow path.
Tests: PostIt.Tests 17/17 pass, Yavsc.Org.Tests 13/14 (the one
remaining failure is SendEMailSynchrone, pre-existing and
unrelated to this change).
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
/signin only renders the local-account sign-in form on Yavsc.Org;
new accounts live at /Account/Register. Update RegisterUrl in
LoginPageViewModel and the matching test accordingly.
The Yavsc.Org sign-in page and the password-reset page are the
canonical entry points for new users and locked-out users; expose
both from PostIt's LoginPage by deriving their URLs from the
configured Authentication.Authority.
* Add RegisterUrl, ForgotPasswordUrl, HasXxxUrl, ConfigMissing and
ConfigMissingMessage to LoginPageViewModel.
* LoginPage loads settings eagerly in the VM ctor so the URLs are
populated when XAML bindings first fire.
* Two new buttons (Register a new account, Forgot password?) bind to
HasXxxUrl via IsEnabled and fall back to Process.Start on click.
* A yellow banner surfaces when Authentication.Authority is empty,
pointing the user at ~/.config/PostIt/postit-settings.json.
Also drop the duplicated OIDC login logic from LoginPage.axaml.cs:
the page now drives Login through LoginPageViewModel.LoginAsync and
DataContext is auto-attached when HomePage pushes the page without
a VM.
Tests cover the happy-path OIDC flow, URL derivation, and the
ConfigMissing flag.
Add a stubbed OIDC authority (discovery, jwks, /connect/token,
/connect/userinfo) and a fake IBrowser, then cover the full
authorization-code + PKCE flow in LoginPageViewModelTests.
The new LoginPageViewModel(Settings, Func<IBrowser?>) constructor
plus the BrowserFactoryOverride property keep production wiring
unchanged: the existing parameterless ctor and the platform
projects' Platform.CreateBrowser still drive runtime.
The previous commit set Platform.CreateBrowser to null on the desktop
side, so LoginAsync would still fail with 'No browser is available'.
Close that loop with an explicit desktop bootstrap.
PostIt.Desktop/PlatformBootstrap.cs mirrors the Android side: it
populates Platform.DefaultRedirectUri and Platform.CreateBrowser
once at startup. Program.Main calls EnsureInitialized before
BuildAvaloniaApp so the LoginPageViewModel sees a working browser
before any login attempt.
The Yavsc.Org seed now reads Site:ExternalUrl from configuration so
the RedirectUri list for the PostIt client follows the same setting
as the rest of the application (same value used in
Administration/ClientController, AccountController, etc.). Without
this, an embedded 'launch PostIt from a Yavsc.Org page' scenario
would be rejected by IdentityServer (redirect_uri mismatch).
BuildPostItRedirectUris is a small helper that yields the constant
PostItRedirectUris (loopback + Android custom scheme) followed by
Site:ExternalUrl when set. Both SeedNewPostItClient (fresh db) and
MigratePostItClientToPublic (existing db) consume it. The legacy
cleanup block (which used to remove https://yavsc.pschneider.fr/
and yavsc://callback) is dropped: Site:ExternalUrl is now the
canonical way to authorise that path and may legitimately equal
that value.
The earlier commit removed the client_secret and wired
MainActivity.OnNewIntent to AndroidOidcCallbackSink, but
IdentityModel.OidcClient.LoginAsync still had no IBrowser to drive
the user-agent half of the flow. Without it, the desktop / browser
projects continue to fail at login with 'No browser is available'.
Android now plugs in Chrome Custom Tabs:
* PostIt.Android/Services/AndroidSystemBrowser.cs implements
IBrowser.InvokeAsync using CustomTabsIntent.LaunchUrl and waits
for MainActivity.AndroidOidcCallbackSink to deliver the deep-link
Intent (android://postit-signin?code=...&state=...).
* PostIt/Services/Platform.cs is a tiny static indirection the
shared library uses to ask the running platform for an
IBrowser and the appropriate default RedirectUri, without
referencing any UI framework from the shared assembly.
* LoginPageViewModel reads Platform.DefaultRedirectUri and
Platform.CreateBrowser().Invoke() before calling LoginAsync.
* PostIt.Android/PlatformBootstrap.cs wires the Android side at
startup, and MainActivity.OnCreate calls EnsureInitialized().
* Xamarin.AndroidX.Browser 1.8.0 added to the central package
versions so CustomTabsIntent resolves.