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 > exp→invalid:expirednow < iat→invalid: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 existingstorage,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_KEYandPULP_LICENCE_PUBLIC_KEYand 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 onlicensed, 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 reportskind: 'invalid'and you need to confirm the token’scustomerId,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 intoBAKED_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_BASE64URLconstant inpackages/renderer-common/src/licence-verify.ts:32is 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=anythingto suppress the watermark will now run in evaluation mode (the unsigned value rejects asinvalid:malformedand the watermark returns). Real customer tokens issued viascripts/issue-licence.mjsagainst 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 at0.77.0, tag matches HEAD, CHANGELOG[0.77.0]section + link reference present,docs/release-v0.77.0.mdexists.pnpm --filter @pulp-engine/renderer-common test— 79/79 (includes the live-expiry regression test that proves the cache split correctly transitions toinvalid:expiredpastexpwithout process restart).pnpm --filter @pulp-engine/pdf-renderer test— 329/329 (includes the dispatcher env-propagation regressions forPULP_LICENCE_PUBLIC_KEYand 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.claudecontent-gate scanned all 113 with 0 link rewrites.pnpm extract-openapifollowed bypnpm --filter @pulp-engine/sdk codegen—openapi.json(root) andpackages/sdk-typescript/openapi.d.tsconfirmed zero-drift after the lockfile re-resolve.- End-to-end smoke test of the issuer + verifier pipeline:
scripts/generate-licence-keypair.mjs→scripts/issue-licence.mjsagainst the matching private PEM →pulp-engine licence verify <token>returnskind: licensedexit 0 against an env-override matching public key; an obviously-malformed token returnskind: invalid, reason: malformedexit 1.
Not verified
- SQL Server path — no local SQL Server instance available; CI-covered by the
test-sqlserverjob. - 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.ymlexercises 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
pypienvironment in this repo’s GitHub Actions doesn’t have the Trusted Publisher relationship registered with PyPI,publish-sdk-python.ymlwill fail loudly at thepypa/gh-action-pypi-publish@release/v1step. 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 viaworkflow_dispatch. - Mirror token (
PULPENGINE_RELEASES_TOKEN) must be present on this repo for the public-mirror release to succeed. Setup runbook atdocs/runbooks/release-mirror-setup.md. If missing, the mainrelease.ymlsucceeds but the public mirror atTroyCoderBoy/pulpengine-releaseswon’t update; recovery is aworkflow_dispatchre-run after the secret is set. - CLI lockstep deferred. The
packages/clipackage bumped from 0.65.0 → 0.77.0 in this release but is not yet enforced byscripts/check-version.mjs. A future release may add it to the lockstep once the CLI is published to npm separately.