@blamejs/core 0.14.18 → 0.14.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.20 (2026-06-02) — **OAuth Rich Authorization Requests and client attestation, a sealed-field unseal rate cap, DMARC forensic-report parsing, monitor-mode browser-isolation headers, and FedCM / Storage-Access fetch-metadata.** This release extends several standards surfaces the framework already covered in part. The OAuth client gains RFC 9396 Rich Authorization Requests: a typed `authorizationDetails` array is validated before the request and the granted details in the token response are cross-checked, refusing a grant the OP broadened beyond what was asked. The client also gains the OAuth 2.0 Attestation-Based Client Authentication primitives — it can build the `OAuth-Client-Attestation` / `-PoP` JWT pair and verify an inbound attestation. Sealed-field reads gain an opt-in unseal-failure rate cap that throttles a decryption-oracle / brute-force burst against attacker-written sealed columns (CWE-307). The inbound mail stack gains a DMARC forensic (RUF) report parser, the inverse of the aggregate-report parser. On the browser side, the security-headers middleware adds report-only COOP / COEP / Document-Policy variants for safe cross-origin-isolation rollout plus the embedder Require-Document-Policy and Service-Worker-Allowed headers, and the fetch-metadata middleware recognizes the FedCM `webidentity` destination and the Storage-Access request headers first-class. Observability adds a batch of current OpenTelemetry semantic-convention attributes. Every addition is additive or opt-in: an operator who sets no new option, and configures no rate cap, sees prior behavior unchanged. **Added:** *OAuth client: RFC 9396 Rich Authorization Requests (`authorizationDetails`)* — The OAuth / OIDC client accepts an `authorizationDetails` array (RFC 9396 §2 — each element a typed object) on the authorization and pushed-authorization-request paths; it is validated at config time (every element must be an object carrying a string `type`) and serialized as the `authorization_details` parameter, with a cap on the serialized payload. When `verifyAuthorizationDetails` is set, the granted `authorization_details` returned in the token response (RFC 9396 §7) is cross-checked against the request, and a grant that exceeds what was requested is refused — defending against an upstream broadened-grant privilege escalation. Without `authorizationDetails`, the request is unchanged. · *OAuth 2.0 Attestation-Based Client Authentication* — `b.auth.oauth.buildClientAttestation` and `buildClientAttestationPop` mint the `OAuth-Client-Attestation` JWT (a client-backend-signed assertion binding a per-instance key, `typ: oauth-client-attestation+jwt`) and its per-instance `OAuth-Client-Attestation-PoP` proof; `verifyClientAttestation` validates the pair, including the alg allowlist (`ATTESTATION_ALGS`), the audience, and a constant-time `jti` check. This is the wallet / per-device client-authentication shape used in OpenID4VCI and the EUDI Wallet, an alternative to a shared `client_secret`. · *`b.cryptoField.configureUnsealRateCap` — sealed-field decryption-oracle throttle* — An opt-in per-(actor, table, column) sliding-window cap on sealed-column unseal failures. A DB-write attacker who can place crafted `vault:` / `vault.aad:` bytes in sealed columns can force KEM decapsulation / AEAD verification on attacker-controlled input on every read; past `threshold` failures within `windowMs`, further unseal attempts for that tuple are refused for `cooldownMs` with a typed `CryptoFieldRateError` and a distinct `system.crypto.unseal_rate_exceeded` audit event (CWE-307; OWASP ASVS v5 §2.2.1; NIST SP 800-63B §5.2.2). Default off — with no cap configured, `unsealRow` behaves exactly as before (null the field, emit `system.crypto.unseal_failed`); the cap is count-based and lazily pruned, with no background timer. · *`b.mail.dmarc.parseForensicReport` — RFC 6591 forensic (RUF) report parser* — Parses a DMARC failure (forensic) report: a `multipart/report; report-type=feedback-report` message (RFC 6591 §3) whose `message/feedback-report` subpart carries the `Feedback-Type: auth-failure` fields, with the required-field set validated (`Auth-Failure` and the other §3.1 fields) and a part-count and byte cap bounding a hostile report. It is the inbound inverse of the aggregate-report path, so an operator ingesting RUF mail has a parser symmetric with the existing aggregate parser and the v0.14.19 aggregate builder. · *Monitor-mode cross-origin-isolation and embedder headers* — `b.middleware.securityHeaders` gains `coopReportOnly`, `coepReportOnly`, and `documentPolicyReportOnly` — set a policy string to emit the matching `*-Report-Only` header so a UA evaluates and reports violations without enforcing, the safe way to stage a COOP / COEP / Document-Policy rollout (WHATWG HTML cross-origin isolation; W3C Document Policy). `requireDocumentPolicy` emits the embedder `Require-Document-Policy` a parent demands of subframes, and `serviceWorkerAllowed` emits `Service-Worker-Allowed` to broaden a service worker's registration scope (W3C Service Workers). All default off. · *fetch-metadata: FedCM `webidentity` and Storage-Access headers* — `b.middleware.fetchMetadata` recognizes the `webidentity` `Sec-Fetch-Dest` (a FedCM credentialed request) first-class and adds `deniedDest` — a list of destinations refused outright on the gated methods regardless of site, so a `webidentity` request hitting a route that is not an identity endpoint is refused. `allowStorageAccess` (default true) governs whether a request carrying `Sec-Fetch-Storage-Access: active` / `inactive` (the Storage Access API headers) is allowed, and `strictDest` throws at config time on an `allowedDest` / `deniedDest` value outside the known `Sec-Fetch-Dest` vocabulary, catching a typo at boot. · *OpenTelemetry semantic-convention attributes* — The observability semantic-convention map gains a batch of current stable attributes: `peer.service`, the `faas.*` function attributes (`name` / `version` / `instance` / `trigger`), `deployment.environment.name`, `telemetry.distro.*`, `otel.scope.*`, and a Kubernetes subset (`k8s.cluster.name` and the node / container / cronjob / daemonset / job / replicaset / statefulset names), so a span or metric carrying these keys is recognized and emitted under the canonical name. **Fixed:** *`b.auth.sdJwtVc.holder` signed the key-binding JWT with a non-key-derived algorithm* — The holder helper defaulted the key-binding JWT (KB-JWT) signing algorithm to a fixed `ES256` regardless of the holder key type, so a non-EC-P-256 holder key produced a presentation that either could not be built (an Ed25519 or P-384 key) or whose KB-JWT header advertised `ES256` while the signature used the actual key — a self-invalid token a verifier rejects. The algorithm is now derived from the holder key: ES256 / ES384 by EC curve, EdDSA for Ed25519 / Ed448, and ML-DSA-87 / ML-DSA-65 for an ML-DSA key. An RSA holder key, which has no supported KB-JWT algorithm, is refused at `holder.create` with a clear error instead of producing a broken presentation. An EC P-256 holder key, and any explicit `algorithm`, behave exactly as before. **Security:** *Throttle a sealed-column decryption oracle (opt-in)* — `b.cryptoField.configureUnsealRateCap` lets an operator bound repeated unseal failures against sealed columns, so an attacker who can write crafted bytes into a sealed field cannot hammer the KEM / AEAD verify path indefinitely while only an off-band alert rule notices the burst (CWE-307). It is opt-in because a sensible threshold and window depend on a deployment's legitimate sealed-read volume; the always-on defense (null the field plus an audit event on every unseal failure) is unchanged. · *Refuse a broadened OAuth grant* — With `verifyAuthorizationDetails` enabled, the OAuth client refuses a token response whose granted `authorization_details` exceed the requested set (RFC 9396 §7), so a compromised or misbehaving authorization server cannot silently widen a client's authorization beyond what the request asked for. **Migration:** *No action required; additions are additive or opt-in* — The OAuth `authorizationDetails` request parameter, the granted-details cross-check, the client-attestation builders / verifier, the sealed-field unseal rate cap, the DMARC forensic-report parser, the monitor-mode and embedder browser headers, the fetch-metadata FedCM / Storage-Access options, and the new OpenTelemetry attribute keys are all additive or opt-in. A client that passes no `authorizationDetails`, an operator who calls no `configureUnsealRateCap`, and a security-headers / fetch-metadata configuration that sets none of the new options all see prior behavior byte-for-byte unchanged. The one behavior change is the SD-JWT VC holder fix: a `b.auth.sdJwtVc.holder` built with an RSA holder key is now refused at `holder.create` (RSA has no supported KB-JWT algorithm; it previously produced a self-invalid presentation) — switch such a holder to an EC P-256 / P-384, Ed25519, or ML-DSA key. EC P-256 holders and any explicit `algorithm` are unaffected.
12
+
13
+ - v0.14.19 (2026-06-02) — **ZIP64 write completes large-archive support, plus a PKCE-downgrade defense, SCIM Bulk, OID4VCI kid-resolution, SPF macros, DMARC report building, and OpenAPI 3.2.** This release fills out several standards the framework previously read but could not write, or advertised but did not fully implement. The ZIP writer now emits ZIP64, so archives larger than 65535 entries or 4 GiB are produced instead of refused (the reader gained ZIP64 in the previous release; the two now round-trip). The OAuth client refuses an OP whose discovery metadata advertises PKCE methods without S256 — closing a stripped-S256 downgrade. The SCIM server gains opt-in Bulk operations, the OID4VCI issuer accepts a kid-resolution hook for EUDI-Wallet attested-key proofs (and a latent cache-cleanup crash on the issuance path is fixed), the inbound SPF check expands RFC 7208 macros and evaluates the exists mechanism, a DMARC aggregate-report builder is the inverse of the existing parser, and the OpenAPI emitter supports 3.2 with webhooks and a JSON Schema dialect. Every behavior-changing addition is opt-in or additive: classic archives emit byte-for-byte unchanged, OpenAPI still defaults to 3.1.0, SCIM Bulk stays disabled unless configured, and the PKCE refusal only fires for an OP that explicitly advertises a non-S256 method set. **Added:** *`b.archive.zip` writes ZIP64* — When an archive exceeds 65535 entries, or any entry's compressed/uncompressed size or local-header offset exceeds 4 GiB, the writer now emits ZIP64 — the classic field carries the `0xFFFF`/`0xFFFFFFFF` sentinel and a ZIP64 extended-information extra field (header id `0x0001`) plus a ZIP64 EOCD record and locator are written (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.4.8 / §4.5.3). Archives within the classic limits emit byte-for-byte unchanged, and `b.archive.read.zip` reads the ZIP64 output transparently. Previously the writer refused past 65535 entries. · *`b.middleware.scimServer` opt-in SCIM 2.0 Bulk operations* — A `/Bulk` POST endpoint (RFC 7644 §3.7) with `maxOperations` / `maxPayloadSize` caps, `bulkId` cross-reference resolution (§3.7.2), and `failOnErrors` short-circuiting, advertised in `ServiceProviderConfig` when enabled via `opts.bulk`. The request body is read through the bounded stream reader. Bulk stays disabled (and `/Bulk` returns 501) unless `opts.bulk` is set. · *`b.auth.oid4vci` accepts a `resolveKid` proof-key hook* — An OID4VCI proof JWT that carries a header `kid` without an inline `jwk` (the EUDI-Wallet attested-key shape) can now be verified by supplying `create({ resolveKid(kid, header) })`, which maps the key id to a holder key. The resolved key passes the same alg/key-type cross-check (CVE-2026-22817) as the inline path before import and becomes the credential's `cnf` binding. Without a resolver, a kid-only proof is still refused, exactly as before. · *SPF macro expansion + `exists`; DMARC aggregate-report builder* — `b.mail.spf.verify` now expands RFC 7208 §7 macros (`%{i}`/`%{s}`/`%{l}`/`%{d}`/`%{o}`/`%{h}`/`%{v}` with the reverse / digit-count / delimiter transformers and `%%`/`%_`/`%-` escapes) and evaluates the `exists` mechanism (§5.7); the §4.6.4 DNS-lookup and void-lookup ceilings still bound every macro-driven query. New `b.mail.dmarc.buildAggregateReport` serializes a DMARC aggregate (RUA) report to RFC 7489 Appendix C XML — the inverse of `parseAggregateReport` — with optional gzip and XML-metacharacter escaping of observed identifiers. · *OpenAPI 3.2 — webhooks and `jsonSchemaDialect`* — `b.openapi.create({ openapi: "3.2.0" })` opts into OpenAPI 3.2; a `webhook(name, method, opts)` builder registers top-level `webhooks` (API-initiated out-of-band operations), and `jsonSchemaDialect` declares the document's default JSON Schema dialect. `parse()` accepts 3.1.x or 3.2.x and validates webhook operations (including dangling-security references) with the same rules as paths. 3.1.0 stays the default emitted version and existing 3.1 documents are unchanged. **Fixed:** *OID4VCI issuance crashed on cache cleanup* — The pre-authorized-code exchange and credential-issuance paths called `.delete()` on their internal `b.cache` stores, which expose `del()` — so the cleanup step threw and the issuance flow could not complete end-to-end. The three call sites now use `del()`. **Security:** *PKCE-downgrade defense for OAuth / OIDC clients* — The OAuth client already mandates PKCE-S256 on its own side but never inspected the OP's published `code_challenge_methods_supported`. It now refuses, at `authorizationUrl` / `pushAuthorizationRequest`, an OP whose discovery metadata advertises that field without `"S256"` (plain-only / empty) — blocking a stripped-S256 downgrade that would otherwise drive the client into an authorization request the OP claims it cannot verify (RFC 9700 §4.13, RFC 7636). An OP that does not publish the field, and static-endpoint clients that perform no discovery, are unaffected. **Migration:** *No action required; additions are additive or opt-in* — ZIP64 write is additive — archives within the classic 65535-entry / 4 GiB limits emit identical bytes, and the writer simply no longer refuses larger ones. SCIM Bulk, the OID4VCI `resolveKid` hook, the DMARC report builder, and OpenAPI 3.2 are all opt-in (OpenAPI still defaults to 3.1.0). The SPF `exists` mechanism, previously returning permerror, now evaluates per RFC 7208 — `b.mail.spf.verify` returns a verdict and refuses nothing. The PKCE-downgrade refusal only fires for an OP that explicitly advertises a non-S256 PKCE method set.
14
+
11
15
  - v0.14.18 (2026-06-01) — **Advertised endpoints that silently failed now work, plus opt-in response-shaping, transport hardening, and ZIP64 read.** This release closes a set of advertised-but-broken surfaces and adds the extensibility hooks operators kept having to work around. Three endpoints silently failed: the CSP-report endpoint returned 413 to every POST (it never parsed a report), the SCIM server broke on any streamed request body, and both came from misusing the bounded-buffer collector as if it consumed a stream — so this release adds the missing b.safeBuffer.collectStream(stream, opts) primitive and routes both through it. The agent orchestrator's graceful-drain phase never registered (wrong method name), the OpenAPI/AsyncAPI doc endpoints ignored a documented single-origin CORS allowlist, and the EU AI Act Article 50 HTML banner skipped Buffer response bodies. Deny-path refusals gained an RFC 9457 problem-type derived from the problem code (a 429 read about:blank before) and now treat a consumer hook that commits headers as terminal. On top of the fixes, several middlewares gain operator hooks (custom JSON/HTML error formatters, problem+json, refusal callbacks), several entry points gain escape-hatch opts (a configurable Authorization scheme, legacy filename charsets, a submission relay port, an exit-after-phases shutdown), the local job queue can point at an operator-supplied database/table/schema, breach-notification deadlines gain a running escalation clock, the archive reader now reads ZIP64, and external databases gain an opt-in TLS-required posture plus OpenTelemetry db.* trace attributes. Every behavior-changing addition is strictly opt-in — an operator who sets no new option sees no change. **Added:** *`b.safeBuffer.collectStream(stream, opts)` — bounded stream-to-Buffer reader* — Reads a Node Readable (a request body, an upstream response) fully into one Buffer with the byte cap enforced at every chunk — the streaming sibling of `boundedChunkCollector`, which is a push-collector, not a stream consumer. Resolves the concatenated Buffer on end; rejects (and destroys the stream) the moment a chunk would overflow `maxBytes`. · *Operator response-shaping hooks* — `b.errorPage` gains `jsonFormatter(info, req)` and `renderHtml(info, req)` overrides plus a `problemDetails: true` RFC 9457 mode (each falls back to the built-in envelope on throw, so the original error is never masked); `b.render.json` gains an `opts.replacer` passthrough (BigInt/Date); `b.middleware.cspReport` gains `onReject(req, res, { status, reason })` for the otherwise-empty-bodied 405/413/400 refusals; `b.static` gains `onError` firing on every refusal path, mirroring `onServe`. · *Escape-hatch opts for non-default deployments* — `b.appShutdown.create({ exitAfterPhases })` lets a manual `shutdown()` exit after its phases (not only a signal-driven shutdown); `b.middleware.attachUser({ bearerScheme, tokenExtractor })` reads `Token`/`DPoP`/gateway Authorization schemes (RFC 6750 / RFC 9449); `b.middleware.bodyParser` multipart `filenameCharsets` opts iso-8859-1 `filename*` decoding in (RFC 5987); `b.mail.send.deliver.create({ port })` routes through a 587/465 submission relay (RFC 6409 / RFC 8314). · *Local job queue: bring-your-own database, table, and schema* — `b.queue.init` local-backend config accepts `db` (an operator-supplied handle), `table`, and `schema`. Table and schema names are validated and quoted as SQL identifiers (refused at init time when unsafe); the sealed `payload`/`lastError` columns stay sealed regardless of the physical table. Defaults are unchanged (`_blamejs_jobs` on the framework/cluster database). · *`b.breach.deadline.createClock` — running breach-notification escalation clock* — A detection-to-notification clock that escalates each affected US state's statutory breach-notification deadline (an approaching warning, a passed alert) and accepts per-state filing acknowledgements, complementing the existing static deadline lookup. It composes the incident-report deadline clock, so there is a single timer to start and stop. · *`b.archive.read.zip` reads ZIP64* — Archives with more than 65535 entries or with sizes/offsets past 4 GiB now decode transparently (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.5.3). Previously such archives were refused outright. The bomb, Zip-Slip, PATH_MAX, and entry-type defenses all continue to apply to the resolved 64-bit values. · *External-database opt-in TLS posture and OpenTelemetry `db.*` traces* — `b.externalDb` backends accept `requireTls: true` (default off), which refuses a backend at config time unless its declared transport is TLS (`tls` / a truthy `ssl` / `sslmode` `require|verify-ca|verify-full`) — for cardholder data or ePHI (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e)). Query/transaction/read traces now also carry OpenTelemetry database semantic-convention attributes (`db.system`, `db.operation`, `db.statement` (sanitized), `db.name`). · *Opt-in schema-drift detection* — `b.db.init({ onDrift })` (and the underlying `reconcile({ onDrift })`) opts into config-vs-live column-drift detection: `"warn"` reports columns present in the live database but absent from the declared schema; `"refuse"` makes the framework refuse to boot on drift (a strict-schema posture — ISO 27001:2022 A.8.9 / SOC 2 CC8.1). The default (`"ignore"`) is unchanged, and drift is never resolved destructively. **Changed:** *`b.guardEmail` documents its actual Unicode scope* — Domain-side IDN/Punycode and mixed-script confusable detection are supported (and unchanged); the local-part is ASCII atext only (RFC 5321/5322). The documentation previously implied RFC 6531 SMTPUTF8/EAI mailbox names were accepted — they are rejected by default, re-openable behind a future `allowUnicodeLocalPart` opt-in, to keep homograph/confusable exposure bounded to the domain side. No validation behavior changed. · *Body-parse 4xx responses send `Connection: close`* — Every body-parse rejection (malformed JSON, poisoned key, oversized payload, bad content-length) now closes the connection, matching the chunked-decode path, to deny an upstream proxy reusing a socket whose request stream the parser abandoned mid-body (RFC 9112 §9.6). · *Archive-reader default entry-count cap raised to 1,048,576* — To read large (ZIP64) archives, the default `bombPolicy.maxEntries` rises from 65535 to 2^20. An operator who set `maxEntries` explicitly is unaffected; the cap can still be lowered. **Fixed:** *`b.middleware.cspReport` returned 413 to every POST; `b.middleware.scimServer` broke on streamed bodies* — Both read the request body by calling `b.safeBuffer.boundedChunkCollector(req, …)` — but that primitive takes a single options object and returns a push-collector, it does not consume a stream. The call passed the request as the options argument (so `maxBytes` was undefined and it threw), turning every CSP-report POST into a 413 and failing every streamed SCIM request. Both now read the body through the new `b.safeBuffer.collectStream`. A valid CSP report now reaches the parse/audit/`onReport` path and returns 204. · *Agent orchestrator graceful-drain phase never registered* — `b.middleware.agentOrchestrator` registered its drain phase against a method the shutdown handle does not expose, so the phase silently never ran — connections were not drained on a graceful shutdown. It now registers through the real `addPhase` so the documented drain actually fires. · *OpenAPI / AsyncAPI doc endpoints ignored `accessControl: { allowOrigin }`* — The documented single-origin CORS form was read by neither serve middleware (only `accessControl: "public"` was handled), so an operator passing `{ allowOrigin: "https://docs.example.com" }` got no `Access-Control-Allow-Origin` header. The object form now emits a validated origin (passed through `b.safeUrl`, rejecting CR/LF injection, userinfo, and non-http(s) schemes) with `Vary: Origin`. · *EU AI Act Article 50 HTML banner skipped Buffer response bodies* — In HTML mode the disclosure banner was injected only when the final response chunk was a string; a `res.end(Buffer.from(html))` body (the common path through templating/render) silently received no banner. Buffer bodies are now decoded under the response charset, injected, and re-encoded (with a one-time warning and untouched bytes for charsets without a transcoder). · *Deny-path 429 (and any refusal) emitted `type: about:blank`* — The shared deny-path writer documented deriving the RFC 9457 problem-type URI from the refusal's problem code, but only ever read an explicit problem type — so a rate-limit 429 (which supplies a code, not a type) reported `about:blank`. The type is now derived from the problem code (`<base>/<code>`) when no explicit type is given, so a 429 reads `…/rate-limit-exceeded`. · *A deny-path `onDeny` hook that commits headers is treated as terminal* — The deny-path writer's terminal check looked only at `res.writableEnded`; a response-wrapping consumer whose `onDeny` sent headers without ending the response fell through into a second `writeHead` and hit "headers already sent". The writer now also treats `res.headersSent` as terminal, so wrapping responders compose cleanly. · *`b.mail.send.deliver` DANE TLSA lookup ignored the configured port* — The DANE TLSA query name (`_<port>._tcp.<host>`) was hardcoded to port 25; a delivery configured for a 587/465 relay still queried port 25. The lookup now uses the configured port. **Security:** *Closed a request-smuggling socket-reuse window on body-parse rejections* — Pairing every body-parse 4xx with `Connection: close` stops an upstream proxy from reusing a connection whose request body the parser abandoned mid-stream — a request/response desync vector that previously applied only to the chunked-decode rejections. · *Opt-in TLS-required posture for regulated external databases* — Set `requireTls: true` on an external-database backend carrying cardholder data or ePHI so a non-TLS (or fallback-permitting `sslmode`) connection is refused at boot rather than silently transmitting in the clear (PCI-DSS v4.0 Req 4 / HIPAA §164.312(e)). **Detectors:** *Regression guards for every fixed bug class* — The internal pattern gate gains detectors so each fixed mistake cannot return: a bounded-collector call used as a stream consumer, a deny-path writer that does not guard `headersSent`, an `appShutdown` phase registered through a non-existent method, a body-parse error writer missing `Connection: close`, a raw `_blamejs_jobs` table reference bypassing the configured-table quoting, and a breach clock that re-rolls its own timer instead of composing the incident-report clock. **Migration:** *No action required; new behavior is opt-in* — Every addition is additive or opt-in: `requireTls`, schema-drift `onDrift`, the response-shaping hooks, the new entry-point opts, and the breach clock all default to prior behavior when unset. Two defaults shift in a backward-compatible direction: body-parse 4xx now send `Connection: close`, and the archive reader's default entry-count ceiling rises to 2^20 (lower it via `bombPolicy.maxEntries` if you relied on the old cap). `b.db.reconcile` now returns a drift report object instead of undefined; the framework's own call ignores the return, so existing callers are unaffected.
