diff --git a/CHANGELOG.md b/CHANGELOG.md index 7933c05..f91bfd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ release. ## [Unreleased] +## [0.8.0] — 2026-06-21 + +SignWell field-placement fixes from field feedback, plus signing-order control, +a test-mode safety banner, and the field-coordinate contract. + +### Added + +- **`--ordered true|false`** on `request send`, `request run-email`, and `request send-embedded` — controls SignWell `apply_signing_order`. `false` requests parallel/unordered signing; default stays sequential when there are 2+ signers. +- **`bottomLeftToTopLeft()`** helper in `field-placement` + **[`docs/field-coordinates.md`](docs/field-coordinates.md)** — documents the per-provider `--field` coordinate contract (top-left origin; 1-based `page`/`signer` vs 0-based `doc`) and converts bottom-left/pdfjs detector coordinates to provider space. +- Loud **SignWell test-mode banner** on every send. Test mode stays the default (non-binding, watermarked) but is no longer silent: a prominent stderr banner fires whenever it's active, pointing at `--test-mode false`. Suppressed when test mode is off. + +### Fixed + +- **SignWell: custom `--field` placements were silently dropped.** `with_signature_page` was hardcoded `true`, which makes SignWell discard supplied fields and auto-place its own. It is now `false` whenever custom fields are present. +- **SignWell: fields were sent in the wrong place in the payload.** Fields were nested under `files[].fields`; SignWell requires a top-level 2-D `fields` array (one inner array per file). With both fixes, custom placements reach SignWell instead of being silently dropped (or rejected with `recipients.with_no_fields`). Adds a payload-level regression test. +- **Stray `ExperimentalWarning: SQLite` no longer clutters stderr.** `node:sqlite` is loaded lazily and the warning is filtered (set `SIGN_SHOW_WARNINGS=1` to restore it), keeping machine-readable output clean. +- **`sign mcp --help` now lists subcommands** (`mcp serve`, `mcp tools`) instead of erroring with "No help entry for mcp". Applies to any parent command. + +### Changed + +- `--field` help now documents `width`, `height`, the full `type:` set (`signature|initials|date|text|name|email`), the 0-based `doc` vs 1-based `page`/`signer` indexing, and points at `docs/field-coordinates.md`. Send commands document `--ordered` and `--test-mode`. + ## [0.7.1] — 2026-06-03 Build-tooling only — **no runtime or API changes** from 0.7.0. Cut so the SEA diff --git a/docs/field-coordinates.md b/docs/field-coordinates.md new file mode 100644 index 0000000..d4d4540 --- /dev/null +++ b/docs/field-coordinates.md @@ -0,0 +1,90 @@ +# Field placement & the coordinate contract + +`--field` (on `request create` and `request run-email`) places a signature, +initials, date, or text box at an explicit spot on a document. This page is the +contract for what those coordinates mean — per provider — because the values are +passed through to the provider largely untransformed, and getting the origin +wrong lands a signature in the middle of your body text. + +## The `--field` grammar + +``` +--field signer:N,doc:N,page:N,x:N,y:N[,type:T][,width:N][,height:N][,required:true|false] +``` + +| Key | Meaning | Indexing | +|------------|-------------------------------------------------------------------------|----------| +| `signer` | Which signer fills the field. Matches the `order:N` on `--signer`. | **1-based** | +| `doc` | Which document (when you pass multiple `--document`). | **0-based** | +| `page` | Page within that document. | **1-based** | +| `x`, `y` | Top-left corner of the field box, in provider units (see below). | — | +| `type` | `signature` (default) \| `initials` \| `date` \| `text` \| `name` \| `email`. | — | +| `width` | Field box width. Optional; providers apply a default if omitted. | — | +| `height` | Field box height. Optional; providers apply a default if omitted. | — | +| `required` | `true` (default) or `false`. | — | + +> The mixed indexing is historical: `doc` is a 0-based array index, while `page` +> and `signer` mirror the 1-based numbers a human reads off the page and the +> `--signer order:N`. If you pass `doc:1` with a single document you'll get +> `Field doc:1 is out of range`. + +Anchor / text-tag placement (`anchor:"Sign here"`) is **not** supported through +this CLI for any provider — you must supply explicit `page` + `x` + `y`. + +## The origin: top-left, every provider + +All three remote providers this CLI targets place fields from the **top-left +corner of the page**, with `x` increasing rightward and `y` increasing +**downward**: + +| Provider | Origin | `x`/`y` refer to | Units | +|---------------|-----------|-------------------------|-----------------------------------------| +| SignWell | top-left | top-left of the field | page pixels (top-left origin) | +| Dropbox Sign | top-left | top-left of the field | pixels from the top-left of the page | +| DocuSign | top-left | top-left of the tab | pixels (`xPosition`/`yPosition`) | + +So `--field ...,x:72,y:50` is "72 across, 50 down from the top-left corner". + +## The trap: bottom-left detectors + +PDF user space — and most pdfjs-based "find the signature line" detectors — use a +**bottom-left** origin, where `y` increases **upward**. If you feed those numbers +straight into `--field`, the field is mirrored vertically and lands in the wrong +place (often in the body text near the top). + +Convert before you place. The flip is: + +``` +y_top_left = pageHeight - y_bottom_left - fieldHeight +``` + +This CLI ships that conversion so you don't hand-roll it: + +```ts +import { bottomLeftToTopLeft } from "sign-cli/dist/lib/field-placement.js"; + +// pageHeight and y in the same units (e.g. PDF points; US Letter = 792pt tall) +const { x, y } = bottomLeftToTopLeft({ x: 72, y: 100, pageHeight: 792, height: 30 }); +// → { x: 72, y: 662 } ready for --field x:72,y:662,height:30 +``` + +Pass `height` so the box's top edge lands where you expect; omit it and you get +the baseline point flipped (the field's top edge sits on the detected line and +the box extends downward). + +### Units / DPI + +`x`, `y`, `width`, `height` must all be in the **same** unit as the page +dimension you used for the conversion. If your detector reports PDF points +(72 per inch), keep everything in points. If it reports pixels rendered at some +DPI, convert the page height to that same pixel space first. Mixing points and +rendered pixels is the most common way to be "close but off by a scale factor". + +## Quick checklist + +- [ ] `signer` matches a `--signer order:N` (1-based). +- [ ] `doc` is 0-based; a single document is always `doc:0`. +- [ ] `x`/`y` are top-left origin, `y` increasing downward. +- [ ] If your coordinates came from a pdfjs/bottom-left detector, run them + through `bottomLeftToTopLeft` first. +- [ ] `x`/`y`/`width`/`height` are all in the same unit. diff --git a/package.json b/package.json index d808128..9d68768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@drbaher/sign-cli", - "version": "0.7.1", + "version": "0.8.0", "mcpName": "io.github.DrBaher/sign-cli", "publishConfig": { "access": "public" diff --git a/server.json b/server.json index d87a85c..de12b30 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/DrBaher/sign-cli", "source": "github" }, - "version": "0.7.1", + "version": "0.8.0", "packages": [ { "registryType": "npm", "identifier": "@drbaher/sign-cli", - "version": "0.7.1", + "version": "0.8.0", "transport": { "type": "stdio" } diff --git a/src/cli.ts b/src/cli.ts index 5459e23..b7fe6e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import "./lib/silence-warnings.js"; import process from "node:process"; import { openDatabase } from "./lib/db.js"; import { requireDropboxApiKey, requireDropboxClientId, resolveDropboxTestMode } from "./lib/dropbox-sign.js"; @@ -20,6 +21,8 @@ import { runSignerWatch } from "./lib/signer-watch.js"; import { buildCatalogJson, findCommand, + findCommandGroup, + formatCommandGroupHelp, formatCommandHelp, formatExamples, formatTopLevelHelp, @@ -94,7 +97,7 @@ import { parseFieldSpec } from "./lib/field-placement.js"; import { loadPolicySpec } from "./lib/policy-engine.js"; import { parseImageInput, stampImageOnPdf, type StampPosition } from "./lib/pdf-image-stamp.js"; import { loadRequestSpec } from "./lib/request-spec.js"; -import { parsePrefillSpec, parseSignerSpec } from "./lib/util.js"; +import { parseBooleanFlag, parsePrefillSpec, parseSignerSpec } from "./lib/util.js"; import { loadWebhookPayloadFile, verifyDropboxCallback } from "./lib/webhook.js"; import { startWebhookServer } from "./lib/webhook-server.js"; @@ -342,11 +345,36 @@ function resolveProviderTestMode(provider: ReturnType { loadEnv(); const parsed = parseArgs(process.argv.slice(2)); @@ -432,6 +460,12 @@ async function main(): Promise { return; } } + // No exact match — fall back to listing subcommands for a parent like "mcp". + const group = findCommandGroup(queryPositionals.join(" ")); + if (group.length > 0) { + console.log(formatCommandGroupHelp(queryPositionals.join(" "), group)); + return; + } console.error(`No help entry for "${queryPositionals.join(" ")}". Run \`sign --help\` to list commands.`); process.exitCode = 1; return; @@ -754,6 +788,7 @@ async function main(): Promise { provider: selectedProvider, apiKey: resolveProviderApiKey(selectedProvider), testMode: resolveProviderTestMode(selectedProvider, flagValue(parsed, "test-mode")), + applySigningOrder: resolveOrderedFlag(flagValue(parsed, "ordered")), }); console.log(JSON.stringify({ mode: "email-only", @@ -2098,6 +2133,7 @@ async function main(): Promise { provider: selectedProvider, apiKey: resolveProviderApiKey(selectedProvider), testMode: resolveProviderTestMode(selectedProvider, flagValue(parsed, "test-mode")), + applySigningOrder: resolveOrderedFlag(flagValue(parsed, "ordered")), force, ...(flagValue(parsed, "provider") ? { strictProvider } : {}), }); @@ -2114,6 +2150,7 @@ async function main(): Promise { apiKey: resolveProviderApiKey(selectedProvider), clientId: selectedProvider === "dropbox" ? requireDropboxClientId(flagValue(parsed, "client-id")) : undefined, testMode: resolveProviderTestMode(selectedProvider, flagValue(parsed, "test-mode")), + applySigningOrder: resolveOrderedFlag(flagValue(parsed, "ordered")), }); if (selectedProvider === "signwell") { const document = (result.responseBody as any) ?? {}; diff --git a/src/lib/db.ts b/src/lib/db.ts index 120ecff..8756a71 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,11 +1,25 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { createRequire } from "node:module"; +import type { DatabaseSync } from "node:sqlite"; import { applyPendingMigrations } from "./migrations.js"; import { SignCliError } from "./sign-error.js"; export type SqliteDb = DatabaseSync; +// node:sqlite is loaded lazily (require, not a static ESM import) so its +// "ExperimentalWarning" is emitted through the patched process.emitWarning in +// silence-warnings.js and can be filtered. A static import would load the builtin +// during module linking — before that patch runs — and the warning would leak. +const nodeRequire = createRequire(import.meta.url); +let cachedDatabaseSync: typeof DatabaseSync | undefined; +function getDatabaseSync(): typeof DatabaseSync { + if (!cachedDatabaseSync) { + cachedDatabaseSync = (nodeRequire("node:sqlite") as typeof import("node:sqlite")).DatabaseSync; + } + return cachedDatabaseSync; +} + function hasColumn(db: SqliteDb, tableName: string, columnName: string): boolean { const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>; return rows.some((row) => row.name === columnName); @@ -37,7 +51,8 @@ export function openDatabase(dbPath: string): SqliteDb { } throw err; // Other failures (ENOENT on a truly broken path, etc.) bubble up. } - const db = new DatabaseSync(resolved); + const DatabaseSyncCtor = getDatabaseSync(); + const db = new DatabaseSyncCtor(resolved); try { db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA synchronous = NORMAL;"); diff --git a/src/lib/field-placement.ts b/src/lib/field-placement.ts index cbcdd54..02484a8 100644 --- a/src/lib/field-placement.ts +++ b/src/lib/field-placement.ts @@ -114,6 +114,28 @@ export function parseFieldSpec(raw: string): SignatureField { }; } +/** + * Map a field position from a bottom-left origin (PDF user space, as emitted by + * pdfjs-based detectors) to the top-left origin that every provider this CLI + * targets — Dropbox Sign, SignWell, DocuSign — expects for `--field x/y`. + * + * `y` and `pageHeight` must be in the same units (points or pixels at the same + * DPI). `height` is the field box height in those units; pass it so the box's + * top edge lands where you expect (omit it and you get the baseline point, which + * places the box's *top* at the detected line and pushes the field downward). + * + * See docs/field-coordinates.md for the full per-provider contract. + */ +export function bottomLeftToTopLeft(input: { + x: number; + y: number; + pageHeight: number; + height?: number; +}): { x: number; y: number } { + const height = input.height ?? 0; + return { x: input.x, y: input.pageHeight - input.y - height }; +} + function dropboxFieldType(type: FieldType): string { switch (type) { case "signature": return "signature"; diff --git a/src/lib/help-catalog.ts b/src/lib/help-catalog.ts index 6b79cf6..4970c48 100644 --- a/src/lib/help-catalog.ts +++ b/src/lib/help-catalog.ts @@ -5,7 +5,7 @@ // sign examples → walkthrough snippets // Bumped manually on each release; mirrored in package.json. -export const SIGN_CLI_VERSION = "0.7.1"; +export const SIGN_CLI_VERSION = "0.8.0"; export type FlagSpec = { name: string; // e.g. "--request-id" or "--token" @@ -140,7 +140,7 @@ export const HELP_CATALOG: CommandSpec[] = [ { name: "--title", description: "Document title." }, { name: "--document", description: "Path to a PDF (repeatable for multi-doc)." }, { name: "--signer", description: "Signer spec name:X,email:Y,order:N (repeatable)." }, - { name: "--field", description: "Field placement signer:N,doc:N,page:N,x:N,y:N,type:signature." }, + { name: "--field", description: "Field placement (repeatable). Keys: signer:N (1-based, matches --signer order), doc:N (0-based document index), page:N (1-based), x:N, y:N (provider pixels, top-left origin), type:signature|initials|date|text|name|email (default signature), width:N, height:N, required:true|false (default true). Anchor strings are not supported via this CLI. See docs/field-coordinates.md for the per-provider coordinate contract." }, { name: "--prefill", description: "Template prefill name:K,value:V[,signer:N]." }, { name: "--token-ttl-minutes", description: "Token lifetime in minutes (default 60)." }, { name: "--auto-approve", description: "true to skip the approval gate (default false)." }, @@ -167,6 +167,11 @@ export const HELP_CATALOG: CommandSpec[] = [ { command: "request run-email", summary: "Convenience: create + send in one step. Auto-approves.", + flags: [ + { name: "--field", description: "Same as `request create --field` (repeatable). See docs/field-coordinates.md." }, + { name: "--test-mode", description: "true|false. SignWell defaults to true (non-binding, watermarked); pass false for a real binding send." }, + { name: "--ordered", description: "true|false (SignWell). true = sequential signing, false = parallel/unordered. Default: sequential when 2+ signers." }, + ], }, { command: "request send", @@ -174,11 +179,19 @@ export const HELP_CATALOG: CommandSpec[] = [ flags: [ { name: "--request-id", required: true, description: "Request id." }, { name: "--force", description: "Resend even if provider_request_id is already set." }, + { name: "--test-mode", description: "true|false. SignWell defaults to true (non-binding, watermarked); pass false for a real binding send." }, + { name: "--ordered", description: "true|false (SignWell). true = sequential signing, false = parallel/unordered. Default: sequential when 2+ signers." }, ], }, { command: "request send-embedded", summary: "Send via the provider's embedded-signing flow.", + flags: [ + { name: "--request-id", required: true, description: "Request id." }, + { name: "--client-id", description: "Embedded client id (Dropbox Sign)." }, + { name: "--test-mode", description: "true|false. SignWell defaults to true (non-binding, watermarked); pass false for a real binding send." }, + { name: "--ordered", description: "true|false (SignWell). true = sequential signing, false = parallel/unordered. Default: sequential when 2+ signers." }, + ], }, { command: "request sign-url", @@ -845,6 +858,22 @@ export function findCommand(query: string): CommandSpec | null { return HELP_CATALOG.find((entry) => entry.command === normalized) ?? null; } +// A parent command like "mcp" has no entry of its own, but "mcp serve" / "mcp tools" +// do. Return those subcommands so `sign mcp --help` lists them instead of erroring. +export function findCommandGroup(query: string): CommandSpec[] { + const prefix = query.trim().replace(/\s+/gu, " ") + " "; + return HELP_CATALOG.filter((entry) => entry.command.startsWith(prefix)); +} + +export function formatCommandGroupHelp(query: string, specs: CommandSpec[]): string { + const lines: string[] = [`sign ${query} — subcommands`, ""]; + for (const spec of specs) { + lines.push(` sign ${spec.command.padEnd(28)} ${spec.summary}`); + } + lines.push("", "Run `sign --help` for focused help on any subcommand."); + return lines.join("\n"); +} + export function formatTopLevelHelp(): string { const lines: string[] = ["sign — consent-gated, auditable e-sign CLI", ""]; // Group by first word for readability. diff --git a/src/lib/signing-service.ts b/src/lib/signing-service.ts index 2f40a6e..71b7583 100644 --- a/src/lib/signing-service.ts +++ b/src/lib/signing-service.ts @@ -235,6 +235,7 @@ type ProviderApi = { fields: SignatureField[]; apiKey?: string; testMode: boolean; + applySigningOrder?: boolean; }): Promise; sendEmbedded?: (input: { request: RequestRow; @@ -244,6 +245,7 @@ type ProviderApi = { apiKey?: string; clientId?: string; testMode: boolean; + applySigningOrder?: boolean; }) => Promise; sendFromTemplate?: (input: { request: RequestRow; @@ -252,6 +254,7 @@ type ProviderApi = { templateId: string; apiKey?: string; testMode: boolean; + applySigningOrder?: boolean; }) => Promise; sendFromTemplateEmbedded?: (input: { request: RequestRow; @@ -261,6 +264,7 @@ type ProviderApi = { apiKey?: string; clientId?: string; testMode: boolean; + applySigningOrder?: boolean; }) => Promise; getEmbeddedSignUrl?: (input: { signatureId: string; @@ -760,6 +764,7 @@ function getProviderApi(provider: SignProvider): ProviderApi { document_hash: input.request.document_hash, }, testMode: input.testMode, + applySigningOrder: input.applySigningOrder, }); return { providerRequestId: result.documentId, @@ -783,6 +788,7 @@ function getProviderApi(provider: SignProvider): ProviderApi { }, testMode: input.testMode, embeddedSigning: true, + applySigningOrder: input.applySigningOrder, }); return { providerRequestId: result.documentId, @@ -836,6 +842,7 @@ function getProviderApi(provider: SignProvider): ProviderApi { prefills: input.prefills, metadata: { request_id: input.request.id, document_hash: input.request.document_hash }, testMode: input.testMode, + applySigningOrder: input.applySigningOrder, }); return { providerRequestId: result.documentId, @@ -1315,6 +1322,7 @@ export async function sendSigningRequest( testMode: boolean; force?: boolean; now?: Date; + applySigningOrder?: boolean; providerSend?: () => Promise; sendRequest?: typeof sendSignatureRequest; /** When true and `provider` is supplied, fail loudly if it doesn't match @@ -1374,6 +1382,7 @@ export async function sendSigningRequest( templateId, apiKey: input.apiKey, testMode: input.testMode, + applySigningOrder: input.applySigningOrder, }); } : input.sendRequest && provider === "dropbox" @@ -1398,7 +1407,7 @@ export async function sendSigningRequest( responseBody: result.responseBody, }; } - : () => providerApi.send({ request, signers, documents, fields, apiKey: input.apiKey, testMode: input.testMode }); + : () => providerApi.send({ request, signers, documents, fields, apiKey: input.apiKey, testMode: input.testMode, applySigningOrder: input.applySigningOrder }); const result = await send(); const now = input.now ?? new Date(); @@ -1699,6 +1708,7 @@ export async function sendEmbeddedSigningRequest( clientId?: string; testMode: boolean; now?: Date; + applySigningOrder?: boolean; createEmbeddedRequest?: typeof createEmbeddedSignatureRequest; }, ): Promise<{ @@ -1731,6 +1741,7 @@ export async function sendEmbeddedSigningRequest( apiKey: input.apiKey, clientId: input.clientId, testMode: input.testMode, + applySigningOrder: input.applySigningOrder, }) : input.createEmbeddedRequest && provider === "dropbox" ? async () => { @@ -1763,6 +1774,7 @@ export async function sendEmbeddedSigningRequest( apiKey: input.apiKey, clientId: input.clientId, testMode: input.testMode, + applySigningOrder: input.applySigningOrder, }); const result = await sendEmbedded(); diff --git a/src/lib/signwell.ts b/src/lib/signwell.ts index 2424e98..780ee0d 100644 --- a/src/lib/signwell.ts +++ b/src/lib/signwell.ts @@ -18,6 +18,9 @@ export type SignWellSendInput = { metadata: Record; testMode: boolean; embeddedSigning?: boolean; + // When set, controls SignWell's apply_signing_order (true = sequential, false = + // parallel/unordered). When undefined, defaults to sequential for 2+ signers. + applySigningOrder?: boolean; }; function readJsonSafe(response: Response): Promise { @@ -136,9 +139,12 @@ export async function sendSignWellDocument(input: SignWellSendInput): Promise<{ const fieldsPerFile = (input.fields && input.fields.length > 0) ? signwellFieldsPerFile(input.fields, recipientIdByOrder, baseFiles.length) : null; - const files = baseFiles.map((file, index) => fieldsPerFile && fieldsPerFile[index].length > 0 - ? { ...file, fields: fieldsPerFile[index] } - : file); + // SignWell wants custom field placements as a top-level 2-D array — one inner + // array per file — NOT nested under each file. When fields are nested there, or + // when with_signature_page is left on, SignWell silently discards them and + // auto-places its own. So only send our fields, and turn off the auto signature + // page, when the caller actually supplied custom placements. + const hasCustomFields = Boolean(fieldsPerFile && fieldsPerFile.some((perFile) => perFile.length > 0)); const body = await signWellJsonRequest(input.apiKey, { method: "POST", endpoint: "/documents", @@ -149,10 +155,11 @@ export async function sendSignWellDocument(input: SignWellSendInput): Promise<{ subject: input.title, message: `Please sign: ${input.title}`, draft: false, - with_signature_page: true, - apply_signing_order: signers.length > 1, + with_signature_page: !hasCustomFields, + apply_signing_order: input.applySigningOrder ?? signers.length > 1, embedded_signing: Boolean(input.embeddedSigning), - files, + files: baseFiles, + ...(hasCustomFields ? { fields: fieldsPerFile } : {}), recipients: signers.map((signer, index) => ({ id: String(index + 1), name: signer.name, @@ -258,6 +265,7 @@ export type SignWellTemplateInput = { metadata: Record; testMode: boolean; embeddedSigning?: boolean; + applySigningOrder?: boolean; }; export async function sendSignWellTemplateDocument(input: SignWellTemplateInput): Promise<{ @@ -289,7 +297,7 @@ export async function sendSignWellTemplateDocument(input: SignWellTemplateInput) message: `Please sign: ${input.title}`, embedded_signing: Boolean(input.embeddedSigning), template_ids: [input.templateId], - apply_signing_order: signers.length > 1, + apply_signing_order: input.applySigningOrder ?? signers.length > 1, recipients, placeholders, metadata: input.metadata, diff --git a/src/lib/silence-warnings.ts b/src/lib/silence-warnings.ts new file mode 100644 index 0000000..fc6aa6f --- /dev/null +++ b/src/lib/silence-warnings.ts @@ -0,0 +1,24 @@ +// Node emits `ExperimentalWarning: SQLite is an experimental feature ...` (and a +// companion `--trace-warnings` hint) to stderr the first time `node:sqlite` is +// loaded. That noise corrupts otherwise machine-readable CLI output, so we filter +// out only ExperimentalWarning here — every other warning still reaches stderr. +// +// This module is intended to be imported FIRST (before any module that pulls in +// node:sqlite), since ESM evaluates imports in source order. It honors an opt-out: +// set SIGN_SHOW_WARNINGS=1 to restore Node's default behavior. +import process from "node:process"; + +if (process.env.SIGN_SHOW_WARNINGS !== "1") { + const originalEmitWarning = process.emitWarning.bind(process); + // process.emitWarning has several overloads; normalize enough to read the type. + process.emitWarning = ((warning: unknown, ...rest: unknown[]) => { + const options = rest[0]; + const type = typeof options === "string" + ? options + : (options as { type?: string } | undefined)?.type; + if (type === "ExperimentalWarning") { + return; + } + return (originalEmitWarning as (...args: unknown[]) => void)(warning, ...rest); + }) as typeof process.emitWarning; +} diff --git a/src/tests/field-pipeline.test.ts b/src/tests/field-pipeline.test.ts index 9294e2e..3d40364 100644 --- a/src/tests/field-pipeline.test.ts +++ b/src/tests/field-pipeline.test.ts @@ -107,3 +107,102 @@ test("Dropbox send forwards form_fields_per_document JSON when fields are presen cleanup(); } }); + +test("SignWell send forwards fields as a top-level 2-D array and disables the auto signature page", { concurrency: false }, async () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + const documentPath = createDocumentFixture("signwell-fields"); + const originalFetch = globalThis.fetch; + try { + const created = createSigningRequest(db, { + title: "SignWell fields", + documentPath, + signers: [{ name: "Alice", email: "alice@example.com", order: 1 }], + fields: [parseFieldSpec("signer:1,page:1,x:50,y:60,type:signature,width:200,height:30")], + tokenTtlMinutes: 30, + provider: "signwell", + autoApprove: true, + now: new Date("2026-01-01T00:00:00.000Z"), + }); + + let capturedBody: any = null; + globalThis.fetch = (async (_url: string, init?: any) => { + capturedBody = typeof init?.body === "string" ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ + id: "doc_x", + status: "sent", + recipients: [{ id: "1" }], + }), { status: 200, headers: { "content-type": "application/json" } }) as any; + }) as any; + + await sendSigningRequest(db, { + requestId: created.requestId, + provider: "signwell", + apiKey: "k", + testMode: true, + }); + + assert.ok(capturedBody, "SignWell request body should be captured"); + // Fields must be a top-level 2-D array (one inner array per file), NOT nested + // under files[].fields — otherwise SignWell silently drops them. + assert.ok(Array.isArray(capturedBody.fields), "fields should be a top-level array"); + assert.equal(capturedBody.fields.length, 1, "one inner array per file"); + assert.equal(capturedBody.fields[0].length, 1); + assert.equal(capturedBody.fields[0][0].type, "signature"); + assert.equal(capturedBody.fields[0][0].x, 50); + assert.equal(capturedBody.fields[0][0].recipient_id, "1"); + assert.ok( + !capturedBody.files.some((file: any) => file.fields), + "fields must not be nested under files[]", + ); + // The auto signature page would override our placements, so it must be off. + assert.equal(capturedBody.with_signature_page, false); + } finally { + globalThis.fetch = originalFetch; + db.close(); + cleanup(); + } +}); + +test("SignWell send keeps the auto signature page when no custom fields are supplied", { concurrency: false }, async () => { + const { dbPath, cleanup } = makeTempDb(); + const db = createDb(dbPath); + const documentPath = createDocumentFixture("signwell-nofields"); + const originalFetch = globalThis.fetch; + try { + const created = createSigningRequest(db, { + title: "SignWell no fields", + documentPath, + signers: [{ name: "Alice", email: "alice@example.com", order: 1 }], + tokenTtlMinutes: 30, + provider: "signwell", + autoApprove: true, + now: new Date("2026-01-01T00:00:00.000Z"), + }); + + let capturedBody: any = null; + globalThis.fetch = (async (_url: string, init?: any) => { + capturedBody = typeof init?.body === "string" ? JSON.parse(init.body) : null; + return new Response(JSON.stringify({ + id: "doc_y", + status: "sent", + recipients: [{ id: "1" }], + }), { status: 200, headers: { "content-type": "application/json" } }) as any; + }) as any; + + await sendSigningRequest(db, { + requestId: created.requestId, + provider: "signwell", + apiKey: "k", + testMode: true, + }); + + assert.ok(capturedBody, "SignWell request body should be captured"); + assert.equal(capturedBody.with_signature_page, true); + assert.ok(!("fields" in capturedBody), "no top-level fields when none supplied"); + } finally { + globalThis.fetch = originalFetch; + db.close(); + cleanup(); + } +}); diff --git a/src/tests/field-placement.test.ts b/src/tests/field-placement.test.ts index 64ad8e4..b98d527 100644 --- a/src/tests/field-placement.test.ts +++ b/src/tests/field-placement.test.ts @@ -1,12 +1,27 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + bottomLeftToTopLeft, docusignTabsForSigner, dropboxFormFieldsPerDocument, parseFieldSpec, signwellFieldsPerFile, } from "../lib/field-placement.js"; +test("bottomLeftToTopLeft flips the origin and accounts for field height", () => { + // A field whose bottom-left sits at y=100 on a 792pt-tall (US Letter) page, + // 30pt tall, should have its top edge at 792 - 100 - 30 = 662 from the top. + assert.deepEqual( + bottomLeftToTopLeft({ x: 72, y: 100, pageHeight: 792, height: 30 }), + { x: 72, y: 662 }, + ); + // Without a height we get the baseline point flipped (top edge at the line). + assert.deepEqual( + bottomLeftToTopLeft({ x: 72, y: 100, pageHeight: 792 }), + { x: 72, y: 692 }, + ); +}); + test("parseFieldSpec accepts coordinate-based fields", () => { const field = parseFieldSpec("signer:1,doc:0,page:2,x:120,y:300,type:signature,width:200,height:30"); assert.equal(field.signerOrder, 1);