Pulp Engine Document Rendering
Get started
Release v0.77.0

Release v0.77.0 — signed-licence-v1 (cryptographic licence enforcement)

Date: 2026-04-25 Tag: v0.77.0

Summary

First release to ship cryptographic licence enforcement. The PULP_LICENCE_KEY env var is now an Ed25519-signed token verified offline against a public key baked into the build; invalid, expired, or missing keys collapse to evaluation mode and the watermark returns. The EVALUATION-LICENCE.md §2.2 watermark-suppression clause becomes a reinforcement of the technical gate rather than the binding control.

This release also retires the Go and .NET SDK packages. Neither ever successfully published to a registry on any v0.75.x release; operators who need a Go or .NET client generate one from openapi.json via openapi-generator (see docs/sdk-generation-guide.md).

No breaking API/schema changes. The /health/ready response gains a licence block + checks.licence field; existing fields are preserved. The PDF/HTML/DOCX/PPTX/XLSX render contracts are unchanged.

What landed

Cryptographic licence enforcement

The verifier core lives at packages/renderer-common/src/licence-verify.ts.

Token wire format (5-part dotted base64url):

{iat_ms}.{exp_ms}.{customerId_b64url}.{claims_b64url_json}.{sig_b64url}

Signed payload (UTF-8 bytes fed verbatim to crypto.sign(null, ...)):

licence-v1:{iat_ms}:{exp_ms}:{customerId}:{claims_json}

v1 claims object: { "licenceId": "<id>" }. Both customerId and licenceId are validated against ^[A-Za-z0-9._-]{1,64}$.

Verification semantics:

  • now > expinvalid:expired
  • now < iatinvalid:not-yet-valid
  • structural / charset / JSON failures → invalid:malformed
  • signature failure → invalid:bad-signature (a single reason — Ed25519 verify cannot distinguish tamper from wrong-key)
  • missing or whitespace-only env value → evaluation
  • valid signature within validity window → licensed

All non-licensed outcomes collapse to evaluation mode at the renderer boundary — the watermark returns and rendering succeeds. There is no path where licence verification refuses to render.

Cache architecture. getLicenceState() memoises only the structural+signature outcome (parsing, JSON decode, Ed25519 verify); the time check is recomputed against Date.now() on every call. A long-running process started inside the validity window correctly transitions to invalid:expired once the token’s exp passes — without restart. The pure verifyLicenceToken(token, publicKey, nowMs) function is the time-injection surface for tests; getLicenceState() is argless.

/health/ready licence surfacing

Schema in apps/api/src/schemas/shared.ts. The response gains:

  • licence: { kind: 'licensed' | 'evaluation' | 'invalid', customerId?, expiresAt?, daysUntilExpiry?, reason? } (top-level)
  • checks.licence: 'ok' | 'evaluation' | 'invalid' (alongside existing storage, assetBinaryStore, renderer, rateLimitRedis)

Licence state never participates in the 503 calculation. Operators wire alerts directly on licence.kind === 'invalid' and licence.daysUntilExpiry < N independently of the readiness HTTP status.

Public-key distribution and rotation

The baked production public key lives at packages/renderer-common/src/licence-verify.ts:32 as a raw 32-byte base64url constant. Operators may override it via PULP_LICENCE_PUBLIC_KEY env var (same format — no PEM/SPKI accept path). A malformed override is rejected at startup with a WARN log and the verifier falls back to the baked key.

The verifier trusts one key at a time; there is no overlap window. Two rotation paths are documented in docs/initiatives/signed-licence-v1.md:

  • Path A — planned rotation in a release: generate keypair → re-issue customer tokens against new private key → distribute new tokens with an “install before upgrading to vX.Y.Z” note → ship the release that updates the baked constant → destroy the old private key after rollout completes.
  • Path B — emergency rotation via env override: generate keypair → re-issue affected customers’ tokens → send each customer the new token AND the new public-key string, asking them to set both PULP_LICENCE_KEY and PULP_LICENCE_PUBLIC_KEY and restart. The next regular release rolls the baked constant; customers drop the override after they upgrade.

Both paths require pre-distributing new tokens to customers BEFORE cutting over.

CLI: verify and inspect

packages/cli/src/commands/licence.ts.

  • pulp-engine licence verify <token> — signature-verifies against the baked / env-override key. Exit 0 on licensed, exit 1 on any failure. Prints structured JSON.
  • pulp-engine licence inspect <token> — base64url-decodes claims without verifying the signature. Useful for support triage when a customer’s deployment reports kind: 'invalid' and you need to confirm the token’s customerId, licenceId, and expiry independently of trust. Output is prefixed with a non-trust-decision warning.

Operator scripts (private)

  • scripts/generate-licence-keypair.mjs — prints a fresh Ed25519 keypair (PKCS8 PEM private key + raw 32-byte base64url public). Run once per rotation; private key archived in 1Password / KMS, public key pasted into BAKED_PUBLIC_KEY_BASE64URL.
  • scripts/issue-licence.mjs — takes --private-key <pem>, --customer-id, --licence-id, --expires-at, and optional --issued-at; signs the canonical payload and prints a token to stdout. NOT in the public CLI — issuance is an operator-only path.