12
16
 
13
17
  - v0.14.17 (2026-05-31) — **In-session API-encrypt errors stay confidential, and the encrypted client can read them.** When b.middleware.apiEncrypt is active, a normal response is sealed in the { _ct } envelope, but a terminal error that bypassed res.json (an error page, a validation refusal, an RFC 9457 problem+json document, a deny-middleware body) shipped in plaintext on the otherwise-encrypted channel, and the b.httpClient.encrypted client threw on the response shape because it tried to decrypt every reply. This release makes errors symmetric: those four sinks now seal their body in the same envelope a success uses whenever a session is active (via a new req.apiEncryptEncode the middleware installs after a successful decrypt), and the client gains a responseMode: "passthrough" that reads a non-2xx — decrypting an in-session error and returning a plaintext one verbatim — instead of throwing. Errors raised before a session exists (a Bearer 401, a handshake rejection, a replay refusal) deliberately stay plaintext and human-readable. Two adjacent streaming fixes ride along: a streamed (responseMode "stream") non-2xx now keeps a bounded prefix of the error body on the thrown error instead of draining it, and an SSE channel closed by a transport fault audits as a failure with a reason rather than looking like a clean operator close. **Added:** *`b.httpClient.encrypted` gains `responseMode: "passthrough"`* — The encrypted client defaults to `responseMode: "reject"` (a non-2xx rejects, exactly as before). Set `responseMode: "passthrough"` at create time, or per request, to resolve a non-2xx instead: the result is `{ statusCode, headers, body, ok }`, where `body` is the decrypted object when the reply carries an encrypted `_ct` envelope (an in-session error) and the parsed plaintext otherwise (a pre-session error such as a Bearer 401 or a proxy 502). The additive `ok` boolean (status 200–299) is present on every result. This lets a client read an error's status and detail instead of failing on the response shape. · *`req.apiEncryptEncode` — the in-session error encoder* — `b.middleware.apiEncrypt` installs `req.apiEncryptEncode(obj)` after it successfully decrypts a request body. It returns the same `{ _ct }` (plus `_sid` / `_ctr` in per-session mode) envelope a normal response uses, so a terminal handler that writes its body directly (bypassing the wrapped `res.json`) can keep an error confidential on the encrypted channel. It is present only after a valid decrypt, so every pre-session path has no encoder and its errors stay plaintext. **Changed:** *In-session terminal errors are sealed in the encrypted envelope* — The error page (JSON branch), the router's schema-validation refusal, `b.problemDetails.respond` (now accepting an optional `req`), and the access-deny middleware now seal their error body via `req.apiEncryptEncode` when a session is active — so an in-session error no longer ships as plaintext while the surrounding traffic is encrypted. A sealed problem+json is labelled `application/json` (the envelope), not `application/problem+json`. With no active session (or if encoding fails) the body stays plaintext and readable, unchanged. · *Streamed non-2xx responses preserve a bounded error-body prefix* — `b.httpClient.request({ responseMode: "stream" })` (and `b.httpClient.downloadStream`, which composes it) previously drained and discarded the body of a `>= 400` response. The rejection is unchanged, but the thrown error now carries a bounded (16 KiB) prefix of the body on `err.body`, so a caller can read the problem+json / error detail. The prefix is collected through `b.safeBuffer.boundedChunkCollector`, so a hostile oversized error body can't accumulate unbounded. · *SSE transport-fault close audits as a failure* — An SSE channel closed by a transport fault (a stream `error`, a failed heartbeat write) now emits its `sse.channel_closed` audit event with `outcome: "failure"` and a `reason`, instead of the `"success"` outcome an intentional `channel.close()` records — so an operator's evidence stream can tell a dropped connection from a clean shutdown. **Security:** *Error bodies no longer leak in plaintext on an encrypted channel* — Before this release, a request that established an apiEncrypt session but then failed (validation error, access denial, server error rendered through the error page or problem-details) returned its error body in plaintext, even though every successful response on the same channel was encrypted — exposing error detail (paths, field names, refusal reasons) to a network observer. In-session errors are now encrypted symmetrically with successes; pre-session errors, which a client must be able to read to recover, remain plaintext by design. **Migration:** *Opt into reading non-2xx encrypted errors* — No change is required for existing callers — `b.httpClient.encrypted` still defaults to `responseMode: "reject"` (throw on non-2xx). To read an error's status and decrypted detail, create the client with `responseMode: "passthrough"` (or pass it per request) and branch on the returned `ok` / `statusCode`. The new `ok` field is additive and does not affect existing `{ statusCode, headers, body }` consumers.
