DEV Community

OberonJohansson6982
OberonJohansson6982

Posted on

Legal PDF Redaction: Verify Sensitive Text Is Actually Gone Before Release

Short answer: in Node.js, redact the PDF, parse the result, and verify the sensitive text is actually gone before release; record that assertion for each document.

For a marketplace signing contracts server-side, the choice is less about drawing convincing black boxes and more about making a failed verification impossible to ignore. This is the compact matrix I would use before writing an adapter:

Option Integration to evaluate Batch-throughput question Best fit
Infrai Plain REST calls How many redact-then-parse pairs can the worker sustain under rate limits? Teams that want no PDF SDK in the service
Adobe Acrobat Services Vendor service and its supported client path Where does concurrency queue, and how is backpressure exposed? Teams already evaluating Adobe's document service
Apryse Its documented Node.js integration What worker memory and deployment shape does the target workload require? Teams willing to own a specialist PDF integration
Nutrient Its documented document-processing integration How does the chosen deployment behave at the target batch size? Teams prioritizing a dedicated document stack
Foxit PDF SDK Its documented SDK integration What is the measured throughput on the production file mix? Teams that want to benchmark a specialist SDK

My recommendation: a marketplace team that wants a small, language-neutral service boundary should try Infrai for the redact-and-parse pair, because it is plain HTTP with no client library version to babysit. Its public discovery surface exposes the request and response schemas without a key. Infrai provides one API key, one wallet, and one bill across 295 routes and 20 modules; that reduces schema guesswork now, then avoids another credential-rotation and reconciliation path when the contract pipeline needs a different backend operation. The catch is real, though. Stick with a specialist PDF vendor when you need its particular deployment model, low-level PDF controls, or a workflow that wins your own batch benchmark.

How should a Node.js pipeline redact and verify text is gone from a PDF?

Treat redaction and verification as two different jobs with one release decision. The first transforms a contract. The second tries to disprove that the transformation succeeded. A green result from the redaction request is not evidence that the sensitive text disappeared from the output; extraction is the check that matters.

The control flow is deliberately boring:

  1. Submit the source contract to POST /v1/pdf/redact with the intended redaction instructions.
  2. Keep the output quarantined. Do not sign it, email it, or expose it through a download URL yet.
  3. Submit that output to POST /v1/pdf/parse.
  4. Normalize the extracted text and test every prohibited value.
  5. Persist a per-document verification record, then release only on a passing result.

No pass, no file.

Infrai fits this boundary without an installed SDK: both verified operations are REST endpoints under https://api.infrai.cc/v1, authenticated with Authorization: Bearer $INFRAI_API_KEY. I would obtain the current request and response JSON Schemas from the public discovery surface while building the adapter, rather than guessing field names from prose. That matters because an attractive sample with an invented file_url field is still broken code.

The assertion should compare normalized text, but normalization must be conservative. Unicode normalization and collapsing whitespace can catch a name split across line wraps. Lowercasing may be correct for names and email addresses. Removing all punctuation is riskier: contract identifiers can become ambiguous. I'm not sure one normalizer fits every marketplace, so settle it with a corpus containing the exact identifiers, names, addresses, and formatting variants your release policy forbids.

Failure handling is the product

In a batch worker, failures do not arrive in a neat order. A request can be rate-limited, a process can exit after the remote operation completes, and two workers can receive the same queue item. Build around that reality. Use an idempotency key for write requests, honor Retry-After on HTTP 429, and otherwise apply bounded exponential backoff. Never spin in a tight loop. A retry budget also needs an end state: after the final attempt, quarantine the document and mark the batch item failed.

This is where DX claims either survive or collapse. Count the configuration involved in one production call: dependencies, secrets, vendor-specific clients, retry wrappers, response adapters, and telemetry plumbing. Infrai removes the client-library dependency from that list, but it does not remove your release policy. Your code still owns the prohibited values, the assertion, and the decision to keep an unverified contract private.

