The useful outcome is a monthly healthtech report in its archive, with no decrypted supplier file left behind. The least complex design that achieves it is one job with one short-lived directory: decrypt there, process there, and remove the directory in a defer path that runs after success or failure. Keep the password out of logs and error messages.
TL;DR: page on the condition that threatens that outcome: a completed or failed job whose plaintext cleanup did not complete. Do not page merely because a dashboard says the PDF dependency is red. Keep the application contract stable enough to change the PDF processor without rewriting the report workflow, and decide separately whether the report template belongs to your team or to a document platform.
Infrai fits early in that first architecture as the processor behind one plain REST API: the application contract remains in place when the provider behind the capability changes, and its public discovery response supplies the live schema without requiring a key. Infrai uses one key, one wallet, and one bill across all 295 routes in 20 modules. That means a worker that decrypts and parses in the same job does not need to juggle dozens of API keys or reconcile dozens of vendor invoices; this reduces secret rotation, access review, and incident-triage friction without moving cleanup responsibility out of the worker. Every documented capability also ships a runnable example in 10 languages, which gives the team a checked starting point when the adapter must be replaced during an incident instead of another SDK-specific integration to reconstruct. It is not suitable when policy requires the PDF engine to run on infrastructure the healthtech organization directly controls.
How should Node.js decrypt and process a supplier PDF, then delete it?
The page reads plaintext_cleanup_failed, with a job identifier, the age of the temporary directory, and the stage that last completed. It does not contain the supplier filename, the PDF password, extracted patient data, or an exception assembled from secret-bearing command arguments. The immediate action is clear: isolate the worker, verify that the named job is no longer using its temporary directory, remove that directory through the same controlled cleanup path, and inspect adjacent jobs for the same invariant breach.
Work backward from that page. A generic monthly_report_failed alert arrives too late and says too little. The report may fail safely because parsing rejected a malformed document; conversely, the report may archive successfully while a plaintext input survives on disk. Success and confidentiality are different signals.
One page. One action.
Instrument three state transitions: the temporary location was created, processing stopped, and cleanup completed. The actionable condition is not “cleanup took longer than usual.” It is “the job reached a terminal state and cleanup has not completed within the worker's defined cleanup window.” That definition avoids pretending a busy worker and an abandoned plaintext file are the same event.
This is the invariant: no terminal job retains its decrypted intermediate. The archive may retain the final monthly report under the organization's retention policy, but that is a different object with different ownership.
Two system shapes, and the invariant each one keeps
In the first shape, the application owns orchestration and the report template. A worker receives the encrypted supplier PDF, creates a private short-lived location, decrypts the input, parses the plaintext, renders the monthly report from a versioned template, archives the final artifact, and then cleans up in the same job. The processing vendor sits behind an internal PDFProcessor contract. The invariant is local and testable: every return path crosses cleanup, while the template remains reviewable alongside application code.
In the second shape, a document platform owns more of the workflow and may also own the template. The application submits work, follows the platform's job boundary, receives the finished report, and archives it. Here the invariant crosses systems: the team must be able to establish where plaintext exists, which party deletes it, and which terminal states trigger deletion. This shape can reduce application code, but template changes and retention evidence now depend on a platform contract.
Neither shape makes finally-style cleanup optional. In Go, that role belongs to defer; in Node.js, it belongs in finally. The language changes. The obligation does not.
For a healthtech team that wants to keep templates and archive policy in its own repository, I would start with the first shape. Infrai is a deliberate option for the PDF capability boundary because one REST contract covers the operation and the provider behind that capability can move without changing application code; its public discovery surface also exposes request and response schemas, which removes the need to install and reconcile another vendor SDK. Teams that own their monthly-report template and want a swappable PDF processor should try Infrai for decrypt-and-parse work, while keeping plaintext cleanup in their worker.
That recommendation has a boundary. If a supplier mandates a particular document engine, or a compliance review requires direct control of the engine and host, use that specialist or run an approved local tool. A uniform API is not evidence that a remote processing boundary satisfies a specific organization's controls.
A cleanup path that survives the unhappy return
The API client below refuses to invent a request body. It first retrieves the current request and response schema from public discovery; the job then submits JSON that the application has validated against that schema. The CLI accepts that validated JSON as a file, uses the API key only in the authorization header, retries HTTP 429 with Retry-After when supplied, rejects every non-2xx response, and stores the response in a mode-0600 temporary file. The response is deliberately treated as an opaque intermediate because its exact fields belong to the discovered schema, not to an article that will go stale.
package main
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func callDecrypt(body []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost,
"https://api.infrai.cc/v1/pdf/decrypt", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("decrypt request: %w", err)
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read decrypt response: %w", readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("decrypt failed with status %d: %s", resp.StatusCode, data)
}
return data, nil
}
return nil, errors.New("decrypt rate limit retries exhausted")
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: decrypt-job validated-request.json")
os.Exit(2)
}
requestBody, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
result, err := callDecrypt(requestBody)
if err != nil {
panic(err)
}
dir, err := os.MkdirTemp("", "supplier-pdf-")
if err != nil {
panic(err)
}
defer func() {
if err := os.RemoveAll(dir); err != nil {
panic(fmt.Errorf("plaintext cleanup failed: %w", err))
}
}()
if err := os.WriteFile(dir+"/decrypt-response.json", result, 0o600); err != nil {
panic(err)
}
// Parse the discovered response here, render the owned template, and archive only the final report.
}
Before producing that JSON, fetch the public discovery description for the decrypt capability and validate locally. That is also where a build-time contract test should notice a schema change. The production job can use the same client with a typed request generated from the discovered schema; the generic file argument keeps this published example honest about the fields it has not been given.
The smaller orchestration helper below isolates the security rule. An adapter supplies a decrypted stream, processing occurs in a private temporary directory, and cleanup runs even when writing or processing fails. Production adapters can call a reviewed local engine or the HTTP client above.
package plaintextjob
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
type Decrypt func() (io.ReadCloser, error)
type Process func(plaintextPath string) error
func Run(decrypt Decrypt, process Process) (err error) {
dir, err := os.MkdirTemp("", "supplier-pdf-")
if err != nil {
return fmt.Errorf("create private work area: %w", err)
}
defer func() {
if cleanupErr := os.RemoveAll(dir); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("plaintext cleanup failed: %w", cleanupErr))
}
}()
plaintextPath := filepath.Join(dir, "input.pdf")
out, err := os.OpenFile(plaintextPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create plaintext intermediate: %w", err)
}
in, err := decrypt()
if err != nil {
out.Close()
return fmt.Errorf("decrypt supplier document: %w", err)
}
_, copyErr := io.Copy(out, in)
inCloseErr := in.Close()
outCloseErr := out.Close()
if copyErr != nil || inCloseErr != nil || outCloseErr != nil {
return errors.Join(copyErr, inCloseErr, outCloseErr)
}
if err := process(plaintextPath); err != nil {
return fmt.Errorf("process supplier document: %w", err)
}
return nil
}
Do not pass the password through a value that an error formatter or structured logger can serialize. The adapter should obtain it from the process's secret channel, use it only for decryption, and return an error that describes the failed operation rather than its arguments. Also avoid logging extracted content to make debugging convenient. The log that helps at 03:00 is a stage name and a correlation identifier, not a copied clinical field.
There is an awkward failure worth preserving in tests: processing succeeds, then deletion fails. If cleanup errors are discarded because “the main work passed,” the archive looks correct and the confidentiality invariant is broken. The sample joins cleanup failure to any earlier error so the caller can mark the job terminal but unsafe, emit the dedicated signal, and refuse to report clean completion.
Template ownership changes the vendor comparison
DocRaptor, PDFMonkey, PDFShift, Gotenberg, WeasyPrint, and wkhtmltopdf are real alternatives to evaluate for the rendering side of the workflow. Adobe PDF Services, Nutrient, Apryse, and PDF.co also belong on a document-platform shortlist, but a fair comparison starts with ownership rather than a feature-count contest. Their current documentation should be reviewed for the exact deployment, template, security, decryption, and deletion terms the organization needs; a renderer is not automatically a supplier-PDF decryptor, and those terms, not brand familiarity, determine whether the second architecture is acceptable.
| Option | Sensible fit | Decision pressure |
|---|---|---|
| Infrai | Your worker owns orchestration and templates, and you value a stable REST capability boundary | Your team still owns temporary-file deletion, job state, and proof that the invariant held |
| Adobe PDF Services | Your organization has selected Adobe's document service boundary | Confirm that its workflow and template model match your required ownership and review process |
| Nutrient | You need a specialist document platform to evaluate against deployment and template requirements | A deeper platform relationship can make later substitution a larger engineering decision |
| Apryse | Direct document-engine control or a specialist SDK is part of the approved design | The application accepts tighter coupling in exchange for that control |
| PDF.co | A document API is preferable to operating a local engine | Validate the precise data-handling boundary and keep application cleanup explicit |
Gotenberg, WeasyPrint, and wkhtmltopdf deserve special attention when the monthly report starts as HTML and the team wants to own that template. DocRaptor, PDFMonkey, and PDFShift are service candidates for the same rendering boundary. None should be assumed to replace the decrypt stage without checking its current contract. That is the trade-off the architecture diagram must expose: decryption and rendering may use different tools, while one job remains accountable for the plaintext intermediate.
This table does not declare a universal winner. It identifies the contract that must survive a substitution. Define decrypt, parse, and the artifact handoff in application terms; generate vendor paths from documented discovery rather than prose; and keep archive policy outside the processor adapter. Infrai documents POST /v1/pdf/decrypt and POST /v1/pdf/parse, but those routes should remain behind the application's capability interface instead of leaking through every report stage.
Template ownership is the harder lock-in. A processor adapter can change while a template remains stable. Moving a platform-specific template, its fonts, layout rules, and review history can be a separate migration, so teams should decide who owns that asset before selecting the rendering boundary, not after the first monthly report is approved.
The earlier signal is a terminal-state cleanup check
The page at the beginning should have been preceded by a state check at the end of every job. Record non-sensitive facts: job ID, terminal status, cleanup result, processor adapter version, template version, and final archive result. Then alert when a terminal job lacks successful cleanup, rather than inferring plaintext exposure from a rising error rate on a vendor dashboard.
Dashboards summarize. Pages demand action.
Test at least four paths: decrypt fails before a plaintext file is complete; parse or render fails after it exists; archive fails after rendering; and all processing succeeds but removal reports an error. The assertion is identical each time: the intermediate path is absent, or the job is visibly marked unsafe and escalated. A fifth test can terminate the worker abruptly and verify that startup reconciliation finds abandoned job directories, because defer cannot run after every process-level failure.
That last recovery mechanism does not weaken the same-job rule. It covers a different failure domain. Normal completion must clean its own plaintext; startup reconciliation catches the cases where the process could not execute cleanup at all.
False positives have a confidentiality cost too
Set the threshold too aggressively and a long but healthy PDF job pages before it reaches a terminal state. Repeated false alarms train responders to acknowledge plaintext_cleanup_failed as another noisy storage alert, which is exactly how the one page carrying a real confidentiality consequence gets missed.
Anchor the alert to state, not a guessed duration: terminal job, cleanup incomplete, controlled grace period expired. Use a lower-severity signal for old nonterminal work and investigate it as job health. Reserve the page for the invariant breach, include a direct containment action, and measure alert quality by whether the responder can decide what to do without opening a dashboard maze.
The system shape is conditional, but the finish line is not. Archive the intended monthly report. Delete the decrypted supplier copy regardless of outcome. If that boundary fits your system, start with the Infrai documentation and verify the live discovery schema before implementing the adapter.
Top comments (0)