Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath

FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Schneider 2026-09-21 16:15:18 +01:00
commit 709846c58d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
10 changed files with 5249 additions and 430 deletions

View file

@ -1,158 +0,0 @@
#nullable enable annotations
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Yavsc.Models.Billing;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.Server.Helpers;
/// <summary>
/// Filesystem counterpart of <see cref="Signature"/>: writes
/// the wire-format JSON payload next to the user's other files,
/// under <c>signatures/</c>, and updates the user's disk quota.
///
/// This is the JSON counterpart of the legacy
/// <c>ReceiveProSignatureAsync</c> method, which stored
/// <c>sign-{billingCode}-{signType}-{estimateId}.png</c> blobs.
/// We don't reuse that helper because (a) the wire format is no
/// longer a binary image, (b) there's no <c>billingCode</c> on
/// a freshly signed estimate in our model, and (c) the legacy
/// helper takes an <see cref="IFormFile"/> whereas our pipeline
/// decodes a JSON body upstream of the controller and passes
/// <see cref="SignaturePadPayload"/> in directly.
/// </summary>
public static class EstimateSignatureFileHelper
{
/// <summary>
/// Sub-directory under the user's root where signature
/// payloads live. Kept short to leave room in PATH_MAX on
/// legacy filesystems; the rest of the filename is
/// <c>sign-{type}-{estimateId}-{utcTicks}.json</c>.
/// </summary>
public const string SignaturesSubdir = "signatures";
/// <summary>
/// Format string for signature file names. Public so the
/// migration and the admin tools can list by pattern.
/// </summary>
public static string FileNameFormat(SignatureType type, long estimateId, long utcTicks)
=> $"sign-{type.ToString().ToLowerInvariant()}-{estimateId}-{utcTicks}.json";
/// <summary>
/// Persist a signature wire payload to disk. Returns the
/// file info (relative path under the user's root) suitable
/// for storing in <see cref="Signature.FilePath"/>; the
/// caller is responsible for the database write.
/// </summary>
/// <param name="user">Signed-in user. Their
/// <c>Identity.Name</c> locates the disk root via
/// <see cref="AbstractFileSystemHelpers.UserFilesDirName"/>.
/// </param>
/// <param name="estimateId">Estimate this signature
/// attaches to. Used in the file name for human inspection
/// and to support multiple versions over time.</param>
/// <param name="type">Provider or client signature.</param>
/// <param name="payload">Decoded wire payload (strokes +
/// coordinateMax + capturedAtUtc). Already validated
/// upstream.</param>
/// <param name="token">Cancellation token forwarded to
/// the file write.</param>
public static async Task<FileReceivedInfo> ReceiveEstimateSignatureAsync(
this ClaimsPrincipal user,
long estimateId,
SignatureType type,
SignaturePadPayload payload,
SiteSettings siteSettings,
CancellationToken token = default)
{
if (user is null) throw new ArgumentNullException(nameof(user));
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
// Ensure the user has a /signatures/ sub-directory we can
// write to. EnsureDestinationDirectory throws on invalid
// paths and creates the directory on the way; the
// SignaturesSubdir constant is a server-controlled value
// (not user-derived), so we skip the IsValidYavscPath
// check that ReceiveUserFile performs on user-supplied
// subpaths.
var root = user.EnsureDestinationDirectory(SignaturesSubdir, siteSettings);
var fileName = FileNameFormat(type, estimateId, DateTime.UtcNow.Ticks);
var fullPath = Path.Combine(root, fileName);
var envelope = new
{
format = "yavsc.signature/v1",
coordinateMax = payload.CoordinateMax,
capturedAtUtc = payload.CapturedAtUtc,
estimateId,
type = type.ToString(),
// Identity.Name is the username; we keep the wire
// payload keyed on the username rather than the
// numeric/guid Id so disk-side human inspection
// (e.g. cat sign-pro-1234-...json) is self-evident.
signerName = user.Identity?.Name,
strokes = payload.Strokes,
strokeCount = CountStrokes(payload.Strokes),
};
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8, token).ConfigureAwait(false);
// Quota update is the controller's responsibility: the
// helper has no DbContext access, and a ClaimsPrincipal
// is not an ApplicationUser. The controller looks up
// the user by Identity.Name and bumps DiskUsage after
// a successful database write.
return new FileReceivedInfo(root, fileName);
}
private static int CountStrokes(int[] strokes)
{
int n = 0;
for (int i = 0; i < strokes.Length;)
{
int k = strokes[i];
if (k <= 0) break;
n++;
i += 1 + 2 * k;
}
return n;
}
}
/// <summary>
/// Wire payload accepted by the signature endpoint and
/// persisted by <see cref="EstimateSignatureFileHelper"/>.
/// Mirrors <c>PostIt.Models.SignaturePadData</c>'s JSON shape
/// (without the disk-only envelope fields) so the two sides
/// stay trivially compatible.
/// </summary>
public class SignaturePadPayload
{
/// <summary>
/// Normalised coordinate upper bound. Must be
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
/// (10_000) today; declared as a property so a future
/// resolution change can be replayed against the same
/// wire format.
/// </summary>
public int CoordinateMax { get; set; } = 10_000;
/// <summary>
/// Client-reported capture time. The server may ignore
/// this for ordering (UTC now is the truth) but keeps it
/// for round-trip display.
/// </summary>
public DateTime CapturedAtUtc { get; set; }
/// <summary>
/// Wire strokes. See
/// <c>PostIt.Models.SignaturePadData</c> for the format.
/// </summary>
public int[] Strokes { get; set; } = Array.Empty<int>();
}

View file