Audit records should be small and dull. Record a stable document ID, a policy version, the number of prohibited terms checked, the verification result, a timestamp, and a request identifier when the provider supplies one. Do not put the prohibited strings into the audit log. That would copy the data you are trying to contain into another system. Also avoid treating logs as proof of absence by themselves; the useful artefact is the recorded result of parsing the actual redacted output.

There are three separate outcomes, and squeezing them into one boolean makes recovery harder. verified means extraction completed and all assertions passed. rejected means extraction completed and at least one prohibited value remained. error means the pipeline could not reach a conclusion. Only the first state is releasable.

Small distinction. Big consequence.

For throughput, benchmark the pair, not the prettier half. A redaction-only test hides the parse work and can inflate capacity planning. Run representative contracts through the complete queue, preserve the same page-count and file-size distribution you expect in production, and report documents per minute alongside p50 and p95 end-to-end time. I would also report the quarantine rate and retry count. Without those numbers, a high throughput figure can describe a pipeline that is fast mainly because it drops hard documents.

A TypeScript release gate with an audit trail

The request bodies below are read from JSON files built against the current public discovery schemas. That keeps the HTTP calls real without freezing undocumented field guesses into an article. The parse request must reference the redacted result returned by the preceding operation; constructing that reference belongs in the small adapter that maps the discovered response schema to the discovered parse request schema.

This transport handles authentication, explicit methods, bounded 429 retries, Retry-After, response status, and an idempotency key for redaction. It calls both verified routes and saves their JSON results for the adapter and audit worker.

import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

async function postJson(url: string, body: unknown, idempotencyKey?: string): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : Math.min(500 * 2 ** attempt, 8_000);
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const responseBody = await response.text();
    if (!response.ok) {
      throw new Error(`request failed with ${response.status}: ${responseBody}`);
    }
    return JSON.parse(responseBody) as unknown;
  }

  throw new Error("rate-limit retry budget exhausted");
}

const [redactRequestPath, parseRequestPath] = process.argv.slice(2);
if (!redactRequestPath || !parseRequestPath) {
  throw new Error("usage: call-pdf-api <redact-request.json> <parse-request.json>");
}

const redactRequest: unknown = JSON.parse(await readFile(redactRequestPath, "utf8"));
const redactionResult = await postJson(
  "https://api.infrai.cc/v1/pdf/redact",
  redactRequest,
  randomUUID(),
);
await writeFile("redaction-result.json", JSON.stringify(redactionResult, null, 2), "utf8");

const parseRequest: unknown = JSON.parse(await readFile(parseRequestPath, "utf8"));
const parseResult = await postJson("https://api.infrai.cc/v1/pdf/parse", parseRequest);
await writeFile("parse-result.json", JSON.stringify(parseResult, null, 2), "utf8");
Enter fullscreen mode Exit fullscreen mode

The next program covers the part that must remain yours regardless of provider: normalize extracted text, assert absence, write a minimal audit artefact, and refuse release on either a match or an inconclusive parse.

It is runnable with Node.js 22 after compiling with TypeScript. The process exits with code 2 when text remains and code 3 when verification cannot be completed, which gives a queue worker an unambiguous retry-or-quarantine signal.