Render-mode env propagation

The verifier runs inside the worker process for out-of-process render modes (RENDER_MODE=child-process, container, socket). All three dispatchers now forward both PULP_LICENCE_KEY and PULP_LICENCE_PUBLIC_KEY into the worker env so an operator-rotated public key reaches the verification path. Without this, an env override would silently fall back to the baked key inside the worker.

Go and .NET SDK retirement

Deleted packages/sdk-go/, packages/sdk-dotnet/, their publish workflows, the OpenAPI generator orchestrator (scripts/generate-sdks.ts), the Go mirror sync script, and the root openapi-generator configs. The check-version.mjs lockstep dropped from nine sites to seven. Customers who need a Go or .NET client generate one from the published openapi.json via openapi-generator — see sdk-generation-guide.md for the recipe.

Operational posture

  • Production keypair active. The baked BAKED_PUBLIC_KEY_BASE64URL constant in packages/renderer-common/src/licence-verify.ts:32 is the production public key generated for v0.77.0; the matching private key is archived in 1Password / KMS. The pre-v0.77.0 development key is no longer trusted.
  • Customers must hold a signed token. Any deployment that was previously setting PULP_LICENCE_KEY=anything to suppress the watermark will now run in evaluation mode (the unsigned value rejects as invalid:malformed and the watermark returns). Real customer tokens issued via scripts/issue-licence.mjs against the production private key continue to verify.
  • PyPI publish — first attempt. This is the first release to actually attempt the Python SDK publish since v0.75.1; previous releases left it deferred on Trusted Publishing config. Outcome (success / configuration gap) recorded in the GitHub Release body.

Verified before tagging

CI-verified

The release commit will be required to pass the standard ci.yml matrix on branch=main, event=push, sha=<release commit> before release.yml’s check-ci gate releases the Docker / Windows installer / SDK publish jobs. The matrix covers: ci, test-file-mode, test-sqlserver, test-e2e, test-e2e-auth, docker-build-smoke.

Locally verified

  • node scripts/check-version.mjs — all 7 lockstep sites at 0.77.0, tag matches HEAD, CHANGELOG [0.77.0] section + link reference present, docs/release-v0.77.0.md exists.
  • pnpm --filter @pulp-engine/renderer-common test — 79/79 (includes the live-expiry regression test that proves the cache split correctly transitions to invalid:expired past exp without process restart).
  • pnpm --filter @pulp-engine/pdf-renderer test — 329/329 (includes the dispatcher env-propagation regressions for PULP_LICENCE_PUBLIC_KEY and the watermark round-trip integration test).
  • pnpm --filter @pulp-engine/api typecheck — clean.
  • pnpm --filter @pulp-engine/cli typecheck — clean.
  • pnpm --filter @pulp-engine/website build — 113 pages built; postbuild .claude content-gate scanned all 113 with 0 link rewrites.
  • pnpm extract-openapi followed by pnpm --filter @pulp-engine/sdk codegenopenapi.json (root) and packages/sdk-typescript/openapi.d.ts confirmed zero-drift after the lockfile re-resolve.
  • End-to-end smoke test of the issuer + verifier pipeline: scripts/generate-licence-keypair.mjsscripts/issue-licence.mjs against the matching private PEM → pulp-engine licence verify <token> returns kind: licensed exit 0 against an env-override matching public key; an obviously-malformed token returns kind: invalid, reason: malformed exit 1.

Not verified

  • SQL Server path — no local SQL Server instance available; CI-covered by the test-sqlserver job.
  • E2E suites — Playwright runs are CI-only; no local browser pool was driven for this release.
  • Deployment rehearsal — no local Docker compose-up or migration dry-run was performed before tagging. The Docker-build smoke job in ci.yml exercises the image build path; full deployment validation happens post-tag against the published GHCR image.
  • PyPI publish — first attempt happens at tag time. Outcome recorded post-tag in the GitHub Release body.

Known residual (tracked, not blocking)

  • PyPI Trusted Publishing must be configured for the Python SDK publish to succeed. If the pypi environment in this repo’s GitHub Actions doesn’t have the Trusted Publisher relationship registered with PyPI, publish-sdk-python.yml will fail loudly at the pypa/gh-action-pypi-publish@release/v1 step. The rest of the release (Docker, Windows installer, npm SDK, GitHub Release) is unaffected. Recovery: configure Trusted Publishing per PyPI’s documentation, then re-run the workflow via workflow_dispatch.
  • Mirror token (PULPENGINE_RELEASES_TOKEN) must be present on this repo for the public-mirror release to succeed. Setup runbook at docs/runbooks/release-mirror-setup.md. If missing, the main release.yml succeeds but the public mirror at TroyCoderBoy/pulpengine-releases won’t update; recovery is a workflow_dispatch re-run after the secret is set.
  • CLI lockstep deferred. The packages/cli package bumped from 0.65.0 → 0.77.0 in this release but is not yet enforced by scripts/check-version.mjs. A future release may add it to the lockstep once the CLI is published to npm separately.