Commit graph

3,121 commits

Author SHA1 Message Date
375e6482a6 test(yavsc.org): cover the kid derivation in ComputeKid
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).
2026-07-09 20:28:58 +01:00
2c6d11577c Yavsc.Org: set KeyId on signing credentials
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.
2026-07-09 00:32:20 +01:00
6055117929 first, the blogs 2026-07-08 23:18:14 +01:00
0adc60d5ed simpler 2026-07-08 23:16:16 +01:00
2eadd6e1b7 better 2026-07-08 23:16:04 +01:00
b7873ebd7c GET posts [dev] 200 2026-07-08 22:46:46 +01:00
0aea6c0dbd PostIt: Sauver button on SettingsPage with dirty tracking
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.
2026-07-08 20:05:09 +01:00
e9df13a477 test(postit): pin blogs scope on bearer, fix post-refactor tests
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.
2026-07-08 19:36:40 +01:00
4a6609e2f1 PostIt: route Paramètres button to SettingsPage
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.
2026-07-08 19:03:54 +01:00
0480e3e3e8 fix(blogs): drop debug MapGet("/identity") claim-dumper
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.
2026-07-07 22:13:29 +01:00
006e05a375 fix(blogs): register controller endpoints via MapControllers
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.
2026-07-07 21:48:02 +01:00
4dc1946c82 PostIt: drop unused Settings.folder + align ViewLocator nullability
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.
2026-07-07 20:59:26 +01:00
9e272a8147 PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
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
2026-07-07 20:47:00 +01:00
c3f2408c4a test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
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).
2026-07-06 23:29:51 +01:00
6d222cf819 fix(blogs): accept JSON on POST /api/v1/blog (no file)
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.
2026-07-06 22:03:41 +01:00
9f5a1505e3 test(blogs): GET /api/v1/blog returns 200 with empty list
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).
2026-07-06 21:58:14 +01:00
8c38bab45a feat(tests): scaffold Yavsc.Blogs.Tests with BlogsWebServerFixture
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.
2026-07-06 21:49:53 +01:00
49b9619af7 Merge pull request 'postIt' (#1) from postIt into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #1
2026-07-06 21:35:47 +01:00
349ddc03f5 refactor: extract WebHostFixture + TestAuthPolicyProvider to shared lib
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
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.
2026-07-06 21:33:57 +01:00
20a6f22ec3 PostIt: document the BaseAddress / pathPrefix URL convention
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
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.
2026-07-06 21:03:10 +01:00
0d2d4160af PostIt: fix blog API double-prefix; drop redundant New
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.
2026-07-06 20:58:58 +01:00
f2ae01729a clean up
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
2026-07-06 19:19:28 +01:00
dce17888ac code cleanup 2026-07-06 03:31:05 +01:00
f11913ec08 build 2026-07-06 03:26:57 +01:00
a5acfcfc05 revert 2026-07-06 03:25:19 +01:00
89aa2bc37d migration 2026-07-06 03:17:23 +01:00
4a70abc0f9 Merge branch 'feat/estimate' 2026-07-06 01:07:43 +01:00
835cb47b18 could fix the CI 2026-07-06 01:06:50 +01:00
aca3ceffe2
Merge pull request #66 from pazof/feat/estimate
Feat/estimate
2026-07-06 00:58:04 +01:00
19e3b30830
Potential fix for pull request finding 'CodeQL / Missing cross-site request forgery token validation'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-06 00:57:47 +01:00
3932d8823f
Merge pull request #65 from pazof/dependabot/github_actions/all-actions-640176b5ab
build(deps): bump actions/checkout from 4 to 7 in the all-actions group across 1 directory
2026-07-06 00:50:17 +01:00
6ac264fa2c tests 2026-07-06 00:47:35 +01:00
c08ff81776 fixes the startup 2026-07-06 00:14:22 +01:00
333b066e66 Identity reloaded 2026-07-05 23:56:10 +01:00
dependabot[bot]
a2c7cf9f9c
build(deps): bump actions/checkout
Bumps the all-actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-05 16:04:33 +00:00
ef0f5ddcac refacto FrontmatterParser 2026-07-04 22:58:29 +01:00
7b3a236bcc refacto query status 2026-07-04 22:35:53 +01:00
ee6cb34c23 renaming Reviewed 2026-07-04 22:25:59 +01:00
c74ac71b5d layouts 2026-07-04 20:09:58 +01:00
bce6280750 titres 2026-07-04 20:03:56 +01:00
c8894c4220 gives titles 2026-07-04 19:57:03 +01:00
34b4203cd1 Ui fixes 2026-07-04 19:49:48 +01:00
17838bc78e fixes 2026-07-04 18:46:24 +01:00
a0342ea988 Revert "search all user by email at forgotten password"
This reverts commit 406e2ff03a.
2026-07-04 17:29:49 +01:00
406e2ff03a search all user by email at forgotten password 2026-07-04 17:24:12 +01:00
742da7c3f0 Activity moderated 2026-07-04 17:11:43 +01:00
90dfe9c13f drop the deigner.cs 2026-07-04 16:06:02 +01:00
Lum
f4eb14d083 feat(api): POST /api/bill/estimate/{id}/sign — JSON signature capture
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.
2026-07-04 15:47:55 +01:00
1d26cbdf3d refacto chathub 2026-07-04 15:28:07 +01:00
Lum
b939c403f6 feat(postit): signature capture page (dev entry, file persistence)
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.
2026-07-04 15:11:22 +01:00