package/README.md CHANGED
@@ -119,7 +119,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
119
119
  - **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
120
120
  ### HTTP
121
121
 
122
- - **Router + API specs** — schema-validated routes; OpenAPI publication (`b.openapi`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
122
+ - **Router + API specs** — schema-validated routes; OpenAPI 3.1 / 3.2 publication (`b.openapi` — webhooks + `jsonSchemaDialect`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
123
123
  - **Middleware stack (`createApp`)** — security layers wired ON by default (Core Rule §3); each is configurable via `middleware.<name>` (operator cookie / field names flow straight through — nothing static is baked in) or opt-out with `false` (disabling a default is audited via `app.middleware.disabled`). Ordered so each layer has what it needs (cookies + CSP nonce + fetch-metadata, then body parser, then CSRF last):
124
124
  - Request-ID tagging and bot-guard
125
125
  - Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
@@ -167,7 +167,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
167
167
  - **CloudEvents + SSE** — CloudEvents 1.0.2 for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF: `wrap` / `parse` envelopes, non-throwing `validate` / `isValid`, the JSON event + batch formats (`toJSON` / `fromJSON` / `toJSONBatch` / `fromJSONBatch`), and the HTTP binding in both binary and structured content modes with auto-detecting `http.decode` (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
168
168
  - **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
169
169
  - **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
170
- - **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
170
+ - **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays, plus DMARC aggregate (RUA) + forensic (RUF) report parsing (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
171
171
  - **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
172
172
  - **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
173
173
  - **Mail crypto** — PQC-first S/MIME via CMS (`b.mail.crypto.cms`) + OpenPGP encrypt/decrypt + WKD key discovery with IDN-homograph defense (`b.mail.crypto.pgp`)
package/lib/archive.js CHANGED
@@ -33,16 +33,20 @@
33
33
  * null bytes, and `..` segments throw `archive/bad-name`.
34
34
  * - No symlink emission — only regular file entries are produced.
35
35
  * - SHA3-512 fingerprint via `digest()` for operator integrity logs.
36
+ * - ZIP64 (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.4.8 / §4.5.3) is
37
+ * emitted automatically when an archive exceeds 65535 entries or
38
+ * any entry's compressed/uncompressed size or local-header offset
39
+ * exceeds 4 GiB: the classic field carries the 0xFFFF/0xFFFFFFFF
40
+ * sentinel, a ZIP64 extended-information extra field supplies the
41
+ * 64-bit value, and the ZIP64 EOCD record + locator precede the
42
+ * classic EOCD. Archives below those limits stay classic
43
+ * byte-for-byte. `b.archive.read.zip` reads the produced ZIP64
44
+ * form transparently.
36
45
  *
37
46
  * Out of scope (v1):
38
- * - ZIP64 (>4 GiB archives, >65535 files) — `toBuffer` and
39
- * `toStream` throw `archive/too-many-entries` past the limit;
40
- * operators at that scale bring their own toolset.
41
47
  * - ZIP-native password encryption (broken-by-design); operators
42
48
  * wrap the produced bytes via `b.crypto.encryptPacked` for
43
49
  * encryption-at-rest.
44
- * - Reading / extraction — write-only; operators use yauzl or
45
- * `unzip` for read paths.
46
50
  *
47
51
  * @card
48
52
  * ZIP archive creation primitive.
@@ -61,6 +65,30 @@ var ArchiveError = defineClass("ArchiveError", { alwaysPermanent: true });
61
65
  var SIG_LFH = 0x04034b50; // local file header
62
66
  var SIG_CFH = 0x02014b50; // central directory file header
63
67
  var SIG_EOCD = 0x06054b50; // end of central directory
68
+ var SIG_EOCD64 = 0x06064b50; // APPNOTE §4.3.14 ZIP64 EOCD record
69
+ var SIG_EOCD64_LOCATOR = 0x07064b50; // APPNOTE §4.3.15 ZIP64 EOCD locator
70
+
71
+ // ZIP64 sentinels (APPNOTE §4.4 + §4.5.3) — a classic field set to its
72
+ // all-ones value signals that the true value lives in the ZIP64 record /
73
+ // extended-information extra field. 16-bit fields use 0xFFFF, 32-bit
74
+ // fields use 0xFFFFFFFF.
75
+ var ZIP64_U16_SENTINEL = 0xffff;
76
+ var ZIP64_U32_SENTINEL = 0xffffffff;
77
+ // 0xFFFFFFFF as a value boundary: any size/offset > this overflows the
78
+ // classic 32-bit field and must be carried in the ZIP64 extra field.
79
+ var ZIP64_U32_MAX = 0xffffffff;
80
+ // Classic EOCD entry-count field is 16-bit (APPNOTE §4.4.21/§4.4.22);
81
+ // more than 65535 entries forces the ZIP64 EOCD record.
82
+ var ZIP64_MAX_CLASSIC_ENTRIES = 65535;
83
+ // ZIP64 "version needed to extract" — 4.5 (APPNOTE §4.4.3.2).
84
+ var ZIP64_VERSION_NEEDED = 45;
85
+ // ZIP64 extended-information extra field (§4.5.3): 4-byte header
86
+ // (id(2) + dataSize(2)) then up to four fields. Each 64-bit field is
87
+ // 8 bytes; diskStart is a 4-byte dword.
88
+ var ZIP64_EXTRA_HEADER_ID = 0x0001;
89
+ var ZIP64_EXTRA_FIELD_BYTES = 8; // one 64-bit field (uSize / cSize / lfhOffset)
90
+ var ZIP64_EOCD64_BYTES = 56; // §4.3.14 fixed-size record (no extensible-data tail)
91
+ var ZIP64_EOCD64_LOCATOR_BYTES = 20; // §4.3.15 fixed-size locator
64
92
 
65
93
  // Compression methods (APPNOTE 4.4.5 — protocol-fixed method IDs)
66
94
  var METHOD_STORE_ID = 0;
@@ -101,6 +129,52 @@ function _msdosDateTime(date) {
101
129
  return { time: dosTime, date: dosDate };
102
130
  }
103
131
 
132
+ // ZIP64 (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.4.8 / §4.5.3) — small
133
+ // archives stay classic byte-for-byte; the ZIP64 trailer + per-entry
134
+ // sentinels only appear once a size/offset overflows the classic 32-bit
135
+ // field (or the entry count exceeds the classic 16-bit cap). The reader
136
+ // in archive-read.js resolves these symmetrically.
137
+
138
+ // True when a single size/offset overflows the classic 32-bit field.
139
+ function _overflows32(n) { return n > ZIP64_U32_MAX; }
140
+
141
+ // Does this entry need a per-record ZIP64 extra block? An entry overflows
142
+ // when its compressed size, uncompressed size, or local-header offset
143
+ // exceeds the 32-bit limit. `lfhOffset` is only known at central-directory
144
+ // build time, so the local-header path passes `lfhOffset = 0` (the LFH
145
+ // extra never carries the offset — §4.5.3).
146
+ function _entryNeedsZip64(csize, usize, lfhOffset) {
147
+ return _overflows32(csize) || _overflows32(usize) || _overflows32(lfhOffset);
148
+ }
149
+
150
+ // Build the ZIP64 extended-information extra field (§4.5.3) carrying ONLY
151
+ // the fields whose classic value overflowed, in APPNOTE order:
152
+ // uncompressedSize, compressedSize, localHeaderOffset, diskStart. Returns
153
+ // an empty buffer when nothing overflowed. `includeOffset` controls
154
+ // whether localHeaderOffset is appended (the LFH variant omits it). The
155
+ // reader keys presence off the matching classic sentinel, so a field is
156
+ // emitted here iff the caller also writes the sentinel into the classic
157
+ // slot. diskStart is never emitted — single-disk archives only.
158
+ function _buildZip64Extra(csize, usize, lfhOffset, includeOffset) {
159
+ var needUsize = _overflows32(usize);
160
+ var needCsize = _overflows32(csize);
161
+ var needOffset = includeOffset && _overflows32(lfhOffset);
162
+ if (!needUsize && !needCsize && !needOffset) return Buffer.alloc(0);
163
+ var fields = 0;
164
+ if (needUsize) fields += 1;
165
+ if (needCsize) fields += 1;
166
+ if (needOffset) fields += 1;
167
+ var dataLen = fields * ZIP64_EXTRA_FIELD_BYTES;
168
+ var extra = Buffer.alloc(C.BYTES.bytes(4 + dataLen));
169
+ extra.writeUInt16LE(ZIP64_EXTRA_HEADER_ID, C.BYTES.bytes(0)); // §4.5.3 extra-field tag
170
+ extra.writeUInt16LE(dataLen, C.BYTES.bytes(2)); // §4.5.1 data size
171
+ var q = 4;
172
+ if (needUsize) { extra.writeBigUInt64LE(BigInt(usize), C.BYTES.bytes(q)); q += ZIP64_EXTRA_FIELD_BYTES; }
173
+ if (needCsize) { extra.writeBigUInt64LE(BigInt(csize), C.BYTES.bytes(q)); q += ZIP64_EXTRA_FIELD_BYTES; }
174
+ if (needOffset) { extra.writeBigUInt64LE(BigInt(lfhOffset), C.BYTES.bytes(q)); q += ZIP64_EXTRA_FIELD_BYTES; }
175
+ return extra;
176
+ }
177
+
104
178
  /**
105
179
  * @primitive b.archive.zip
106
180
  * @signature b.archive.zip()
@@ -223,66 +297,156 @@ function zip() {
223
297
  var nameBuf = Buffer.from(entry.name, "utf8");
224
298
  var dt = _msdosDateTime(entry.mtime);
225
299
  var flags = FLAG_UTF8_NAME | (streaming ? FLAG_DATA_DESCRIPTOR : 0);
300
+ // ZIP64 (§4.3.7 + §4.5.3) applies to the buffer path only — the LFH
301
+ // sizes are written up-front there. Streaming entries carry zeros
302
+ // under the data-descriptor flag (sizes unknown at header time), so
303
+ // they never carry an LFH ZIP64 extra; their 64-bit values ride the
304
+ // data descriptor + central-directory ZIP64 extra. When either size
305
+ // overflows the 32-bit field, the LFH carries the sentinel and a
306
+ // ZIP64 extra block supplies uncompressedSize + compressedSize (the
307
+ // offset is never in the LFH extra — §4.5.3).
308
+ var csize = streaming ? 0 : entry.stored.length;
309
+ var usize = streaming ? 0 : entry.uncompressedSize;
310
+ var zip64 = !streaming && _entryNeedsZip64(csize, usize, 0);
311
+ var zip64Extra = zip64 ? _buildZip64Extra(csize, usize, 0, false) : Buffer.alloc(0);
226
312
  // APPNOTE 4.3.7 — local file header. Offsets are byte positions
227
313
  // within the 30-byte fixed header; each route through C.BYTES.bytes
228
314
  // so the framework's byte-math discipline applies even to format-
229
315
  // fixed offsets.
230
316
  var hdr = Buffer.alloc(C.BYTES.bytes(30));
231
317
  hdr.writeUInt32LE(SIG_LFH, C.BYTES.bytes(0));
232
- hdr.writeUInt16LE(20, C.BYTES.bytes(4)); // version needed
318
+ hdr.writeUInt16LE(zip64 ? ZIP64_VERSION_NEEDED : 20, C.BYTES.bytes(4)); // version needed
233
319
  hdr.writeUInt16LE(flags, C.BYTES.bytes(6)); // flags: bit 11 UTF-8, bit 3 data-descriptor
234
320
  hdr.writeUInt16LE(entry.method, C.BYTES.bytes(0x08));
235
321
  hdr.writeUInt16LE(dt.time, C.BYTES.bytes(10));
236
322
  hdr.writeUInt16LE(dt.date, C.BYTES.bytes(12));
237
323
  hdr.writeUInt32LE(streaming ? 0 : entry.crc, C.BYTES.bytes(14));
238
- hdr.writeUInt32LE(streaming ? 0 : entry.stored.length, C.BYTES.bytes(18));
239
- hdr.writeUInt32LE(streaming ? 0 : entry.uncompressedSize, C.BYTES.bytes(22));
324
+ hdr.writeUInt32LE(_overflows32(csize) ? ZIP64_U32_SENTINEL : csize, C.BYTES.bytes(18));
325
+ hdr.writeUInt32LE(_overflows32(usize) ? ZIP64_U32_SENTINEL : usize, C.BYTES.bytes(22));
240
326
  hdr.writeUInt16LE(nameBuf.length, C.BYTES.bytes(26));
241
- hdr.writeUInt16LE(0, C.BYTES.bytes(28)); // extra field length
242
- return Buffer.concat([hdr, nameBuf]);
327
+ hdr.writeUInt16LE(zip64Extra.length, C.BYTES.bytes(28)); // extra field length
328
+ return Buffer.concat([hdr, nameBuf, zip64Extra]);
243
329
  }
244
330
 
245
331
  function _buildDataDescriptor(crc, csize, usize) {
246
- // APPNOTE 4.3.9 — 16-byte data descriptor (with optional sig dword).
247
- var dd = Buffer.alloc(C.BYTES.bytes(16));
248
- dd.writeUInt32LE(SIG_DATA_DESCRIPTOR, C.BYTES.bytes(0));
249
- dd.writeUInt32LE(crc, C.BYTES.bytes(4));
250
- dd.writeUInt32LE(csize, C.BYTES.bytes(0x08));
251
- dd.writeUInt32LE(usize, C.BYTES.bytes(12));
252
- return dd;
332
+ // APPNOTE 4.3.9 — data descriptor (with optional sig dword). The
333
+ // classic form carries 4-byte csize/usize; §4.3.9.2 widens both to
334
+ // 8 bytes when the entry is ZIP64 (either size overflows the 32-bit
335
+ // field). The central directory carries the authoritative sizes, so
336
+ // the wide form here is for external single-pass extractors.
337
+ var zip64 = _overflows32(csize) || _overflows32(usize);
338
+ if (!zip64) {
339
+ var dd = Buffer.alloc(C.BYTES.bytes(16));
340
+ dd.writeUInt32LE(SIG_DATA_DESCRIPTOR, C.BYTES.bytes(0));
341
+ dd.writeUInt32LE(crc, C.BYTES.bytes(4));
342
+ dd.writeUInt32LE(csize, C.BYTES.bytes(0x08));
343
+ dd.writeUInt32LE(usize, C.BYTES.bytes(12));
344
+ return dd;
345
+ }
346
+ var dd64 = Buffer.alloc(C.BYTES.bytes(24));
347
+ dd64.writeUInt32LE(SIG_DATA_DESCRIPTOR, C.BYTES.bytes(0));
348
+ dd64.writeUInt32LE(crc, C.BYTES.bytes(4));
349
+ dd64.writeBigUInt64LE(BigInt(csize), C.BYTES.bytes(0x08));
350
+ dd64.writeBigUInt64LE(BigInt(usize), C.BYTES.bytes(0x10));
351
+ return dd64;
253
352
  }
254
353
 
255
354
  function _buildCentralDirectoryEntry(entry, lfhOffset) {
256
355
  var nameBuf = Buffer.from(entry.name, "utf8");
257
356
  var dt = _msdosDateTime(entry.mtime);
258
357
  var flags = FLAG_UTF8_NAME | (entry.kind === "stream" ? FLAG_DATA_DESCRIPTOR : 0);
358
+ var csize = entry.stored.length;
359
+ var usize = entry.uncompressedSize;
360
+ // ZIP64 (§4.3.12 + §4.4.8 + §4.5.3): the central-directory entry
361
+ // carries the offset, so its ZIP64 trigger includes localHeaderOffset
362
+ // overflow. Each overflowed field becomes the classic sentinel and is
363
+ // supplied 64-bit in the extra block, in APPNOTE order.
364
+ var zip64 = _entryNeedsZip64(csize, usize, lfhOffset);
365
+ var zip64Extra = zip64 ? _buildZip64Extra(csize, usize, lfhOffset, true) : Buffer.alloc(0);
259
366
  // APPNOTE 4.3.12 — central directory file header (46-byte fixed prefix).
260
367
  var hdr = Buffer.alloc(C.BYTES.bytes(46));
261
368
  hdr.writeUInt32LE(SIG_CFH, C.BYTES.bytes(0));
262
369
  hdr.writeUInt16LE(0x033f, C.BYTES.bytes(4)); // version made by (UNIX | 6.3)
263
- hdr.writeUInt16LE(20, C.BYTES.bytes(6)); // version needed
370
+ hdr.writeUInt16LE(zip64 ? ZIP64_VERSION_NEEDED : 20, C.BYTES.bytes(6)); // version needed
264
371
  hdr.writeUInt16LE(flags, C.BYTES.bytes(0x08)); // flags: bit 11 UTF-8, bit 3 data-descriptor (stream)
265
372
  hdr.writeUInt16LE(entry.method, C.BYTES.bytes(10));
266
373
  hdr.writeUInt16LE(dt.time, C.BYTES.bytes(12));
267
374
  hdr.writeUInt16LE(dt.date, C.BYTES.bytes(14));
268
375
  hdr.writeUInt32LE(entry.crc, C.BYTES.bytes(0x10));
269
- hdr.writeUInt32LE(entry.stored.length, C.BYTES.bytes(20));
270
- hdr.writeUInt32LE(entry.uncompressedSize, C.BYTES.bytes(0x18));
376
+ hdr.writeUInt32LE(_overflows32(csize) ? ZIP64_U32_SENTINEL : csize, C.BYTES.bytes(20));
377
+ hdr.writeUInt32LE(_overflows32(usize) ? ZIP64_U32_SENTINEL : usize, C.BYTES.bytes(0x18));
271
378
  hdr.writeUInt16LE(nameBuf.length, C.BYTES.bytes(28));
272
- hdr.writeUInt16LE(0, C.BYTES.bytes(30)); // extra field length
379
+ hdr.writeUInt16LE(zip64Extra.length, C.BYTES.bytes(30)); // extra field length
273
380
  hdr.writeUInt16LE(0, C.BYTES.bytes(0x20)); // file comment length
274
381
  hdr.writeUInt16LE(0, C.BYTES.bytes(34)); // disk number start
275
382
  hdr.writeUInt16LE(0, C.BYTES.bytes(36)); // internal file attributes
276
383
  hdr.writeUInt32LE(0, C.BYTES.bytes(38)); // external file attributes
277
- hdr.writeUInt32LE(lfhOffset, C.BYTES.bytes(42));
278
- return Buffer.concat([hdr, nameBuf]);
384
+ hdr.writeUInt32LE(_overflows32(lfhOffset) ? ZIP64_U32_SENTINEL : lfhOffset, C.BYTES.bytes(42));
385
+ return Buffer.concat([hdr, nameBuf, zip64Extra]);
279
386
  }
280
387
 
281
- function toBuffer() {
282
- if (entries.length > 65535) {
283
- throw new ArchiveError("archive/too-many-entries",
284
- "ZIP archive cannot contain more than 65535 entries (ZIP64 unsupported in v1)");
388
+ // Build the end-of-central-directory trailer. Returns a Buffer that is
389
+ // just the classic 22-byte EOCD for archives within the classic limits,
390
+ // or the ZIP64 EOCD record (§4.3.14) + ZIP64 EOCD locator (§4.3.15) +
391
+ // the classic EOCD (with sentinels) when the entry count exceeds 65535
392
+ // or the central-directory size/offset exceeds the 32-bit field. The
393
+ // ZIP64 trailer precedes the classic EOCD exactly where the reader's
394
+ // locator-before-classic-EOCD walk expects it.
395
+ function _buildEndOfCentralDirectory(totalEntries, cdSize, cdStart) {
396
+ var needZip64 = totalEntries > ZIP64_MAX_CLASSIC_ENTRIES ||
397
+ _overflows32(cdSize) || _overflows32(cdStart);
398
+ if (!needZip64) {
399
+ // APPNOTE 4.3.16 — end of central directory record (22-byte fixed).
400
+ var eocdClassic = Buffer.alloc(C.BYTES.bytes(22));
401
+ eocdClassic.writeUInt32LE(SIG_EOCD, C.BYTES.bytes(0));
402
+ eocdClassic.writeUInt16LE(0, C.BYTES.bytes(4)); // disk number
403
+ eocdClassic.writeUInt16LE(0, C.BYTES.bytes(6)); // disk where CD starts
404
+ eocdClassic.writeUInt16LE(totalEntries, C.BYTES.bytes(0x08)); // entries on this disk
405
+ eocdClassic.writeUInt16LE(totalEntries, C.BYTES.bytes(10)); // total entries
406
+ eocdClassic.writeUInt32LE(cdSize, C.BYTES.bytes(12)); // size of central directory
407
+ eocdClassic.writeUInt32LE(cdStart, C.BYTES.bytes(0x10)); // offset of central directory
408
+ eocdClassic.writeUInt16LE(0, C.BYTES.bytes(20)); // comment length
409
+ return eocdClassic;
285
410
  }
411
+ // ZIP64 EOCD record (§4.3.14) — fixed 56-byte form, no extensible
412
+ // data tail. The "size of ZIP64 EOCD record" field counts the bytes
413
+ // that FOLLOW it (record total minus the 12-byte sig+size prefix).
414
+ var eocd64 = Buffer.alloc(C.BYTES.bytes(ZIP64_EOCD64_BYTES));
415
+ eocd64.writeUInt32LE(SIG_EOCD64, C.BYTES.bytes(0));
416
+ eocd64.writeBigUInt64LE(BigInt(ZIP64_EOCD64_BYTES - 12), C.BYTES.bytes(4));
417
+ eocd64.writeUInt16LE(0x033f, C.BYTES.bytes(12)); // version made by (UNIX | 6.3)
418
+ eocd64.writeUInt16LE(ZIP64_VERSION_NEEDED, C.BYTES.bytes(14)); // version needed
419
+ eocd64.writeUInt32LE(0, C.BYTES.bytes(16)); // this disk number
420
+ eocd64.writeUInt32LE(0, C.BYTES.bytes(20)); // disk with CD start
421
+ eocd64.writeBigUInt64LE(BigInt(totalEntries), C.BYTES.bytes(24)); // entries on this disk
422
+ eocd64.writeBigUInt64LE(BigInt(totalEntries), C.BYTES.bytes(32)); // total entries
423
+ eocd64.writeBigUInt64LE(BigInt(cdSize), C.BYTES.bytes(40)); // central directory size
424
+ eocd64.writeBigUInt64LE(BigInt(cdStart), C.BYTES.bytes(48)); // central directory offset
425
+ var eocd64Offset = cdStart + cdSize;
426
+ // ZIP64 EOCD locator (§4.3.15) — fixed 20 bytes.
427
+ var locator = Buffer.alloc(C.BYTES.bytes(ZIP64_EOCD64_LOCATOR_BYTES));
428
+ locator.writeUInt32LE(SIG_EOCD64_LOCATOR, C.BYTES.bytes(0));
429
+ locator.writeUInt32LE(0, C.BYTES.bytes(4)); // disk with ZIP64 EOCD
430
+ locator.writeBigUInt64LE(BigInt(eocd64Offset), C.BYTES.bytes(0x08));
431
+ locator.writeUInt32LE(1, C.BYTES.bytes(16)); // total number of disks
432
+ // Classic EOCD (§4.3.16) with ZIP64 sentinels for any overflowed
433
+ // field — readers that don't grok ZIP64 see the sentinel, ZIP64-aware
434
+ // readers follow the locator.
435
+ var eocd = Buffer.alloc(C.BYTES.bytes(22));
436
+ eocd.writeUInt32LE(SIG_EOCD, C.BYTES.bytes(0));
437
+ eocd.writeUInt16LE(0, C.BYTES.bytes(4));
438
+ eocd.writeUInt16LE(0, C.BYTES.bytes(6));
439
+ eocd.writeUInt16LE(totalEntries > ZIP64_MAX_CLASSIC_ENTRIES
440
+ ? ZIP64_U16_SENTINEL : totalEntries, C.BYTES.bytes(0x08));
441
+ eocd.writeUInt16LE(totalEntries > ZIP64_MAX_CLASSIC_ENTRIES
442
+ ? ZIP64_U16_SENTINEL : totalEntries, C.BYTES.bytes(10));
443
+ eocd.writeUInt32LE(_overflows32(cdSize) ? ZIP64_U32_SENTINEL : cdSize, C.BYTES.bytes(12));
444
+ eocd.writeUInt32LE(_overflows32(cdStart) ? ZIP64_U32_SENTINEL : cdStart, C.BYTES.bytes(0x10));
445
+ eocd.writeUInt16LE(0, C.BYTES.bytes(20));
446
+ return Buffer.concat([eocd64, locator, eocd]);
447
+ }
448
+
449
+ function toBuffer() {
286
450
  for (var k = 0; k < entries.length; k++) {
287
451
  if (entries[k].kind === "stream") {
288
452
  throw new ArchiveError("archive/streaming-entry",
@@ -307,17 +471,7 @@ function zip() {
307
471
  pieces.push(cdh);
308
472
  cdSize += cdh.length;
309
473
  }
310
- // APPNOTE 4.3.16 end of central directory record (22-byte fixed).
311
- var eocd = Buffer.alloc(C.BYTES.bytes(22));
312
- eocd.writeUInt32LE(SIG_EOCD, C.BYTES.bytes(0));
313
- eocd.writeUInt16LE(0, C.BYTES.bytes(4)); // disk number
314
- eocd.writeUInt16LE(0, C.BYTES.bytes(6)); // disk where CD starts
315
- eocd.writeUInt16LE(entries.length, C.BYTES.bytes(0x08)); // entries on this disk
316
- eocd.writeUInt16LE(entries.length, C.BYTES.bytes(10)); // total entries
317
- eocd.writeUInt32LE(cdSize, C.BYTES.bytes(12)); // size of central directory
318
- eocd.writeUInt32LE(cdStart, C.BYTES.bytes(0x10)); // offset of central directory
319
- eocd.writeUInt16LE(0, C.BYTES.bytes(20)); // comment length
320
- pieces.push(eocd);
474
+ pieces.push(_buildEndOfCentralDirectory(entries.length, cdSize, cdStart));
321
475
  return Buffer.concat(pieces);
322
476
  }
323
477
 
@@ -451,11 +605,6 @@ function zip() {
451
605
  "toStream: writable must be a Writable (or omit to receive a Readable)");
452
606
  }
453
607
 
454
- if (entries.length > 65535) {
455
- throw new ArchiveError("archive/too-many-entries",
456
- "ZIP archive cannot contain more than 65535 entries (ZIP64 unsupported in v1)");
457
- }
458
-
459
608
  var run = (async function () {
460
609
  var offsets = [];
461
610
  var totalLocalBytes = 0;
@@ -480,15 +629,7 @@ function zip() {
480
629
  await _writeChunk(dest, cdh);
481
630
  cdSize += cdh.length;
482
631
  }
483
- var eocd = Buffer.alloc(C.BYTES.bytes(22));
484
- eocd.writeUInt32LE(SIG_EOCD, C.BYTES.bytes(0));
485
- eocd.writeUInt16LE(0, C.BYTES.bytes(4));
486
- eocd.writeUInt16LE(0, C.BYTES.bytes(6));
487
- eocd.writeUInt16LE(entries.length, C.BYTES.bytes(0x08));
488
- eocd.writeUInt16LE(entries.length, C.BYTES.bytes(10));
489
- eocd.writeUInt32LE(cdSize, C.BYTES.bytes(12));
490
- eocd.writeUInt32LE(cdStart, C.BYTES.bytes(0x10));
491
- eocd.writeUInt16LE(0, C.BYTES.bytes(20));
632
+ var eocd = _buildEndOfCentralDirectory(entries.length, cdSize, cdStart);
492
633
  await _writeChunk(dest, eocd);
493
634
  if (typeof dest.end === "function") dest.end();
494
635
  _emitAudit(opts, "archive.zip.streamed.completed", "success", {
@@ -574,4 +715,17 @@ module.exports = {
574
715
  // Test-only export — operators don't call this; it's here for unit-testing
575
716
  // the CRC implementation against known vectors.
576
717
  _crc32ForTest: _crc32,
718
+ // Test-only export — exercises the per-entry ZIP64 extended-information
719
+ // extra-field builder (§4.5.3) at logical sizes/offsets that exceed the
720
+ // 32-bit field, which the buffer path can only reach with multi-GiB
721
+ // payloads. The entry-count and EOCD64 paths are covered by full
722
+ // round-trips through the random-access reader.
723
+ _zip64ForTest: {
724
+ entryNeedsZip64: _entryNeedsZip64,
725
+ buildExtra: _buildZip64Extra,
726
+ U16_SENTINEL: ZIP64_U16_SENTINEL,
727
+ U32_SENTINEL: ZIP64_U32_SENTINEL,
728
+ U32_MAX: ZIP64_U32_MAX,
729
+ EXTRA_HEADER_ID: ZIP64_EXTRA_HEADER_ID,
730
+ },
577
731
  };