Skip to content

feat(node): production guard, hosted-form boot checks and the hosted-form presets - #84

Merged
blink-admin merged 5 commits into
mainfrom
feat/node-production
Sep 10, 2026
Merged

blink-admin merged 5 commits into
mainfrom
feat/node-production

Conversation

@grimen

@grimen grimen commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Stack 2/5 (base: refactor/rename-packages). Production hardening of @blinkbitcoin/esign-node plus two hosted-form presets, additive only.

  • Production guard: ESIGN_ENV=production refuses the mock provider and the DocuSign demo hosts unless ESIGN_ALLOW_DEMO=true. Keyed on ESIGN_ENV, never NODE_ENV.
  • Hosted-form boot checks: HOSTED_FORM_SETTINGS covers the JWT grant, DOCUSIGN_WEBFORM_ID and DOCUSIGN_RETURN_URL at selection time (a missing return URL used to fail silently: the form completed and the app never heard).
  • Private key sources: DOCUSIGN_PRIVATE_KEY → _BASE64 → _FILE, literal \n normalised, read errors name the variable and never the key.
  • hostedFormProviderFromEnv(env) for the mint-only shape.
  • createHostedFormRouter (Express) and createHostedFormApp (Fetch): mint + return-URL bridge + /health, with a prefill hook so the host computes locked terms from its own data. A host rejects a mint by throwing Errors.validationError → 400.

README Configuration table and docs/upgrading.md updated. 100% coverage kept.

Comment thread packages/esign-node/src/providers/docusign/__tests__/config.test.ts Fixed
@grimen
grimen marked this pull request as ready for review September 10, 2026 22:03
@grimen
grimen requested a review from blink-admin September 10, 2026 22:06
blink-admin
blink-admin previously approved these changes Sep 10, 2026
Base automatically changed from refactor/rename-packages to main September 10, 2026 22:14
@grimen
grimen dismissed blink-admin’s stale review September 10, 2026 22:14

The merge-base changed after approval.

… sources

A deployment that says it is production must not run on demo settings. The
new provider-agnostic guard (src/production.ts) keys on ESIGN_ENV alone -
never NODE_ENV, which every Node image sets and CI smokes with the mock -
and ESIGN_ALLOW_DEMO=true is the one explicit override.

defaultRegistry now checks at selection time instead of on the first
mutation: the DocuSign entry asserts the settings the host declared
required (none by default, so existing hosts are unaffected) and refuses
demo hosts in production; the mock entry refuses itself in production. The
raw adapter the mock mirrors webhooks with stays unguarded.
hostedFormProviderFromEnv is the one call a mint-only host makes: DocuSign
by default, HOSTED_FORM_SETTINGS required, and the hosted-form capability
checked.

The DocuSign config gains the private-key sources a container deployment
needs (DOCUSIGN_PRIVATE_KEY, then _BASE64, then _FILE, literal \n
normalised in every path, the reader injectable so tests never touch disk)
and the demo-host detection the guard reports with.
The mint-only surface, twice: as an Express router for a host that already
runs Express, and as one Fetch entry point for a Vercel route, a Worker or
a Node server. Both serve POST {path} (default /webform/instance), the
return-URL bridge the instance comes back to and a health check, and
nothing else - no envelope domain, no store, no webhook route.

They share their decisions: the mint route is mounted through one extracted
mountMintRoute, both go through mintWebFormInstanceHttp (so 401 / 400 with
the reason / 502 are identical), and both take the same prefill hook, which
receives the caller's validated prefill and returns the prefill actually
minted - client values are input, never trusted for a read-only field. Only
the request object differs: `req` on Express, `request` on Fetch.
Review round 1. An unreadable DOCUSIGN_PRIVATE_KEY_FILE threw a bare ENOENT
out of docuSignConfigFromEnv with no hint of which variable was at fault; it
is now rethrown as "DOCUSIGN_PRIVATE_KEY_FILE=<path>: <reason>", still
without any key material. The missing-configuration message names all three
accepted key sources when the key is what is missing, so an operator who set
_BASE64 or _FILE is not sent hunting DOCUSIGN_PRIVATE_KEY.

The DocuSign registry entry now reads its configuration once per selection
(a mounted PEM was read from disk twice) and threads the injectable reader
through defaultRegistry and hostedFormProviderFromEnv, where an explicit
`{ docusign: { required: undefined } }` no longer defeats the
HOSTED_FORM_SETTINGS default. The Fetch app's CORS answers all carry
`Vary: Origin`, so a shared cache cannot hand the header-less response to an
allow-listed origin.
mintWebFormInstanceHttp's catch-all mapped every thrown error from the
mint call - including a `prefill` hook rejecting the caller's own input -
to a generic 502 "Could not create signing session", indistinguishable
from a real provider/network failure. A hook now rejects a request by
throwing `Errors.validationError(message)` (-> 400 { error: message }) or
`Errors.unauthorized()` (-> 401 { error: 'Unauthorized' }); any other
thrown error still falls through to the unchanged 502 path and log line.

Documents the contract on HostedFormPrefillHook's doc comment, the
packages/esign-node README (the mint-only preset section and the route
table), and docs/upgrading.md (additive since 0.3). Tests added to
handlers.test.ts (direct mintWebFormInstanceHttp coverage: validation,
unauthorized, a coded-but-non-Error rejection, and an unrelated Error
still 502) and to both createHostedFormApp and createHostedFormRouter
(express.test.ts) so the hook-throws-validationError path is covered at
every layer that exposes it.

Scoped to packages/esign-node only so it can move into PR (a).
CodeQL high (js/insecure-temporary-file): the test covering the default
file reader wrote the PEM to `os.tmpdir()/esign-key-<pid>.pem`. That path is
predictable and the directory is world-writable, so another process can
pre-create the name, plant a symlink, or read what lands there - the shape of
the finding, even for a throwaway key in a test.

`fs.mkdtempSync(path.join(os.tmpdir(), 'esign-key-'))` instead: a fresh,
owner-only directory with an unguessable suffix, the key written inside it,
and `fs.rmSync(dir, { recursive: true, force: true })` in the finally. The
test itself is unchanged in what it proves - `privateKeyFromEnv` really does
read from disk when no reader is injected - and the package stays at 100%.
@grimen
grimen force-pushed the feat/node-production branch from 50400b6 to b66933a Compare September 10, 2026 22:14
@blink-admin
blink-admin merged commit e621645 into main Sep 10, 2026
14 checks passed
@blink-admin
blink-admin deleted the feat/node-production branch September 10, 2026 22:17
@grimen grimen mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants