ink-signature: pen-plotter pipeline for original wet-ink CMS signatures

The Standard no-login CMS path needs an ORIGINAL ink signature on paper
(CMS-10114: 'Stamped, faxed or copied signatures will not be accepted'). This
adds a pipeline to redraw the provider's own captured strokes in real ink with a
pen on a CR-10 V2 (or any Marlin/GRBL machine) — original, in ink, never copied.

- migration 090: esign_records.signature_vector (JSONB stroke paths, 0..1).
- signing page now captures normalized stroke paths alongside the PNG; API
  stores a size-bounded vector for drawn signatures.
- ink_signature_plotter.py (hardware-independent): fit strokes to the signature
  anchor box, PDF-pt -> bed-mm via jig offset, emit Marlin/GRBL G-code (Z pen or
  M280 servo/BLTouch), SVG toolpath preview, and render_signature_on_pdf (a
  digital twin that proves the toolpath lands on the cert line). Gated serial
  sender (dry_run default).
- ink_signature_cli.py: end-to-end load-record -> gcode+preview, --test-box jig
  calibration, --plot to stream over USB.
- Corrected CMS-10114 signature anchor to sit inside the Section 4A signing cell
  (above the bottom rule, below the label).
- docs/ink-signature-plotter.md documents the CR-10 retrofit + interpretive risk.

Tests: test_ink_signature.py 30/30, test_cms10114.py 27/27, test_paper_batch.py
15/15, API tsc clean, Astro build 58 pages.
This commit is contained in:
justin 2026-06-07 02:34:17 -05:00
parent e6a630ada1
commit b0a8563a93
8 changed files with 994 additions and 19 deletions

View file

@ -117,7 +117,7 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res:
const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!;
const { signature, agreed_at, user_agent } = req.body as {
signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string };
signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string; vector?: any };
agreed_at?: string;
user_agent?: string;
};
@ -174,6 +174,23 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res:
? signature.image_b64!.replace(/^data:image\/png;base64,/, "")
: signature.name!.trim();
// Sanitize the optional vector (stroke paths) — bound the size so a hostile
// client can't store an enormous JSON blob. Only kept for drawn signatures.
let sigVector: string | null = null;
if (signature.type === "drawn" && signature.vector && Array.isArray(signature.vector.strokes)) {
const v = signature.vector;
const strokeCount = v.strokes.length;
const pointCount = v.strokes.reduce((n: number, s: any) => n + (Array.isArray(s) ? s.length : 0), 0);
if (strokeCount > 0 && strokeCount <= 500 && pointCount > 0 && pointCount <= 20000) {
sigVector = JSON.stringify({
v: 1,
w: Number(v.w) || 0,
h: Number(v.h) || 0,
strokes: v.strokes,
});
}
}
const clientIp = (req as any).clientIp || req.ip || "";
const signedAt = new Date().toISOString();
@ -182,16 +199,18 @@ router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res:
SET status = 'signed',
signature_type = $1,
signature_data = $2,
signer_email = $3,
signer_ip = $4,
signer_user_agent = $5,
signed_at = $6,
perjury_agreed = $7,
signature_vector = $3,
signer_email = $4,
signer_ip = $5,
signer_user_agent = $6,
signed_at = $7,
perjury_agreed = $8,
updated_at = NOW()
WHERE id = $8`,
WHERE id = $9`,
[
signature.type,
sigData,
sigVector,
email,
clientIp,
user_agent || "",