Changelog
All notable changes to VerifyKit will be documented in this file.
The format is based on Keep a Changelog.
[Unreleased]
Website and tooling only — nothing here is inside a published tarball, so it carries no version bump.
Breaking
-
(site) Every operator sign-in has to be set up again from scratch. The operator console used to be opened with a single shared key checked against a hash in the server's environment. It now has real accounts — an address, a password, a role — held in a database, and there is no migration path between the two: a shared secret is not an account and cannot be turned into one. So on the day this ships, every existing session stops working and no account exists yet. Somebody with shell access on the server has to create the first one with the seeding command described in the project's development workflow guide, and further operators are then added from inside the console. There is deliberately no self-service sign-up, so this is the only door in on a fresh database and it cannot be reached from the network. The two environment variables the old scheme read are no longer used and should be removed.
-
(site) Signing in moved to
/admin-login./dashboard/login, which the previous release introduced, permanently redirects there. The reason is the same one that created it: the sign-in form has to live outside the part of the site that is withheld from visitors without a session, and/dashboard/…is now withheld as a whole rather than page by page. -
(site) The switch that turns the package registry on is off until somebody turns it on. It reads from the database now, and when the database has never been told either way the answer is "off" rather than "on". This is a deliberate reversal. The switch gates downloads of a paid, proprietary SDK, and the two ways of being wrong are not comparable: defaulting to "on" means a fresh or restored database quietly serves every package to anyone who asks, with nothing looking wrong from the outside and no way to un-download what went out; defaulting to "off" means installs fail loudly with a clear status until an operator flips one switch. An access gate whose default is "open" is not really a default — it is a decision to grant access taken by whoever last emptied the table.
Changed
-
(site) The operator console's data moved out of files and into a database. The CDN origin allow-list, the registry switch, sign-in rate-limiting state and the record of who changed what were four different JSON files on the server's disk, read and rewritten in place. They are now four tables, with schema migrations that are applied and verified before the application is allowed to start — a failed migration blocks the deploy rather than leaving a half-migrated database serving traffic. The old JSON files are left where they are as a historical snapshot and are read by nothing; a one-off command carries their contents across, and it now reports failure rather than success when it cannot find them, which is what "there is nothing to migrate" and "you are looking in the wrong place" used to be indistinguishable from.
-
(site) Sign-in rate limiting survives a restart again. Moving to the authentication library's own limiter briefly put the counters in memory, which hands an attacker who can wait out a deploy a fresh budget. They are back on disk — in the database this time, so they also survive the process being replaced rather than merely restarted.
-
(site) The console is a sidebar with four separate pages instead of one page holding everything. Overview, revocation logs, downloads and settings each load their own data, so three of the four no longer pay for reading the ~29 MB revocation log that only one of them displays. Every page carries its own authorisation check rather than relying on the shared frame, because the framework does not re-run a shared frame when you navigate between pages inside it — which would have let a revoked session keep reading panel data until its cookie expired. Sign-out, which had been lost in the rearrangement, is back.
-
(site) The site runs as a container stack. The application, its database, its migrations, its one-off operator commands and a nightly backup are one description that travels with the repository, instead of a process manager on one host plus steps in someone's memory. The database is reachable only from inside that stack and publishes no port at all. The application reports health on its own endpoint that actually touches the database, so "running" and "working" stop being the same word. Container logs are capped rather than growing until the disk fills — the arrangement this replaces had log rotation and losing it was an unnoticed regression.
-
(site) The browser bundle and the verification engine are served from the same place. They are generated together by one release step and are not interchangeable — the JavaScript glue is emitted for one specific binary. One of the two was being frozen into the container image while the other was read live, so publishing a release swapped the engine underneath a running site and left the glue behind; both are cached for an hour, so a customer could have paired the old glue with the new engine for that long. Both now come from the same live location, and the release check compares the bytes actually served against the bytes on disk for each.
Security
-
(site) The whole operator area is withheld, not just its pages. The interface file behind the console is served only to requests carrying a session cookie this site issued, checked by verifying its signature rather than by noticing that a cookie with the right name exists. A request without one is answered exactly as a request for a file that never existed. This survives every spelling of the path we could construct: the check is made on the fully-decoded path, after an earlier version could be stepped around by percent-encoding a single character of it.
-
(site) Changes made in the console are attributed. Adding or removing a CDN origin, flipping the registry switch and deleting logs each record who did it, from what address, and when. Deleting logs additionally requires the admin role, having previously required only a signed-in viewer.
-
(site) Roles fail closed. An account whose role is empty, unknown, or anything the application does not explicitly recognise is refused rather than admitted, and that decision is made before any check that could accidentally pass it.
-
(site) The verification engine no longer fails hard when its allow-list cannot be read. The engine is served to allow-listed origins by adding a CORS header, and computing that header reads a list from the database. If that read failed — a database that has not finished starting, most plausibly after a host reboot — the request errored instead of falling back to this route's own designed refusal, which is simply to send the bytes without the header and let the browser withhold them. Same-origin requests never took that path, so the failure was reachable only by paying customers, and only on a cold cache.
-
(site) The web-facing process no longer has write access to the database dumps. It was given the backup directory and never had a reason to read or write anything in it.
-
(site) The SMTP password behind contact-form delivery is encrypted at rest. It is stored AES-256-GCM-encrypted, keyed by a secret held only in the server's environment, so a stolen database dump —
backups/sits on the same disk as the database itself — yields ciphertext and nothing that decrypts it. Contact submissions are also rate-limited to three per IP per hour, so the enquiry-storage table this release adds cannot be filled by a script faster than an operator could ever read it.
Added
-
(site) Nightly database backups with 30-day retention. A dump per day, written to a staging name and moved into place only after it succeeds, so a failed retry cannot destroy a good dump taken earlier the same day. The restore procedure has been rehearsed against a real dump rather than assumed, and what it does not prove is written down beside it — notably that the dumps sit on the same disk as the database, which makes this "the database can be undone", not disaster recovery.
-
(site) A health endpoint, and checks that fail for the right reasons. The database check now knows about every table every migration creates, not just the first migration's — a stack where the later migrations never applied used to pass green, which is precisely the failure that check exists to notice. The container check asserts that every Markdown file any page declares is actually inside the image, by looking inside it rather than by requesting the page, because those pages are pre-rendered and keep answering correctly from build output after the file behind them has gone missing.
-
(site) Contact-form enquiries are stored in Postgres and listed in the operator console. A submission is durable — inserted — before delivery is ever attempted, so a failing or unconfigured mail server downgrades an enquiry to an Undelivered badge with a Retry button rather than losing it. The new
/dashboard/contactpanel lists the 200 most recent, security disclosures sorted first and flagged with their own badge, and the new/dashboard/settings/emailpanel is where SMTP is configured, with a one-click Send test email. The test goes to both the signed-in operator and the configured notification address, and names both, so what it proves is the delivery path real enquiries actually take rather than only that the mail server answered. -
(site) The console says how many enquiries are waiting undelivered. A count sits on the Contact item in the sidebar from every panel, and the Overview page shows a banner while any are outstanding. Until a mail server is configured, every enquiry — including a vulnerability disclosure — is stored and undelivered, and previously nothing anywhere said so; the panel had to be opened by somebody who already suspected.
-
(site) Enquiries can be deleted from the console. Each one holds a name, an email address, a company, free text a stranger wrote, and an IP address, and there was no way to remove any of it short of hand-written SQL against the live database. Deleting is restricted to administrators and every deletion is recorded in the audit trail. Note that backups keep their own copy until they age out.
-
(site)
security.txtnow says which topic to choose, and links to the form with it already selected. Whether a vulnerability report is flagged, badged and sorted ahead of sales enquiries depends entirely on the reporter picking "Security vulnerability" from a nine-entry dropdown — and filing a disclosure under "Bug report" was an entirely reasonable thing to do.
Fixed
-
(site) Contact-form submissions were being lost twice over. The route used to
console.loga submission and call that success; pm2's log rotation deleted the evidence after fourteen days, so an enquiry that never reached an inbox left no trace anywhere.security.txtalso points vulnerability disclosures at this same form, so those were quietly going missing on the same schedule. Every submission is now a durable database row from the moment it is received, independent of whether mail delivery ever succeeds. -
(site) An enquiry marked "Delivered" now means a message actually went out. Delivery was briefly marked at the moment an attempt started, to stop two operators retrying the same enquiry at once. That closed the double send but opened a worse hole: a server restarted mid-attempt left the enquiry marked delivered, with the Retry button gone, for mail nobody ever received — and nothing in the console indicated it. Marking and locking are now separate, so an interrupted attempt reverts to Undelivered and becomes retryable again on its own a couple of minutes later. The worst case is a duplicate email rather than a disclosure that silently never arrives.
-
(site) A visitor whose computer clock runs fast is no longer silently dropped. The spam filter compared the browser's clock against the server's and treated the difference as time-on-page; a clock running even slightly ahead produced a negative interval, which read as "submitted instantly" — a bot. Those submissions were discarded while the visitor was told "Message sent successfully".
-
(site) A submission is no longer lost without trace if the database refuses it. In that case the visitor correctly sees an error, but the enquiry existed nowhere at all. The fields are now written to the server log first, so there is still a record to recover it from.
Website and docs
-
(site) Code blocks in the docs follow the theme instead of ignoring it. In light mode every docs page rendered github-dark's palette — pale purple and pale blue on a light ground.
MdxContentpassed a single theme, so shiki baked those colours into the markup and no CSS could recover them; the CSS that does the switching was scoped to.shiki-container, which only the home page's Quick Start uses; and the wrapper hadbg-[#111111]hardcoded in both themes, which is why the dark palette had looked tolerable there. Worth recording that the two shiki outputs are shaped differently —codeToHtmlputs the light theme in a plaincolorand only dark in a variable, rehype-pretty-code puts both in variables — so a light rule ofvar(--shiki-light)applied to both collapses the first to a single inherited colour. Measured across home and five docs pages: 6–9 token colours in both themes, worst-case contrast 4.16 in light against 3.92 in dark, where light had been showing dark's colours. -
(site) The product screenshots follow the theme too. The viewer has both themes and the site shipped only the dark captures, so a light-mode reader got a near-black slab on a white page. There is now a light frame for the hero and all six on
/screenshots. Two<img>switched on the.darkclass rather than a<picture>media query, because the theme is a class the reader can toggle independently of the OS. It costs nothing, which was measured rather than assumed: aloading="lazy"image insidedisplay: noneis never fetched and an eager one is, so the hero tradespriorityfor lazy on both frames. After: the wrong theme's files are fetched zero times in either mode. -
A guard on the eight check names.
scripts/check-check-names.mjsreads the labels out ofcheck-builders.tsand asserts every name the site lists is one the viewer renders. That drift shipped twice — an "Expiry" check that does not exist as a row, then a caption naming it again directly above a screenshot of the real eight — and a reviewer caught both. Wired intonpm run release. -
(site) The landing page is rebuilt around what the product reports rather than photographs of it. Measured on a 390px phone, the screenshot strip rendered its 2180px viewer captures at 348px — a 6.3x shrink that turned 11px UI text into under two pixels, so all three images became the same grey smudge under captions describing detail the reader could not see. The verdict bar and the eight checks are real markup now, which reads at any width, stays selectable and is indexable; the captures keep their place on
/screenshots, which has the room for them. Three sections became one along the way — the strip and the Verification Pipeline were showing the same eight checks, and Technical Highlights turned out to be Features restated, with a fourth card ("Base64-embedded WASM binary") that has been false since the engine became a separate file. Desktop went from 10,464px to about 6,100px, mobile from 9,972 to 7,800, and the page now has four distinct shapes rather than five repetitions of one. -
(site) Scroll-driven motion, and no JavaScript for it. The eight checks tick in one after another as the pipeline enters view, the hero screenshot flattens out of its tilt as the reader scrolls past it, and section content rises as it arrives. Every rule keeps its start state inside an
@supportsandprefers-reduced-motionguard, because a browser withoutanimation-timelinewould otherwise leave an unguardedopacity: 0exactly where it is — blank to readers and to crawlers both. The checks container neededoverflow-cliprather thanoverflow-hiddenfor any of it to work:hiddenmakes an element a scroll container, and aview()timeline resolves against the nearest one, so the rows were timed against a box that never scrolls. -
(site) The site named two different sets of eight checks. The viewer reports Signer's Identity and never renders Expiry as its own row — it is an engine field folded into that check — but eight places, including the FAQ's JSON-LD,
llms.txt, pricing and the docs index, listed Expiry instead. Publishing screenshots of the real list is what made the contradiction reachable. -
(site) The
dev:demotrap is documented where someone would hit it.dev-workflow.mdadvised stopping the production server so the dev server could have port 1365 — but both serve fromapps/demo/.next, and startingnext devreplaces the production build with a dev-mode directory, soBUILD_IDandstatic/disappear and every content-hashed chunk the live HTML asks for returns 400. It took the live site down on 2026-08-02. README and CONTRIBUTING carry the same warning beside theirdev:demorecommendations. -
(site) The CDN engine is served only to allow-listed origins.
/cdn/*is the one path by which the commercial viewer can be used without going through the private registry, and since the engine became a separate.wasmit was served withAccess-Control-Allow-Origin: *— so hotlinking it from any site worked. Enforcement goes on the engine rather than the bundle, and that was measured rather than assumed:<link>and<script src>send noOriginat all, while the engine is fetched withfetch()and always carries one. An unknown origin now receives noACAOand the browser refuses it; the bundle is inert without an engine, so the JavaScript stays open. The allow-list is the one the operator dashboard already manages, read at request time by a route handler — middleware runs on the Edge runtime and cannot open the file — so adding a customer is an entry rather than a deploy. Two limits worth stating plainly: this stops hotlinking, not self-hosting, since files served from someone else's origin involve no CORS check at all; and removing an origin takes effect only as the engine's one-hour cache expires. It stands in for enforcement until the licence server is out of testing, and is commercial hygiene rather than a licence. -
(site) Security headers on every page, and an enforcing Content-Security-Policy.
middleware.tshad been setting headers on /dashboard for a while; everywhere else had none — no HSTS, nonosniff, noReferrer-Policy, no framing rule. All five are set site-wide now, withX-Frame-Options: SAMEORIGINrather than DENY (the dashboard keeps its own DENY) and HSTS withoutpreload, that being a one-way door not worth taking on someone else's behalf. The CSP enforces: the obviousdefault-src 'self'would have broken the demo, since pdf.js fetches its worker from unpkg and wraps it in a blob to run — found by reading the network log rather than by reasoning about it, which is the argument against shipping a policy straight to enforcing. Download, print, attachment export and the docs' Mermaid diagrams build blob URLs too. It went out report-only first and was flipped once every path that could plausibly trip it came back clean: the demo's whole verification pipeline, a docs page rendering two Mermaid diagrams, print's blob: PDF in a hidden iframe, download's blob: URL on an<a download>(which really did download), and a blob: image. Then re-verified with the policy enforcing rather than reporting, since a report-only pass is evidence and not proof — the signed PDF still verifies and the document still renders, meaning the unpkg worker still loads, with no violations. Note thatscript-srccarries'unsafe-inline'— Next inlines 34 hydration scripts per page and nonces would force dynamic rendering, costing the 49 prerendered pages — so this is not XSS protection; what it constrains (object-src,base-uri,form-action, connectable origins) is real and cheap. -
The
keywordsmeta tag is gone, and the/ogroute with it. Google stopped using meta keywords for ranking in 2009 and Bing treats stuffing it as a negative signal; it was on all 49 pages, ten to twenty-five terms each, costing bytes in every response and maintenance whenever a page shifted subject, for nothing any engine reads. Thekeywordsin blog and comparison JSON-LD is a different property -- schema.org Article, which Google does read -- and stays./ogbuilt social cards on demand and had no caller left once the pre-rendered ones landed, so it is deleted; robots.txt loses the Googlebot/bingbot group that existed solely to disallow it, since a group naming a crawler replaces the wildcard for it rather than adding to it, and one kept up to date by hand is a hazard rather than dead weight. -
The Twitter card stops crediting an account that does not exist.
twitter:siteandtwitter:creatornamed @trexolab on every page; there is no such account -- confirmed with the owner afterx.com/trexolabturned out to 404. Both are attribution rather than format, so the card is unaffected:summary_large_imagestays, and it is what makes the 1200x630 social image render full-width. What went is a credit pointing nowhere, and an invitation to click it and find out. -
The organisation schema stops advertising profiles that do not resolve.
sameAsis how Google reconciles this organisation with the same one elsewhere, so every URL in it has to answer a logged-out request -- that being the only kind a crawler makes. Both entries did not: the GitHub repository is private and 404s anonymously, andtwitter.com/trexolabredirects to an X handle that 404s too. The repository is now behind aREPO_IS_PUBLICflag, the same shape as the existingON_PUBLIC_NPM, so it returns on its own when the repository opens; with nothing left to list, the property is omitted rather than emitted empty.check:sitenow resolves everysameAsit finds. -
npm run smoke:releaseverifies a release rather than the code behind it. The existing gate packs tarballs out of the working tree, which proves the code is right and says nothing about whether the release reached anyone: publishing to the internal registry is a separate step from tagging, and on 2026-07-17 the registry sat at 0.5.6 while git was at 0.5.12 because it had been skipped six times. This installs the packages the waydocs/installation.mdtells a customer to -- a scoped.npmrc,npm install, nothing else -- verifies a real signed PDF with what comes back, and checks that the version, the engine binary, the absence of base64 and the absence of embedded source all survived the trip, then that every manifest,Cargo.toml, the tag on HEAD and the push state agree.
[0.13.3]
Only @trexolab/verifykit-react changed. core, vanilla and
plugin-revocation are rebuilt and re-versioned in lockstep with it, as they
have been since the packages started shipping together, but carry no code
changes of their own.
-
The Signature Properties dialog scrolls when its content does not fit. Since 0.13.0 the dialog sizes to its content and caps at
calc(100% - 8px). When the content exceeded that cap it was clipped mid-row with nothing to scroll — reported from a phone, where a signature with three certificates and three timestamps ran well past a short viewport.The cause was a percentage height, not the cap. The tab's scrolling region was
height: 100%inside a parent whose own height comes from flex layout, and browsers treat that as indefinite while laying the child out — so the percentage resolved asautoand the scroller grew to its full content instead of overflowing inside a fixed box. Measured on the built viewer: 1057px of content in a 163px box,overflow: hidden, no scrollbar. It is now a flex item (flex: 1+min-height: 0), which gives it a definite height to overflow. The sameheight: 100%pattern inCertificateViewer— the Certificates and Timestamp tabs — is fixed the same way.Desktop was affected too, contrary to how it looked: at a 900px viewport the Details tab clipped 1057px of content into 663px. It was simply less visible than on a phone.
min-height: 0is now explicit on each panel in the flex chain. Every one of them setsoverflow: hidden, which already implies a zero automatic minimum size, but relying on that means the dialog silently stops scrolling the day someone changes an overflow.Verified on the built viewer at 412×520, 900×400 and 1440×900, on all four tabs: every case either fits or has a user-scrollable region. Programmatic
scrollTopis not what was checked — anoverflow: hiddenbox moves underscrollTopwhile offering the reader no scrollbar, no wheel and no drag.
[0.13.2]
Only @trexolab/verifykit-react changed. core, vanilla and
plugin-revocation are rebuilt and re-versioned in lockstep with it, as they
have been since the packages started shipping together, but carry no code
changes of their own.
-
The signature panel docks as a bottom sheet on touch devices up to 840px, not just below 560px. This is the actual fix for the Firefox-on-Android report that 0.13.1 chased. It was never a race: the
mdband starts at 560px, and the phone's viewer measured ~569px — nine pixels above the boundary — so it got a 272px side panel taking 48% of the screen, which is what the design said to do at that width. Chrome on the same device picks a different device pixel ratio, landed under 560, and got the sheet. Nothing about the device or its settings differed between the two.Width alone cannot tell a phone from a narrow window on a desktop, and the two want different layouts. The pointer can, so
(pointer: coarse)now widens the sheet's band to everything belowlg. With a fine pointer the rule is unchanged, so an embedded viewer in a narrow column on a desktop behaves exactly as before. Atlga side panel still leaves the document 568px or more, which is enough on either input.Measured on the built viewer at every band on both input kinds: 569px with touch is now a sheet and 569px with a mouse is still a side panel; 900px is a side panel on both.
-
The sheet-or-panel decision is published once, as
data-sig-layouton the viewer root.base.csspositions the sheet andSignatureListPanelsizes it, and both previously derived the answer fromdata-breakpointindependently. That was safe only while the rule was "xs or sm" in both places; with the pointer in it, a second derivation is a second chance to disagree — and disagreement means a sheet with a height and no dock. The attribute is additive anddata-breakpointis unchanged.
[0.13.1]
Only @trexolab/verifykit-react changed. core, vanilla and
plugin-revocation are rebuilt and re-versioned in lockstep with it, as they
have been since the packages started shipping together, but carry no code
changes of their own.
-
The viewer's breakpoint is correct in the first frame it paints.
useContainerSizestarted atlgand corrected inside arequestAnimationFrame, so the first frame every device painted was the desktop layout — and everything narrow (the signature bottom sheet, the collapsed rails) only appeared once that correction landed. The measurement now happens in a layout effect, before paint; the frame is still used to coalesce later resize bursts, where one frame of latency is worth not measuring per event.Instrumenting
data-breakpointat a 390px viewport showed Chromium and Firefox both publishinglgand only thenxs, so the window was real in every browser — it simply closed fast enough to be invisible in most of them. After the change Chromium publishesxswith no intermediate value and docks the signature panel as a sheet.This was prompted by a report of Firefox on Android showing the desktop side panel instead of the sheet, with "Request desktop site" off. That failure could not be reproduced — desktop Firefox at the same width behaves correctly both before and after — so this closes a proven race rather than a confirmed cause of that report.
[0.13.0]
Only @trexolab/verifykit-react changed. core, vanilla and
plugin-revocation are rebuilt and re-versioned in lockstep with it, as they
have been since the packages started shipping together, but carry no code
changes of their own.
-
The signature properties dialog sizes to its content instead of to the viewer. It was
height: 100%, so it filled the viewer whatever tab was open — on Timestamp, a short certificate summary, that left roughly 800px of empty dialog below the last row, and the taller the display the worse it looked. Measured after: Details is 77% of the viewer's height and Timestamp 45%, each centred.ViewerDialogContentalready capped atcalc(100% - 8px)and its container centres on both axes, so removing the fixed height was the whole change; a 30rem floor keeps the four tabs from resizing wildly against each other and yields to the cap on a short viewport. -
That resize is now animated. Switching tabs used to snap. Two obvious fixes do not work here and both are documented in
viewer-dialog.tsxso they are not tried again:transition: heightdoes nothing because the dialog isheight: autoand a tab swap changes only the content, so no property ever transitions; and pinning the old height before setting the new one fails too, deceptively —getAnimations()reports a running transition while the box never moves, because React has already committed the new content and laid it out at its final size before a layout effect can run. The start and end are handed toelement.animate()explicitly instead. Underprefers-reduced-motion: reduceno animation is created and the height snaps. -
New optional prop:
ViewerDialogContenttakesanimateHeightKey. Change it when the dialog's content changes enough to resize it — the properties dialog passes its active tab. Any dialog that omits it behaves exactly as before, so this is additive. -
The certificate Summary panel stops floating its pills. An unheaded row sat between two cards carrying three unrelated things at once: the certificate's role, its key algorithm, and every extended key usage — while plain Key Usage had a card of its own, putting the two halves of one question in two different places. Role and algorithm now sit under a divider inside the Validity card, and both kinds of key usage share one card under separate labels, "Permitted key operations" and "Extended key usage".
[0.12.0] -- 2026-07-31
The engine stops travelling as a string, and the packages stop shipping their own source. Both came out of an SEO audit of the website, which is an odd place to find either -- the audit was looking at what pages weigh, found 1.8 MB of JavaScript on pages that never verify anything, and followed it into the packaging.
Between 0.3.1 and 0.11.0 the WASM binary was base64-encoded into the JavaScript.
That needed no bundler support at all, which is why it was chosen, and it charged
for it three times over: base64 is four bytes per three, so 1.1 MB became 1.5 MB;
a string literal cannot be split out of the chunk holding it, so every route
importing the package carried the engine whether or not it verified anything; and
the bytes had to be decoded before compilation could begin. It ships as a .wasm
file again. Bundlers have handled that natively for years, so the "nothing to
configure" property survives -- arrived at differently.
The second finding is not about size. All four packages built with sourcemaps
on, and a JS sourcemap carries sourcesContent: a verbatim copy of every file
that went into the bundle. Each package publishes the whole of dist/, so the
tarballs contained 13,689 KB of TypeScript across 863 entries. For a licensed
product that is the thing being licensed.
A minor rather than a patch. main now points at a Node-specific build and
exports grows a node condition, so a tool that reads neither exports nor
browser resolves somewhere new. Any bundler from the last several years reads
one of them, and no call site changes. On a 0.x version ^ locks the minor, so
a project on ^0.11.x stays where it is until it asks for this one.
docs/migration.md has the upgrade notes.
The website entries at the end are marked (site) and are not inside any published tarball.
Changed
- The WASM engine ships as a
.wasmfile instead of a base64 string. Nothing in the API moves and, for anyone using a bundler, nothing in the setup does either: Vite, webpack, Next.js, Rollup and Parcel all resolve the reference wasm-bindgen makes and emit the binary as a content-hashed asset. What changes is what that costs. The file is 1.1 MB rather than 1.5 MB,WebAssembly.instantiateStreamingcompiles it while it downloads rather than after a base64 decode, and it is cached apart from the JavaScript, which changes far more often. Node needed the one genuinely different path -- wasm-bindgen resolves the binary throughfetch(new URL(…, import.meta.url))and Node'sfetchrejectsfile:URLs -- so there is now a separate Node build that reads it from the installed package, selected by anodecondition inexportsrather than by a runtime check, which keepsnode:fs/promisesout of the graph every browser bundler has to resolve.setWasmUrl()andcreateVerifier({ wasmUrl })are unchanged and remain the way to load it from somewhere else. Two things are worth checking on upgrade: that your server sends.wasmasapplication/wasm, since browsers refuse to stream-compile anything else and fall back with only a console warning; and that any deploy step which copies build output by extension is not leaving the engine behind. @trexolab/verifykit-vanillano longer inlines the engine either, and takes awasmUrloption. This package could not simply follow the change above: Vite builds it in library mode, which inlines assets unconditionally -- confirmed by building withassetsInlineLimit: 0, which produces a byte-identical chunk -- so the binary was being rewritten into adata:application/wasm;base64,…URL whatever the rest of the SDK did.dist/now containsverifykit_core_wasm_bg.wasmand the bundle resolves it against its own script URL, so keeping the two in one directory needs no configuration, which is what copying the whole ofdist/already gets you.verifykit.umd.jsdrops from 2.9 MB to 1.3 MB, and the engine is fetched only when a document is verified. If you serve this from a CDN, two things change. The.wasmmust be uploaded alongside the JS — where they cannot sit together, passwasmUrltoVerifyKit.create(). And the host must sendAccess-Control-Allow-Originon the.wasm: a<script src>needs no CORS, so a cross-origin CDN worked by accident for as long as the engine was a string inside that script, and the moment it became a file the bundle fetches, every embedder outside the serving origin gets a blocked request — after the viewer has loaded, so it fails at the point of verifying rather than at the point of loading. Give the JS and the.wasmthe same cache lifetime too, since the wasm-bindgen glue and its binary are generated together and are not interchangeable across releases.
Security
- Sourcemaps are no longer published. All four packages shipped them, and a JS sourcemap is not line mappings alone --
sourcesContentembeds a verbatim copy of every source file in the bundle. Across the four tarballs that was 13,689 KB of TypeScript in 863 entries: 107 source files from@trexolab/verifykit-react, 320 from@trexolab/verifykit-vanilla(which inlines its dependencies, so pdf.js, React and scheduler came too), and the loader from@trexolab/verifykit-core. Nothing was exploitable and no key or credential was among it; it was simply the source of a licensed product, handed to everyone who installed it. Declaration maps are kept -- they reference source paths but embed no code, and they are what gives an editor go-to-definition. A release-time check now fails the build if any published sourcemap embeds source, because this is invisible in a diff and was for several versions.
Fixed
dts.sourcemapis set explicitly in the twotsdown-built packages. Turning sourcemaps off left them still being written, on the ESM side only, which looked like the bundler ignoring the flag. It was not:rolldown-plugin-dtsderives its own sourcemap setting fromdeclarationMapintsconfigand ORs it ahead of the build's, so with declaration maps on it won every time -- and only for ESM, because the CJS declarations are built through a separate pass. This is whysourcemap: false,--no-sourcemapand the documented default all appeared to do nothing.
Documentation
- The docs describe the engine as a file. Ten pages said the opposite -- that the binary is base64-embedded and no
.wasmneeds to be served -- across the introduction, quick start, installation, deployment, architecture, security and troubleshooting guides, the examples, and both API references.docs/vanilla.mdhad gone the other way and promised sibling-file resolution that did not yet happen. All of them now match what ships, note that the file must be served asapplication/wasm, and cover the failure mode this introduces: a deploy step that copies by extension and drops the engine. The pre-0.3.1 migration entry keeps its history and is marked superseded.
Added
- (site) Every page has a pre-rendered social card. The pages pointed
og:imageat/og?title=…&description=…, which builds an image for any pair of strings and is therefore an unbounded set of crawlable URLs -- 60 of bingbot's last 107 requests to the site went there, against 28 for actual pages. Disallowing it for Googlebot and bingbot fixed the crawl budget and silently cost the previews, because it was the only image any page declared: themax-image-preview:largethose same pages ask Google for had nothing to show, and Discover, which requires a large image, could not consider them. Each page now points at one of 49 files rendered at build time, which is a finite set needing no rule to keep crawlers out of it. The on-demand route stays for ad-hoc cards and stays closed to the two indexers. - (site)
/blog,/contactand/demodeclare their place in the site. All three were missingBreadcrumbList, which is one of the few rich results Google still shows./demoomitted it deliberately, reasoning that it draws no visible trail and the guidelines say not to mark up what a reader cannot see -- but that guidance is aimed at markup claiming something the page does not support, and where a page sits is not that. The six hand-written builders across the site are now one function, which putsitemon every crumb: a list may omit that only on its final element, and a middle crumb without a URL is what invalidated the breadcrumb on all 28 doc pages once before.
[0.11.0] -- 2026-07-30
Two follow-ups to the scroll-mode work in 0.10.0, both of them decisions rather
than repairs: the horizontal wheel now follows the pages, and wrapped is gone.
0.10.0 made the whole of a zoomed page reachable in horizontal mode by handing the wheel back to the browser. That was right about the reachability and wrong about the axis -- the browser scrolls Y and only falls back to X once there is no Y left, so on a tall page a plain wheel never advanced through the document. Wrapped, meanwhile, was fixed in 0.10.0 and then removed here, which is not a contradiction: it worked, and working was not enough to make it distinguishable from vertical at the zoom the viewer opens on.
A minor rather than a patch, because 'wrapped' comes off the ScrollMode
union and that is a name leaving the public surface. Nothing throws if the
string arrives anyway -- every runtime read of scrollMode is one of two plain
equality tests, so an unrecognised mode lays out as vertical and a stale
persisted value degrades rather than breaks. On a 0.x version ^ locks the
minor, so a project on ^0.10.x stays where it is until it asks for this one.
docs/migration.md has the swap.
Changed
- In horizontal scroll mode, a plain wheel now scrolls across and Shift+wheel scrolls down. 0.10.0 fixed horizontal by handing the wheel back to the browser, which was right about the axis being reachable and wrong about which axis the reader wanted. The browser's rule is "scroll Y, and only fall back to X once there is no Y left", so on a page zoomed taller than the window a plain wheel scrolled down the page and never advanced through the document -- in a layout that reads left-to-right, a wheel that does not move you along it reads as a wheel that is broken. The axes now follow the layout: plain wheel moves through the pages, Shift+wheel moves within a page taller than the window, Ctrl/Cmd+wheel still zooms and is not touched at all, since that gesture belongs to the zoom plugin. This is a deliberate departure from pdf.js, which does no wheel remapping anywhere -- verified against the copy in this repository, whose only
deltaX/deltaYreader is the zoom normaliser. It applies to horizontal alone; vertical and single-page keep the platform behaviour, which is what every other scroll container on the page does. Two details that are easy to get wrong are handled: the dominant axis is read rather thandeltaYalone, because some browsers deliver Shift+wheel pre-swapped asdeltaXand others asdeltaYwith the modifier set; anddeltaModeis normalised, because Firefox's mouse wheel reports lines rather than pixels and an unnormalised three-line notch would move the page three pixels. The event is claimed only while the target axis can still move, so at either end the wheel goes back to whatever the viewer is embedded in and a host page can keep scrolling past it.
Removed
- The
wrappedscroll mode, and'wrapped'from theScrollModeunion. It flowed pages left-to-right and broke onto a new row when the column ran out, so how many pages shared a row was decided by the zoom. That is the problem: the viewer opens at fit-width, and a page fitted to its column is by definition as wide as the column, so exactly one fits. Wrapped and vertical were pixel-identical until the reader zoomed out to roughly 60%, and only below about 25% did it become the page grid it was for. Measured on a twelve-page document in a 1024px column: one page per row at 120%, two at 60%, six at 25%. Nobody finds a mode they cannot see, and the thumbnail sidebar already answers the question a page grid was there to answer -- on a product whose documents are mostly a few pages of signed PDF, it was answering it better. The menu entry, the layout branch, the legacy toolbar menu entry and the demo's settings option all go with the type. A'wrapped'string that still reaches the viewer -- from persisted settings, or a consumer not yet rebuilt -- lays out as vertical rather than throwing, so the removal cannot break a running app on contact;docs/migration.mdhas the swap.
[0.10.0] -- 2026-07-30
Three of the viewer's four scroll modes did not work. Vertical -- the default, and so the one that gets looked at -- was correct; horizontal could not be scrolled down, wrapped was a second copy of vertical under a different name, and single-page opened each page at the offset the previous one was left at. Each has its own cause and its own entry below.
A minor rather than a patch, for two reasons. It adds names to the public
surface: PageNavigationPluginOptions in both @trexolab/verifykit-react and
@trexolab/verifykit-vanilla, an optional argument to pageNavigationPlugin(),
and a pageNavigation key on DefaultLayoutPluginOptions and on vanilla's
VerifyKitOptions. And the floating page control those options govern is on by
default, so upgrading puts something on screen that was not there before. On a
0.x version ^ locks the minor, so a project on ^0.9.x stays where it is
until it asks for this one -- which is the right way round for a change that is
visible without having been asked for. Everything added is optional, and every
existing call keeps compiling untouched.
The remaining entries are documentation and website only and marked as such. None of that is inside a published tarball.
Security
-
(site) The operator dashboard's interface is no longer served to visitors who are not signed in. The page itself has always required a session and has never handed data to anyone without one -- but the interface it renders once you are signed in was being built into a file the site served to anybody who asked for it. Nothing in that file was a credential or a customer record; it was the panel's own layout and the names of the endpoints behind it, all of which reject an unauthenticated caller. It should not have been readable all the same, and it no longer is: the file is withheld unless the request carries a session this site issued, and a request without one gets the same answer as a request for a file that does not exist. Signing in has moved to
/dashboard/login, which is what makes this possible -- the sign-in form and the panel used to be built into one file, so withholding it took the sign-in form down with it. Sessions issued before this change are ended, so signing in once more is expected. (Superseded: sign-in is at/admin-loginsince the operator platform rebuild --/dashboard/loginnow redirects there. See[Unreleased].) -
(site) Sign-in attempts are limited, and failures are recorded. There was previously no ceiling on how many times the dashboard's key could be guessed. Repeated failures from one source now lock it out for a period that lengthens with each further attempt, with a separate ceiling for attempts spread across many sources so that no single one has to reach its own limit for the pattern to be caught; an address that has signed in successfully before is exempt from the second, so the ceiling cannot be used to lock the operator out. Every failure is written to a capped log. The sign-in form now distinguishes "too many attempts, wait" from "wrong key", which are not the same problem. (Superseded: there is no shared "dashboard key" any more -- operators have individual accounts, and rate limiting is BetterAuth's, persisted in Postgres. The escalating-lockout and capped-log scheme described here was removed with the code that implemented it. See
[Unreleased].) -
(site) Request logs record the address the proxy saw, not one the caller can choose. The forwarding header this site reads is appended to by the proxy, so a caller that sends its own value has that value show up first and be recorded as the source. Three request logs took it at face value, which made the addresses shown in the dashboard suggestions rather than facts and would have made anything keyed on them trivial to sidestep. All of them now read the hop the proxy itself added.
-
(site) The dashboard sends framing, referrer and indexing headers.
X-Frame-Options: DENYandReferrer-Policy: no-referreralongside the no-store caching andnoindexit already sent. -
(site) Package downloads now go through the registry endpoint, so the switch that turns the registry off actually turns it off. The packaged releases were kept in the site's static directory, and a file in that directory is served straight from disk without the endpoint in front of it ever running -- which is where the on/off switch is read. The switch was off, the endpoint was correctly refusing requests, and a direct request for a package still returned it in full. The packages have been moved somewhere only the endpoint can reach, so the switch governs every download; the published addresses that consumers install from are unchanged.
Added
-
A floating previous/next control in single-page scroll mode. Single-page is the one mode where scrolling cannot reach the next page -- every page but the current one is
display: none-- so the toolbar at the top of the viewer was the only way forward, which is a long way from where the reader's eye is once they have read to the bottom of a page. A pill carrying previous, "n / total" and next now fades in over the bottom of the page while the pointer is anywhere over the document, and fades out when it leaves. It is portalled into the document area rather than rendered where the overlay slot sits, so it centres over the page itself and not over the whole viewer, which would put it visibly off-centre whenever the sidebar or the signature panel is open. A device that cannot hover keeps it permanently, since there is no hover there to reveal it. While hidden it isvisibility: hiddenrather than merely transparent, so the two buttons leave the focus order with it and tabbing cannot land on a control that is not on screen. It lives inpageNavigationPlugin, so a viewer that does not install page navigation does not get it, andpageNavigationPlugin({ floatingNav: false })-- reachable asdefaultLayoutPlugin({ pageNavigation: { floatingNav: false } })-- turns it off for one that does.@trexolab/verifykit-vanillatakes the same object as a top-levelpageNavigationoption, alongside thezoomone it already had: the control ships to both packages, so the switch had to reach both rather than leaving a vanilla embed with a control it could not turn off. -
(docs) Three comparison pages, written from the vendors' own documentation rather than from feature grids.
/compareheld two pages and 173 words of its own, which is a hub with nothing in it, and the two commercial SDKs a buyer actually shortlists against -- Apryse and Nutrient -- appeared nowhere./compare/verifykit-vs-apryse,/compare/verifykit-vs-nutrientand/compare/pdf-signature-verification-sdknow cover them, each with the API names quoted so every row is checkable against the source it came from, and each opening with what the other product does that VerifyKit does not: Apryse and Nutrient both sign, edit, redact and OCR across Web, iOS, Android and server, and VerifyKit does none of it. The rows that go the other way are the ones drawn from their guides: Nutrient'sPAdELevelenum hasb_b,b_tandb_ltand no B-LTA; neither vendor's verification guide documents a per-signature revocation field; Nutrient's signature validation needs two licence components, and without Form Viewing and FillinggetSignaturesInfo()"resolves with an emptysignaturesarray -- even on a signed document", withshowSignatureValidationStatusdefaulting toNEVERon top of that; Apryse's needsfullAPI: trueplus a separate Digital Signature package. Where a guide is silent the row says "not documented" and the page says outright that this is a question to put to the vendor, not a proven absence -- and Apryse'sGetUnsupportedFeatures, which VerifyKit has no equivalent for, and Nutrient'scertificateCheckTime, which is more configurable than ours, are both stated as points against us. The third page is the one for someone who has not shortlisted yet: the eight questions a verifier has to answer separately, then the four categories -- open-source JS libraries, EU DSS (complete and free, and Java, which is the whole constraint), the two platforms, and VerifyKit -- with an honest matrix underneath and a section on when verification is not the job at all. -
(docs) Where the verdict is drawn, on all three comparison pages. Most web viewers report a signature by floating an HTML badge over the page, and a comparison that omits this leaves out the part a reviewer notices first. VerifyKit puts the tick, cross or question mark inside the PDF canvas, on the signature field itself, in the widget's own rectangle at its scale and rotation -- and shows the verdict that signature actually got from the eight checks, not the grey placeholder the signing tool baked in. The properties that make it safe to switch on are stated with it: verification runs on the original bytes, what reaches the renderer is display-only so a download returns the untouched file, and password-protected documents are supported rather than skipped or corrupted. Over that canvas every signature field, signed or unsigned, is an individually addressable overlay tinted by that signature's status with the eight checks behind it. The limit is stated just as plainly, because it is a real reason to buy something else:
renderFormsis off, so every other AcroForm field renders read-only. This is a verification viewer, not a form filler.
Changed
-
(docs) The published documentation now describes behaviour, not implementation. A reference manual for a licensed product should tell you what the software does, what it guarantees and where it stops -- not how it is built inside. Pages that had drifted into the second category were rewritten into the first: the architecture reference now describes the four packages, what each publishes and what you build against, in place of a file-by-file map of the source tree; the guides describe what the viewer draws and under what conditions, in place of the document structures it writes; and history entries state what changed and who it affects, in place of the internal names involved. Nothing a customer relies on was removed -- every public API name, every documented guarantee, every stated limitation and the full Adobe-parity behaviour table are unchanged, and where a detail was genuinely load-bearing for integration (which ciphers are supported, why the package ships unminified, why pdf.js is pinned) it is still stated outright. Two things are new rather than removed: a bill of materials for the WASM binary is offered to customers for security review, and the custom-icon path now points at us instead of at build steps no published tarball contains.
-
(docs) Every comparison page is now reachable from the content that shares its subject. The related-reading block added in 0.9.0 ranked blog posts only, so the comparison pages sat behind a single link from the
/comparehub. It now ranks blog posts and comparison pages separately and interleaves them, comparison first each round, so a comparison cannot be crowded out by a better-tagged post; each link carries a "Blog" or "Compare" chip so the destination is legible before the click. Measured across the tag graph: 48 inbound related-reading links now point at/compare/*, between 6 and 13 per page, from 21 of the 28 doc pages, all 9 blog posts and the sibling comparisons./pricinglinks four of them directly. -
(docs)
/compareand/pricingsay enough to be worth landing on. The comparison hub had 173 words and the pricing page 222, both of them almost entirely link text./comparenow opens with the eight questions a verification result has to answer separately and states the "not documented" convention before a reader hits their first table./pricinggains a "What every licence includes" section -- the whole eight-check pipeline, all 134 trust roots, every package, browser and Node -- which exists because the tier grid implied a cut-down tier that does not exist, and a box linking four of the comparisons./pricingalso emits aBreadcrumbList, the one structured-data type it can state truthfully; it was the last top-level page without one, and the reasoning for whyProduct/Offerstill cannot go there is in the comment above it. -
(docs) Titles and descriptions across every page now fit what Google displays. 42 of the 46 pages then live carried a description past 155 characters, the longest at 287, and Google truncates the snippet mid-word past roughly that point -- what was being cut was the end of the sentence, which is where the distinguishing detail sat. Two blog titles and
/demoran past 60 characters including the| VerifyKit SDKsuffix the root layout appends, which is the number that matters and is easy to miss when reading the source string alone. Every page is now inside both limits, measured with the suffix included and with HTML entities decoded first, and the rule is written down so the next page added is measured the same way. -
(docs)
dateModifiedand sitemaplastmodcome from one source. Blog and comparison pages reported their publication date asdateModifiedand aslastmod, which told Google that nothing on the site had ever been revised -- including pages that had been substantially rewritten. Both now come fromlastModifiedISO(), the Markdown file's mtime floored at its publication date, so a page and its sitemap entry cannot disagree, and a file whose mtime carries no signal falls back to the publication date rather than to the build time. Stamping the deploy time on unchanged text is a claim Google can check, and one that fails is a reason to ignore the field site-wide.
Fixed
-
Horizontal scroll mode can be scrolled down. Zoom a page in horizontal mode until it is taller than the window and the bottom of it was simply unreachable: no scrollbar, no wheel, no touch gesture got to it. The scroll container pinned
overflow-y: hiddenin that mode alone. The content was laid out the whole time and still moved when scrolled programmatically -- measured at 160% zoom in a 729px-tall column,scrollHeightwas 1561 against aclientHeightof 729, so 832px of page existed below the fold with nothing able to reach it -- which is why the page looked cropped rather than broken. Both axes now scroll in every mode, and the wheel behaves as it does anywhere else on the web: plain wheel scrolls down, Shift+wheel scrolls across, Ctrl/Cmd+wheel still zooms, all three verified with real wheel events rather than synthesised ones. Nothing was added to intercept the wheel; removing thehiddenwas enough, because the browser's own handling was never the problem. -
Wrapped scroll mode wraps. It was
flex-direction: columnwithflex-wrap: wrap, and a column flex container whose height is content-driven never runs out of room to wrap out of -- so it never wrapped, and every page stacked in one column exactly as vertical mode does. The two modes were byte-identical in layout, which is why this survived: switching to it changed nothing visible, so it read as a mode that had not been implemented rather than one that was broken. Pages now flow left to right and break onto a new row when the column runs out -- verified at 25% zoom, where two pages sit side by side at the samey, against the old behaviour that put the second 339px below the first. A page wider than the column still gets a row to itself, which is the same arrangement as before but arrived at for the right reason. -
Single-page navigation opens each page at the top of it. Going to the next page while scrolled down left the view at exactly the offset the previous page was left at -- read to the bottom of page 1, press next, and page 2 opened already scrolled past its own top with no indication that anything above was missed.
scrollToPagenavigates byscrollIntoView, and in single-page mode the page being navigated to isdisplay: noneat the moment it is called, so the call did nothing at all and the scroll position simply stayed where it was. Swapping which page is mounted is the whole of the navigation in that mode, so the view now returns to the top instead. The horizontal offset is deliberately kept: a reader zoomed into the right-hand column of a page wants the same column of the next one, not a jump back to the left margin. -
Centring a page can no longer push it out of reach. Not observed, and prevented while the modes above were being fixed. Both row modes centre their pages, and a flex item centred inside a box smaller than itself overflows equally at both ends -- the top and left halves of that overflow landing in negative scroll space, which no scroll container can reach. What has been holding this off is
min-width: fit-contenton the page box, which grows it to the widest page so the centring has nothing to overflow; that is load-bearing and now says so in a comment, since it reads like a cosmetic line.safecentring backs it up, and degrades to flex-start -- equally unable to clip -- on a browser that does not support it. Verified across all four modes at 306% zoom, where the page overflows the column on every side: no page has any part of itself at a negative offset at the scroll origin, or beyond the far edge at the end of the scroll range. -
(docs) The trust store has 134 roots, not 119. Twenty-four statements across ten files --
docs/architecture.md,core-concepts.md,examples.md,introduction.md,troubleshooting.md,security.md, three blog posts and the home page's feature and FAQ copy -- said the WASM binary embeds 119 AATL roots. It embeds 134, of which 16 are the Indian CCA hierarchy and 118 are AATL. The old number was both wrong and wrong in the way that matters: it undercounted by exactly the certificates that decide whether a DigiLocker, Income Tax, MCA or GST document verifies, since none of those chain to an AATL root. Every occurrence now reads 134, with the split named wherever there is room for it.
[0.9.0] -- 2026-07-29
Pinch zoom on touch devices, and the removal of the legacy PdfViewer that
held the only pinch code this repository has ever had. The two are one change:
the viewer every consumer actually renders had no touch-zoom path at all, and
the hook that did exist sat behind an export nothing rendered.
A minor rather than a patch, because the removal takes three names off the
public surface -- PdfViewer, PdfViewerHandle and usePinchZoom. Nothing
else moves: <Viewer />, <CoreViewer />, every plugin and every other export
are untouched, and for a project using them the upgrade is drop-in. On a 0.x
version ^ locks the minor, so a project on ^0.8.x stays where it is until
it asks for this one -- docs/migration.md has the details.
The remaining entries are documentation only and marked as such: the reference pages and the site that renders them. None of it is inside a published tarball.
Added
-
Two-finger pinch zoom, in the viewer consumers actually render. Reported as pinch doing nothing on a phone. It turned out there was nothing to do it:
zoomPluginhandled Ctrl/Cmd+wheel, the toolbar controls and the fit modes, and had no touch path whatsoever, so the gesture reached the browser, found no listener, and stopped. TheusePinchZoomhook the docs pointed at belonged to the legacyPdfViewer(removed below), which nothing in this repository mounts -- not the demo, notpackages/vanilla, whose bootstrap renders<Viewer>as well, and not the quick-start. Measured against the live 0.8.0 site at 390x844 with touch emulation:DOMDebugger.getEventListenerson the real scroll container returned no pinch listener at all, and a genuine two-finger sequence delivered twopointerdown, twelvepointermoveand twopointerupwith nopointercancel-- the browser never took the gesture away, nobody was listening for it. The same geometry sent as Ctrl+wheel moved the zoom from 68% to 78% and the canvas from 418px to 479px; sent as touch, both stayed exactly where they were. Compounding it,canvas-layer.tsxsetstouch-action: pan-x pan-yon the scroll container, and that list omitspinch-zoom, so the browser's own pinch was suppressed there too and there was no fallback behind the missing handler.Pinch now lives in
zoomPluginitself, which is the single point both the React and the vanilla packages pass through, so it arrives for every consumer ofdefaultLayoutPluginwith no opt-in and no hook to wire up. It shares the cursor-anchored zoom math with Ctrl+wheel rather than carrying a second copy -- the anchor is the midpoint between the fingers, clamped into the page area so a finger straying over the toolbar mid-gesture cannot send the re-centring somewhere nobody pointed -- and it honours the plugin's existingminScale/maxScale, stopping at 25% and 1000% by default instead of running past them. Zoom leaves fit mode as it should: the label changes from "Fit width" to a percentage. The gesture is claimed by callingpreventDefault()on thetouchstartthat brings the second finger down, not by flippingtouch-action, which the browser has already latched by that point -- that was the structural defect in the removed hook, never observable because the code never ran. Thetouchmovelistener is bound for the duration of a pinch and released with it, rather than living on the container, because a non-passivetouchmovelistener takes ordinary one-finger scrolling off the compositor fast path; single-finger panning is untouched, andcanvas-layer.tsxkeeps itstouch-actionas it was. Verified by dispatching real touch streams: a 100px to 200px finger spread takes the zoom from 52% to exactly 104%, repeated pinches clamp at exactly 1000% and 25%, every event of a one-finger drag comes backdefaultPrevented: falsewith the window's touch-listener list identical to before the drag, and the listener set after a completed pinch is identical to the set before it. -
(docs)
/demosays what it does, for someone who has not read the rest of the site. The page carried 137 visible words under an<h1>reading "Live Demo" -- a heading that describes the page's role in this site's navigation and answers nothing for the person who arrived holding a signed PDF and a question. It now opens with "Verify a PDF Signature" and runs to 1,476 words below the tool: what the eight checks each test and what a failure in that specific one means for your document (deliberately not the home page's eight descriptions, which describe what the SDK implements rather than what a red cross is telling you), what a verified signature does not claim, the four signature subfilters that are recognised, and six questions written for this page rather than reused from/faq. The privacy section is the one that mattered most to get right, and it is narrower than what the page used to claim: "Processed entirely in your browser -- nothing leaves your device" and "All processing happens in your browser" were both false in the default configuration, becauserevocationEnabledis on and the signer's certificate is relayed through/api/revocationto reach a CA whose CRL and OCSP endpoints send no CORS headers. Every claim is now scoped to the document -- your PDF is never uploaded, which is true and checkable -- with a section that names the one request that does leave, what it carries, and how to switch it off. The title drops "Live Demo" for "Verify a PDF Signature Online -- Without Uploading It" and stays clear of the word "free", since the layout appends "| VerifyKit SDK" to every title and the SDK is not; the offer is spelled out in the body, where there is room to say which half is which. NoFAQPagenode was added for the question accordion -- the site emits none, for the reasons under Removed. -
(docs) Doc pages link to the blog posts that share their subject. The blog was reachable only from itself: every post was linked from
/blogor a sibling post and from nowhere else, so a reader who wanted background on what a doc page was describing had no path to it. Doc pages and blog posts now tag themselves from one vocabulary, and each page ends with the posts it overlaps, ordered by how much of the subject they share. The relationship is derived from those tags rather than kept as a hand-written list, so a post added later reaches the pages it belongs on without anyone editing anything, and a page with no real overlap — the changelog, the glossary, the migration guide — correctly shows no block at all. -
(docs) The Adobe Reader messages a reader actually sees now have pages that answer them. Of the six user-visible strings Acrobat shows on a signed PDF, four appeared nowhere on the site and a fifth only inside a table in
docs/api/react.md.docs/troubleshooting.mdhas 23 sections and not one of them was written for "my PDF shows an Adobe error" -- every one addresses "my VerifyKit integration is broken", which is a different person with a different problem. Two posts cover the two messages that were missing an explanation, and both are written from the engine's actual behaviour rather than around it. Why Adobe Says "Altered or Corrupted" is the more useful of the two, because the message is about byte coverage and not about tampering: a PDF is designed to be appended to, sobyteRangeCoversWholeFile: falseis the expected state for every signature but the last in a countersigned document, and appending DSS/LTV data after signing is a permitted update. The engine already separates the four cases behind that one message -- hash mismatch, DSS-only bytes beyond coverage, unsigned content beyond coverage, and an earlier signature in a multi-signature file -- returninginvalid,valid,warningandvalidrespectively, with the reason inintegrityCheck.detail. Adobe: "At Least One Signature Has Problems" covers the banner, which is a summary of the worst signature in the document rather than a diagnosis, and routes to the specific check: it is the only page covering the five causes the other two Adobe posts do not -- expiry evaluated at signing time, the missing RFC 3161 timestamp that makes an aged certificate start failing, revocation and theattemptedflag that separates "not checked" from "checked and inconclusive", weak algorithms, and EKU.troubleshooting.mdgains a table mapping each exact Adobe string to the field that produces it, plus theintegrityCheck/byteRangeCoversWholeFilematrix. Both posts tag into the shared vocabulary, so the docs pick them up through the existing related-reading block with no list to maintain.
Removed
-
The legacy
PdfViewer, and theusePinchZoomhook only it used.PdfViewerpredates the plugin architecture that landed in 0.3.1 and was superseded by<Viewer>/<CoreViewer>when it did. Nothing in this repository has rendered it since: the demo,packages/vanilla's bootstrap and every documented example mount<Viewer>, andgrepforusePinchZoomreturned exactly two hits,PdfViewer.tsxand the export barrel. Six modules go --viewer/PdfViewer.tsx, its fourpdf-viewer-*helpers, andhooks/usePinchZoom.ts-- taking three names off the public surface:PdfViewer,PdfViewerHandleandusePinchZoom. That is the whole of the breaking change, and a project that imports none of the three is unaffected. Eight internal fragments went with them, checked against the pre-removal tree rather than assumed:LeftTabStrip, theCtxItem/CtxDividercontext-menu primitives,PdfViewerContextMenu,ToolbarSection,ErrorPanel,HighlightPopoverandFullscreenExitButtonwere each reachable only from a deleted module, and thePdfViewerHandleinterface described the imperative handle of a component that no longer exists. None of the eight was ever exported from the package root.LoadingProgressBarandTabStripButton, whichCoreViewerdoes use, stay.Two things that look like collateral are not. The five types
PdfViewerre-exported --SigWidget,Highlight,ScrollMode,SpreadModeandCursorTool-- are declared inviewer/pdf-viewer-state.ts, which stays, and they are still exported from the package root under the same names, so an import of any of them keeps compiling untouched. And the 22 modules left underviewer/, whichCoreViewerand the plugins share, all remain: the outline, thumbnail and attachment panels, the find bar, the password, properties and about dialogs, the keyboard-shortcut help,ViewerToolbar,PdfPage, and the error boundary namedPdfViewerErrorBoundarydespite being the oneCoreViewermounts. Anyone still onPdfViewermoves to<Viewer>withdefaultLayoutPlugin;docs/migration.mdhas the prop-by-prop mapping, and pinch zoom is one of the things that move brings with it. -
(docs) Structured data for four rich results that Google no longer awards. The site asked for each of them and could never have received any: the
HowToon/docs/quick-startand the home page (Google withdrew that rich result on 2023-09-14 -- its documentation page now redirects to the changelog entry announcing the removal), theFAQPageon/faqand the home page (restricted to government and health sites in 2023, deprecated 2026-05-08, documentation withdrawn 2026-06-15), and theSearchActionin the site-wideWebSitenode, which requested the sitelinks search box that Search dropped on 2024-11-29 -- and pointed at/docs?q=, which is not a search endpoint at all: that route reads nosearchParamsand serves the same page for every value ofq. All four were dead weight in the page source and in the Search Console reports. Every question, step, and answer they duplicated is unchanged: the FAQ still renders fromFAQ_ITEMS, the quick-start steps still render on the home page, and both remain ordinary indexable text. -
(docs) The
SoftwareApplicationthat was invalid on all 44 URLs, and theWebApplicationon/demo. Google's Software App feature requires three things --name,offers.price, and one ofaggregateRating/review-- and treatsWebApplicationthe same way. Both nodes hadnameand nothing else, so neither could ever produce the app rich result they were added for; they only reported as invalid items, the site-wide one on every page. Neither missing property can be supplied honestly. VerifyKit is licensed by quote, so there is no price to state -- every paid tier on/pricingroutes to/contact, and the only free thing on the site is the in-browser demo, not the SDK -- and there are no customer reviews, which Google warns about directly: ratings "not by actual users may result in manual action". That is the same reasoning/pricinghas always applied toProduct./demonow describes itself as theWebPageit is. Invalid structured data costs the rich result, not indexing or ranking, so removing markup that could not be completed loses nothing and empties the report.
Fixed
-
(docs) The home page, the FAQ and one blog post stop promising a network silence the SDK does not keep.
/demowas rewritten to say only what its code backs, which left the rest of the site making the older, larger claim: "no data leaves the browser", "No server round-trips", "no third-party services", and on/faq, the same "All processing happens in your browser" sentence that had just been removed from the demo for being false. Checking them turned up something wider than the demo's revocation proxy --enableAIAdefaults totruein@trexolab/verifykit-core, so out of the box the verifier chases missing intermediate certificates by calling thecaIssuersURLs inside the chain, straight from the browser. Every install has been making third-party requests, revocation plugin or not. The home page's own FAQ answer had already half-admitted this, listing AIA and revocation as "the only optional network requests" one sentence after declaring there were none. What survives scrutiny is now what the site says: the document never leaves the device -- verified, nothing on any path carries PDF bytes -- and there is no telemetry and no analytics, which is true to the letter, since the packages contain no beacon, no analytics import and no phone-home of any kind. Alongside that, the two calls that do go out, what each carries, and how to switch them off. "Runs entirely client-side" is untouched throughout: the verification itself does, and these requests fetch its inputs rather than perform any part of it. -
(docs) The demo's heading is visible on a phone, and the page has one
<main>instead of two. Two faults found while rewriting/demo, neither introduced by it. At 768px and below the sidebar is an overlay drawer atz-index: 30and the status bar sits above it at31-- deliberately, so the bar's own toggle stays clickable -- which costs the drawer its first rows, and the top row is the page heading. Geometry gave no sign of it:getBoundingClientRectput theh1at y=80 with the drawer unscrolled and nothing clipped, and it was still invisible.document.elementFromPointat the heading's first line returned a topbar chip, which is what proved it. The drawer's padding now starts below the bar, measured from a--ds-topbar-hvariable the bar is given rather than from whatever height it happens to compute to, so the offset and the bar cannot drift apart;white-space: nowrapcame off the title, which was safe while it read "Live Demo" and is not now. Separately,.ds-mainwas a<main>inside the root layout's<main>: a second landmark in one document, which leaves assistive technology and content extractors choosing between two candidates. It is adivnow, styled by the same class, so nothing moved. -
(docs) The doc URLs crawlers still ask for now lead somewhere. Googlebot is still fetching four URLs that 404, and all four have a live equivalent:
/docs/getting-startedand/docs/core-apiwere renamed to/docs/quick-startand/docs/api/corebefore the site had any redirects, and/docs/architecture.mdand/docs/deployment.mdare residue from the cross-reference bug that rendered all 59 doc-to-doc links as paths to the Markdown file instead of the route it becomes. That rendering was fixed, but a crawler that walked the site while it was broken keeps the.mdURLs and keeps asking. Each now redirects to the page it means, so the request ends on content rather than on an error and whatever value those links carry arrives somewhere. The.mdrule is written once per depth --/docs/:page.mdand/docs/:dir/:page.md-- rather than as a single:path*: a repeating param capturesapi/coreas one string while the destination needs an array to put the slash back, so the nested case throws, and naming both depths sidesteps that instead of depending on how it is handled. Verified against every.mdform of every doc page and against the live routes, which all still answer 200. -
(docs)
robots.txtstops hiding the stylesheet and stops spending the crawl budget on pictures. Two faults, found by reading a month of bingbot requests in the proxy log rather than by inspecting the file.Disallow: /_next/covered/_next/static/, which is where the CSS, the JS chunks and the fonts are -- 20 references on a typical doc page -- so every crawler that obeyed us was told it could not fetch the stylesheet and then asked to judge a page it was not allowed to lay out. Google asks directly that CSS and JS not be blocked, and Bing renders too;/_next/static/is now allowed, which is a longer match than theDisallowbeside it and therefore wins on both engines, while/_next/data/and/_next/imagestay shut. Separately,/ogrenders a social card per title and description and is linked from theog:imageof all 46 pages, so crawlers work through it: 60 of bingbot's last 107 requests went to/og, against 28 for actual pages -- on a site Bing visits about four times a day, more than half the allowance was going on images with no indexable text, and it had reached 22 of the 46 URLs in a month./ogis now closed toGooglebotandbingbotonly. It stays open to*, which is what Facebook, LinkedIn, Twitter and Slack match -- they read robots.txt as well, and without the image there is no preview card. That group repeats the/api/and/_next/rules rather than adding to them, since a crawler obeys only the group naming it and an omitted rule would read as permission. -
(docs) "N min read" is measured from the article instead of typed next to it. Nine of the eleven labels were wrong, all in the same direction -- a 678-word post was advertised as a 7 minute read, a 696-word one as 8 minutes, and the 816-word PAdES explainer as 9. At the low end that implies a reading speed of about 90 words per minute, roughly half of what an adult reader manages on technical prose, so a reader deciding whether to open a page was being told it would cost twice what it does. Nobody had guessed badly: the figure was typed once, correctly enough at the time, and then stayed put through every revision that shortened the prose around it, because nothing connected the two. The only two labels that were accurate were the two written last week, which is what a hand-maintained number always looks like -- right until it ages. The registries now carry only the fields that cannot be derived, and
readingTimeis computed at build time from the Markdown file itself at 200 words per minute, resolving the path the same three ways the post body already does. A file that cannot be read yields an empty label rather than a confident "0 min read", and the five render sites drop the separator with it, so a renamed Markdown file shows one thing less instead of something false. -
(docs) The logo in every Organization node is one Google can see.
logo.svgis drawn for a dark surface: its checkmark -- which is the V of the VK monogram, and the whole "verify" idea the mark is built on -- is#FEFDFD, about a 1.02:1 contrast ratio against pure white. Google renders the Organizationlogoon white, so what it had to work with was a shield with a stray K in it. All five structured-data references (the home page'sOrganization,orgLd(),trexoLabOrgLd(),blogPostingLd()and the blog index'sBlog.publisher) now point atlogo-on-white.svg, identical geometry with the checkmark in the site's light-mode text colour, behind a singleORG_LOGO_URLconstant so the five cannot drift apart. The visible mark is untouched: the app icon and the PWA manifest still uselogo.svg, which is correct for the surfaces they appear on. -
(docs) Titles stop naming the site twice and stop running past the width a result is drawn in.
/pricingrendered as "Pricing & Licensing -- VerifyKit SDK | VerifyKit SDK" and/contactas "Contact Us -- VerifyKit SDK Enquiry | VerifyKit SDK": both spelled the brand out in their owntitleand then had the root layout's%s | VerifyKit SDKtemplate append it again. Google lists "site name duplication in the title element" among the reasons it discards a page's title and writes its own, so these two pages were arguing for a title they could not keep. Separately, 12 of the 44 titles ran past 60 characters -- the longest 98 -- and Google truncates the displayed title "as needed, typically to fit the device width", so the words that told one blog post apart from another were the ones being cut. The blog and comparison registries now hold a title written for the result page rather than for the page itself; the visible<h1>still comes from the Markdown and is unchanged, since a reader who has already clicked is not paying for the extra words. The brand suffix stays everywhere it was: Google asks for "only the site name at the start or end of each title", and the fault was saying it twice, not saying it at all. The home page keeps its own full title --app/page.tsxis the root layout's own segment rather than a child of it, so no template reaches it -- and now leads with the words matching the one query in Search Console that squarely fits it, "sdk for verifying pdf files", which sits at position 22. -
(docs) Doc pages describe themselves with the properties Google asks an article for. The
TechArticleon each of the 28 doc pages carriedheadline,descriptionandauthorbut neitherimagenordateModified, both of which Google lists as recommended and both of which the site already knew: the page's generated OG card, and the modification time of the Markdown file behind it. The article now states both --dateModifiedfrom the same file mtime the sitemap'slastmoduses, so the two can never contradict each other, and omitted rather than guessed when that mtime carries no signal.datePublishedstays out: nothing in the repository records when a doc page first appeared. The staleaboutreference went with it -- it pointed at#software, the id of theSoftwareApplicationnode that no longer exists. -
(docs) Doc pages emit a valid breadcrumb again. Every one of the 28 pages carried a
BreadcrumbListwhose third entry was the sidebar section -- "Guides", "API Reference", "Overview", "Resources" -- as a barenamewith noitem. That mirrored the visible trail, where the section is plain text rather than a link, butitemmay only be omitted on a list's final entry, so the section crumb made the whole list invalid: Google's URL inspection reportsMissing field "item"and shows no breadcrumb rich result at all. Nor was there a URL to supply -- sections have no route, and/docsrendersintroduction.md, which has no per-section heading to anchor to. The machine-readable trail is now the URL hierarchy it has to be,/->/docs-> the page, and on/docsitself it stops at/docsinstead of repeating that URL under a second name. The visible breadcrumb is unchanged and still names the section. -
(docs) The docs sidebar no longer links to a demo that does not exist. Its "Demo" group offered a Vanilla JS Demo entry pointing at
/demo/vanilla, a route that was removed while the pdfjs-dist compatibility work was going on and never came back. The link stayed, so every one of the 28 doc pages carried a link to a 404 -- a dead end for a reader and a crawled 404 for a search engine, on the highest-linked path on the site. The entry is gone;/demois unaffected. Every internal link on every page in the sitemap now resolves. -
(docs) The sitemap stops claiming that every page changed at build time. 35 of the 44 URLs carried
lastmod: <build timestamp>, so each deploy told search engines that the entire site had just been rewritten -- including pages untouched for months. Google only honourslastmodwhile it stays verifiably accurate and disregards the field site-wide once it does not, which is the opposite of what a sitemap is for. Doc pages now carry the modification time of the Markdown file behind them, the blog and comparison indexes carry the date of their newest entry, and the pages with no content date to draw on -- home, demo, pricing, FAQ, contact -- carry nolastmodat all, which is both legal and what Google asks for when there is nothing accurate to say. -
(docs) Cross-references between doc pages resolve on the site again. The docs are Markdown files that get read in two places -- in the repository, where a cross-reference is a path to a neighbouring file (
./installation.md#pdfjs-worker-setup), and on the docs site, where the same file is a route (/docs/installation#pdfjs-worker-setup). The source carried the file paths and the site rendered them verbatim, so all 59 cross-page links on the site pointed at.mdURLs that 404. Rewriting the Markdown would have fixed the site and broken every reader who opens these files in an editor or on GitHub, so the mapping now happens once at render time, inMdxContent: a relative link is resolved against the file being rendered and looked up in the doc-page registry, which handles the extra depth underdocs/api/and keeps the#anchor. A link that lands on no known page is left alone and warned about during the build, so a reference to a page that does not exist is loud rather than a silent 404. Six links written the other way round -- site-absolute/docs/..., working online and dead in the repository -- were converted to file paths so one form now works in both places, andplugins.mdnow points at the revocation plugin's API page instead of a package README that the site does not publish. -
(docs) The worker setup is now stated wherever it is needed, with the version.
workerUrlis required and the file it points at must bepdf.worker.min.mjsfrompdfjs-dist@5.5.207-- pdf.js refuses to run a library and worker whose versions differ, and since 0.6.0 the library is bundled, so there is nonode_modules/pdfjs-distto copy the worker out of andnpm ls pdfjs-distreports nothing useful. Three places got this wrong.deployment.md's air-gapped recipe self-hosted the CMaps and fonts and then handed out a unpkg URL for the worker, in the same config block -- an external fetch in a section premised on there being no external fetches; it now downloads the worker as its own step.vanilla.mdhad no worker guidance at all, and its self-hosting section said to copydist/, which does not contain the worker and never did.i18n.mdandtesting.mdconfigured/pdf.worker.min.mjswithout saying where that file was supposed to come from.installation.md's canonical section now leads with which file and which version, why the version is fixed rather than chosen, and the runtime error that names both versions when they disagree -- and offers a one-linecurlalongside the existingnpm packextraction.
[0.8.0] -- 2026-07-27
Two viewer fixes -- the right-click menu ignored disable, so a plugin the
consumer had turned off stayed reachable from it, and on a phone the toolbar's
menus opened over the signature sheet while the sheet went on taking the taps --
and, separately, repository tooling. The tooling entries are marked as such:
nothing in them is inside a published tarball.
A minor rather than a patch, for two reasons that only touch code written
against the plugin API: ViewerPluginContext.getPlugins() is a required
member, so a hand-built context object literal no longer compiles, and the
context menu now draws only what the installed plugins contribute, which takes
entries away from a viewer that had those plugins disabled. Neither is
reachable from ordinary use of <Viewer /> or defaultLayoutPlugin, and for
those the upgrade is drop-in. On a 0.x version ^ locks the minor, so a
project on ^0.7.x stays where it is until it asks for this one --
docs/migration.md has the details.
Added
-
renderContextMenuItems, a plugin hook for right-click entries. The same contribution model asrenderMenuItems, which the toolbar overflow menu has used since 0.7.0: a plugin returns the entry it wants in the context menu, and a plugin that is not installed is never asked. Entries are ordered and grouped by themenuOrder/menuGroupa plugin already declares for the overflow menu, so an action holds the same relative place on both surfaces.ContextMenuItemRenderPropsandContextMenuItemsRenderare exported for typing the hook, andcollectContextMenuItemsalongside the existingcollectMenuItems. The props are deliberately smaller than the overflow menu's: nothing can overflow into the context menu, so there is noisOverflowedto ask. -
ViewerPluginContext.getPlugins()returns every plugin resolved into the viewer, composed sub-plugins included. Only a plugin that draws a surface assembled from what the others contribute needs it — the context menu is the one in the box — and everything else should stay ignorant of its neighbours. It is a required member of the interface, so code that builds aViewerPluginContextobject literal by hand (a test double, most likely) now has to supply the method; a plugin that only receives the context ininstall(ctx)is unaffected.
Changed
-
The context menu's order shifts slightly, now that both menus sort by the same
menuOrder. Find in Document moves to the top, Copy Selected Text sits in a group of its own, and Save As… comes before Print. Nothing was removed. -
(tooling) Published tarballs are now immutable. Re-publishing a package without bumping its version used to replace the live tarball in place, which npm rejects against a lockfile holding the old integrity hash (
EINTEGRITY). A given version now resolves to the same bytes forever: re-publishing identical content is a no-op, and re-publishing changed content is refused. If you have ever seen anEINTEGRITYerror installing VerifyKit, this was the cause and it cannot recur. -
(tooling) A sibling dependency can no longer fall out of the manifest by omission. The script already warned when its
depsKeyslist named a dependencypackage.jsondid not declare. The opposite direction -- a sibling inpackage.jsonthatdepsKeysforgets -- was silent, and it is the dangerous one: npm resolves from the registry manifest, not from the tarball'spackage.json, so the edge simply disappears. That is the same class of failure as the"*"ranges fixed in 0.7.3. Every@trexolabsibling must now be accounted for in eitherdepsKeysor the newbundledDepslist -- the latter for@trexolab/verifykit-vanilla, which inlines the React shell at build time on purpose -- and the run exits non-zero otherwise.
Fixed
-
A disabled plugin can no longer be reached through the right-click menu.
defaultLayoutPlugin({ disable: { download: true } })removed the toolbar button, the overflow-menu entry and the Ctrl+S shortcut — and right-click, Save As… still wrote the full PDF to disk. The context menu kept a hardcoded list of entries and called the actions itself, so it had no way of knowing what was installed; the same bypass applied to every other entry it drew, which isprint,rotation,search,fullscreen,propertiesandshortcuts. It now renders only what the installed plugins contribute through the newrenderContextMenuItemshook, so there is no second list to fall out of step — a disabled plugin contributes nothing and the entry is simply absent. Copy Selected Text stays in every case: it is the browser's own clipboard, governed by no feature toggle, and it keeps the menu from ever coming up empty.The toolbar-slot setting is a separate knob and is unchanged:
toolbar: { download: false }hides the button and nothing else, by design — Ctrl+S and the menu entries keep working, because that setting says where a control appears, not whether the feature exists. Onlydisabletakes a feature away, and onlydisablenow empties the context menu. -
Print from the context menu prints the document, not the page around it. It called
window.print(), which printed the host application — headers, sidebars and all — with the PDF as whatever the canvas happened to show. It now goes through the print plugin, the same path as the toolbar button and Ctrl+P: the file is rendered into an off-screen frame and that is what the print dialog receives. -
Zoom from the context menu respects
zoomPlugin({ minScale, maxScale, step }). It wrotescaleinto the store directly, in hardcoded steps of 0.1 clamped to 0.25–5, ignoring the plugin's options entirely — so on the defaults (0.25, 10, 0.15) it moved in the wrong increment and stopped at 500%, while the toolbar's own buttons carried on to 1000%. Both entries now call the plugin'szoomIn()/zoomOut(). -
Fullscreen from the context menu fullscreens the viewer. It requested fullscreen with no element, which falls back to
document.documentElement— the entire host page. It now uses the fullscreen plugin's API, which targets the viewer container, matching the toolbar button and F5. -
A toolbar menu can be tapped where it overlaps the signature sheet. On a narrow viewer the signature panel docks to the bottom as a sheet, and any menu opened from the toolbar — the More menu, the search popover, the zoom and text-size dropdowns — drops down across it. Every entry below the top edge of the sheet drew normally, highlighted on hover and did nothing when clicked: at 390×844 with the sheet at the height it opens to, that was the last four entries of the More menu — Fullscreen, Document Properties, Keyboard Shortcuts and About VerifyKit — and ten of the fourteen zoom presets once the sheet was dragged taller. The toolbar carried a
z-indexof its own, which makes it a stacking context, and that pins every popover inside it to the toolbar's level however high the popover's ownz-indexclimbs; the sheet sat above that level and took the clicks through the menu drawn on top of it. The toolbar is now above the sheet, so what it opens is above it too. The sheet still covers the document, and dialogs, the right-click menu and tooltips still cover the toolbar.0.7.0's audit of "every enabled control reachable at every width" missed this because it checked that each control was present in a menu at each width. These four were present, and drawn, and unclickable.
[0.7.3] -- 2026-07-26
A packaging fix. The packages declared their dependency on each other as "*",
which npm reads as "any copy already installed will do" -- so upgrading the React
shell in a project that already had an older core left the old core in place and
said nothing. No runtime code changed in this release.
Fixed
-
The packages no longer depend on each other with
"*".@trexolab/verifykit-reactand@trexolab/verifykit-plugin-revocationdeclared"@trexolab/verifykit-core": "*", and@trexolab/verifykit-vanilladeclared"@trexolab/verifykit-react": "*". Because every version satisfies"*", npm resolved the edge to whatever copy was already in the tree and skipped the upgrade: a project on 0.5.13 that rannpm install @trexolab/verifykit-react@0.7.2ended up with the 0.7.2 shell driving the 0.5.13 engine. Nothing errored -- the shell imports 18 symbols from core and all 18 exist in 0.5.13, so it loaded and ran -- and the About dialog reports the React package's own version, so it read 0.7.2 while core-side fixes -- DocMDP detection (0.6.3) and the Node ESM entry (0.6.5) -- were silently absent. Fixes that landed in the React package itself, such as theDocumentPermissionsmapping in 0.6.8, were unaffected: that is the package the consumer upgrades. Each sibling edge is now a real range,^<version>, so npm fetches a matching engine.Only an upgrade was affected. A fresh install had no older copy to reuse and always resolved correctly.
npm updatedid not rescue an affected project either, since the consumer's own range held core on 0.5.x. The edges have read"*"since the packages were first published under the@trexolabscope, so every release up to and including 0.7.2 carries it; tarballs already published are unchanged, and a project pinned to one of them needs 0.7.3 or a manualnpm install @trexolab/verifykit-core@<version>to pick up the fix.^rather than an exact pin: on a 0.x version^locks the minor, which is the boundary this SDK breaks at, while still letting a project already on a compatible core keep the single copy it has. An exact pin would force a second copy of the WASM engine over a patch difference -- and the engine holds the trust store and the algorithm policy in module state, so a second copy means a second set of both. -
Each package now depends on an exact sibling version rather than
"*". The ranges had been written by hand once and never revisited, so a lockfile could in principle pair one package with a mismatched sibling.
[0.7.2] -- 2026-07-26
A documentation-accuracy release. A grounded audit of every code sample in the docs against the shipped type definitions found samples that could not compile, and public API that the docs described but the package did not export. The fixes are the exports below plus corrections throughout the docs; no runtime behaviour changed.
Added
-
SidebarTabProps,MenuItemRenderProps,MenuItemsRenderandCollectedMenuItemsare now exported. All four were reachable in the type graph -- a plugin author could receive one -- but had no name to import, so the documented signatures were unwritable.renderMenuItemsshipped in 0.7.0 documented as takingMenuItemRenderProps; typing that parameter failed withTS2724. -
MenuItem,MenuDivider,MenuSectionandMenuContainerare now exported.renderMenuItemsis public API, so the primitives its own built-in plugins use to render entries have to be public too. Without them a contributed entry could only be built from raw JSX and would not match the menu around it. -
collectMenuItemsis exported alongsidecollectToolbarSlotsandcollectSidebarTabs, which were already public.
Fixed
-
The plugin context was documented under a name that resolves to a different type. The package exports the core engine's
PluginContextunder the plain name and the viewer's asViewerPluginContext. Every sample wroteimport { PluginContext }, which silently resolves to the core one -- it has.config, not.storeor.registerShortcut-- soctx.storedid not type-check anywhere. Corrected acrossplugin-development.md,plugins.md,examples.md,api/react.mdandcustomization.md, with a note in each explaining which name is which. -
The documented store API did not exist. Samples taught
store.setState({ … })and a one-argumentstore.subscribe(state => …). The store has nosetState; writes go throughupdate(), andsubscribe()takes a key first, then a listener receiving that key's new and old value.subscribeAll()is the watch-everything form and hands the listener whole state snapshots -- it does not report which key changed, and was documented as if it did. Four occurrences, including the store reference block itself. -
ZoomChangeEvent.fitModewas documented with literals from a different union. It is'none' | 'width' | 'page', not the scroll/spread values it was listed with. -
A sample imported a package consumers do not have.
import('pdfjs-dist').PDFDocumentProxy-- pdf.js has been bundled since 0.6.0, so it is not an installed dependency. The document type now comes fromDocumentLoadEvent['document']. -
The Node.js blog post called a revocation export that does not exist.
createRevocationPlugin({ mode: 'direct' })-- the export isrevocationPlugin, and direct mode is selected by omittingendpoint, not by amodeoption. -
useContainerSizewas documented without itsbreakpointreturn value, which is the field the rest of the docs tell you to use. -
CSS custom-property values had drifted from the stylesheet.
--fg-muted,--fg-subtle,--borderand--border-strongall listed pre-contrast-fix values. The layout table also documented--btn-sizeand--toolbar-heightas the knobs to set, but both are derived --calc(var(--btn-base) * var(--ui-scale))-- and shift by pointer type and viewport height; the table now covers the bases and the four media contexts. -
The migration guide listed v0.6.1→v0.6.2 after v0.6.0→v0.6.1, breaking the newest-first order the rest of the file follows.
Documentation
- New Overflow Menu Items section in the plugin development guide covering
renderMenuItems,menuOrder,menuGroup, the menu primitives, and theisOverflowedtruth table -- including why it returns false for a slot the consumer removed. api/react.mdgainsViewerPluginContext,MenuItemRenderProps,SidebarTabProps,ToolbarSlotProps, thePdfViewerHandleref handle, and a view-mode types section noting that'page'and'none'mean different things in different unions.customization.mdgains tables for the panel, tab and welcome-screen tokens, which had none.
[0.7.1] -- 2026-07-26
Fixed
-
Fit-to-width measured the viewer, not the column the page renders into. 0.7.0 adopted fit-width on
xs/smviewers, which is the wrong axis: what has to hold the page is the space left after the sidebar and signature panel take theirs. Measured on a 1024×768 desktop -- amdviewer, 644px, nowhere near narrow -- the document column was 336px against a 735px page, so 419px of the page (57%) opened out of view; at 1280 it was 163px. The test is now the page against its column, at every width, so the same viewer fits at 1024 and keeps the default 120% at 1920 where the page already fits. Unchanged: afitModeorscalenamed by the consumer is never overridden, and a reader who zooms owns the zoom from then on. -
A fit mode turned on after load never recalculated when its column changed. The plugin viewer's fit
ResizeObserverreadfitModewhen the effect was set up and did not list it as a dependency, so a fit chosen later -- by the reader picking Fit Width, or by auto-fit -- got no observer and kept the scale it was given at that instant. Cold-loading a document at 1024 fitted the page against the column it had before the signature panel opened and left it 252px too wide. The observer now reads the fit mode when the column changes. The legacy<PdfViewer>already hadfitModein its dependencies and was not affected.
[0.7.0] -- 2026-07-26
Fixed
-
Toolbar buttons disappeared on narrow viewers with no way to reach them. Reported against Open File: the plugin was enabled, the button was in the DOM, and it was
display: noneat every phone width. The toolbar collapsed by hard CSS --.verifykit-toolbar__collapsible > :nth-child(n+3) { display: none }at 768px and the whole group at 580px -- while the overflow menu was a hardcoded list with no view of the plugin registry, so anything hidden that the list had not been taught about was simply gone. Measured at ≤480px, seven controls had no route at all: Open File, Font Size, Theme Toggle, Zoom Out/Zoom/Zoom In and Hand Tool. The count is now zero at every width, verified at 1280 / 768 / 640 / 480 / 390 / 320 and 844×390. Thenth-childrule was also spending a collapse slot onOpenFileButton's hidden<input type="file">, so which buttons survived depended on plugin registration order. -
The overflow menu ignored plugin disablement, and would still download the file. It called
downloadPdffromactionsdirectly, so a consumer who disabled the Download plugin -- the control you would reach for when embedding this for confidential documents -- still got a working Download entry in the menu. The same applied to Print, Fullscreen and Document Properties. Menu entries are now contributed by the plugins that own them, so a plugin that is not installed contributes nothing and the leak closes by construction rather than by remembering to check a flag. Verified both ways: with the plugin enabled the entry appears and produces a file; with it disabled there is no slot, no menu entry and no download, even when the consumer explicitly asks for the toolbar button. -
The document was clipped on phones while the controls that would fix it were unreachable. At 390px the page rendered 735px wide in a 358px column -- 51% off-screen -- at the default 120% zoom with
fitModeunset, and Zoom Out was one of the seven controls with no route. The viewer now defaults to fit-width when it is given a container too narrow to show the page whole. An explicitfitModefrom the consumer is still respected and left alone. -
The signature panel took 64% of a phone viewer and could not be resized. It was a fixed
height: 70%with.verifykit-sig-panel__resize { display: none }below 640px, leaving the document 202px. It is now a real bottom sheet: three snap points at 35% / 60% / 90%, opening at 35% so the document is readable, with a visible grip that drags freely and snaps to the nearest detent on release, cycles the snap points on tap, and steps them with the arrow keys. -
Touch targets stayed at mouse size on the devices that have fingers. The coarse-pointer rule carried
and (min-width: 769px), so it grew targets on large touch screens and left phones at the 26px desktop size. The width clause is gone: a coarse pointer is a finger at any width, and buttons are now 40px with a 48px toolbar. Menu rows grow with them, since the whole row height is the target. -
Nothing in the stylesheet keyed on height. A landscape phone gives the viewer about 285px and the chrome was claiming a third of it. Short viewports now trim the toolbar and message bar rather than the document, and the overflow menu caps its height and scrolls instead of running off-screen. A viewport that is short and coarse gets most of the touch target back.
-
Long Document Properties values ran off the dialog. A row overflowed by 226px at 390px with no wrapping -- the Producer string was unreadable. Values now break anywhere. Measured across Document Properties, Keyboard Shortcuts and About: 0px of overflow at 390px.
-
The legacy
<PdfViewer>overflow menu had the same Open File gap. It already did container-driven, config-gated overflow, but omitted Open File from the menu atsmandxs, where its inline button is hidden. Added.
Added
-
renderMenuItems-- plugins can contribute overflow-menu entries. A new optional member ofViewerPlugin, mirroring howrenderToolbarSlotalready worked, collected bycollectMenuItems()alongside the existingcollectToolbarSlots(). Contributions receiveMenuItemRenderProps: the store, the list ofoverflowSlots, anisOverflowed(slot)predicate, andonClose.isOverfloweddeliberately returns false for a slot that was never registered -- a consumer who removedDownloadthroughtoolbar.transformhas said they do not want that action, so the menu must not offer it either. Plugins with no toolbar slot of their own (document properties, shortcut help) skip the check and always contribute.MenuItemRenderProps,MenuItemsRenderandCollectedMenuItemsare exported. -
breakpointandviewerHeighton the viewer store, anddata-breakpointon.verifykit-viewer. Both describe the viewer's own box, never the window.useContainerSizealready computed this andCoreViewerwas discarding the result. -
useResizableworks on both axes. It wasclientX/col-resizeonly; it now takes adirectionofleft | right | top | bottomand an optionalonResizeEnd, which is what the bottom sheet snaps with.
Changed
-
The overflow menu is assembled from plugin contributions instead of a hardcoded list.
MoreMenuDropdownrenders its own genuinely global items -- First/Last page, scroll and spread modes, shortcuts, about -- plus whatever the installed plugins contribute. Its direct calls intoactionsfor Download, Print, Fullscreen and Document Properties are gone. This changes behaviour for anyone who was relying on those entries working independently of plugin installation; see the migration guide. -
The toolbar measures itself instead of hiding by viewport width. Every slot renders inside a stable
[data-slot]wrapper -- one wrapper per slot, however many DOM nodes the plugin returns -- and a layout effect measures the row and drops slots in a defined priority order until it fits, reserving room for the menu button. What survives at a given width is now intentional rather than a consequence of plugin registration order, and everything dropped goes to the menu. -
Width-dependent layout keys off the viewer's box, not the window. The five
@media (max-width:)blocks are replaced by rules on[data-breakpoint], published from a ResizeObserver. This is the axis that matters for an embeddable viewer: a viewer in a 380px column on a 1440px desktop is narrow, and a media query cannot see that. Verified -- a 380px wrapper on a 1440px window takes the full narrow layout, overflow menu and bottom sheet included.
[0.6.8] -- 2026-07-26
Fixed
-
The legacy
<PdfViewer>never showed Printing or Copying. Its Document Properties dialog reads those two rows fromdocPermissions, which was declared asuseState(null)with the setter dropped -- nothing could ever set it, so the rows were skipped on every document. The permissions are now read duringload(), alongside the metadata that populates the rest of the dialog. The 0.6.7 fixes to the printing label and the flag mapping were only reachable through the properties plugin until now. -
Both viewer paths mapped the /P flags themselves, and disagreed. The plugin and the loader each had their own copy of the bit tests, which is how one of them ended up checking
perms.includes(2052)-- a value no PDF ever sets, fixed in 0.6.7. There is now onereadDocumentPermissions()inhelpers/pdf-encryption.ts, next to the other/Encryptreading, and both paths call it. -
DocumentPermissionswas returned two-thirds empty behind a cast. The interface has ten fields; both paths filled two (printing,copying) and usedas DocumentPermissionsto silence the difference. Anything readingencrypted,permissionFlags,modification,annotations,formFilling,accessibilityorassemblyfrom the viewer gotundefined— falsy, so a permitted operation read as forbidden. All ten are now filled from the flags pdf.js reports, and the cast is gone. -
Document Properties could show the previous document's values. The dialog kept whatever it last held, so if the metadata or permission read failed for a newly opened file, the rows still showed the file before it. Both are cleared when the loader sees a different document. An appearance swap re-enters
load()with new bytes for the same document and deliberately does not clear them.
Changed
- Unencrypted documents now show "Printing: Allowed" and "Copying: Allowed" instead of omitting both rows.
getPermissions()resolves tonullwhen there is no/Encryptdictionary, and the old code treated that as "nothing to say". It means the opposite -- a document that restricts nothing -- so it maps to every permission granted.encryptionMethodreadsNonefor these, matching the wording the WASM extractor already used.
[0.6.7] -- 2026-07-26
Fixed
-
<CertificateViewer>crashed when it went from no certificates to some. The component returned early on an empty certificate list -- above the twouseMemocalls that build the tree order -- so the hook count changed between renders and React threwRendered more hooks than during the previous render.Every call site inside the viewer happens to guard onhasCertsbefore rendering it, which is why the demo never hit this;CertificateVieweris a public export, so any consumer that mounted it empty and then populated it did. The twouseMemocalls now run unconditionally (they already returned[]for empty input). -
decompressFlate()'s headerless fallback never ran. The second format in the fallback loop was'raw', which is not one of the three stringsDecompressionStreamaccepts (deflate,deflate-raw,gzip) -- so the constructor threw on every attempt and thecatchswallowed it. Headerless/FlateDecodestreams therefore always returnednullinstead of being decompressed. The format is now'deflate-raw'. Anas anyon the constructor argument had been hiding the mismatch from the compiler. -
Document properties from the properties plugin showed no dates and a wrong printing permission. The plugin built its own metadata object instead of going through the loader's path: creation and modification dates were handed through as raw
D:20240319…strings where aDatewas expected, so both rows rendered--, and the permission check testedperms.includes(2052)-- 2052 is not a pdf.jsPermissionFlagvalue, so copying and printing were always reported as not allowed. Both now mirrorpdf-viewer-load.ts: dates go throughparsePdfDate, and permissions are read from the individual flags pdf.js returns (0x04print,0x10copy,0x800high-quality print). These were latent behind anas anythat made the mismatched shapes typecheck. -
The Document Properties dialog reported "Allowed" for documents that forbid printing.
printingis three-valued --allowed,low-res,not-allowed-- and the row rendered it as a yes/no truthiness check, so the non-empty string'not-allowed'read as allowed. It now maps through the three labels, including "Low resolution only", which had no way to appear before. -
The find bar left its previous search behind when reopened. Closing it reset six pieces of state one setter at a time from an effect; the panel is now mounted only while open, so query, matches, index and error strings go away with it and the injected
<mark>highlights are cleaned up on unmount.
Changed
-
extractSignaturesFromPdf()now returnsRawPdfSignature[]instead ofany[]. The shape has always been fixed by the RustSimpleSigstruct; it is now written down as an exported interface in@trexolab/verifykit-core. Consumers destructuring the result get completion and checking. This is a type-level change only -- the runtime value is unchanged -- but code that assigned the result to a narrower hand-written type may now need to adjust it. -
Lint is enforced in CI.
npm run lintwent from 180 errors to 0, and the CI step lost itscontinue-on-error: true. Roughly 140 of the 180 were React Compiler advisories fromeslint-plugin-react-hooks@7, whoserecommendedpreset enables the whole compiler rule set as errors -- for a project that does not run React Compiler. Those three rules (refs,preserve-manual-memoization,immutability) are downgraded to warnings ineslint.config.jsfor the seven imperative pdf.js integration files only, and remain errors everywhere else so new code cannot drift. The rest were real: the fixes above, plus 24no-explicit-anysites given real types and sevenset-state-in-effecteffects rewritten to set state from the event that causes it.
[0.6.6] -- 2026-07-25
Fixed
./package.jsonis now exported from all four packages. Reading@trexolab/verifykit-core/package.jsonthrewERR_PACKAGE_PATH_NOT_EXPORTED, because anexportsmap replaces the old free-for-all file access and this entry was missing. Bundler plugins, resolvers, and version-reporting tooling read that file as a matter of course, and a package with anexportsmap has to opt back in. Added tocore,react,vanillaandplugin-revocation.
Added
-
A Node consumer smoke test (
npm run test:node), wired into CI. Every existing check saw the packages through a bundler — the demo app, the docs site,npm run build. Bundlers resolve specifiers Node's ESM resolver rejects, which is precisely how theERR_MODULE_NOT_FOUNDfixed in 0.6.5 survived from 0.3.x to 0.6.4 with every release passing review. The new script packs each package exactly asnpm publishwould, installs the tarball into a throwaway project, and imports it from a plain Node process: ESM and CJS entries for the core, a realsha256_hexresult rather than a mere successful import, the./package.jsonsubpath, and@trexolab/verifykit-reactimporting under Node without touching the DOM (the server-render path 0.6.0 fixed). Confirmed to fail on the reintroduced 0.6.4 defects and pass once reverted. -
A CI workflow (
.github/workflows/ci.yml). The repository had none. It runs the Rust unit tests, builds the WASM, typechecks, builds, and runs the Node smoke test on every push and pull request. Two notes for whoever edits it:npm cineeds--ignore-scriptsbecause the rootpreparescript builds every workspace and the core build has no WASM to embed on a fresh checkout, and lint is reported but not enforced while 180 pre-existing errors remain.
[0.6.5] -- 2026-07-25
Fixed
-
@trexolab/verifykit-corecould not initialise under plain Node.js. The built bundle emittedimport("../pkg/verifykit_core_wasm")with no file extension. Bundlers (Vite, webpack, Next.js) resolve an extensionless specifier, which is why every browser integration worked and this went unnoticed; Node's ESM resolver does not, soawait initWasm()— and thereforecreateVerifier()andverifyPdf()— threwERR_MODULE_NOT_FOUND: Cannot find module '…/pkg/verifykit_core_wasm'for any consumer that did not run the package through a bundler. Confirmed by installing the published 0.6.4 tarball into an empty Node project. The import now carries its.jsextension, which both Node and every bundler accept.This affected Node.js, Deno and Bun consumers of
@trexolab/verifykit-coreon every release up to and including 0.6.4. Browser consumers were never affected. -
WASM initialisation logged a deprecation warning to every consumer's console.
using deprecated parameters for the initialization function; pass a single object insteadappeared once per initialisation (eight times in a typical demo session). wasm-bindgen's generated init takes a single options object; the loader was passing the source positionally. It now passes{ module_or_path: … }. Verified in the browser: eight warnings before, zero after, with verification results unchanged.
[0.6.4] -- 2026-07-25
Fixed
- The document message bar's text ran underneath the "Signature Panel" button. The message was
white-space: nowrapwithoutmin-width: 0oroverflow, so as a flex item its minimum size was the full width of the text and it refused to shrink — it simply overflowed under the button, which does not shrink. Latent since the bar was written; it became visible in 0.6.3, when the Adobe-worded certification sentence grew to ~140 characters.
Changed
-
The certification banner now fits itself to the space it has. Clipping the Adobe sentence with an ellipsis would cut it mid-email, so the bar instead measures three complete phrasings and renders the longest one that fits:
Sentence full Certified by <CN> <email>, <OU>, certificate issued by <issuer CN>.compact Certified by <CN>, <OU>.minimal Certified by <CN>.Measuring rather than switching on viewport breakpoints matters because the length of this sentence comes from the certificate, not the window: a long common name has to degrade sooner than a short one at the very same width. The permission sentence ("No changes to the document are permitted.") is dropped when the message needs its room — it remains in the tooltip and the signature panel — and the full text is always available as the bar's
title. Below the narrowest complete phrasing, an ellipsis is the floor.Non-certified documents are unaffected: their messages are short, single-variant, and render exactly as before.
[0.6.3] -- 2026-07-25
Fixed
-
Certified (DocMDP) documents were not recognised as certified, so the banner showed the generic "signed" message where Adobe shows "Certified by …". The DocMDP permission extractor required
/TransformParamsto follow/DocMDPimmediately. PDF dictionaries are unordered, and iText — among others — writes/Reference[<</TransformMethod/DocMDP/Type/SigRef/TransformParams<</P 1…>>…], placing/Type/SigRefbetween the two. Such files were read as uncertified:mdpPermissionstayed null, the message bar fell through to "Signed and all signatures are valid.", and the panel's CERTIFIED badges never appeared. Extraction is now order-independent and bounded by the enclosing dictionary, so it also handles a/Pthat is not the first/TransformParamskey. The Catalog's/Perms<</DocMDP N 0 R>>reference continues to be ignored, as it carries no permission value.Signature validity is unaffected:
apply_doc_mdp_rulesonly invalidates a P=1 certification when the certifying signature does not cover the whole file, which is checked independently of this extraction.
Changed
-
The certification banner now matches Adobe's wording. It read
Certified by <name>.; it now readsCertified by <CN> <email>, <OU>, certificate issued by <issuer CN>.— the same sentence Adobe Reader shows, built from data already present inCertificateInfo(subject, issuer, and therfc822NameSAN). Every part degrades independently, so a certificate without an email or organisation still produces a clean sentence, and a signature with no certificate falls back toCertified by <name>.The organisation segment prefers the subject's OU, falling back to O. This matches the observed Adobe output for a certificate whose
OU=TESTandO=TEST CERTIFICATE.
[0.6.2] -- 2026-07-25
Infrastructure and documentation. No SDK code changed — installing 0.6.2 gets you the same viewer as 0.6.1.
Fixed
- The registry kept only the newest release, so pinning an older version 404'd.
pack:registrywiped the tarball directory on every run; the moment 0.6.1 shipped,npm install @trexolab/verifykit-react@0.6.0failed. Because that directory is gitignored, the old tarballs were gone rather than merely unreferenced. Versions now accumulate: the script merges into the existing manifest and recomputeslatestas the highest version present, so re-packing an older release cannot demote a newer one. The/api/registryroute serves every version, each with its own shasum, integrity hash and tarball URL — npm verifies these per version, so a shared checksum would break pinned installs. 0.5.13, 0.5.14 and 0.6.0 have been rebuilt from their git tags and restored, and each advertises the dependencies it actually shipped with (pdfjs-distfor 0.5.x, none for 0.6.x). - The registry advertised
pdfjs-distas a dependency of@trexolab/verifykit-reactafter it was bundled. The manifest contradictedpackage.json, so a consumer resolving through the registry installed ~10 MB of pdf.js the viewer does not use. The dependency builder also defaulted to'*'for any key it could not find, which is why removing it frompackage.jsonwas not enough; it now skips and warns instead of inventing a range. sync-versionnow updates the "Packages" version tables inREADME.mdanddocs/README.md. These were bumped by hand every release and had drifted —docs/README.mdsat on 0.5.12 for four releases.
Documentation
- Package READMEs caught up with v0.6.0's bundling change:
npm ls pdfjs-dist/npm install pdfjs-dist@5.5.207troubleshooting replaced with the real failure mode (aworkerUrlpointing at a mismatched worker), and asset-copy commands repointed fromnode_modules/pdfjs-dist/tonode_modules/@trexolab/verifykit-react/. Thedocs/tree had already been corrected in 0.6.1; this bringspackages/*/README.mdin line.
[0.6.1] -- 2026-07-25
Fixed
- Signature Properties showed encrypted bytes as the Field Name. On a password-protected PDF the dialog's "Field Name" row rendered mojibake such as
{:žhe*K÷#ΘoIT{…instead of a real name. Field names are among the values encrypted in a password-protected document, and the Rust/WASM core does not decrypt them, soPdfSignature.fieldNamecan carry undecodable bytes on such a file. v0.5.13 added that row, which surfaced them for the first time. The dialog now shows the decrypted name the viewer already has for that widget, so it readsSignature1/Signature2as expected, and omits the row entirely where no readable name is obtainable rather than printing bytes.PdfSignature.fieldNameitself is deliberately unchanged — other parts of the pipeline depend on its current value, so if you display it yourself, guard for undecodable bytes on encrypted documents. - Synthetic "unverified" signatures no longer show ciphertext as the signer name. On an encrypted PDF the names
makeUnverifiedSignature()reads are undecodable for the same reason as above. The panel title now falls back toSignature Nwhen the value is not displayable.
Added
SignatureDetailsTabaccepts an optionalfieldNameprop — the decrypted name to display, overridingsig.fieldName. Backward compatible: when omitted,sig.fieldNameis used if it is displayable and the row is hidden if it is not.
[0.6.0] -- 2026-07-25
Added
- Next.js works out of the box — no
ssr: false, nonext/dynamic, no wrapper file.@trexolab/verifykit-reactnow ships a'use client'directive, so it is its own client boundary and can be imported straight from an App Router Server Component. Verified server-rendering and hydrating with zero hydration warnings under both Turbopack and webpack, in dev and in production builds. The demo app's owndynamic(() => import("@/components/DemoViewer"), { ssr: false })workaround has been deleted as proof.ssr: falsestill works if you already have it, so upgrading forces no change.
Fixed
useVerification().load()no longer throws "Verifier not configured" when called from a mount effect.VerifyKitProvidercreates the WASM verifier in an effect, and React runs child effects before parent ones — so a consumer loading a document on mount arrived before the verifier existed, andwaitForVerifier()threw instead of waiting, despite its name. The provider now hands out a latch promise created during render (pure allocation, safe on the server) which the effect settles, so an early caller simply waits.createVerifier()is also cached per config identity, so React StrictMode's deliberate double-mount no longer initialises WASM twice.
Changed
- pdf.js is loaded lazily and bundled into its own chunk. It was previously a static top-level import and an external peer-style dependency. Two consequences of that arrangement blocked server rendering: importing it under Node threw
ReferenceError: DOMMatrix is not defined(before any component rendered, so notypeof windowguard could help), and webpack could not place pdf.js's.mjs— itself a webpack bundle — into an async chunk without failing withTypeError: Object.defineProperty called on non-object. pdf.js now loads inside the effects that use it, from a chunk this package bundles itself, which resolves both. It is therefore no longer in the initial bundle at all. - The package ships unminified. esbuild's name mangling corrupts pdf.js's private class fields when bundling it here, producing output SWC rejects with
duplicate private name. Consumer bundlers minify anyway, and stack traces stay readable. pdfjs-distis no longer an external dependency of@trexolab/verifykit-react. It is bundled into the lazy chunk, so consumers no longer need it installed or aliased — and can no longer swap the build via bundler alias.
[0.5.14] -- 2026-07-25
Fixed
- Encrypted PDFs: the on-page signature icon now swaps to its verified state in every integration, not just the demo app. The encrypted appearance swap shipped in 0.5.11 needed the password PDF.js had accepted, but the only route from the viewer (which owns the password dialog) to
useVerification(which owns the swap) was the optionalonPasswordAcceptedprop — and nothing in the SDK wired it. Only the hosted demo passed it, so every other integration kept showing Adobe's yellow "?" on the page while the signature panel, status bar and Signature Properties dialog all correctly said valid. The accepted password now travels automatically throughVerifyKitProvider, so React (Viewer,CoreViewerand the legacyPdfViewer),@trexolab/verifykit-vanillaand Vue all show the validated icon with no changes in the host app.onPasswordAcceptedandapplyPasswordkeep working as an opt-in escape hatch for hosts that drive their own PDF.js instance; a password reported twice is ignored rather than swapping twice. - Owner-password-only PDFs are no longer skipped. A PDF that is encrypted but opens with an empty user password never triggers a password prompt, so no password was ever recorded and the swap was skipped exactly as if the document were locked. The viewer now detects encryption directly (via PDF.js
getPermissions()) and reports the validated empty password, and the swapper distinguishes "no password known" (undefined, leave the file alone) from "empty password" ('', a real key input). Unencrypted documents report nothing, so they never pay for a second swap pass. - Legacy
PdfViewer: encrypted documents no longer re-prompt for the password after the swap. The appearance-swapped bytes are still encrypted, butPdfViewerneither remembered the accepted password nor passed one when re-parsing, so they triggered a fresh password dialog and itsuseSwapAppearancere-parse failed outright on encrypted files. It now remembers the password for the lifetime of the document (keyed onfileName, so a genuinely new document still prompts) and reuses it for both re-parse paths, matchingCoreViewer.
Security
- The appearance swap now validates that it can safely rewrite the document before it writes anything. PDF.js also unlocks a document when the owner password is typed rather than the user password, and in that case the swap cannot produce a file that opens again. It is now detected up front and the original bytes are returned untouched instead (the "?" simply stays). The original signed bytes are never modified in any path.
[0.5.13] -- 2026-07-24
Fixed
- Invalid / ObjStm-embedded signatures now show the signer's name and certificate (Adobe parity). A signature the engine cannot fully verify — an illegal
/Contents, an ObjStm-embedded signature, or a CMS that fails SignedData validation — previously fell back to the signature dictionary's/Namein the panel title, which for many signing tools is the software name (e.g.ATick_dart 1.0.7), not the person. The engine now extracts the signer certificate from the CMS even when the signature cannot be validated — preferring the exact signer by IssuerAndSerialNumber, otherwise the leaf certificate — so the panel title shows the signer CN (e.g.Test), the organisation, and the certificate chain, exactly as Adobe Reader does. This is display-only: the signature's overall status stays Invalid and every per-check result remainsunknown/ "not evaluated". - Signature Properties: the signed-certificate view now shows the Field Name. The "Field Name" row (AcroForm
/T) previously appeared only for signatures without a certificate. It now also appears in the Signer section for signed signatures, so the field name is always visible.
[0.5.12] -- 2026-07-16
Added
- Mobile-responsive viewer and demo. On phones the signature panel no longer collapses the page to a sliver: below 640px it drops out of the flex row and docks to the bottom as a full-width sheet (rounded top, dismissable), so the document keeps full width with the panel open. The demo's "Live Demo" sidebar becomes an overlay drawer below 768px — it auto-collapses when a document loads so the viewer gets full height, slides in from the toggle, and closes on a tap of the dim scrim. Desktop layout is unchanged (fixed sidebar column + right-side signature panel).
[0.5.11] -- 2026-07-16
Fixed
- Encrypted PDFs: the on-page signature icon now reflects verified status (green check), not Adobe's yellow "?". Since 0.5.9 the appearance swap was skipped entirely for password-protected PDFs, so encrypted documents kept the signer's static yellow "?" on the page even when the signature panel showed valid. The swap now runs for them too, once the viewer has accepted the password. The original encrypted bytes stay byte-for-byte intact, the password still opens the document, and Download returns the pristine original — the swapped copy is display-only. Documents using the AES-128 Standard security handler are supported; any other cipher, a missing password, or any failure falls back to leaving the page untouched — never worse than the "?" shown before. Non-encrypted PDFs are byte-for-byte unaffected.
- Multi-signature appearance mapping made robust. When signature fields could not all be matched to signatures by name, the positional fallback miscounted documents where one signature is drawn on several pages, or where a field's appearance sits on a child widget rather than the field itself — so a later signature could be given an earlier one's icon. Fixes the second signature's icon in two-signature documents.
[0.5.10] -- 2026-07-16
Fixed
- Encrypted PDFs: on-page signature fields are now interactive (no longer treated as unsigned). In a password-protected PDF the on-page signature fields rendered as empty unsigned placeholders and clicking them did nothing, even though the signature panel already showed the correct verified status. The core cannot decrypt PDF string objects, so a signature field's
/Tname is read as encrypted bytes that never match the signer name PDF.js exposes after decryption — and the CoreViewer signature plugin mapped widgets to signatures per-page by name only, leaving encrypted fields unmatched (sigIndex < 0→ unsigned styling and a dead click). The plugin now aggregates raw widgets across all rendered pages and maps them globally through the sharedcomputeWidgetsresolver (name match first, then an order-based fallback), so encrypted fields resolve to their signatures and become clickable — opening the Signature Properties dialog. Ordinary PDFs are unaffected: when every signature matches by name, no signatures remain for the fallback to place, so genuine unsigned placeholders still render as unsigned.
Added
- Certification identification in the signature panel (Adobe parity). A certifying (DocMDP) signature now shows a CERTIFIED badge in its panel row, with a tooltip naming the permission level (e.g. "Certification signature (DocMDP P2) — Only form filling and digital signatures are allowed."). Approval signatures are unchanged, and the timestamp/document-timestamp rows never show it. The permission wording is shared with the document message bar (
mdp-utils) so the badge and the bar always agree.
[0.5.9] -- 2026-07-16
Fixed
- Certified-then-approved signatures no longer wrongly invalidated (DocMDP, Adobe parity). In a document that was certified (author signature with a DocMDP transform) and then approved by a second signature, the first (certification) signature was reported invalid with "The document has been modified or corrupted since this signature was applied." The DocMDP permission extractor was matching the Catalog's
/Perms /DocMDP <ref>indirect reference — which carries no/Pvalue nearby — and defaulting toP = 1(no changes permitted), so the legitimate later approval signature looked like a forbidden modification. The extractor now reads the certification's/DocMDP /TransformParams << … /P N >>form specifically; aP = 2certification (form-fill and signing allowed) correctly permits the second approval signature, and both signatures show valid — matching Adobe Reader. - Password-protected PDFs: correct password no longer rejected. For an encrypted PDF, the signature-appearance swap produced a byte stream the viewer could no longer open, so PDF.js rejected even the correct password ("Incorrect password. Please try again."). The appearance swap and the unverified-signed-field safety-net scan now skip encrypted documents entirely, so PDF.js receives the original bytes and the correct password opens the document. Verified signature status is unaffected (the panel still shows it); only the on-page appearance swap is skipped for encrypted docs.
- Password dialog: reveal toggle stays centered. The show/hide-password eye toggle could drop below the input's text area after hover or click, because its mouse handlers reset the button's CSS
transformto empty and discarded thetranslateY(-50%)that vertically centers it. The handlers now preserve the centering transform, so the toggle stays put through all hover/press/release states.
[0.5.8] -- 2026-07-15
Added
- Support information in the signature panel. For an INVALID signature, a collapsible "Support information" section surfaces the engine's own per-check technical reason (e.g.
SigDict /Contents illegal data, a revocation reason, a byte-range hash mismatch), with a Copy action for bug reports. Collapsed by default; shown only for errors, so the actual issue is always discoverable without cluttering valid/warning cases.
Fixed
- Navigation: linked previously-orphaned pages.
/faq(now in Footer › Resources) and the/docs/vue(Vue Integration) and/docs/desktop-extensions(Desktop Extensions) guides were reachable by URL but not linked anywhere. They are now in the footer and the docs sidebar (plus the docs prev/next order). Pages that exist for our own use remain intentionally unlinked.
[0.5.7] -- 2026-07-15
Fixed
- ObjStm-embedded signatures are now detected. A signature whose dictionary lives inside a compressed object stream (
/Type /ObjStm) — e.g. a re-encoded/re-saved signed PDF — was previously missed by the raw-text scan, so the field showed the signer's baked-in appearance (a green "Signature Valid" check) and was treated as unsigned. The core now decompresses object streams, detects the signature, and reports it as invalid ("SigDict /Contents illegal data") — matching Adobe Reader. - Malformed-signature messaging corrected (Adobe parity). For a signature whose
/Contentscannot be parsed, per-check results are nowunknown("not evaluated") with the CMS check carrying the real reason, instead of fabricating specific failures. The panel/modal no longer claims "The document has been modified or corrupted", "The signer's certificate has been revoked", or "A forbidden algorithm was used" for a signature that was never actually verified — it now shows Adobe's wording ("There are errors in the formatting or information contained in this signature", "Signer's identity has not yet been verified"). - Signature appearance now reflects the real verdict. Fixed a document-loader race in the core viewer where the initial original-bytes load could overwrite the appearance-swap re-parse, leaving the canvas showing a signer-controlled appearance while the store held the swapped bytes. An invalid signature now renders a red ✗ / "Signature Invalid".
- Ctrl/Cmd+wheel zoom now works. The zoom plugin attached its wheel listener to the scroll container at install time, before it mounted, so it never registered. It now attaches to the always-mounted viewer container and resolves the scroll container per event. Plain wheel scrolls vertically; Shift+wheel scrolls horizontally.
- Hardened the ObjStm parser against out-of-order object-stream offsets (panic guards +
MAX_SIGNATUREScap) in bothsignature_extractionandfield_resolution.
Added
- Viewer safety net for unverified signed fields. A
/Sigfield carrying a signature value the engine did not verify is surfaced as a synthetic "unknown — could not verify" entry (grey "?" icon), so a signer-controlled "valid" appearance can never imply validity. - Security docs: client-side Trust Boundary.
docs/security.mdnow states that VerifyKit's verdict is computed on the client for the viewing user and is not a server-side attestation — run verification server-side for backend security decisions.
[0.5.6] -- 2026-06-06
Changed
Internal refactor (no behavior change)
- Large modules split up across all packages, with every public API preserved by re-export -- no consumer code changes required. Every commit in the 0.5.6 sequence was gated on a browser test asserting the signature panel reaches the same state as the 0.5.5 baseline, so the refactor is verified end-to-end rather than only type-checked.
[0.5.5] -- 2026-06-06
Fixed
Verification engine (core)
- CMS signatures without
signedAttrsare now accepted (RFC 5652 §5.4). Some signing engines — notably Indian DSC tokens (Capricorn, eMudhra, etc.) used via Adobe Reader DC — produceadbe.pkcs7.detachedsignatures whose SignerInfo has nosignedAttrsblock at all. Per RFC 5652 §5.4 this is legal forid-datacontent: the signature is computed directly over the message digest of the content, with no separatemessageDigestattribute. Adobe Reader DC andopenssl smime -verifyboth accept this form; VerifyKit was rejecting it with "Document has been modified or corrupted since this signature was applied" + "No messageDigest attribute in signedAttrs" + "cryptographic signature could not be fully verified". Two fixes:check_integrityno longer fails whenmessageDigestis absent. A newintegrity_result_no_signed_attrspath returns Valid (or coverage-based Warning) with an explanation that the cryptographic signature step performs the digest match. The byte-range / DSS / "later signatures" reporting is preserved.manual_verify_cmsgains anexternal_signed_bytesparameter, used as the fallback when bothsignedAttrsandencapContentInfo.eContentare absent. The signature is then RSA-verified againstHash(signed_bytes)— the same pathopenssl smime -verify -content <bytes>takes. RFC 3161 timestamp verification continues to passNone(eContent is sufficient there).
[0.5.4] -- 2026-04-29
Fixed
Verification engine (core)
-
DSS indirect array references now resolved correctly. The Document Security Store parser previously only handled inline arrays (
/OCSPs [77 0 R 78 0 R]); when a PDF stored the array as a separate object (/OCSPs 84 0 Rwhere object 84 is the array), OCSPs/CRLs/Certs were silently read as empty. Addedresolve_dss_arraywhich tries inline first and falls back to dereferencing the indirect object. Fixes LTV detection and PAdES level reporting (wasB-T, now correctlyB-LTA) for PDFs produced by Adobe Acrobat, DocuSign, and other signers that emit DSS with indirect arrays. -
dss_only_beyond_coveragescan window expanded to the full file. The check that detects whether content beyond a signature's byte-range is only DSS/revocation data (a permitted post-signing update) was limited to the first 100 KB past the coverage end. For large PDFs or late-appended DSS dictionaries that fall beyond that window the check incorrectly treated the content as a modification and returnedunknownfor integrity. The window is now the full remaining file length. -
is_last_signaturedetermined by byte-range coverage end, not vector index. Whenexpand_shared_sig_fieldsclones a CAdES signature value across multiple AcroForm fields, the clones are appended to the end of the signature vector. The previousidx == sig_count - 1logic picked a clone as the "last" signature, causing a false "document has been modified since it was signed" warning on the actual last signature (the RFC 3161 document timestamp). The flag is now set on whichever signature has the highestoffset2 + length2byte-range end, which is always the document timestamp.
[0.5.3] -- 2026-04-24
Added
Verification engine (core)
- Configurable algorithm policy — Adobe-parity default for deprecated algorithms with opt-in strict mode.
- New
AlgorithmPolicytype +VerifyKitCoreConfig.algorithmPolicyoption. Shape:{ sha1: 'valid' | 'warn' }. - Default is
{ sha1: 'valid' }to match Adobe Reader: SHA-1 family signatures pass the algorithm-strength check and surface the algorithm name via the newSignatureCheckResult.algorithmNamefield, so UIs can render a "Signed with SHA-1" disclosure without marking the overall signature as warning. - Set
{ sha1: 'warn' }to surface SHA-1 signatures as warnings (pre-0.5.3 behavior). - Runtime helpers
setAlgorithmPolicy(policy)/resetAlgorithmPolicy()exported from@trexolab/verifykit-coreand@trexolab/verifykit-react. - MD5 / MD2 / MD4 remain hardcoded
Invalidand cannot be overridden — they are cryptographically broken, not merely deprecated.
- New
SignatureCheckResult.algorithmName— optional human-readable algorithm name (e.g."SHA-1","SHA-256 with RSA") populated by the algorithm-strength check regardless of status, enabling integrators to build their own badge UI.CertificateInfo.rawDer(Uint8Array) — raw DER-encoded certificate bytes are now included in the WASM output so UIs can offer per-cert and chain export. The verification pipeline never reads this field; it is serialization-only (#[serde(skip_serializing_if = "Option::is_none")]).buildCertChainPkcs7(certs)— new core helper that assembles a degenerate CMS SignedData (certs-only PKCS#7 /.p7b) from aCertificateInfo[]. Pure Rust ASN.1 encoding, no cryptographic operations, completely decoupled from the verification pipeline.
React / viewer
- Certificate chain export from the Signature Properties dialog. The Certificates tab (and the TSA tab when TSA certs are embedded) now shows an
Export Chain ▾dropdown with three formats:- PEM chain (
.pem) — all certs concatenated as-----BEGIN CERTIFICATE-----blocks, ready for Unix / server / Java trust stores. - PKCS#7 bundle (
.p7b) — Windows / Adobe Reader native import format. - All certificates (
.zip) — oneNN-<CN>.pem+NN-<CN>.cerpair per cert, written by a dependency-free stored-ZIP writer (CRC-32 + local / central / EOCD records). Exports sort from signer to root. - Default filename:
<signerCN>-chain.{pem,p7b,zip}with filesystem-safe sanitization. - Existing per-cert PEM / DER buttons in
CertificateViewer.DetailPanelnow actually render (they always existed but requiredrawDer), covering "export signer" and "export any individual certificate" without new UI.
- PEM chain (
exportCertChainAsPem,exportCertChainAsPkcs7,exportCertChainAsZipre-exported from@trexolab/verifykit-reactalongside the existingexportCertAsPem/exportCertAsDer.- React signature list panel shows an Adobe-style
Signed with SHA-1 (legacy algorithm).disclosure in the expanded signature summary when a legacy algorithm is detected. - Configurable zoom range.
zoomPlugin()now acceptsZoomPluginOptions { minScale?, maxScale?, step? };defaultLayoutPlugin({ zoom })and vanillaVerifyKit.create({ zoom })pipe the same option through. The zoom dropdown filters presets to the configured range so no unreachable level is ever offered. New preset levels:25%,600%,800%,1000%.
Documentation site
/faqpage with 12 developer-focused questions and answers.
Documentation
docs/security.md— new "Algorithm Policy" section (tier table, Adobe-parity rationale, opt-in code sample).docs/api/core.md—AlgorithmPolicytype,setAlgorithmPolicy/resetAlgorithmPolicyfunction references,algorithmNamefield onSignatureCheckResult.docs/api/react.md—ZoomPluginOptionsdocumented onzoomPlugin()andDefaultLayoutPluginOptions.docs/customization.md— new "Zoom range" section with React à-la-carte,defaultLayoutPlugin, and vanilla examples.
Changed
Behavior
- Default overall status for pure-SHA-1 signatures — signatures whose only issue was the SHA-1 algorithm previously produced
overallStatus: 'warning'; with the new Adobe-parity default they produceoverallStatus: 'valid'. Integrations relying on the old behavior can pass{ algorithmPolicy: { sha1: 'warn' } }tocreateVerifier()or callsetAlgorithmPolicy({ sha1: 'warn' }). - Default zoom range — min
0.4→0.25, max5→10(40%–500% → 25%–1000%, matchingpdf.js). Restore the old range withzoom: { minScale: 0.4, maxScale: 5 }.
Visual / theme
- Light-theme contrast tuned across the docs site and the SDK viewer (CSS tokens only, no component changes). Docs-site card/border tokens widened so cards are distinguishable from the background; SDK viewer
--fg-muted,--fg-subtle,--border, tab-bar / status-bar / find-bar / floating-menu text tokens darkened to hit WCAG AA 4.5:1 on white. Dark theme untouched.
Repository layout
- Internal reorganisation of the monorepo. No published package name, entry point or API changed.
Fixed
Verification engine
- Legacy
adbe.pkcs7.sha1signatures now verify correctly. Signatures using the PDF 1.3 PKCS#7 format — whereencapContentInfo.eContentIS the SHA-1 hash of the byte-range bytes andsignedAttrsare absent — were previously reported asINVALIDwith "document has been modified or corrupted." The Rust engine now recognises this format: the integrity check comparesSHA-1(signed_bytes)againsteContent, and CMS verification falls back to verifying the signature directly overeContentper RFC 5652 §5.4 whensignedAttrsare missing. Restores Adobe Reader parity for multi-signature PDFs that mix a modern CAdES certification signature with a legacy SHA-1 approval signature.
JS / React wrapper
rehydrateDates()was destructuringUint8Arrayvalues returned from WASM into plain{0: byte, …}objects because the recursive walker did not special-case typed arrays. Binary fields (e.g. the newCertificateInfo.rawDer) now pass through verbatim. Date rehydration itself is unchanged.- TypeScript 5.7+
BlobPartnarrowing —Uint8Array<ArrayBufferLike>(which includesSharedArrayBuffer) is no longer assignable toBlobPart. The two new chain-export helpers (exportCertChainAsPkcs7,exportCertChainAsZip) now cast toBlobPartexplicitly; runtime buffers are always plainArrayBuffer, so behavior is unchanged. - Certificate chain row in
CertificateViewermixed shorthand (borderLeft,border: 'none') with longhand (borderLeftStyle/Width/Color) properties on the same<button>, triggering a React dev-mode warning. Replaced with per-side longhand properties; visual output is unchanged.
Documentation site
- Documentation routes returned 404 in production after the repository reorganisation above. Fixed.
[0.5.2] -- 2026-04-22
Fixed
- PAdES level detection now correctly reports B-LTA for signatures covered by a later
ETSI.RFC3161document-time-stamp when the DSS carries complete chain revocation data. Previously, a signature whose embedded TSTInfo failed to parse was demoted to B-B and the archive-timestamp promotion step only handled B-LT → B-LTA, so such files were misclassified relative to Adobe Reader DC. - Timestamp evidence for PAdES classification now recognises the presence of the
signature-time-stampunsigned attribute (matching Adobe Reader), instead of requiring a fully verified TSTInfo. - Archive-timestamp promotion extended to cover B-B / B-T → B-LTA when the covered signature has complete chain revocation data, not just B-LT → B-LTA.
[0.5.0] -- 2026-03-26
Improved
- Full codebase audit: resolved all TypeScript type errors, unused variables, and dead code across core, react, and plugin-revocation packages
- Rust/WASM engine: zero clippy warnings — replaced manual iterator patterns with idiomatic
contains(),strip_prefix(),?operator, and const thread-local initializers - Removed dead functions (
injectIconIntoStream,getBannerSub), unused imports, and stale constants - Fixed type safety in plugin-revocation handler and proxy request paths
- ESLint configuration overhauled: proper ignore patterns for generated files,
_-prefix convention for unused params, removed inapplicable react-refresh rules - All workspace dependency references aligned to wildcard (
*) for consistent local resolution - Fresh production builds verified clean across all packages
[0.4.6] -- 2026-03-26
Added
- PAdES conformance tab in signature properties with progress bar and level timeline
- PAdES level badges (B-T, B-LT, B-LTA) and TIMESTAMP badge in signature panel
- Document Timestamp label and TSA name display for ETSI.RFC3161 signatures
- SDK logo in viewer About modal, documentation header, and favicon
- Branded name with logo colors (Verify in cyan, Kit in default)
- About modal redesigned with logo, capability pills, link cards, and TrexoLab product link
- Five new documentation pages: Security, Testing, Internationalization, Browser Support, Error Reference
- Documentation version badge in website header (read from root package.json at build time)
Fixed
- PAdES B-LTA detection: upgrade B-LT signatures when followed by RFC3161 document timestamp
- PAdES level detection restricted to ETSI signatures only (PKCS#7 signatures no longer incorrectly tagged as PAdES)
padesLevelfield now optional (nullfor non-PAdES signatures likeadbe.pkcs7.detached)- Documentation audit: 15 files corrected (wrong props, broken examples, stale versions, missing types)
Changed
- Details tab redesigned: verdict-first layout, verification checks promoted, signer card with avatar
- Removed GitHub references from documentation website
- All hardcoded version numbers removed from documentation (single source of truth via root package.json)
[0.4.5] -- 2026-03-26
Fixed
- Display status aligned with Adobe Reader DC default behavior — valid signatures with unchecked revocation now show as "Valid" instead of "Unknown" (matches Adobe's non-strict revocation mode)
- Missing React key props in plugin overlay, right panel, and page overlay collection functions
Added
- Changelog page added to documentation site
- Mermaid diagram rendering support in documentation
- Architecture diagrams (SDK overview + verification pipeline) using Mermaid
- "Adobe Reader Aligned" capability added to About modal
- All four packages now release on a single synchronised version number
- Version and release guide (
docs/creating-a-package.md)
Changed
- About modal redesigned — technology-neutral descriptions, cleaner layout with bordered cards, copyright footer
- Polyfills no longer need separate import — auto-included via main entry point
[0.4.4] -- 2026-03-25
Added
- Certificate chain resolution with AKI/SKI matching and PKCS#7 embedded certificate support
- Signature status icon artwork refreshed (build-time change; no API or output difference for consumers)
Fixed
- Signature display aligned with Adobe Reader for expired certificates, certification level, and N4 text
- Keyboard shortcuts now toggle instead of one-shot activate
- Multi-field signatures sharing a single PKCS#7 blob are correctly detected
- Unknown signature status aligned with Adobe Reader DC behavior
- Signature validation for malformed PDFs aligned with Adobe Reader
- Status icons no longer rendered with missing strokes on some signature fields
Changed
- Documentation synced with recent SDK changes
- Homepage redesigned with stone palette, SEO metadata, and grid sections
- Demo site restyled with dark-first design tokens, Geist fonts, muted palette, and flat styles
[0.4.3] -- 2026-03-17
Fixed
- ETSI.RFC3161 document timestamp integrity check -- use TSTInfo messageImprint instead of signedAttrs messageDigest for document timestamps (PAdES B-LTA)
- Document timestamp EKU validation -- accept id-kp-timeStamping (1.3.6.1.5.5.7.3.8) for TSA certificates instead of requiring document-signing EKUs
- PDF field name extraction from binary stream data -- strict delimiter validation prevents
/Tmatches inside compressed streams from corrupting the field name - Signature widget overlay matching for multi-page fields -- handle sibling-replica suffix (
_sr{N}) and shared/Vreference patterns - Status icon placement for PDFs where several signature fields share a single signature value -- each field now gets the icon for the signature it belongs to
- Revocation check leniency for failed document timestamps -- require valid CMS signature before granting timestamp grace period
Added
- Scroll-to-signature on panel click -- viewport centers on the signature field with 4-blink highlight animation (matching Adobe Reader behavior)
- Widget registry in signature plugin for cross-page widget lookup
- Shared
field-name-utils.tsutility for normalized field name matching across the codebase
Changed
- Overall verification messages use "Document timestamp" language for ETSI.RFC3161 signatures instead of generic "Signature" wording
- Document timestamp signing time extracted from TSTInfo genTime (the CMS itself is the timestamp)
- Extracted duplicated field name suffix stripping into shared
normalizeFieldName()/isSameFieldFamily()helpers (was duplicated in 3 files with inconsistent order) - Removed redundant
overall_message.to_string()allocation and trivialextract_tst_gen_time_from_econtentwrapper - Marked file-dependent integration tests with
#[ignore]for CI compatibility
[0.4.2] -- 2026-03-16
Changed
- Enable
wasm-opt -Ozpost-compilation pass -- WASM binary reduced by 15.7% (1297 KB to 1093 KB) - Smaller WASM binary, with no loss of verification speed
- Async base64 decode via
fetch(data:...)in browser -- avoids 1.3 MB intermediateatob()string, reducing peak init memory - Trust store refactor: removed unused
allfield, ownership transfer instead of cloning, single-pass classification with inlineroot_dnsbuild -- reduces heap allocations by ~1 MB
[0.4.1] -- 2026-03-16
Fixed
- Support BER indefinite-length CMS encoding (
0x30 0x80) -- PDF signatures using this encoding were rejected by the ASN.1 parser, causing all verification checks to cascade-fail - DSS extraction now resolves latest object revision in incremental PDF updates -- fixes LTV/DSS revocation data not being found when the catalog was updated after signing
Changed
- Bump version to 0.4.1
[0.4.0] -- 2026-03-16
Changed
- Version bump to 0.4.0
[0.3.3] -- 2026-03-16
Changed
- Flicker-free appearance swap for PDF page rendering
- Single-render VerificationFloater for improved performance
Added
- Automated registry and CDN publishing
[0.3.2] -- 2026-03-15
Added
- Embedded VerificationFloater component in the viewer
- Smart context menu with signature-aware actions
- Live demo page
- CORS headers on revocation API route for cross-origin requests
- Revocation guide added to documentation navigation
Changed
workerUrlis now mandatory -- removed auto-detection and bundled worker- Updated all documentation for mandatory
workerUrlconfiguration - Updated Viewer props API
Fixed
- pdfjs-dist
getOrInsertComputedcompatibility error - Polyfill issues resolved
[0.3.1] -- 2026-03-13
Added
- Base64-embedded WASM binary for universal bundler compatibility -- no external
.wasmfile needed, works in Vite, webpack, Next.js, Node.js, Deno, and Bun with zero configuration - Comprehensive documentation update covering all packages
pack-registryscript for building registry tarballs
Changed
- Aligned all packages to v0.3.1
- Fixed type declaration paths across all packages
- Removed registry tarballs from git tracking