@ -0,0 +1,111 @@
#nullable enable annotations
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Billing;
namespace Yavsc.Server.Helpers;
/// <summary>
/// Shared signature-persistence logic for estimates, extracted from
/// <c>BillingController.Sign</c>. Stages the <see cref="Signature"/>
/// row (find-or-add by <c>(EstimateId, Type)</c>) for the PostIt
/// wire-format payload — but does <b>not</b> call
/// <c>SaveChanges</c>. The caller owns the transaction so it can
/// combine the signature write with its own changes (e.g. setting a
/// validation date or a query status) in a single
/// <c>SaveChanges</c>.
///
/// <para>
/// The <see cref="Signature.Strokes"/> column is the source of
/// truth; there is no on-disk copy, so no disk-quota accounting is
/// involved.
/// </para>
/// </summary>
public static class EstimateSignaturePersister
{
/// <summary>
/// Stage a signature for <paramref name="estimateId"/> signed by
/// <paramref name="signerId"/> as <paramref name="type"/>. Upserts
/// the <see cref="Signature"/> row for the
/// <c>(EstimateId, Type)</c> pair, writing the wire payload
/// (<c>Strokes</c>, <c>CoordinateMax</c>, <c>CapturedAtUtc</c>)
/// and <paramref name="signerId"/>. Returns the staged
/// <see cref="Signature"/>; the caller persists with
/// <c>SaveChanges</c>.
/// </summary>
public static async Task<Signature> StageAsync(
ApplicationDbContext db,
long estimateId,
SignatureType type,
string signerId,
SignaturePadPayload payload,
CancellationToken token = default)
{
if (db is null) throw new ArgumentNullException(nameof(db));
if (payload is null) throw new ArgumentNullException(nameof(payload));
if (estimateId <= 0) throw new ArgumentOutOfRangeException(nameof(estimateId));
// Find-or-add: the (EstimateId, Type) pair is unique, so a
// second submission for the same side of the estimate
// replaces the previous signature. EF translates this into a
// single UPDATE when the row exists and an INSERT otherwise;
// the unique index in ApplicationDbContext is the
// database-level guarantee that the contract holds if two
// requests race.
var signature = await db.Signatures
.FirstOrDefaultAsync(s => s.EstimateId == estimateId && s.Type == type, token)
.ConfigureAwait(false);
if (signature is null)
{
signature = new Signature
{
EstimateId = estimateId,
SignerId = signerId,
Type = type,
};
db.Signatures.Add(signature);
}
signature.SignerId = signerId;
signature.CoordinateMax = payload.CoordinateMax;
signature.Strokes = payload.Strokes;
signature.CapturedAtUtc = payload.CapturedAtUtc;
return signature;
}
}
/// <summary>
/// Wire payload accepted by the signature path and staged by
/// <see cref="EstimateSignaturePersister"/>. Mirrors
/// <c>PostIt.Models.SignaturePadData</c>'s JSON shape (without the
/// disk-only envelope fields) so the two sides stay trivially
/// compatible.
/// </summary>
public class SignaturePadPayload
{
/// <summary>
/// Normalised coordinate upper bound. Must be
/// <c>PostIt.Models.SignaturePadData.CoordinateMax</c>
/// (10_000) today; declared as a property so a future
/// resolution change can be replayed against the same
/// wire format.
/// </summary>
public int CoordinateMax { get; set; } = 10_000;
/// <summary>
/// Client-reported capture time. The server may ignore
/// this for ordering (UTC now is the truth) but keeps it
/// for round-trip display.
/// </summary>
public DateTime CapturedAtUtc { get; set; }
/// <summary>
/// Wire strokes. See
/// <c>PostIt.Models.SignaturePadData</c> for the format.
/// </summary>
public int[] Strokes { get; set; } = Array.Empty<int>();
}

View file

@ -7,8 +7,8 @@ namespace Yavsc.Models.Billing;
/// <summary>
/// One captured signature, attached to a single
/// <see cref="Estimate"/>. Multiple versions are allowed per
/// (EstimateId, Type, SignerId) tuple — the controller reads
/// the most recent when asked. The wire-format payload is the
/// (EstimateId, Type, SignerId) tuple — the controller reads the
/// most recent when asked. The wire-format payload is the
/// same <c>int[]</c> shape PostIt produces (see
/// <c>PostIt.Models.SignaturePadData</c>): a length-prefixed
/// sequence of strokes, each stroke being
@ -16,13 +16,12 @@ namespace Yavsc.Models.Billing;
/// CoordinateMax]</c>.
///
/// <para>
/// Why a separate table (instead of a JSON column on
/// <see cref="Estimate"/>): the jalon 1 spec calls for at least
/// two distinct signatures per estimate cycle (provider
/// validation + client agreement) and the audit value of
/// preserving superseded versions. A dedicated table also keeps
/// the <see cref="Estimate"/> row narrow, which matters for
/// list views.
/// The <see cref="Strokes"/> column is the source of truth: the
/// signature is captured and stored entirely in-app (PostIt) and
/// round-tripped as JSON, with no on-disk copy. A dedicated table
/// (instead of a JSON column on <see cref="Estimate"/>) keeps the
/// <see cref="Estimate"/> row narrow for list views and preserves
/// superseded versions for audit.
/// </para>
/// </summary>
public class Signature
@ -75,16 +74,4 @@ public class Signature
public int[] Strokes { get; set; } = Array.Empty<int>();
public DateTime CapturedAtUtc { get; set; }
/// <summary>
/// Path to the JSON-serialised wire payload on disk,
/// relative to <c>UserFilesDirName</c>. The disk copy is the
/// source of truth for the wire bytes; the <see cref="Strokes"/>
/// column is a denormalised index for queries. They are
/// written together in the same transaction by the
/// controller; the migration should keep them in sync
/// through <c>ApplicationDbContext.SaveChanges</c>.
/// </summary>
[Required]
public string FilePath { get; set; }
}
}