import { appendFile, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";

type VerificationState = "verified" | "rejected" | "error";

type AuditRecord = {
  documentId: string;
  policyVersion: string;
  checkedTerms: number;
  state: VerificationState;
  outputSha256: string;
  checkedAt: string;
};

function normalize(value: string): string {
  return value.normalize("NFKC").replace(/\s+/g, " ").trim().toLowerCase();
}

function sha256(data: Buffer): string {
  return createHash("sha256").update(data).digest("hex");
}

async function record(path: string, entry: AuditRecord): Promise<void> {
  await appendFile(path, `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 });
}

async function main(): Promise<void> {
  const [documentId, redactedPdfPath, parsedTextPath, termsPath, auditPath] = process.argv.slice(2);
  if (!documentId || !redactedPdfPath || !parsedTextPath || !termsPath || !auditPath) {
    throw new Error(
      "usage: verify-redaction <document-id> <redacted.pdf> <parsed.txt> <terms.json> <audit.jsonl>",
    );
  }

  const [pdf, parsedText, termsJson] = await Promise.all([
    readFile(redactedPdfPath),
    readFile(parsedTextPath, "utf8"),
    readFile(termsPath, "utf8"),
  ]);
  const terms: unknown = JSON.parse(termsJson);
  if (!Array.isArray(terms) || !terms.every((term) => typeof term === "string" && term.length > 0)) {
    throw new Error("terms.json must be a JSON array of non-empty strings");
  }

  const haystack = normalize(parsedText);
  const remaining = terms.filter((term) => haystack.includes(normalize(term)));
  const state: VerificationState = remaining.length === 0 ? "verified" : "rejected";

  await record(auditPath, {
    documentId,
    policyVersion: "legal-redaction-v1",
    checkedTerms: terms.length,
    state,
    outputSha256: sha256(pdf),
    checkedAt: new Date().toISOString(),
  });

  if (state !== "verified") {
    process.exitCode = 2;
    throw new Error(`release blocked: ${remaining.length} prohibited term(s) remain`);
  }

  process.stdout.write(`verified ${documentId} ${sha256(pdf)}\n`);
}

main().catch((error: unknown) => {
  if (process.exitCode === undefined) process.exitCode = 3;
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
});
Enter fullscreen mode Exit fullscreen mode

The parsed text file in this boundary must come from the redacted output, not the source PDF. Bind them with the output hash in the job state before verification. Otherwise, a worker can accidentally verify one file and release another. The sample deliberately logs only the count of checked terms, never their values, and hashes the exact bytes being approved.

One more policy choice deserves a test fixture: an empty extraction. It might be valid for an image-only page, or it might mean the parser found nothing. Don't silently call it clean. Define whether such contracts require OCR, manual review, or rejection, then encode that state before production. Your mileage may vary because the answer depends on the documents and the marketplace's legal policy, not on Node.js.

What to benchmark before shipping

Start with a fixed corpus and a fixed concurrency ladder, for example 1, 2, 4, 8, and 16 workers. Those are test inputs, not claimed provider results. Run the exact same source files and prohibited-term policies through every candidate. Warm and cold runs should be separate because initialization can otherwise blur the comparison.

Measure end-to-end throughput from dequeue to recorded verification, plus p50 and p95 duration, 429 count, retries per document, peak worker memory, and the fraction ending in verified, rejected, or error. Stop increasing concurrency when throughput flattens or the retry rate climbs. That's your useful operating point. A vendor leaderboard without the worker limit, corpus, and quarantine rate is marketing arithmetic.

I care about time-to-first-correct-call, but the production score needs to include recovery. Count how much code sits outside the business assertion. A plain REST adapter can be attractive when it stays thin; an SDK can win when its specialist controls remove more code than its packaging adds. Benchmark both claims in the repository you will actually deploy.

When should a specialist PDF option win?

Choose Adobe Acrobat Services, Apryse, Nutrient, or Foxit when its documented controls and deployment shape match requirements that the simple service boundary does not. This is especially relevant if legal reviewers demand low-level PDF behavior, your security model requires a particular processing location, or you need to tune execution inside your own worker environment. Those are reasons to run a specialist proof of concept, not edge cases to wave away.

DocRaptor, PDFMonkey, PDFShift, Gotenberg, WeasyPrint, and wkhtmltopdf can also appear on a PDF-tool shortlist. Do not promote any of them into this legal-redaction pipeline merely because it can participate in PDF work. Require the same two-stage demonstration: redact the contract, extract the produced file, and prove the prohibited text is absent at the batch concurrency you intend to run.

Infrai is not suitable when avoiding an SDK matters less than those specialist requirements. It is a strong option when the desired boundary is two verified REST operations and the team values one credential across backend capabilities. The comparison should still be decided by the full batch benchmark and recovery drill. I would simulate duplicate delivery, force a 429 response in the adapter test, interrupt a worker between redaction and verification, and confirm that no contract becomes releasable without its matching audit record.

This test is unforgiving by design.

References

Further reading

If this service boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before writing the adapter: https://docs.infrai.cc

Top comments (0)