diff --git a/.gitignore b/.gitignore
index fd26d33..44adb34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,7 @@ reports/bench-results.json
# Temporary files
tmp/
+
+# opencode tooling lockfiles (machine-local)
+.opencode/package-lock.json
+.opencode/node_modules/
diff --git a/bun.lock b/bun.lock
index e8ebb8d..15366b6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -41,11 +41,11 @@
},
},
"packages": {
- "@babel/generator": ["@babel/generator@8.0.0-rc.5", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.5", "@babel/types": "^8.0.0-rc.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg=="],
+ "@babel/generator": ["@babel/generator@8.0.0-rc.6", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.6", "@babel/types": "^8.0.0-rc.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
- "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.5", "", {}, "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg=="],
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.6", "", {}, "sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
@@ -53,13 +53,13 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
- "@cantoo/pdf-lib": ["@cantoo/pdf-lib@2.6.5", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "color": "^4.2.3", "crypto-js": "^4.2.0", "node-html-better-parser": ">=1.4.0", "pako": "^1.0.11", "tslib": ">=2" } }, "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA=="],
+ "@cantoo/pdf-lib": ["@cantoo/pdf-lib@2.7.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "color": "^4.2.3", "crypto-js": "^4.2.0", "node-html-better-parser": ">=1.4.0", "pako": "^1.0.11", "tslib": ">=2" } }, "sha512-oHVfp0JrHYodyF18dG1r85LStjJJs42LLoiu+elB9qZu9YfYoE66omF5hJ3gEKEXk6PPUUEDpBngeZiHMSnueQ=="],
- "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
+ "@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
- "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
@@ -113,9 +113,9 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
- "@google-cloud/kms": ["@google-cloud/kms@5.5.0", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-feaZLZ0G64gxejaemMUSliBkbzEicgXP+AFxt8DRUjRBTbP8qpHlZcGQxC+J+YF1pg0sTcV+WNy2syGW23irpA=="],
+ "@google-cloud/kms": ["@google-cloud/kms@5.5.1", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-7O3mnspIlotzToIsSrv+YfnGhGdncOyZmjI0gG/r+jcsYN0t8WCa8v9YGuZ0F1VRa+49p/hUT/6ZQh9/s79PfA=="],
- "@google-cloud/secret-manager": ["@google-cloud/secret-manager@6.1.2", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-X4GiHC1OsZU8LOcnM/hk7Q+W/orZLSJz6IAvBJrjaQCj1pSRHkTHJnU87A6E5G8/ubIJjL6vs1GJ8f17TkDzPw=="],
+ "@google-cloud/secret-manager": ["@google-cloud/secret-manager@6.1.3", "", { "dependencies": { "google-gax": "^5.0.0" } }, "sha512-3e/5GLusy1sWBEUvIlJbJpaaUlaf4MeeGQDUBdIVCqWYnn0lNPtbO6ZbJhTPiOkct2yxi8DXWgzWDbCJdDq9Bw=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
@@ -133,13 +133,13 @@
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
- "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
- "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
+ "@oxc-project/types": ["@oxc-project/types@0.135.0", "", {}, "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-ICXQVKrDvsWUtfx6EiVJxfWrajKTwTfRV8vz2XiMkxZeuCKJLgD4YAj6dE3BWvpqDlkVkie4VSTAtMUWO9LDXg=="],
@@ -235,35 +235,35 @@
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
- "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.1", "", { "os": "android", "cpu": "arm64" }, "sha512-BLf9Wak/gfwVb7NQTQW4wBgL3oAfPy7ArEkhwV543OVw/uY6B47z5xYsqPSZ9PDOorvURPinws6ThaFuNgGLgA=="],
- "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rRZRPy/Ynb+Mxu0O6tfPldHeDgAn0sRij+IOUy6sFdUlv3hArGW/DloE3GfAxtqpOJuRNgF74Nr5gM4xBeU2jQ=="],
- "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="],
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/MtefPxhKPyWWFM8L45OWiEqRf+eSU2Qv9ZAyTaoZOoGcoPKxbbhjTJO2/U2IThv0uDZ4NWHc3/oTsR6IEOtww=="],
- "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="],
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-202K+cpIi1kx/Zn7AtxBi4LTXSY67Aszb2K9rNsuW7FeBeh0nqoNmYLOSZidV0p88VPBzMmTZcHAdPNo3kRYzQ=="],
- "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="],
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.1", "", { "os": "linux", "cpu": "arm" }, "sha512-wl9NfeXNUwrXtUc063tddmZFUI6qiNs1CNOwni0OL4vC7MqVSYugra3ZgtDmtVy8e0DluJTENmzIv2BwqLzT4Q=="],
- "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="],
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-at2EO4o7D/PJLC4Xik16bU4CcjQE2tSv1LfqMA0TRYQYQihRm3gZeDB8xaX28A9SFedibcAk5DeMCKt4REKG0A=="],
- "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="],
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-5PUjZx366h9tkJTPJF5eibxOlK3sGoeRiBJLLjjEB5/kLDuhr6qB3LkhqLz1smXNgsX+pBhnbcJBrPE30HznAA=="],
- "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="],
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1WK84XPeio3tjP1sM/TMXiC0G1i1iq1qGZ71KfNQjEFLU1kwD+Cv5T8nGySg/JUFwLbaScu6ve9DmeXlmqpkFA=="],
- "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="],
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-1nS1X5z1uMJ369RU25hTpKCFvUwXZp12dIzlzk4S+UxCTcSVGsAE6tzkOSufv/7jnmAtK0ZlrsJxh2fGmsnVSw=="],
- "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="],
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NwX/wspnq4vYyMFsqbYvzums3ki/Tk8FZbMzMAovPDp3OfLeYKby/D+9osokadXuYEV3OvpeHlwnr/bG8QMixA=="],
- "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="],
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+n46LhDrJFQM+229y4oXtVpj1G50U/+XuHMlpnisFTEXhrg9f/YIjp/HymX+PVJjBEr7XHRs3CFLelV464pqwA=="],
- "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="],
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.1", "", { "os": "none", "cpu": "arm64" }, "sha512-qGwEu47zOWYo7LdRHhCWTNhzwGtxXpdY6CERs8QEOqC0PXGGics/e3vHnyEUKt8xK6YkbZXFUCeklrpB6js8ag=="],
- "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="],
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.1", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-qczfgEH8u0wHGGOXtA7UMAybNKuQjjEXairyQaw4WzjiMztfbgatG1h4OKays/smhtwbWltpKCRGtVhU6h40Sg=="],
- "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="],
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-4psXSh63mSbwJF+mB8/9yfUUEzBiHYcUjxa32EO9ZwKy0Ypwjcg4F10D8SvVXgd+isy2UUUjF9HJJnDu1T/4Gg=="],
- "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="],
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MUvC/HLXVjzkQkWiExdVTEEWf0py+GfWm8WKSZsekG3ih6a21iy0BHPF07X3JIf3ifoklZXTIaHTLPBgH1C3dw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -317,7 +317,7 @@
"@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
- "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
@@ -335,17 +335,17 @@
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="],
- "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="],
+ "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
- "@vitest/mocker": ["@vitest/mocker@4.1.6", "", { "dependencies": { "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ=="],
+ "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
- "@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="],
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
- "@vitest/runner": ["@vitest/runner@4.1.6", "", { "dependencies": { "@vitest/utils": "4.1.6", "pathe": "^2.0.3" } }, "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA=="],
+ "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
- "@vitest/snapshot": ["@vitest/snapshot@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw=="],
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
- "@vitest/spy": ["@vitest/spy@4.1.6", "", {}, "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg=="],
+ "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
@@ -357,7 +357,7 @@
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
- "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
+ "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
@@ -427,7 +427,7 @@
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
- "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
+ "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
@@ -531,7 +531,7 @@
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
- "lru-cache": ["lru-cache@11.4.0", "", {}, "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA=="],
+ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -613,15 +613,15 @@
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
- "rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="],
+ "rolldown": ["rolldown@1.1.1", "", { "dependencies": { "@oxc-project/types": "=0.135.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.1", "@rolldown/binding-darwin-arm64": "1.1.1", "@rolldown/binding-darwin-x64": "1.1.1", "@rolldown/binding-freebsd-x64": "1.1.1", "@rolldown/binding-linux-arm-gnueabihf": "1.1.1", "@rolldown/binding-linux-arm64-gnu": "1.1.1", "@rolldown/binding-linux-arm64-musl": "1.1.1", "@rolldown/binding-linux-ppc64-gnu": "1.1.1", "@rolldown/binding-linux-s390x-gnu": "1.1.1", "@rolldown/binding-linux-x64-gnu": "1.1.1", "@rolldown/binding-linux-x64-musl": "1.1.1", "@rolldown/binding-openharmony-arm64": "1.1.1", "@rolldown/binding-wasm32-wasi": "1.1.1", "@rolldown/binding-win32-arm64-msvc": "1.1.1", "@rolldown/binding-win32-x64-msvc": "1.1.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IN750c0p+s3jqJIsFLRZrQazmbAB1kkQDTtQjSt/gbS2ywLhlv4R5Shazer0FZKmuo/BsO3/w2UoYnUjuOZqHg=="],
- "rolldown-plugin-dts": ["rolldown-plugin-dts@0.25.1", "", { "dependencies": { "@babel/generator": "8.0.0-rc.5", "@babel/helper-validator-identifier": "8.0.0-rc.5", "@babel/parser": "8.0.0-rc.4", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20260325.1", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-zK82aC/8z1iVW+g0bCnlQZq04Y5bNeL/RcRwTYBwsnU6wH0N+6vpIFkN7JC0kYRS5qKA+pxQyfIPvXJ6Q5xSpQ=="],
+ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.25.2", "", { "dependencies": { "@babel/generator": "8.0.0-rc.6", "@babel/helper-validator-identifier": "8.0.0-rc.6", "@babel/parser": "8.0.0-rc.6", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20260325.1", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-nMhN/R+vmR8GM45ZW1FWMSjRTSDDn/6w4GTf8RNrEFCBdl8B1kySWrU1ixPtbwzXoRlcO+R/S88VgXuJQwfdDg=="],
"rollup": ["rollup@4.53.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.5", "@rollup/rollup-android-arm64": "4.53.5", "@rollup/rollup-darwin-arm64": "4.53.5", "@rollup/rollup-darwin-x64": "4.53.5", "@rollup/rollup-freebsd-arm64": "4.53.5", "@rollup/rollup-freebsd-x64": "4.53.5", "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", "@rollup/rollup-linux-arm-musleabihf": "4.53.5", "@rollup/rollup-linux-arm64-gnu": "4.53.5", "@rollup/rollup-linux-arm64-musl": "4.53.5", "@rollup/rollup-linux-loong64-gnu": "4.53.5", "@rollup/rollup-linux-ppc64-gnu": "4.53.5", "@rollup/rollup-linux-riscv64-gnu": "4.53.5", "@rollup/rollup-linux-riscv64-musl": "4.53.5", "@rollup/rollup-linux-s390x-gnu": "4.53.5", "@rollup/rollup-linux-x64-gnu": "4.53.5", "@rollup/rollup-linux-x64-musl": "4.53.5", "@rollup/rollup-openharmony-arm64": "4.53.5", "@rollup/rollup-win32-arm64-msvc": "4.53.5", "@rollup/rollup-win32-ia32-msvc": "4.53.5", "@rollup/rollup-win32-x64-gnu": "4.53.5", "@rollup/rollup-win32-x64-msvc": "4.53.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
- "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
+ "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -667,7 +667,7 @@
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
- "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
@@ -675,7 +675,7 @@
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
- "tsdown": ["tsdown@0.22.0", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.0", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.1", "picomatch": "^4.0.4", "rolldown": "^1.0.0", "rolldown-plugin-dts": "^0.25.0", "semver": "^7.7.4", "tinyexec": "^1.1.2", "tinyglobby": "^0.2.16", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.0", "@tsdown/exe": "0.22.0", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-FgW0hHb27nGQA/+F3d5+U9wKXkfilk9DVkc5+7x/ZqF03g+Hoz/eeApT32jqxATt9eRoR+1jxk7MUMON+O4CXw=="],
+ "tsdown": ["tsdown@0.22.2", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.1", "picomatch": "^4.0.4", "rolldown": "~1.1.0", "rolldown-plugin-dts": "^0.25.2", "semver": "^7.8.1", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.2", "@tsdown/exe": "0.22.2", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-VX9gsyKXsTnBZjnIM4jsHl9aRv+GfgkE/k1hQslilaBfZMlaw3JuGR+6yhiU0QxWBtOCDnTjwOSoXzgB7Rr50g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -689,7 +689,7 @@
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
- "vitest": ["vitest@4.1.6", "", { "dependencies": { "@vitest/expect": "4.1.6", "@vitest/mocker": "4.1.6", "@vitest/pretty-format": "4.1.6", "@vitest/runner": "4.1.6", "@vitest/snapshot": "4.1.6", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.6", "@vitest/browser-preview": "4.1.6", "@vitest/browser-webdriverio": "4.1.6", "@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-v8": "4.1.6", "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ=="],
+ "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
@@ -711,9 +711,9 @@
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
- "@babel/generator/@babel/parser": ["@babel/parser@8.0.0-rc.5", "", { "dependencies": { "@babel/types": "^8.0.0-rc.5" }, "bin": "./bin/babel-parser.js" }, "sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ=="],
+ "@babel/generator/@babel/parser": ["@babel/parser@8.0.0-rc.6", "", { "dependencies": { "@babel/types": "^8.0.0-rc.6" }, "bin": "./bin/babel-parser.js" }, "sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog=="],
- "@babel/generator/@babel/types": ["@babel/types@8.0.0-rc.5", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.5", "@babel/helper-validator-identifier": "^8.0.0-rc.5" } }, "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA=="],
+ "@babel/generator/@babel/types": ["@babel/types@8.0.0-rc.6", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.6", "@babel/helper-validator-identifier": "^8.0.0-rc.6" } }, "sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw=="],
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -727,15 +727,15 @@
"@pdf-lib/upng/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
- "@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="],
+ "@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
- "@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="],
+ "@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
- "@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="],
+ "@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="],
@@ -759,7 +759,7 @@
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
- "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.4", "", { "dependencies": { "@babel/types": "^8.0.0-rc.4" }, "bin": "./bin/babel-parser.js" }, "sha512-0S/1yefMa15N4i2v3t8Fw9pgMHhf2gF6Lc1UEXI96Ls6FNAjqvHHZouZ2ZS/deqLhbMFtmfVeFac6iTsvFbLwA=="],
+ "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.6", "", { "dependencies": { "@babel/types": "^8.0.0-rc.6" }, "bin": "./bin/babel-parser.js" }, "sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -775,13 +775,17 @@
"tsdown/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+ "tsdown/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
+
"vite/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
- "vitest/@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="],
+ "vitest/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
+
+ "vitest/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vitest/std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
- "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "vitest/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
@@ -795,7 +799,7 @@
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
- "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.5", "", {}, "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A=="],
+ "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.6", "", {}, "sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -813,7 +817,7 @@
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "rolldown-plugin-dts/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.5", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.5", "@babel/helper-validator-identifier": "^8.0.0-rc.5" } }, "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA=="],
+ "rolldown-plugin-dts/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.6", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.6", "@babel/helper-validator-identifier": "^8.0.0-rc.6" } }, "sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -833,7 +837,9 @@
"ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.5", "", {}, "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A=="],
- "rolldown-plugin-dts/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.5", "", {}, "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A=="],
+ "ast-kit/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.5", "", {}, "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg=="],
+
+ "rolldown-plugin-dts/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.6", "", {}, "sha512-BCkFy+zN6kXQed3YOT7aJl93NfDSzQc3pBfsvTVPs9gU9X3V0aefEF5kwBT0E+mDWH9QgKaZstYUQN9VdQZT4g=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
diff --git a/content/docs/guides/signatures/index.mdx b/content/docs/guides/signatures/index.mdx
index 7b2087a..895e0ac 100644
--- a/content/docs/guides/signatures/index.mdx
+++ b/content/docs/guides/signatures/index.mdx
@@ -209,6 +209,79 @@ const fullySigned = await pdf2.sign({ signer: signer2 });
await writeFile("signed.pdf", fullySigned.bytes);
```
+## Finalizing Multi-Signer Documents
+
+In multi-signer workflows, each recipient signs at B-T level (fast, no
+revocation lookups per signer). Once everyone has signed, finalize the
+document in one pass.
+
+### Add Validation Data (B-T → B-LT)
+
+`addValidationData()` gathers certificates, OCSP responses, and CRLs for
+every signed signature field and writes them as a single DSS incremental
+update:
+
+```ts
+const { bytes, warnings, signatureCount } = await pdf.addValidationData();
+
+console.log(`Embedded LTV for ${signatureCount} signatures`);
+```
+
+Validation data is fetched once per issuer (signers sharing a CA don't
+trigger duplicate lookups) and merged with any existing DSS.
+
+### Add a Document Timestamp
+
+`addTimestamp()` appends a `/DocTimeStamp` signature whose ByteRange covers
+the entire document, sealing all prior signatures with a TSA-attested time:
+
+```ts
+const tsa = new HttpTimestampAuthority("http://timestamp.digicert.com");
+
+const { bytes } = await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ longTermValidation: true, // embed LTV for the timestamp's own chain
+});
+```
+
+Pass `fieldName` to fill a pre-allocated empty signature field — useful when
+the form structure must be locked down before a certification signature:
+
+```ts
+await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ fieldName: "ArchivalTimestamp", // reserved earlier via createSignatureField()
+});
+```
+
+### Full B-LTA Finalization
+
+`addArchivalData()` combines both steps: it gathers validation data for all
+existing signatures, adds an archival document timestamp, and embeds the
+timestamp's own validation data:
+
+```ts
+const tsa = new HttpTimestampAuthority("http://timestamp.digicert.com");
+
+const { bytes, warnings, signatureCount } = await pdf.addArchivalData({
+ timestampAuthority: tsa,
+});
+
+await writeFile("sealed.pdf", bytes);
+```
+
+These methods refuse to run when the document cannot be saved incrementally
+(for example, linearized documents or documents recovered from corruption)
+and throw a `SignatureError` instead — a full rewrite would invalidate every
+existing signature.
+
+
+ If `addTimestamp()`, `addValidationData()`, or `addArchivalData()` throws after partial progress
+ (for example, the timestamp authority is unreachable after a DSS update was already written), the
+ in-memory `PDF` instance may be out of sync with its bytes. Don't keep using it: discard the
+ instance and reload from the last known-good bytes with `PDF.load()`.
+
+
## Check Existing Signatures
```ts
diff --git a/package.json b/package.json
index d9d14a5..cc4f9e9 100644
--- a/package.json
+++ b/package.json
@@ -68,14 +68,14 @@
"@noble/hashes": "^2.2.0",
"@scure/base": "^2.2.0",
"asn1js": "^3.0.10",
- "lru-cache": "^11.4.0",
+ "lru-cache": "^11.5.1",
"pako": "^2.1.0",
"pkijs": "^3.4.0"
},
"devDependencies": {
- "@cantoo/pdf-lib": "^2.6.5",
- "@google-cloud/kms": "^5.5.0",
- "@google-cloud/secret-manager": "^6.1.2",
+ "@cantoo/pdf-lib": "^2.7.1",
+ "@google-cloud/kms": "^5.5.1",
+ "@google-cloud/secret-manager": "^6.1.3",
"@types/bun": "^1.3.14",
"@types/pako": "^2.0.4",
"@vitest/coverage-v8": "4.0.16",
@@ -83,11 +83,11 @@
"lint-staged": "^16.4.0",
"oxfmt": "^0.50.0",
"oxlint": "~1.39.0",
- "oxlint-tsgolint": "^0.11.1",
+ "oxlint-tsgolint": "^0.11.5",
"pdf-lib": "^1.17.1",
- "tsdown": "^0.22.0",
+ "tsdown": "^0.22.2",
"typescript": "^5.9.3",
- "vitest": "^4.1.6"
+ "vitest": "^4.1.8"
},
"peerDependencies": {
"@google-cloud/kms": "^5.0.0",
diff --git a/src/api/pdf-page.ts b/src/api/pdf-page.ts
index dadec4f..628c15c 100644
--- a/src/api/pdf-page.ts
+++ b/src/api/pdf-page.ts
@@ -373,8 +373,8 @@ export class PDFPage {
if (rotate) {
const value = rotate.value % 360;
- // Normalize to 0, 90, 180, 270
+ // Normalize to 0, 90, 180, 270
if (value === 90 || value === -270) {
return 90;
}
diff --git a/src/api/pdf-signature.ts b/src/api/pdf-signature.ts
index 9968013..cdf5a7a 100644
--- a/src/api/pdf-signature.ts
+++ b/src/api/pdf-signature.ts
@@ -18,7 +18,7 @@ import { PdfArray } from "#src/objects/pdf-array";
import { PdfDict } from "#src/objects/pdf-dict";
import { PdfName } from "#src/objects/pdf-name";
import { PdfNumber } from "#src/objects/pdf-number";
-import type { PdfRef } from "#src/objects/pdf-ref";
+import { PdfRef } from "#src/objects/pdf-ref";
import { PdfString } from "#src/objects/pdf-string";
import { CAdESDetachedBuilder } from "#src/signatures/formats/cades-detached";
import { PKCS7DetachedBuilder } from "#src/signatures/formats/pkcs7-detached";
@@ -36,6 +36,8 @@ import {
} from "#src/signatures/placeholder";
import { DefaultRevocationProvider } from "#src/signatures/revocation";
import {
+ type ArchivalDataOptions,
+ type ArchivalDataResult,
type DigestAlgorithm,
type PAdESLevel,
type RevocationProvider,
@@ -45,6 +47,10 @@ import {
type SignWarning,
type SubFilter,
type TimestampAuthority,
+ type TimestampOptions,
+ type TimestampResult,
+ type ValidationDataOptions,
+ type ValidationDataResult,
} from "#src/signatures/types";
import { escapePdfString, hashData } from "#src/signatures/utils";
@@ -133,7 +139,7 @@ export class PDFSignature {
const firstPageRef = this.pdf.context.pages.getPage(0);
if (!firstPageRef) {
- throw new Error("Document has no pages - cannot create signature field");
+ throw new SignatureError("NO_PAGES", "Document has no pages - cannot create signature field");
}
// Create signature dictionary with placeholders
@@ -164,10 +170,12 @@ export class PDFSignature {
const signatureRef = this.pdf.context.registry.register(signatureDict);
// Find or create signature field
- this.findOrCreateSignatureField({
+ this.prepareSignatureField({
fieldName: resolved.fieldName,
pageRef: firstPageRef,
- signatureRef,
+ valueRef: signatureRef,
+ namePrefix: "Signature_",
+ reuseFirstEmpty: true,
});
// Save incrementally to get bytes with placeholders
@@ -248,24 +256,23 @@ export class PDFSignature {
// For B-LTA, add document timestamp after DSS, then add DSS for the timestamp
if (resolved.archivalTimestamp && resolved.timestampAuthority) {
- const docTsToken = await this.addDocumentTimestamp(
- resolved.timestampAuthority,
- resolved.digestAlgorithm,
- );
+ const paddedTimestampBytes = await this.placeDocumentTimestamp({
+ timestampAuthority: resolved.timestampAuthority,
+ digestAlgorithm: resolved.digestAlgorithm,
+ estimatedSize: DEFAULT_PLACEHOLDER_SIZE,
+ });
// Add DSS for the document timestamp's certificate chain.
// This is more proactive than EU DSS (which waits for future LTA extensions),
// but ensures the timestamp is fully LTV-enabled from the start.
- if (docTsToken) {
- const docTsLtvData = await this.gatherTimestampLtvData(
- docTsToken,
- resolved.revocationProvider,
- warnings,
- );
+ const docTsLtvData = await this.gatherTimestampLtvData(
+ paddedTimestampBytes,
+ resolved.revocationProvider,
+ warnings,
+ );
- if (docTsLtvData) {
- await this.addDss(docTsLtvData);
- }
+ if (docTsLtvData) {
+ await this.addDss(docTsLtvData);
}
}
}
@@ -280,58 +287,82 @@ export class PDFSignature {
}
/**
- * Find or create a signature field.
+ * Find or create the /FT /Sig field that will hold a signature or document
+ * timestamp value, then convert it to the merged field+widget model
+ * (the invisible widget pattern used for all signatures in this library).
+ *
+ * Lookup behavior:
+ * - `fieldName` provided + matches an unsigned signature field -> reuse
+ * - `fieldName` provided + matches a signed signature field -> throw
+ * - `fieldName` provided + matches a non-signature field -> throw
+ * - `fieldName` provided + no match -> create
+ * - `fieldName` omitted + `reuseFirstEmpty` -> reuse first
+ * empty signature field, or create with `N`
+ * - `fieldName` omitted otherwise -> create with
+ * `N`
*/
- private findOrCreateSignatureField(options: {
+ private prepareSignatureField(options: {
fieldName?: string;
pageRef: PdfRef;
- signatureRef: PdfRef;
+ valueRef: PdfRef;
+ namePrefix: string;
+ reuseFirstEmpty: boolean;
}): void {
- const { fieldName, pageRef, signatureRef } = options;
+ const { fieldName, pageRef, valueRef, namePrefix, reuseFirstEmpty } = options;
const form = this.pdf.getOrCreateForm();
+ // Collect existing field names so we can both look up a named field
+ // and generate a unique fallback name when none is supplied.
const existingNames = new Set();
let fieldDict: PdfDict | undefined;
- const fields = form.getFields();
-
- for (const field of fields) {
+ for (const field of form.getFields()) {
existingNames.add(field.name);
// If requested name matches an existing field
if (fieldName && field.name === fieldName) {
- if (field instanceof SignatureField) {
- if (field.isSigned()) {
- throw new Error(`Signature field "${fieldName}" is already signed`);
- }
+ if (!(field instanceof SignatureField)) {
+ throw new SignatureError(
+ "FIELD_NOT_SIGNATURE",
+ `Field "${fieldName}" exists but is not a signature field`,
+ );
+ }
- fieldDict = field.getDict(); // Use existing unsigned field
- break;
+ if (field.isSigned()) {
+ throw new SignatureError(
+ "FIELD_ALREADY_SIGNED",
+ `Signature field "${fieldName}" is already signed`,
+ );
}
- throw new Error(`Field "${fieldName}" exists but is not a signature field`);
+ fieldDict = field.getDict(); // Use existing unsigned field
+ break;
}
- // If no name requested, look for first empty signature field
- if (!fieldName && field instanceof SignatureField && !field.isSigned()) {
+ // If no name requested, optionally reuse the first empty signature field
+ if (!fieldName && reuseFirstEmpty && field instanceof SignatureField && !field.isSigned()) {
fieldDict = field.getDict();
break;
}
}
if (!fieldDict) {
+ // PDFForm handles registry registration, /Fields, and /SigFlags 3.
fieldDict = form
- .createSignatureField(fieldName ?? generateUniqueName(existingNames, "Signature_"))
+ .createSignatureField(fieldName ?? generateUniqueName(existingNames, namePrefix))
.getDict();
}
// Set signature value
- fieldDict.set("V", signatureRef);
+ fieldDict.set("V", valueRef);
- // Convert to merged field+widget model (common for invisible signatures)
- // Remove /Kids if present (we're merging into a single object)
+ // Convert to merged field+widget model (common for invisible signatures).
+ // If the field carried widget kids (e.g. pre-allocated by an external
+ // tool), detach them from their pages first so no dangling /Annots
+ // references remain after we drop /Kids.
+ this.removeWidgetKidsFromPages(fieldDict);
fieldDict.delete("Kids");
// Add widget annotation properties
@@ -345,6 +376,52 @@ export class PDFSignature {
);
}
+ /**
+ * Remove a field's widget kids from every page's /Annots array.
+ *
+ * Merging a field with widget kids into a single field+widget object would
+ * otherwise leave those widgets referenced from page /Annots while no
+ * longer being listed in the field's /Kids - an inconsistent structure
+ * that confuses viewers.
+ */
+ private removeWidgetKidsFromPages(fieldDict: PdfDict): void {
+ const registry = this.pdf.context.registry;
+ const resolve = registry.resolve.bind(registry);
+ const kids = fieldDict.getArray("Kids", resolve);
+
+ if (!kids || kids.length === 0) {
+ return;
+ }
+
+ const kidKeys = new Set();
+
+ for (const kid of kids) {
+ if (kid instanceof PdfRef) {
+ kidKeys.add(`${kid.objectNumber} ${kid.generation}`);
+ }
+ }
+
+ if (kidKeys.size === 0) {
+ return;
+ }
+
+ for (const page of this.pdf.getPages()) {
+ const annots = page.dict.getArray("Annots", resolve);
+
+ if (!annots) {
+ continue;
+ }
+
+ for (let i = annots.length - 1; i >= 0; i--) {
+ const item = annots.at(i);
+
+ if (item instanceof PdfRef && kidKeys.has(`${item.objectNumber} ${item.generation}`)) {
+ annots.remove(i);
+ }
+ }
+ }
+ }
+
/**
* Check for MDP (certification signature) violations.
*/
@@ -389,14 +466,8 @@ export class PDFSignature {
*/
async addDss(ltvData: LtvData): Promise {
const registry = this.pdf.context.registry;
-
- // Get catalog
const catalogDict = this.pdf.getCatalog();
- if (!catalogDict) {
- throw new Error("Document has no catalog");
- }
-
// Load existing DSS for merging, or create new builder
const dssBuilder = await DSSBuilder.fromCatalog(catalogDict, registry);
@@ -407,38 +478,372 @@ export class PDFSignature {
const dssRef = dssBuilder.build();
catalogDict.set("DSS", dssRef);
- // Save and reload
- const savedBytes = await this.pdf.save({ incremental: true });
- await this.pdf.reload(savedBytes);
+ await this.saveAndReload();
}
/**
- * Add a document timestamp for archival (B-LTA).
+ * Save incrementally and reload the PDF instance so it reflects the saved
+ * bytes. Skips the reload (a full re-parse) when nothing was written -
+ * `save()` short-circuits and returns the current bytes in that case.
+ */
+ private async saveAndReload(): Promise {
+ const hadChanges = this.pdf.hasChanges();
+ const bytes = await this.pdf.save({ incremental: true });
+
+ if (hadChanges) {
+ await this.pdf.reload(bytes);
+ }
+
+ return bytes;
+ }
+
+ /**
+ * Throw when the document cannot be saved incrementally.
+ *
+ * Timestamping and validation-data updates exist to extend documents that
+ * already carry signatures. A silent fall back to a full rewrite would
+ * change every byte offset and invalidate all existing signatures, so we
+ * refuse up front instead.
+ */
+ private ensureIncrementalSave(operation: string): void {
+ const blocker = this.pdf.canSaveIncrementally();
+
+ if (blocker) {
+ throw new SignatureError(
+ "INCREMENTAL_SAVE_BLOCKED",
+ `${operation} requires an incremental save to preserve existing signatures, ` +
+ `but incremental save is not possible (${blocker}). ` +
+ `Save the document with a full rewrite first, reload it, and retry.`,
+ );
+ }
+ }
+
+ /**
+ * Add an archival document timestamp to the PDF.
+ *
+ * Creates a `/Type /DocTimeStamp` signature whose ByteRange covers the
+ * entire current document, extending the validity of any prior signatures.
+ * This is the timestamping step used at the end of a PAdES B-LTA flow
+ * when signatures have been appended.
*
- * Creates a document timestamp signature that covers the entire document
- * including previous signatures and DSS data.
+ * Does **not** gather validation data for pre-existing signatures - use
+ * `addValidationData()` for that, or `addArchivalData()` to do both in
+ * one call.
*
- * After adding the timestamp, the PDF is reloaded with the updated bytes.
+ * The PDF instance is reloaded with the updated bytes, so subsequent
+ * calls (e.g. another `addTimestamp()`) operate on the timestamped state.
*
- * @param timestampAuthority The timestamp authority to use
- * @param digestAlgorithm Digest algorithm (defaults to SHA-256)
- * @returns The timestamp token bytes (for gathering LTV data)
+ * If this method throws after partial progress (e.g. the TSA request
+ * fails), the in-memory PDF instance may be out of sync with its bytes.
+ * Discard the instance and reload from the last known-good bytes.
+ *
+ * @param options Timestamping options including the TSA
+ * @returns The PDF bytes with the timestamp embedded, plus any warnings
+ *
+ * @example
+ * ```typescript
+ * // Append an archival timestamp to an already-signed PDF.
+ * const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ * const { bytes } = await pdf.addTimestamp({
+ * timestampAuthority: tsa,
+ * longTermValidation: true,
+ * });
+ * ```
*/
- async addDocumentTimestamp(
- timestampAuthority: TimestampAuthority,
- digestAlgorithm: DigestAlgorithm = "SHA-256",
- ): Promise {
- const estimatedSize = DEFAULT_PLACEHOLDER_SIZE;
- const registry = this.pdf.context.registry;
+ async addTimestamp(options: TimestampOptions): Promise {
+ if (!options.timestampAuthority) {
+ throw new SignatureError("INVALID_OPTIONS", "addTimestamp() requires a timestampAuthority");
+ }
+
+ this.ensureIncrementalSave("addTimestamp()");
+
+ return this.addTimestampInternal(options);
+ }
+
+ /**
+ * Implementation of `addTimestamp()`, with an optional shared gatherer so
+ * `addArchivalData()` can reuse OCSP/CRL/AIA results fetched while
+ * gathering validation data for existing signatures.
+ */
+ private async addTimestampInternal(
+ options: TimestampOptions,
+ gatherer?: LtvDataGatherer,
+ ): Promise {
+ const warnings: SignWarning[] = [];
+ const digestAlgorithm = options.digestAlgorithm ?? "SHA-256";
+ const estimatedSize = options.estimatedSize ?? DEFAULT_PLACEHOLDER_SIZE;
+ const longTermValidation = options.longTermValidation ?? false;
+
+ // Place the document timestamp (writes /DocTimeStamp dict, registers the
+ // field with AcroForm, saves incrementally, requests the TSA token, and
+ // patches the placeholders). Returns the padded /Contents bytes that
+ // viewers use as the VRI key for the next DSS update.
+ const paddedTimestampBytes = await this.placeDocumentTimestamp({
+ timestampAuthority: options.timestampAuthority,
+ digestAlgorithm,
+ estimatedSize,
+ fieldName: options.fieldName,
+ });
+
+ // Optionally embed LTV data for the timestamp's certificate chain so the
+ // timestamp itself remains verifiable after the TSA certificate expires.
+ if (longTermValidation) {
+ const ltvData = await this.gatherTimestampLtvData(
+ paddedTimestampBytes,
+ options.revocationProvider,
+ warnings,
+ gatherer,
+ );
+
+ if (ltvData) {
+ await this.addDss(ltvData);
+ }
+ }
+
+ const bytes = await this.saveAndReload();
+
+ return { bytes, warnings };
+ }
+
+ /**
+ * Gather LTV (Long-Term Validation) data for every signed signature
+ * field currently in the document and write it as a single DSS
+ * incremental update.
+ *
+ * This upgrades the validation grade of every existing signature in the
+ * document — turning B-T signatures into B-LT and ensuring document
+ * timestamps have their TSA chain embedded for offline validation.
+ * Validation data is fetched once per issuer (shared OCSP/CRL cache)
+ * and merged with any existing DSS, deduplicating certs/OCSP/CRL.
+ *
+ * Does **not** add a timestamp - use `addTimestamp()` for that, or
+ * `addArchivalData()` to do both in one call.
+ *
+ * Safe to call on a document with no signatures (returns
+ * `signatureCount: 0`, no DSS update written).
+ *
+ * If this method throws after partial progress, the in-memory PDF
+ * instance may be out of sync with its bytes. Discard the instance and
+ * reload from the last known-good bytes.
+ *
+ * @example
+ * ```typescript
+ * // After every recipient has signed (B-T), upgrade all sigs to B-LT.
+ * await pdf.addValidationData();
+ * ```
+ */
+ async addValidationData(options: ValidationDataOptions = {}): Promise {
+ this.ensureIncrementalSave("addValidationData()");
+
+ // A single LtvDataGatherer so its OCSP / CRL cache is shared across
+ // every signature we process.
+ const gatherer = new LtvDataGatherer({
+ revocationProvider: options.revocationProvider ?? new DefaultRevocationProvider(),
+ });
+
+ return this.addValidationDataInternal(gatherer);
+ }
+
+ /**
+ * Implementation of `addValidationData()`, with the gatherer injected so
+ * `addArchivalData()` can share one OCSP/CRL cache across both its
+ * validation-data and timestamp steps.
+ */
+ private async addValidationDataInternal(
+ gatherer: LtvDataGatherer,
+ ): Promise {
+ const warnings: SignWarning[] = [];
+
+ // Collect signed signature fields - both regular signatures and
+ // /Type /DocTimeStamp use a /FT /Sig field with a /V signature dict,
+ // so SignatureField + isSigned() finds both. Without an AcroForm there
+ // are no signature fields at all.
+ const form = this.pdf.getForm();
+ const signedFields = form?.getSignatureFields().filter(field => field.isSigned()) ?? [];
+
+ if (signedFields.length === 0) {
+ const bytes = await this.saveAndReload();
+
+ return { bytes, warnings, signatureCount: 0 };
+ }
+
+ // Build a single DSSBuilder that merges with whatever DSS already
+ // exists in the catalog.
+ const catalogDict = this.pdf.getCatalog();
+ const builder = await DSSBuilder.fromCatalog(catalogDict, this.pdf.context.registry);
+
+ let processed = 0;
+
+ for (const field of signedFields) {
+ const sigDict = field.getSignatureDict();
+
+ if (!sigDict) {
+ warnings.push({
+ code: "LTV_GATHER_FAILED",
+ message: `Signature field "${field.name}" has no /V dictionary`,
+ });
+ continue;
+ }
+
+ // The padded /Contents bytes are exactly what viewers SHA-1 to
+ // compute the VRI key, so we must pass the raw bytes including
+ // zero padding (PdfString.bytes preserves that).
+ const contents = sigDict.get("Contents");
+
+ if (!(contents instanceof PdfString)) {
+ warnings.push({
+ code: "LTV_GATHER_FAILED",
+ message: `Signature field "${field.name}" has no /Contents string`,
+ });
+ continue;
+ }
+
+ try {
+ const ltvData = await gatherer.gather(contents.bytes);
+
+ // Prefix gatherer warnings with the field name so callers can
+ // tell which signature each warning is about.
+ for (const w of ltvData.warnings) {
+ warnings.push({
+ code: w.code,
+ message: `${field.name}: ${w.message}`,
+ });
+ }
+
+ await builder.addLtvData(ltvData);
+ processed += 1;
+ } catch (error) {
+ warnings.push({
+ code: "LTV_GATHER_FAILED",
+ message: `Could not gather LTV for "${field.name}": ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ });
+ }
+ }
+
+ // If nothing could be gathered, don't write an empty DSS revision.
+ if (processed === 0) {
+ const bytes = await this.saveAndReload();
+
+ return { bytes, warnings, signatureCount: 0 };
+ }
+
+ // Write a single incremental update for the DSS, even if some
+ // signatures failed - partial data is still useful for verifiers.
+ const dssRef = builder.build();
+
+ catalogDict.set("DSS", dssRef);
+
+ const bytes = await this.saveAndReload();
+
+ return { bytes, warnings, signatureCount: processed };
+ }
+
+ /**
+ * Finalize the document with full PAdES B-LTA semantics in a single
+ * call: gather LTV for every existing signature, write a DSS update,
+ * add an archival `/DocTimeStamp`, then add a second DSS update for
+ * the timestamp's own certificate chain.
+ *
+ * Equivalent to:
+ *
+ * ```typescript
+ * await pdf.addValidationData({ revocationProvider });
+ * await pdf.addTimestamp({
+ * timestampAuthority,
+ * longTermValidation: true,
+ * revocationProvider,
+ * ...
+ * });
+ * ```
+ *
+ * Use this as the last step of a multi-signer flow once every signer
+ * has appended their signature and you want to seal the document.
+ *
+ * If this method throws after partial progress (e.g. the TSA request
+ * fails after the DSS update was written), the in-memory PDF instance
+ * may be out of sync with its bytes. Discard the instance and reload
+ * from the last known-good bytes.
+ *
+ * @example
+ * ```typescript
+ * const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ * const { bytes, warnings } = await pdf.addArchivalData({
+ * timestampAuthority: tsa,
+ * });
+ * ```
+ */
+ async addArchivalData(options: ArchivalDataOptions): Promise {
+ if (!options.timestampAuthority) {
+ throw new SignatureError(
+ "INVALID_OPTIONS",
+ "addArchivalData() requires a timestampAuthority",
+ );
+ }
+
+ this.ensureIncrementalSave("addArchivalData()");
+
+ const warnings: SignWarning[] = [];
+
+ // One gatherer for both steps so OCSP/CRL responses fetched for the
+ // existing signatures (typically including the same TSA chain the
+ // archival timestamp will use) are not re-fetched in step 2.
+ const gatherer = new LtvDataGatherer({
+ revocationProvider: options.revocationProvider ?? new DefaultRevocationProvider(),
+ });
+
+ // Step 1: gather LTV for all existing signatures and write one DSS.
+ const validation = await this.addValidationDataInternal(gatherer);
+
+ warnings.push(...validation.warnings);
+
+ // Step 2: add the archival timestamp and let addTimestamp handle the
+ // timestamp's own LTV / second DSS write.
+ const timestamp = await this.addTimestampInternal(
+ {
+ timestampAuthority: options.timestampAuthority,
+ digestAlgorithm: options.digestAlgorithm,
+ estimatedSize: options.estimatedSize,
+ fieldName: options.fieldName,
+ longTermValidation: true,
+ revocationProvider: options.revocationProvider,
+ },
+ gatherer,
+ );
+
+ warnings.push(...timestamp.warnings);
+
+ return {
+ bytes: timestamp.bytes,
+ warnings,
+ signatureCount: validation.signatureCount,
+ };
+ }
+
+ /**
+ * Place a document timestamp in the PDF (shared by `addTimestamp()` and the
+ * B-LTA path of `sign()`).
+ *
+ * Returns the padded timestamp bytes (raw token + zero padding to fill the
+ * placeholder) so callers can compute the SHA-1 VRI key per ETSI EN 319
+ * 142-2 / PDF 2.0 § 12.8.4.3.
+ */
+ private async placeDocumentTimestamp(options: {
+ timestampAuthority: TimestampAuthority;
+ digestAlgorithm: DigestAlgorithm;
+ estimatedSize: number;
+ fieldName?: string;
+ }): Promise {
+ const { timestampAuthority, digestAlgorithm, estimatedSize, fieldName } = options;
- // Get first page for widget
const firstPageRef = this.pdf.context.pages.getPage(0);
if (!firstPageRef) {
- throw new Error("Document has no pages");
+ throw new SignatureError("NO_PAGES", "Document has no pages - cannot create timestamp field");
}
- // Create document timestamp dictionary with placeholders
+ // Build the /Type /DocTimeStamp dictionary with placeholders.
const timestampDict = PdfDict.of({
Type: PdfName.of("DocTimeStamp"),
Filter: PdfName.of("Adobe.PPKLite"),
@@ -447,50 +852,51 @@ export class PDFSignature {
Contents: createContentsPlaceholderObject(estimatedSize),
});
- const timestampRef = registry.register(timestampDict);
-
- // Create signature field for timestamp
- const fieldName = `DocTimeStamp_${Date.now()}`;
- const fieldDict = PdfDict.of({
- Type: PdfName.of("Annot"),
- Subtype: PdfName.of("Widget"),
- FT: PdfName.of("Sig"),
- T: PdfString.fromString(fieldName),
- V: timestampRef,
- F: PdfNumber.of(132),
- P: firstPageRef,
- Rect: new PdfArray([PdfNumber.of(0), PdfNumber.of(0), PdfNumber.of(0), PdfNumber.of(0)]),
+ const timestampRef = this.pdf.context.registry.register(timestampDict);
+
+ // Create a /FT /Sig field for the timestamp and register it with the
+ // AcroForm so /SigFlags is set and the field is reachable from /Fields.
+ //
+ // Reusing a pre-allocated field (via fieldName) is the recommended
+ // pattern for multi-signer AdES / DocMDP flows where the author locks
+ // down the /AcroForm /Fields structure before the certification
+ // signature is applied. Unlike signing, we never auto-reuse the first
+ // empty signature field when no name is given - users typically reserve
+ // those for actual signers, not timestamps.
+ this.prepareSignatureField({
+ fieldName,
+ pageRef: firstPageRef,
+ valueRef: timestampRef,
+ namePrefix: "Timestamp_",
+ reuseFirstEmpty: false,
});
- registry.register(fieldDict);
-
- // Save to get bytes with placeholders
- const savedBytes = await this.pdf.save({ incremental: true });
+ // Save incrementally so the file contains the new dict with placeholders.
+ const pdfBytes = await this.pdf.save({ incremental: true });
- // Find placeholders and calculate ByteRange
- const placeholders = findPlaceholders(savedBytes);
- const byteRange = calculateByteRange(savedBytes, placeholders);
+ // Locate the placeholders, compute the ByteRange, and patch it in place.
+ const placeholders = findPlaceholders(pdfBytes);
+ const byteRange = calculateByteRange(pdfBytes, placeholders);
- // Patch ByteRange
- patchByteRange(savedBytes, placeholders, byteRange);
+ patchByteRange(pdfBytes, placeholders, byteRange);
- // Hash and get timestamp
- const signedBytes = extractSignedBytes(savedBytes, byteRange);
+ // Hash everything outside the /Contents placeholder and request a token.
+ const signedBytes = extractSignedBytes(pdfBytes, byteRange);
const documentHash = await hashData(signedBytes, digestAlgorithm);
const timestampToken = await timestampAuthority.timestamp(documentHash, digestAlgorithm);
- // Patch Contents
- patchContents(savedBytes, placeholders, timestampToken);
+ // Write the token into the /Contents placeholder.
+ patchContents(pdfBytes, placeholders, timestampToken);
- // Reload
- await this.pdf.reload(savedBytes);
+ // Reload so the PDF instance reflects the on-disk state.
+ await this.pdf.reload(pdfBytes);
- // Return padded timestamp bytes for correct VRI hash computation.
- // The VRI key is the SHA-1 hash of the FULL /Contents value as stored
- // in the PDF, including zero padding - not just the raw timestamp token.
- const contentsSize = placeholders.contentsLength / 2; // Hex chars -> bytes
+ // Return the padded /Contents bytes (raw token + trailing zeros) for VRI
+ // hash computation. The VRI key is SHA-1 over the full /Contents value
+ // as stored, including the zero padding - not just the raw token.
+ const contentsSize = placeholders.contentsLength / 2; // hex chars -> bytes
const paddedTimestampBytes = new Uint8Array(contentsSize);
- paddedTimestampBytes.set(timestampToken); // Remaining bytes are zeros
+ paddedTimestampBytes.set(timestampToken);
return paddedTimestampBytes;
}
@@ -499,17 +905,25 @@ export class PDFSignature {
* Gather LTV data for a timestamp token.
*
* Used for B-LTA to add validation data for the document timestamp.
+ *
+ * When a shared gatherer is provided (by `addArchivalData()`), it is
+ * reused so cached OCSP/CRL responses carry over. Timestamp tokens carry
+ * no embedded signature timestamps, so the shared gatherer's
+ * `gatherTimestampLtv: true` default has no effect here.
*/
private async gatherTimestampLtvData(
timestampToken: Uint8Array,
revocationProvider: RevocationProvider | undefined,
warnings: SignWarning[],
+ sharedGatherer?: LtvDataGatherer,
): Promise {
// Use LtvDataGatherer - timestamp tokens are just CMS structures
- const gatherer = new LtvDataGatherer({
- revocationProvider: revocationProvider ?? new DefaultRevocationProvider(),
- gatherTimestampLtv: false, // Don't recurse for doc timestamps
- });
+ const gatherer =
+ sharedGatherer ??
+ new LtvDataGatherer({
+ revocationProvider: revocationProvider ?? new DefaultRevocationProvider(),
+ gatherTimestampLtv: false, // Don't recurse for doc timestamps
+ });
try {
const ltvData = await gatherer.gather(timestampToken);
@@ -525,6 +939,7 @@ export class PDFSignature {
code: "DOC_TS_NO_CERTS",
message: "No certificates found in document timestamp",
});
+
return null;
}
@@ -534,6 +949,7 @@ export class PDFSignature {
code: "DOC_TS_LTV_FAILED",
message: `Could not gather LTV data for document timestamp: ${error instanceof Error ? error.message : String(error)}`,
});
+
return null;
}
}
@@ -560,7 +976,6 @@ export class PDFSignature {
}
// Validate timestamp requirements
-
if (
(options.level === "B-T" || options.level === "B-LT" || options.level === "B-LTA") &&
!options.timestampAuthority
diff --git a/src/api/pdf.ts b/src/api/pdf.ts
index e2cbbea..2de5886 100644
--- a/src/api/pdf.ts
+++ b/src/api/pdf.ts
@@ -55,7 +55,16 @@ import { generateEncryption, reconstructEncryptDict } from "#src/security/encryp
import { PermissionDeniedError } from "#src/security/errors";
import { DEFAULT_PERMISSIONS, type Permissions } from "#src/security/permissions";
import type { StandardSecurityHandler } from "#src/security/standard-handler.ts";
-import type { SignOptions, SignResult } from "#src/signatures/types";
+import type {
+ ArchivalDataOptions,
+ ArchivalDataResult,
+ SignOptions,
+ SignResult,
+ TimestampOptions,
+ TimestampResult,
+ ValidationDataOptions,
+ ValidationDataResult,
+} from "#src/signatures/types";
import type { FindTextOptions, PageText, TextMatch } from "#src/text/types";
import { writeComplete, writeIncremental } from "#src/writer/pdf-writer";
import { randomBytes } from "@noble/ciphers/utils.js";
@@ -2763,6 +2772,133 @@ export class PDF {
return signature.sign(options);
}
+ /**
+ * Add an archival document timestamp to the PDF.
+ *
+ * Creates a `/Type /DocTimeStamp` signature whose ByteRange covers the
+ * entire current document, sealing it with a trusted RFC 3161 timestamp.
+ *
+ * This is the timestamping step in a PAdES B-LTA flow: after one or more
+ * signatures have been appended (each as an incremental update), call
+ * `addTimestamp()` to lock the document state with a TSA-attested time.
+ * The timestamp extends the validity of all prior signatures because its
+ * ByteRange covers them.
+ *
+ * Does **not** gather validation data for pre-existing signatures - use
+ * {@link addValidationData} for that, or {@link addArchivalData} to do
+ * both in one call.
+ *
+ * After timestamping, the PDF instance is automatically reloaded with the
+ * updated bytes, so you can call `addTimestamp()` again or `save()` to get
+ * the final bytes. If this method throws after partial progress (e.g. the
+ * TSA request fails), the in-memory instance may be out of sync with its
+ * bytes - discard it and reload from the last known-good bytes.
+ *
+ * @param options Timestamping options including the TSA
+ * @returns The PDF bytes with the timestamp embedded, plus any warnings
+ *
+ * @throws {SignatureError} If `timestampAuthority` is missing, if the
+ * document cannot be saved incrementally (which would invalidate
+ * existing signatures), or if the document has no pages
+ * @throws {PlaceholderError} If the reserved size is too small for the token
+ *
+ * @example
+ * ```typescript
+ * import { HttpTimestampAuthority } from "@libpdf/core";
+ *
+ * // Sign first (B-T or B-LT), then seal with an archival timestamp.
+ * const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ * await pdf.sign({ signer, level: "B-LT", timestampAuthority: tsa });
+ * const { bytes } = await pdf.addTimestamp({
+ * timestampAuthority: tsa,
+ * longTermValidation: true,
+ * });
+ * ```
+ */
+ async addTimestamp(options: TimestampOptions): Promise {
+ const signature = new PDFSignature(this);
+
+ return signature.addTimestamp(options);
+ }
+
+ /**
+ * Gather LTV (Long-Term Validation) data for every signed signature
+ * field in the document and write it as a single DSS incremental
+ * update.
+ *
+ * Upgrades B-T signatures to B-LT in one shot. Use this after a
+ * multi-signer flow where each recipient signed at B-T level and you
+ * now want full long-term validation data embedded for all of them.
+ *
+ * Reuses one OCSP/CRL cache across signatures so issuers shared
+ * between signers don't get re-fetched. Existing DSS contents are
+ * merged with the new data (certs / OCSP / CRL are deduplicated by
+ * SHA-1).
+ *
+ * Does **not** add a timestamp - use {@link addTimestamp} for that, or
+ * {@link addArchivalData} to do both in one call.
+ *
+ * After this call the PDF instance is reloaded with the updated bytes,
+ * so subsequent operations (e.g. `addTimestamp()`) see the new DSS.
+ *
+ * @param options Optional revocation provider override
+ * @returns Bytes, warnings, and the number of signatures processed
+ *
+ * @throws {SignatureError} If the document cannot be saved incrementally
+ * (which would invalidate existing signatures)
+ *
+ * @example
+ * ```typescript
+ * const { signatureCount, warnings } = await pdf.addValidationData();
+ * console.log(`Embedded LTV for ${signatureCount} signatures`);
+ * ```
+ */
+ async addValidationData(options: ValidationDataOptions = {}): Promise {
+ const signature = new PDFSignature(this);
+
+ return signature.addValidationData(options);
+ }
+
+ /**
+ * Finalize the document with full PAdES B-LTA: gather LTV for every
+ * existing signature, embed a DSS, add an archival document timestamp,
+ * and embed a second DSS for the timestamp's own certificate chain.
+ *
+ * This is the convenience wrapper for the typical end-of-flow operation
+ * in a multi-signer advanced electronic signature (AdES) workflow.
+ * Equivalent to calling {@link addValidationData} followed by
+ * {@link addTimestamp} with `longTermValidation: true`. Note that unlike
+ * {@link addValidationData}, this adds a new signature object (the
+ * document timestamp field) to the PDF.
+ *
+ * If this method throws after partial progress (e.g. the TSA request
+ * fails after the DSS update was written), the in-memory instance may be
+ * out of sync with its bytes - discard it and reload from the last
+ * known-good bytes.
+ *
+ * @param options Archival options including the TSA
+ * @returns Bytes, warnings, and the number of pre-existing signatures
+ * for which LTV data was gathered
+ *
+ * @throws {SignatureError} If `timestampAuthority` is missing or the
+ * document cannot be saved incrementally (which would invalidate
+ * existing signatures)
+ *
+ * @example
+ * ```typescript
+ * import { HttpTimestampAuthority } from "@libpdf/core";
+ *
+ * // After every recipient has signed (B-T), seal the document.
+ * const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ * const { bytes } = await pdf.addArchivalData({ timestampAuthority: tsa });
+ * ```
+ */
+ async addArchivalData(options: ArchivalDataOptions): Promise {
+ const signature = new PDFSignature(this);
+
+ return signature.addArchivalData(options);
+ }
+
// ─────────────────────────────────────────────────────────────────────────────
// Layers (Optional Content Groups)
// ─────────────────────────────────────────────────────────────────────────────
@@ -3135,8 +3271,8 @@ export class PDF {
securityHandler = handler;
}
}
- // Note: action === "remove" means no encrypt dict (decrypted on load, written without encryption)
+ // Note: action === "remove" means no encrypt dict (decrypted on load, written without encryption)
// Ensure document has an /ID (required for signatures, recommended for all PDFs)
if (!fileId) {
const idArray = this.ctx.info.trailer.getArray("ID");
diff --git a/src/document/forms/acro-form.test.ts b/src/document/forms/acro-form.test.ts
index 541fe1a..d20ae6c 100644
--- a/src/document/forms/acro-form.test.ts
+++ b/src/document/forms/acro-form.test.ts
@@ -1,5 +1,10 @@
import { PDF } from "#src/api/pdf";
-import { loadFixture } from "#src/test-utils";
+import { PdfArray } from "#src/objects/pdf-array";
+import { PdfDict } from "#src/objects/pdf-dict";
+import { PdfName } from "#src/objects/pdf-name";
+import { PdfNumber } from "#src/objects/pdf-number";
+import type { PdfObject } from "#src/objects/pdf-object";
+import { loadFixture, toAsciiString } from "#src/test-utils";
import { describe, expect, it } from "vitest";
import { AcroForm } from "./acro-form";
@@ -1012,6 +1017,172 @@ describe("Form Writing", () => {
});
});
+ describe("flatten with tagged PDF (structure tree)", () => {
+ /**
+ * Build a structure tree that references every widget via /OBJR entries,
+ * plus one non-widget (Link) annotation, mirroring how tagged (accessible)
+ * forms reference their fields. Without struct tree cleanup, these /OBJR
+ * references keep the removed widgets reachable, so a full save writes
+ * all the orphaned field objects back into the output.
+ */
+ async function loadTaggedForm(): Promise<{ pdf: PDF; form: AcroForm; linkKey: number }> {
+ const bytes = await loadFixture("forms", "form_to_flatten.pdf");
+ const pdf = await PDF.load(bytes);
+ const registry = pdf.context.registry;
+ const resolve = registry.resolve.bind(registry);
+ const catalogDict = pdf.context.catalog.getDict();
+ const form = pdf.getForm()!.acroForm();
+
+ const docKids = new PdfArray([]);
+ const docElem = PdfDict.of({ S: PdfName.of("Document"), K: docKids });
+ const docElemRef = registry.register(docElem);
+
+ const nums: PdfObject[] = [];
+ let nextKey = 0;
+
+ for (const field of form.getFields()) {
+ for (const widget of field.getWidgets()) {
+ if (!widget.ref) {
+ continue;
+ }
+
+ const objr = PdfDict.of({ Type: PdfName.of("OBJR"), Obj: widget.ref });
+ const elem = PdfDict.of({
+ S: PdfName.of("Form"),
+ P: docElemRef,
+ K: PdfArray.of(registry.register(objr)),
+ });
+ const elemRef = registry.register(elem);
+
+ docKids.push(elemRef);
+ widget.dict.set("StructParent", new PdfNumber(nextKey));
+ nums.push(new PdfNumber(nextKey), elemRef);
+ nextKey++;
+ }
+ }
+
+ expect(nextKey).toBeGreaterThan(0);
+
+ // Add a non-widget (Link) annotation referenced from the struct tree.
+ // It must survive flattening untouched.
+ const pageRef = pdf.context.pages.getPage(0)!;
+ const pageDict = resolve(pageRef) as PdfDict;
+ const linkKey = nextKey;
+ const link = PdfDict.of({
+ Type: PdfName.of("Annot"),
+ Subtype: PdfName.of("Link"),
+ Rect: PdfArray.of(new PdfNumber(0), new PdfNumber(0), new PdfNumber(10), new PdfNumber(10)),
+ StructParent: new PdfNumber(linkKey),
+ });
+ const linkRef = registry.register(link);
+ const annots = pageDict.getArray("Annots", resolve);
+
+ if (annots) {
+ annots.push(linkRef);
+ } else {
+ pageDict.set("Annots", PdfArray.of(linkRef));
+ }
+
+ const linkObjr = PdfDict.of({ Type: PdfName.of("OBJR"), Obj: linkRef });
+ const linkElem = PdfDict.of({
+ S: PdfName.of("Link"),
+ P: docElemRef,
+ K: PdfArray.of(registry.register(linkObjr)),
+ });
+ const linkElemRef = registry.register(linkElem);
+
+ docKids.push(linkElemRef);
+ nums.push(new PdfNumber(linkKey), linkElemRef);
+ nextKey++;
+
+ const parentTree = PdfDict.of({ Nums: new PdfArray(nums) });
+ const structTreeRoot = PdfDict.of({
+ Type: PdfName.of("StructTreeRoot"),
+ K: docElemRef,
+ ParentTree: registry.register(parentTree),
+ ParentTreeNextKey: new PdfNumber(nextKey),
+ });
+
+ catalogDict.set("StructTreeRoot", registry.register(structTreeRoot));
+
+ return { pdf, form, linkKey };
+ }
+
+ it("does not leave orphaned field objects in the saved output", async () => {
+ const { pdf, form } = await loadTaggedForm();
+
+ form.flatten();
+
+ const saved = await pdf.save();
+ const text = toAsciiString(saved, saved.length);
+
+ // No field dicts should survive the full-save garbage collection
+ expect(text).not.toContain("/FT");
+ expect(text).not.toContain("/Widget");
+
+ // Only the Link annotation's OBJR should remain
+ expect(text.match(/\/OBJR/g) ?? []).toHaveLength(1);
+ });
+
+ it("removes stale ParentTree entries for flattened widgets", async () => {
+ const { pdf, form, linkKey } = await loadTaggedForm();
+
+ form.flatten();
+
+ const saved = await pdf.save();
+ const pdf2 = await PDF.load(saved);
+ const resolve2 = pdf2.context.registry.resolve.bind(pdf2.context.registry);
+ const catalog2 = pdf2.context.catalog.getDict();
+
+ const structTreeRoot = catalog2.getDict("StructTreeRoot", resolve2);
+ expect(structTreeRoot).toBeDefined();
+
+ const parentTree = structTreeRoot!.getDict("ParentTree", resolve2);
+ const numsAfter = parentTree!.getArray("Nums", resolve2)!;
+
+ // Only the Link annotation's entry should remain
+ const keys: number[] = [];
+
+ for (let i = 0; i + 1 < numsAfter.length; i += 2) {
+ const key = numsAfter.at(i, resolve2);
+
+ if (key instanceof PdfNumber) {
+ keys.push(key.value);
+ }
+ }
+
+ expect(keys).toEqual([linkKey]);
+ });
+
+ it("preserves struct tree references to non-widget annotations", async () => {
+ const { pdf, form } = await loadTaggedForm();
+
+ form.flatten();
+
+ const saved = await pdf.save();
+ const pdf2 = await PDF.load(saved);
+ const resolve2 = pdf2.context.registry.resolve.bind(pdf2.context.registry);
+
+ // The Link annotation should still be on the page
+ const pageRef = pdf2.context.pages.getPage(0)!;
+ const pageDict = resolve2(pageRef) as PdfDict;
+ const annots = pageDict.getArray("Annots", resolve2);
+ expect(annots).toBeDefined();
+
+ let linkFound = false;
+
+ for (let i = 0; i < annots!.length; i++) {
+ const annot = annots!.at(i, resolve2);
+
+ if (annot instanceof PdfDict && annot.getName("Subtype")?.value === "Link") {
+ linkFound = true;
+ }
+ }
+
+ expect(linkFound).toBe(true);
+ });
+ });
+
describe("font management", () => {
it("sets and gets default font", async () => {
const bytes = await loadFixture("forms", "sample_form.pdf");
diff --git a/src/document/forms/acro-form.ts b/src/document/forms/acro-form.ts
index 6665d5a..da0a57c 100644
--- a/src/document/forms/acro-form.ts
+++ b/src/document/forms/acro-form.ts
@@ -37,6 +37,7 @@ export class AcroForm implements AcroFormLike {
private readonly dict: PdfDict;
private readonly registry: ObjectRegistry;
private readonly pageTree: PDFPageTree | null;
+ private readonly catalog: PdfDict | null;
private fieldsCache: TerminalField[] | null = null;
@@ -49,10 +50,16 @@ export class AcroForm implements AcroFormLike {
/** Cache of existing fonts from /DR */
private existingFontsCache: Map | null = null;
- private constructor(dict: PdfDict, registry: ObjectRegistry, pageTree: PDFPageTree | null) {
+ private constructor(
+ dict: PdfDict,
+ registry: ObjectRegistry,
+ pageTree: PDFPageTree | null,
+ catalog: PdfDict | null,
+ ) {
this.dict = dict;
this.registry = registry;
this.pageTree = pageTree;
+ this.catalog = catalog;
}
/**
@@ -71,7 +78,7 @@ export class AcroForm implements AcroFormLike {
return null;
}
- return new AcroForm(dict, registry, pageTree ?? null);
+ return new AcroForm(dict, registry, pageTree ?? null, catalog);
}
/**
@@ -583,7 +590,6 @@ export class AcroForm implements AcroFormLike {
: partialName;
// Check if terminal or non-terminal
-
if (this.isTerminalField(dict)) {
const field = createFormField(dict, ref, this.registry, this, fullName);
@@ -641,7 +647,6 @@ export class AcroForm implements AcroFormLike {
// If first kid has /T, it's a child field → parent is non-terminal
// If first kid has no /T, it's a widget → parent is terminal
-
return !firstKidDict.has("T");
}
@@ -793,7 +798,7 @@ export class AcroForm implements AcroFormLike {
* @param options Flattening options
*/
flatten(options: FlattenOptions = {}): void {
- const flattener = new FormFlattener(this, this.registry, this.pageTree);
+ const flattener = new FormFlattener(this, this.registry, this.pageTree, this.catalog);
flattener.flatten(options);
diff --git a/src/document/forms/form-flattener.ts b/src/document/forms/form-flattener.ts
index 07ca472..d0301e6 100644
--- a/src/document/forms/form-flattener.ts
+++ b/src/document/forms/form-flattener.ts
@@ -31,6 +31,7 @@ import { PdfRef } from "#src/objects/pdf-ref";
import { PdfStream } from "#src/objects/pdf-stream";
import type { ObjectRegistry } from "../object-registry";
+import { removeAnnotationsFromStructTree } from "../struct-tree";
import { SignatureField, type TerminalField } from "./fields";
import type { FormFont } from "./form-font";
import type { WidgetAnnotation } from "./widget-annotation";
@@ -95,11 +96,18 @@ export class FormFlattener {
private readonly form: FlattenableForm;
private readonly registry: ObjectRegistry;
private readonly pageTree: PageTreeAccess | null;
-
- constructor(form: FlattenableForm, registry: ObjectRegistry, pageTree: PageTreeAccess | null) {
+ private readonly catalog: PdfDict | null;
+
+ constructor(
+ form: FlattenableForm,
+ registry: ObjectRegistry,
+ pageTree: PageTreeAccess | null,
+ catalog: PdfDict | null = null,
+ ) {
this.form = form;
this.registry = registry;
this.pageTree = pageTree;
+ this.catalog = catalog;
}
/**
@@ -151,6 +159,24 @@ export class FormFlattener {
this.flattenWidgetsOnPage(pageRef, widgets);
}
+ // Remove structure tree references (/OBJR kids and /ParentTree entries)
+ // to the flattened widgets. In tagged PDFs the structure tree references
+ // widget annotations, which would otherwise keep the removed fields
+ // reachable and cause full saves to write them back as orphan objects.
+ if (this.catalog) {
+ const removedWidgetRefs = new Set();
+
+ for (const field of fieldsToFlatten) {
+ for (const widget of field.getWidgets()) {
+ if (widget.ref) {
+ removedWidgetRefs.add(widget.ref);
+ }
+ }
+ }
+
+ removeAnnotationsFromStructTree(this.catalog, this.registry, removedWidgetRefs);
+ }
+
// Update form structure
const dict = this.form.getDict();
diff --git a/src/document/struct-tree.ts b/src/document/struct-tree.ts
new file mode 100644
index 0000000..f21ba08
--- /dev/null
+++ b/src/document/struct-tree.ts
@@ -0,0 +1,245 @@
+/**
+ * Structure tree maintenance for annotation removal.
+ *
+ * Tagged (accessible) PDFs reference annotations from the logical structure
+ * tree via /OBJR (object reference) entries, and map annotations back to
+ * their structure elements through the /ParentTree number tree (keyed by the
+ * annotation's /StructParent).
+ *
+ * When annotations are removed (e.g. widgets during form flattening), these
+ * references must be cleaned up too. Otherwise:
+ * - The /OBJR entries keep the removed annotations reachable, so full-save
+ * garbage collection writes the orphaned objects back into the output.
+ * - The /ParentTree retains stale keys pointing at structure elements whose
+ * annotations no longer exist.
+ *
+ * PDF Reference: Section 14.7 "Logical Structure"
+ */
+
+import { PdfArray } from "#src/objects/pdf-array";
+import { PdfDict } from "#src/objects/pdf-dict";
+import { PdfNumber } from "#src/objects/pdf-number";
+import type { PdfObject } from "#src/objects/pdf-object";
+import { PdfRef } from "#src/objects/pdf-ref";
+
+import type { ObjectRegistry } from "./object-registry";
+
+/**
+ * Remove all structure tree references to the given annotations.
+ *
+ * Walks the structure tree from the catalog's /StructTreeRoot and:
+ * - Removes /OBJR kids whose /Obj points to a removed annotation
+ * - Removes /ParentTree entries keyed by the removed annotations'
+ * /StructParent values
+ *
+ * No-op if the document has no structure tree.
+ *
+ * @param catalog The document catalog dictionary
+ * @param registry The object registry for resolving references
+ * @param removedAnnotations Refs of annotations being removed. PdfRefs are
+ * interned, so identity comparison via Set membership is safe.
+ */
+export function removeAnnotationsFromStructTree(
+ catalog: PdfDict,
+ registry: ObjectRegistry,
+ removedAnnotations: ReadonlySet,
+): void {
+ if (removedAnnotations.size === 0) {
+ return;
+ }
+
+ const resolve = registry.resolve.bind(registry);
+ const structTreeRoot = catalog.getDict("StructTreeRoot", resolve);
+
+ if (!structTreeRoot) {
+ return;
+ }
+
+ // Collect the /StructParent keys of the removed annotations before any
+ // teardown, so we can prune the matching ParentTree entries.
+ const removedKeys = new Set();
+
+ for (const ref of removedAnnotations) {
+ const annot = resolve(ref);
+
+ if (annot instanceof PdfDict) {
+ const structParent = annot.getNumber("StructParent")?.value;
+
+ if (structParent !== undefined) {
+ removedKeys.add(structParent);
+ }
+ }
+ }
+
+ pruneObjrKids(structTreeRoot, registry, removedAnnotations);
+
+ if (removedKeys.size > 0) {
+ const parentTree = structTreeRoot.getDict("ParentTree", resolve);
+
+ if (parentTree) {
+ pruneNumberTree(parentTree, registry, removedKeys, new Set());
+ }
+ }
+}
+
+/**
+ * Check if a dict is an /OBJR entry pointing at one of the removed annotations.
+ */
+function isRemovedObjr(dict: PdfDict, removed: ReadonlySet): boolean {
+ if (dict.getName("Type")?.value !== "OBJR") {
+ return false;
+ }
+
+ const obj = dict.get("Obj");
+
+ return obj instanceof PdfRef && removed.has(obj);
+}
+
+/**
+ * Check if a dict is a structure element (as opposed to an /MCR or /OBJR kid).
+ * Structure elements may omit /Type, in which case /StructElem is assumed.
+ */
+function isStructElement(dict: PdfDict): boolean {
+ const type = dict.getName("Type")?.value;
+
+ return type === undefined || type === "StructElem";
+}
+
+/**
+ * Walk the structure tree and remove /OBJR kids referencing removed annotations.
+ *
+ * The /K entry of a structure element can be: a number (MCID), a dict
+ * (struct element, MCR, or OBJR), a ref to either, or an array of any of
+ * these. All forms are handled.
+ */
+function pruneObjrKids(
+ root: PdfDict,
+ registry: ObjectRegistry,
+ removed: ReadonlySet,
+): void {
+ const resolve = registry.resolve.bind(registry);
+ const visited = new Set();
+ const stack: PdfDict[] = [root];
+
+ while (stack.length > 0) {
+ const elem = stack.pop()!;
+
+ if (visited.has(elem)) {
+ continue;
+ }
+
+ visited.add(elem);
+
+ const k = elem.get("K");
+ const kResolved = k instanceof PdfRef ? resolve(k) : k;
+
+ if (kResolved instanceof PdfArray) {
+ // Filter in place (iterate backwards so removal doesn't shift indices)
+ for (let i = kResolved.length - 1; i >= 0; i--) {
+ const item = kResolved.at(i);
+ const itemResolved = item instanceof PdfRef ? resolve(item) : item;
+
+ if (!(itemResolved instanceof PdfDict)) {
+ continue; // MCID numbers etc.
+ }
+
+ if (isRemovedObjr(itemResolved, removed)) {
+ kResolved.remove(i);
+ } else if (isStructElement(itemResolved)) {
+ stack.push(itemResolved);
+ }
+ }
+
+ if (kResolved.length === 0) {
+ elem.delete("K");
+ }
+ } else if (kResolved instanceof PdfDict) {
+ if (isRemovedObjr(kResolved, removed)) {
+ elem.delete("K");
+ } else if (isStructElement(kResolved)) {
+ stack.push(kResolved);
+ }
+ }
+ }
+}
+
+/**
+ * Remove entries with the given keys from a number tree (the /ParentTree).
+ *
+ * Handles both flat trees (/Nums on the root) and trees with intermediate
+ * /Kids nodes. Recomputes /Limits on modified leaf nodes.
+ */
+function pruneNumberTree(
+ node: PdfDict,
+ registry: ObjectRegistry,
+ keys: ReadonlySet,
+ visited: Set,
+): void {
+ if (visited.has(node)) {
+ return;
+ }
+
+ visited.add(node);
+
+ const resolve = registry.resolve.bind(registry);
+ const kids = node.getArray("Kids", resolve);
+
+ if (kids) {
+ for (let i = 0; i < kids.length; i++) {
+ const kid = kids.at(i, resolve);
+
+ if (kid instanceof PdfDict) {
+ pruneNumberTree(kid, registry, keys, visited);
+ }
+ }
+ }
+
+ const nums = node.getArray("Nums", resolve);
+
+ if (!nums) {
+ return;
+ }
+
+ // Nums is a flat [key value key value ...] array
+ const remaining: PdfObject[] = [];
+ const remainingKeys: number[] = [];
+ let changed = false;
+
+ for (let i = 0; i + 1 < nums.length; i += 2) {
+ const keyObj = nums.at(i, resolve);
+ const key = keyObj instanceof PdfNumber ? keyObj.value : undefined;
+
+ if (key !== undefined && keys.has(key)) {
+ changed = true;
+ continue;
+ }
+
+ remaining.push(nums.at(i)!, nums.at(i + 1)!);
+
+ if (key !== undefined) {
+ remainingKeys.push(key);
+ }
+ }
+
+ if (!changed) {
+ return;
+ }
+
+ node.set("Nums", new PdfArray(remaining));
+
+ // Keep /Limits consistent with the remaining keys (required on all nodes
+ // except the root; harmless to update wherever it exists).
+ if (node.has("Limits")) {
+ if (remainingKeys.length > 0) {
+ node.set(
+ "Limits",
+ PdfArray.of(
+ new PdfNumber(Math.min(...remainingKeys)),
+ new PdfNumber(Math.max(...remainingKeys)),
+ ),
+ );
+ } else {
+ node.delete("Limits");
+ }
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 497d5ca..742e4c7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -104,6 +104,8 @@ export { PermissionDeniedError, SecurityError } from "./security/errors";
// ─────────────────────────────────────────────────────────────────────────────
export type {
+ ArchivalDataOptions,
+ ArchivalDataResult,
DigestAlgorithm,
HttpTimestampAuthorityOptions,
KeyType,
@@ -116,6 +118,10 @@ export type {
SignWarning,
SubFilter,
TimestampAuthority,
+ TimestampOptions,
+ TimestampResult,
+ ValidationDataOptions,
+ ValidationDataResult,
} from "./signatures";
export {
CertificateChainError,
diff --git a/src/integration/signatures/lta-finalization.test.ts b/src/integration/signatures/lta-finalization.test.ts
new file mode 100644
index 0000000..ca2cc28
--- /dev/null
+++ b/src/integration/signatures/lta-finalization.test.ts
@@ -0,0 +1,312 @@
+/**
+ * Integration tests for `pdf.addValidationData()` and `pdf.addArchivalData()`
+ * — the end-of-flow PAdES B-LTA finalization operations used after a
+ * multi-signer advanced electronic signature (AdES) workflow.
+ */
+
+import { PDF } from "#src/api/pdf";
+import { PdfRef } from "#src/objects/pdf-ref";
+import { PdfStream } from "#src/objects/pdf-stream";
+import { computeSha1Hex } from "#src/signatures/ltv/vri";
+import { HttpTimestampAuthority } from "#src/signatures/timestamp";
+import { loadFixture, saveTestOutput } from "#src/test-utils";
+import { describe, expect, it } from "vitest";
+
+import { loadTestSigner, TEST_TSA_URL } from "./test-helpers";
+
+describe("LTA finalization integration", () => {
+ const tsa = new HttpTimestampAuthority(TEST_TSA_URL);
+
+ describe("addValidationData()", () => {
+ it("is a no-op on an unsigned PDF", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes, warnings, signatureCount } = await pdf.addValidationData();
+
+ expect(signatureCount).toBe(0);
+ expect(warnings).toHaveLength(0);
+
+ // No signatures → no DSS in the resulting bytes.
+ const pdfStr = new TextDecoder().decode(bytes);
+ expect(pdfStr).not.toContain("/Type /DSS");
+ });
+
+ it("upgrades a B-T signature to B-LT", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ // Sign at B-T (no LTV embedded yet).
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ });
+
+ // Pre-condition: no DSS yet.
+ const beforeStr = new TextDecoder().decode(await pdf.save());
+ expect(beforeStr).not.toContain("/Type /DSS");
+
+ const { bytes, warnings, signatureCount } = await pdf.addValidationData();
+
+ expect(signatureCount).toBe(1);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+ expect(pdfStr).toContain("/Type /DSS");
+ expect(pdfStr).toContain("/Certs");
+ expect(pdfStr).toContain("/VRI");
+
+ // Should be safe even if revocation lookups produce CHAIN_INCOMPLETE
+ // warnings — we only assert that no LTV_GATHER_FAILED occurred.
+ const fatal = warnings.filter(w => w.code === "LTV_GATHER_FAILED");
+ expect(fatal).toHaveLength(0);
+
+ await saveTestOutput("signatures/validation-data-upgraded.pdf", bytes);
+ });
+
+ it("gathers LTV for multiple signatures with one DSS write", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ // Two B-T signatures.
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer1",
+ });
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer2",
+ });
+
+ const { bytes, signatureCount } = await pdf.addValidationData();
+
+ expect(signatureCount).toBe(2);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // One DSS, two VRI entries (one per signature).
+ expect(pdfStr).toContain("/Type /DSS");
+
+ // The DSS update should be a single incremental revision — count
+ // xref sections: original + 2 signatures + 1 DSS = 4.
+ const xrefCount = (pdfStr.match(/^xref$/gm) ?? []).length;
+ expect(xrefCount).toBe(4);
+
+ await saveTestOutput("signatures/validation-data-multi-signer.pdf", bytes);
+ });
+
+ it("merges with pre-existing DSS data without duplicating certs", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ // First sign at B-LT (writes DSS for signer 1).
+ await pdf.sign({
+ signer,
+ level: "B-LT",
+ timestampAuthority: tsa,
+ fieldName: "Signer1",
+ });
+
+ // Then add another B-T sig (no DSS).
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer2",
+ });
+
+ const { bytes, signatureCount } = await pdf.addValidationData();
+
+ expect(signatureCount).toBe(2);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // Both signature fields are still present.
+ expect(pdfStr).toContain("/T (Signer1)");
+ expect(pdfStr).toContain("/T (Signer2)");
+
+ // Walk catalog → DSS → VRI in the merged result: the pre-existing
+ // VRI entry (from the B-LT sign) must survive the merge and the new
+ // signature must have its own entry. Both signatures use the same
+ // TSA, whose token gets its own VRI entry, so expect at least 3.
+ const reloaded = await PDF.load(bytes);
+ const resolve = reloaded.context.registry.resolve.bind(reloaded.context.registry);
+ const dss = reloaded.getCatalog().getDict("DSS", resolve);
+
+ expect(dss).toBeDefined();
+
+ const vri = dss?.getDict("VRI", resolve);
+
+ expect(vri).toBeDefined();
+ expect([...(vri?.keys() ?? [])].length).toBeGreaterThanOrEqual(3);
+
+ // Certs must be deduplicated: both signatures share the same signer
+ // chain, so no two /Certs streams may contain identical bytes.
+ const certs = dss?.getArray("Certs", resolve);
+
+ expect(certs).toBeDefined();
+ expect(certs && certs.length).toBeGreaterThan(0);
+
+ const certHashes = new Set();
+
+ for (const item of certs ?? []) {
+ const stream = item instanceof PdfRef ? resolve(item) : item;
+
+ expect(stream).toBeInstanceOf(PdfStream);
+
+ if (stream instanceof PdfStream) {
+ const hash = await computeSha1Hex(stream.getDecodedData());
+
+ expect(certHashes.has(hash)).toBe(false);
+ certHashes.add(hash);
+ }
+ }
+ });
+ });
+
+ describe("addArchivalData()", () => {
+ it("performs full B-LTA on a single B-T signature", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ });
+
+ const { bytes, warnings, signatureCount } = await pdf.addArchivalData({
+ timestampAuthority: tsa,
+ });
+
+ expect(signatureCount).toBe(1);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // Original signature + DSS + DocTimeStamp + DSS for timestamp.
+ expect(pdfStr).toContain("/Type /Sig");
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/SubFilter /ETSI.RFC3161");
+ expect(pdfStr).toContain("/Type /DSS");
+
+ // No fatal warnings.
+ const fatal = warnings.filter(w => w.code === "LTV_GATHER_FAILED");
+ expect(fatal).toHaveLength(0);
+
+ await saveTestOutput("signatures/archival-single-signer.pdf", bytes);
+ });
+
+ it("performs full B-LTA on a multi-signer document", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ // Three B-T signatures — typical multi-recipient AdES flow.
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer1",
+ });
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer2",
+ });
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer3",
+ });
+
+ const { bytes, warnings, signatureCount } = await pdf.addArchivalData({
+ timestampAuthority: tsa,
+ });
+
+ // The three pre-existing signatures get LTV gathered.
+ expect(signatureCount).toBe(3);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // All three signatures present.
+ expect(pdfStr).toContain("/T (Signer1)");
+ expect(pdfStr).toContain("/T (Signer2)");
+ expect(pdfStr).toContain("/T (Signer3)");
+
+ // Archival timestamp + DSS present.
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/Type /DSS");
+
+ // No fatal warnings.
+ const fatal = warnings.filter(w => w.code === "LTV_GATHER_FAILED");
+ expect(fatal).toHaveLength(0);
+
+ await saveTestOutput("signatures/archival-multi-signer.pdf", bytes);
+ });
+
+ it("uses a pre-allocated field for the archival timestamp", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ // Reserve archival timestamp field up front, then sign normally.
+ pdf.getOrCreateForm().createSignatureField("ArchivalTS");
+ await pdf.reload(await pdf.save());
+
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ fieldName: "Signer1",
+ });
+
+ const { bytes } = await pdf.addArchivalData({
+ timestampAuthority: tsa,
+ fieldName: "ArchivalTS",
+ });
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // The reserved field is filled.
+ expect(pdfStr).toContain("/T (ArchivalTS)");
+ // Auto-generated timestamp name should not have been used.
+ expect(pdfStr).not.toContain("/T (Timestamp_1)");
+ });
+
+ it("throws when timestampAuthority is omitted", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ await expect(
+ // oxlint-disable-next-line typescript/no-explicit-any
+ pdf.addArchivalData({} as any),
+ ).rejects.toThrow(/timestampAuthority/);
+ });
+
+ it("works on an unsigned PDF (just adds a timestamp + its DSS)", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes, signatureCount } = await pdf.addArchivalData({
+ timestampAuthority: tsa,
+ });
+
+ // No pre-existing sigs, but the timestamp itself is still embedded.
+ expect(signatureCount).toBe(0);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/Type /DSS");
+ });
+ });
+});
diff --git a/src/integration/signatures/signing.test.ts b/src/integration/signatures/signing.test.ts
index 8f13225..21bca19 100644
--- a/src/integration/signatures/signing.test.ts
+++ b/src/integration/signatures/signing.test.ts
@@ -11,32 +11,9 @@ import { HttpTimestampAuthority } from "#src/signatures/timestamp";
import { loadFixture, saveTestOutput } from "#src/test-utils";
import { describe, expect, it } from "vitest";
-/** Test P12 files with different encryption formats */
-const P12_FILES = {
- /** AES-256-CBC (modern default) */
- aes256: "test-signer-aes256.p12",
- /** AES-128-CBC */
- aes128: "test-signer-aes128.p12",
- /** Triple DES (legacy but common) */
- tripleDes: "test-signer-3des.p12",
- /** RC2-40 (very old legacy format) */
- legacy: "test-signer-rc2-40.p12",
- /** ECDSA P-256 */
- ecdsaP256: "test-signer-ec-p256-aes256.p12",
- /** ECDSA P-384 */
- ecdsaP384: "test-signer-ec-p384-aes256.p12",
-};
+import { loadTestSigner, P12_FILES, TEST_TSA_URL } from "./test-helpers";
describe("signing integration", () => {
- /**
- * Load the test P12 certificate (default AES-256).
- */
- async function loadTestSigner(filename = P12_FILES.aes256) {
- const p12Bytes = await loadFixture("certificates", filename);
-
- return P12Signer.create(p12Bytes, "test123");
- }
-
describe("B-B signing (basic)", () => {
it("signs a simple PDF document", async () => {
const pdfBytes = await loadFixture("basic", "rot0.pdf");
@@ -124,7 +101,7 @@ describe("signing integration", () => {
describe("B-T signing (with timestamp)", () => {
// FreeTSA is a free public timestamp authority
- const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ const tsa = new HttpTimestampAuthority(TEST_TSA_URL);
it("signs with timestamp (B-T level)", async () => {
const pdfBytes = await loadFixture("basic", "rot0.pdf");
@@ -168,7 +145,7 @@ describe("signing integration", () => {
describe("B-LT signing (long-term validation)", () => {
// FreeTSA is a free public timestamp authority
- const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ const tsa = new HttpTimestampAuthority(TEST_TSA_URL);
it("signs with timestamp and LTV data (B-LT level)", async () => {
const pdfBytes = await loadFixture("basic", "rot0.pdf");
diff --git a/src/integration/signatures/test-helpers.ts b/src/integration/signatures/test-helpers.ts
new file mode 100644
index 0000000..f30af6f
--- /dev/null
+++ b/src/integration/signatures/test-helpers.ts
@@ -0,0 +1,34 @@
+/**
+ * Shared helpers for signature integration tests.
+ */
+
+import { P12Signer } from "#src/signatures/signers";
+import { loadFixture } from "#src/test-utils";
+
+/** Public RFC 3161 timestamp authority used by integration tests. */
+export const TEST_TSA_URL = "https://freetsa.org/tsr";
+
+/** Test P12 files with different encryption formats */
+export const P12_FILES = {
+ /** AES-256-CBC (modern default) */
+ aes256: "test-signer-aes256.p12",
+ /** AES-128-CBC */
+ aes128: "test-signer-aes128.p12",
+ /** Triple DES (legacy but common) */
+ tripleDes: "test-signer-3des.p12",
+ /** RC2-40 (very old legacy format) */
+ legacy: "test-signer-rc2-40.p12",
+ /** ECDSA P-256 */
+ ecdsaP256: "test-signer-ec-p256-aes256.p12",
+ /** ECDSA P-384 */
+ ecdsaP384: "test-signer-ec-p384-aes256.p12",
+};
+
+/**
+ * Load a test P12 signer (default AES-256).
+ */
+export async function loadTestSigner(filename: string = P12_FILES.aes256): Promise {
+ const p12Bytes = await loadFixture("certificates", filename);
+
+ return P12Signer.create(p12Bytes, "test123");
+}
diff --git a/src/integration/signatures/timestamping.test.ts b/src/integration/signatures/timestamping.test.ts
new file mode 100644
index 0000000..9cb0b36
--- /dev/null
+++ b/src/integration/signatures/timestamping.test.ts
@@ -0,0 +1,302 @@
+/**
+ * Integration tests for `pdf.addTimestamp()` — archival document timestamps.
+ *
+ * These tests use FreeTSA (a public RFC 3161 timestamp authority) to exercise
+ * the PAdES B-LTA flow where document timestamps are appended after one or
+ * more signatures.
+ */
+
+import { PDF } from "#src/api/pdf";
+import { HttpTimestampAuthority } from "#src/signatures/timestamp";
+import { loadFixture, saveTestOutput } from "#src/test-utils";
+import { describe, expect, it } from "vitest";
+
+import { loadTestSigner, TEST_TSA_URL } from "./test-helpers";
+
+describe("timestamping integration", () => {
+ const tsa = new HttpTimestampAuthority(TEST_TSA_URL);
+
+ describe("standalone document timestamp", () => {
+ it("adds a document timestamp to an unsigned PDF", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes, warnings } = await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ });
+
+ // Should produce a valid, larger PDF.
+ expect(bytes.length).toBeGreaterThan(pdfBytes.length);
+ expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-");
+ expect(warnings).toHaveLength(0);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // Should contain a document timestamp dictionary with the right shape.
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/Filter /Adobe.PPKLite");
+ expect(pdfStr).toContain("/SubFilter /ETSI.RFC3161");
+
+ // Default field name uses the Timestamp_ prefix.
+ expect(pdfStr).toContain("/T (Timestamp_1)");
+
+ // AcroForm /SigFlags must be set so viewers recognize the timestamp.
+ expect(pdfStr).toMatch(/\/SigFlags\s+3/);
+
+ // Incremental update markers must be present.
+ expect(pdfStr).toContain("/Prev");
+ expect(pdfStr.trim()).toMatch(/%%EOF\s*$/);
+
+ await saveTestOutput("signatures/timestamped-unsigned.pdf", bytes);
+ });
+
+ it("appends an archival timestamp after a B-T signature", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+ const signer = await loadTestSigner();
+
+ // First sign with B-T (timestamped signature).
+ await pdf.sign({
+ signer,
+ level: "B-T",
+ timestampAuthority: tsa,
+ reason: "Approval",
+ });
+
+ // Then seal with an archival document timestamp.
+ const { bytes, warnings } = await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ });
+
+ expect(warnings).toHaveLength(0);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // Both the signature and the document timestamp must be present.
+ expect(pdfStr).toContain("/Type /Sig");
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/SubFilter /ETSI.CAdES.detached");
+ expect(pdfStr).toContain("/SubFilter /ETSI.RFC3161");
+
+ // Two incremental updates means at least three xref sections
+ // (original + signature + timestamp).
+ const xrefCount = (pdfStr.match(/^xref$/gm) ?? []).length;
+ expect(xrefCount).toBeGreaterThanOrEqual(3);
+
+ await saveTestOutput("signatures/signed-then-timestamped.pdf", bytes);
+ });
+
+ it("uses a custom field name when provided", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes } = await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ fieldName: "ArchivalTS",
+ });
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ expect(pdfStr).toContain("/T (ArchivalTS)");
+ expect(pdfStr).not.toContain("/T (Timestamp_1)");
+ });
+
+ it("produces a non-empty /Contents value covering the document", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes } = await pdf.addTimestamp({ timestampAuthority: tsa });
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // The /Contents must contain a real timestamp token, not just zeros.
+ const contentsMatches = [...pdfStr.matchAll(/\/Contents\s*<([0-9A-Fa-f]+)>/g)];
+ expect(contentsMatches.length).toBeGreaterThan(0);
+
+ const lastContentsHex = contentsMatches.at(-1)?.[1] ?? "";
+ expect(lastContentsHex).not.toMatch(/^0+$/);
+ expect(lastContentsHex.length).toBeGreaterThan(1000);
+
+ // ByteRange should cover the whole file outside the /Contents window.
+ const byteRangeMatch = pdfStr.match(/\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/);
+ expect(byteRangeMatch).not.toBeNull();
+
+ const [, offset1, length1, offset2, length2] = byteRangeMatch?.map(Number) ?? [];
+
+ expect(offset1).toBe(0);
+ expect(offset2).toBeGreaterThan(length1);
+ expect(offset2 + length2).toBe(bytes.length);
+ });
+ });
+
+ describe("pre-allocated timestamp field (DocMDP / AES flows)", () => {
+ it("fills a pre-allocated empty signature field by name", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ // Reserve the timestamp field up-front, before any signing happens.
+ // In a real DocMDP flow this would be done during document
+ // preparation so the /AcroForm /Fields array is locked in before
+ // the certification signature is applied.
+ const form = pdf.getOrCreateForm();
+ form.createSignatureField("ArchivalTimestamp");
+ const prepared = await pdf.save();
+ const preparedPdf = await PDF.load(prepared);
+
+ const { bytes, warnings } = await preparedPdf.addTimestamp({
+ timestampAuthority: tsa,
+ fieldName: "ArchivalTimestamp",
+ });
+
+ expect(warnings).toHaveLength(0);
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // The reserved field is used; no new Timestamp_N field is created.
+ expect(pdfStr).toContain("/T (ArchivalTimestamp)");
+ expect(pdfStr).not.toContain("/T (Timestamp_1)");
+
+ // And it is a real document timestamp, not a regular signature.
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/SubFilter /ETSI.RFC3161");
+
+ await saveTestOutput("signatures/timestamped-prealloc.pdf", bytes);
+ });
+
+ it("rejects reusing a field that is not a signature field", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const form = pdf.getOrCreateForm();
+ form.createTextField("NotASig");
+ const prepared = await pdf.save();
+ const preparedPdf = await PDF.load(prepared);
+
+ await expect(
+ preparedPdf.addTimestamp({
+ timestampAuthority: tsa,
+ fieldName: "NotASig",
+ }),
+ ).rejects.toThrow(/not a signature field/);
+ });
+ });
+
+ describe("multiple appended timestamps", () => {
+ it("can stack multiple archival timestamps on the same instance", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ await pdf.addTimestamp({ timestampAuthority: tsa });
+ const { bytes } = await pdf.addTimestamp({ timestampAuthority: tsa });
+
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ // Each timestamp gets its own field; default names are generated.
+ expect(pdfStr).toContain("/T (Timestamp_1)");
+ expect(pdfStr).toContain("/T (Timestamp_2)");
+
+ // Two timestamps → at least three xref sections.
+ const xrefCount = (pdfStr.match(/^xref$/gm) ?? []).length;
+ expect(xrefCount).toBeGreaterThanOrEqual(3);
+
+ await saveTestOutput("signatures/timestamped-twice.pdf", bytes);
+ });
+ });
+
+ describe("long-term validation", () => {
+ it("embeds DSS data for the timestamp's certificate chain", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes, warnings } = await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ longTermValidation: true,
+ });
+
+ // Network-flakiness may produce warnings; structure must still be right.
+ const pdfStr = new TextDecoder().decode(bytes);
+
+ expect(pdfStr).toContain("/Type /DocTimeStamp");
+ expect(pdfStr).toContain("/Type /DSS");
+ expect(pdfStr).toContain("/Certs");
+
+ // VRI entry must be present for the timestamp's /Contents value.
+ expect(pdfStr).toContain("/VRI");
+
+ if (warnings.length > 0) {
+ console.log("timestamp LTV warnings:", warnings);
+ }
+
+ await saveTestOutput("signatures/timestamped-ltv.pdf", bytes);
+ });
+ });
+
+ describe("loaded after timestamping", () => {
+ it("the timestamped PDF can be re-parsed", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes } = await pdf.addTimestamp({ timestampAuthority: tsa });
+
+ const reloaded = await PDF.load(bytes);
+ expect(reloaded.getPageCount()).toBe(pdf.getPageCount());
+ });
+
+ it("preserves the original bytes at the head of the file", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const { bytes } = await pdf.addTimestamp({ timestampAuthority: tsa });
+
+ const originalPrefix = bytes.slice(0, 100);
+ const expectedPrefix = pdfBytes.slice(0, 100);
+ expect(originalPrefix).toEqual(expectedPrefix);
+ });
+ });
+
+ describe("error handling", () => {
+ it("throws when timestampAuthority is omitted", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ await expect(
+ // oxlint-disable-next-line typescript/no-explicit-any
+ pdf.addTimestamp({} as any),
+ ).rejects.toThrow(/timestampAuthority/);
+ });
+
+ it("throws when the requested field name is already signed", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ await pdf.addTimestamp({
+ timestampAuthority: tsa,
+ fieldName: "MyTimestamp",
+ });
+
+ // The first call already filled MyTimestamp; a second call against
+ // the same name must refuse to overwrite an already-signed field.
+ await expect(
+ pdf.addTimestamp({
+ timestampAuthority: tsa,
+ fieldName: "MyTimestamp",
+ }),
+ ).rejects.toThrow(/already signed/);
+ });
+
+ it("propagates errors from the timestamp authority", async () => {
+ const pdfBytes = await loadFixture("basic", "rot0.pdf");
+ const pdf = await PDF.load(pdfBytes);
+
+ const failingTsa = {
+ async timestamp(): Promise {
+ throw new Error("TSA unavailable");
+ },
+ };
+
+ await expect(pdf.addTimestamp({ timestampAuthority: failingTsa })).rejects.toThrow(
+ /TSA unavailable/,
+ );
+ });
+ });
+});
diff --git a/src/signatures/index.ts b/src/signatures/index.ts
index e694e64..c14a275 100644
--- a/src/signatures/index.ts
+++ b/src/signatures/index.ts
@@ -42,6 +42,8 @@ export { CryptoKeySigner, GoogleKmsSigner, P12Signer, type P12SignerOptions } fr
export { HttpTimestampAuthority, type HttpTimestampAuthorityOptions } from "./timestamp";
// Types
export type {
+ ArchivalDataOptions,
+ ArchivalDataResult,
DigestAlgorithm,
KeyType,
LtvValidationData,
@@ -54,6 +56,10 @@ export type {
SignWarning,
SubFilter,
TimestampAuthority,
+ TimestampOptions,
+ TimestampResult,
+ ValidationDataOptions,
+ ValidationDataResult,
} from "./types";
// Errors
export {
diff --git a/src/signatures/ltv/dss-builder.ts b/src/signatures/ltv/dss-builder.ts
index 7946da3..c82940c 100644
--- a/src/signatures/ltv/dss-builder.ts
+++ b/src/signatures/ltv/dss-builder.ts
@@ -9,7 +9,7 @@
*/
import type { ObjectRegistry } from "#src/document/object-registry.ts";
-import { formatPdfDate } from "#src/helpers/format.ts";
+import { formatPdfDate, parsePdfDate } from "#src/helpers/format.ts";
import { PdfArray } from "#src/objects/pdf-array.ts";
import { PdfDict } from "#src/objects/pdf-dict.ts";
import { PdfName } from "#src/objects/pdf-name.ts";
@@ -131,13 +131,12 @@ export class DSSBuilder {
const ocspHashes = await builder.extractRefHashes(entry, "OCSP", builder.ocspMap);
const crlHashes = await builder.extractRefHashes(entry, "CRL", builder.crlMap);
- // Get timestamp if present
+ // Preserve the original VRI creation time if present
let timestamp: Date | undefined;
const tuVal = entry.getString("TU", resolve);
if (tuVal) {
- // Parse PDF date format - simplified
- timestamp = new Date();
+ timestamp = parsePdfDate(tuVal.asString());
}
// VRI keys are PdfName, need to get the value string
diff --git a/src/signatures/ltv/gatherer.ts b/src/signatures/ltv/gatherer.ts
index 1211eb7..67cf413 100644
--- a/src/signatures/ltv/gatherer.ts
+++ b/src/signatures/ltv/gatherer.ts
@@ -260,7 +260,6 @@ export class LtvDataGatherer {
// ASN.1 DER: first byte is tag, second+ bytes are length
// If length < 128, it's a single byte
// If length >= 128, high bit is set and low bits indicate how many length bytes follow
-
const lengthByte = bytes[1];
if (lengthByte < 128) {
diff --git a/src/signatures/types.ts b/src/signatures/types.ts
index 9f6393a..232f771 100644
--- a/src/signatures/types.ts
+++ b/src/signatures/types.ts
@@ -245,6 +245,174 @@ export interface SignOptions {
estimatedSize?: number;
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Timestamp Options
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Options for adding an archival document timestamp to a PDF.
+ *
+ * A document timestamp creates a `/Type /DocTimeStamp` signature dictionary
+ * whose ByteRange covers the entire current document, extending the validity
+ * of any existing signatures. This is the timestamping step used at the end
+ * of a PAdES B-LTA flow when signatures have been appended and the caller
+ * wants to seal the document with a trusted time.
+ *
+ * @example
+ * ```typescript
+ * const { bytes, warnings } = await pdf.addTimestamp({
+ * timestampAuthority: new HttpTimestampAuthority("https://freetsa.org/tsr"),
+ * longTermValidation: true,
+ * });
+ * ```
+ */
+export interface TimestampOptions {
+ /** RFC 3161 timestamp authority used to obtain the timestamp token. */
+ timestampAuthority: TimestampAuthority;
+
+ /**
+ * Signature field name to use for the document timestamp.
+ *
+ * Behavior:
+ * - If omitted, a unique name with the `Timestamp_` prefix is generated
+ * and a fresh field is created.
+ * - If provided and the name matches an existing **unsigned** signature
+ * field, that pre-allocated field is reused (the timestamp's ref is
+ * written into its `/V`). This is the recommended pattern for
+ * multi-signer advanced electronic signature (AdES) / DocMDP flows
+ * where the document author reserves signature fields up front before
+ * the certification signature is applied.
+ * - If the name matches a signed signature field, or a non-signature
+ * field, an error is thrown.
+ * - If the name doesn't match any existing field, a new field is created.
+ */
+ fieldName?: string;
+
+ /**
+ * Embed long-term validation data (certificates, OCSP responses, CRLs)
+ * for the timestamp's certificate chain in the DSS.
+ *
+ * Enable this for PAdES B-LTA semantics where the timestamp itself
+ * needs to remain verifiable after its TSA certificate expires.
+ *
+ * @default false
+ */
+ longTermValidation?: boolean;
+
+ /** Provider for OCSP/CRL data when `longTermValidation` is true. */
+ revocationProvider?: RevocationProvider;
+
+ /**
+ * Digest algorithm used to hash the document for the TSA request.
+ *
+ * @default "SHA-256"
+ */
+ digestAlgorithm?: DigestAlgorithm;
+
+ /**
+ * Size to reserve for the timestamp placeholder in bytes.
+ *
+ * Must be large enough to hold the TSA's timestamp token (the CMS
+ * structure plus the full TSA certificate chain).
+ *
+ * @default 12288 (12KB)
+ */
+ estimatedSize?: number;
+}
+
+/**
+ * Result of `addTimestamp()`.
+ */
+export interface TimestampResult {
+ /** The PDF bytes with the document timestamp embedded. */
+ bytes: Uint8Array;
+
+ /** Warnings emitted during the timestamping operation. */
+ warnings: SignWarning[];
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Validation Data Options (DSS-only update)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Options for `pdf.addValidationData()`.
+ *
+ * Walks every signed signature field (regular signatures and document
+ * timestamps), gathers certificates / OCSP responses / CRLs for each one,
+ * and writes a single DSS incremental update that contains all of it.
+ *
+ * Upgrades B-T signatures in the document to B-LT. Does not add a
+ * timestamp — for that, use `pdf.addTimestamp()` or `pdf.addArchivalData()`.
+ */
+export interface ValidationDataOptions {
+ /**
+ * Provider for OCSP/CRL data.
+ *
+ * @default new DefaultRevocationProvider()
+ */
+ revocationProvider?: RevocationProvider;
+}
+
+/**
+ * Result of `addValidationData()`.
+ */
+export interface ValidationDataResult {
+ /** The PDF bytes with the DSS update embedded. */
+ bytes: Uint8Array;
+
+ /** Warnings emitted during gathering (e.g. CHAIN_INCOMPLETE, REVOCATION_UNAVAILABLE). */
+ warnings: SignWarning[];
+
+ /** Number of signed fields for which LTV data was gathered. */
+ signatureCount: number;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Archival Data Options (full B-LTA finalization)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Options for `pdf.addArchivalData()`.
+ *
+ * Convenience wrapper that performs full PAdES B-LTA finalization in a
+ * single call:
+ *
+ * 1. Gathers LTV data for every existing signed field (DSS update)
+ * 2. Adds an archival `/DocTimeStamp`
+ * 3. Gathers LTV data for the new timestamp's own certificate chain
+ *
+ * Use this at the end of a multi-signer flow once every recipient has
+ * signed and you want to seal the document with a trusted time + full
+ * long-term validation data.
+ *
+ * The options match {@link TimestampOptions} except that long-term
+ * validation is always enabled for the archival timestamp.
+ *
+ * @example
+ * ```typescript
+ * const tsa = new HttpTimestampAuthority("https://freetsa.org/tsr");
+ * const { bytes, warnings, signatureCount } = await pdf.addArchivalData({
+ * timestampAuthority: tsa,
+ * });
+ * ```
+ */
+export type ArchivalDataOptions = Omit;
+
+/**
+ * Result of `addArchivalData()`.
+ */
+export interface ArchivalDataResult {
+ /** The PDF bytes after the DSS update + archival timestamp. */
+ bytes: Uint8Array;
+
+ /** Warnings emitted during the operation. */
+ warnings: SignWarning[];
+
+ /** Number of pre-existing signed fields for which LTV data was gathered. */
+ signatureCount: number;
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Sign Result
// ─────────────────────────────────────────────────────────────────────────────