@web-ts-toolkit/express-request-attestation 0.51.0 → 0.52.0

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/README.md CHANGED
@@ -31,15 +31,18 @@ failure or a lost response; a fresh proof is required on a new attempt.
31
31
 
32
32
  ## Install
33
33
 
34
- Backend consumers install Express explicitly (optional peer `>=5`); the
35
- signer entry never needs it. Redis and MongoDB are caller-injected through
36
- structural types; installing each driver is only needed when using that
37
- store.
34
+ Backend consumers need Node `>=22` and install Express explicitly (optional
35
+ peer `>=5`). TypeScript backend consumers also install `@types/express` v5
36
+ and `@types/node`: the root declarations expose Express's router type. Redis
37
+ and MongoDB are caller-injected through structural types; install a driver
38
+ only when using that store.
38
39
 
39
40
  ```sh
40
41
  # Backend (Express >= 5 required at runtime)
41
42
  pnpm add @web-ts-toolkit/express-request-attestation express
42
- # Browser-only (no Express needed)
43
+ # Backend TypeScript declarations
44
+ pnpm add -D typescript @types/express@^5 @types/node
45
+ # Browser-only (no Express, Redis, MongoDB, or Node types needed)
43
46
  pnpm add @web-ts-toolkit/express-request-attestation
44
47
  # With shared Redis replay state (backend only)
45
48
  pnpm add redis
@@ -47,6 +50,10 @@ pnpm add redis
47
50
  pnpm add mongodb
48
51
  ```
49
52
 
53
+ The backend examples compile with `strict: true`, `skipLibCheck: false`, and
54
+ `esModuleInterop: true`. Browser consumers use ES2022 + DOM/DOM.Iterable
55
+ libraries; the `/signer` declarations also compile with `types: []`.
56
+
50
57
  ## Entry imports
51
58
 
52
59
  ```ts
@@ -104,18 +111,24 @@ const entry = generateAttestationKey('v1-20261002-01', {
104
111
  const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
105
112
  const store = createMemoryAttestationStore();
106
113
  const capture = createAttestationBodyCapture({ maxBodyBytes });
107
- const guard = createRequestAttestationMiddleware({
114
+ const guard: express.RequestHandler = createRequestAttestationMiddleware({
108
115
  publicOrigin,
109
116
  replayNamespace,
110
117
  keyProvider,
111
118
  store,
112
119
  });
120
+ const parser: express.RequestHandler = express.json({
121
+ limit: maxBodyBytes,
122
+ verify: capture.verify,
123
+ inflate: false,
124
+ });
125
+ const captureErrorHandler: express.ErrorRequestHandler = capture.errorHandler;
113
126
 
114
127
  const app = express();
115
128
  app.use('/attestation', createSignerBundleRouter({ publicOrigin, replayNamespace, keyProvider }));
116
- app.use(express.json({ limit: maxBodyBytes, verify: capture.verify as never, inflate: false }));
117
- app.use(capture.errorHandler as never);
118
- app.post('/api/submit', guard as never, (_req, res) => {
129
+ app.use(parser);
130
+ app.use(captureErrorHandler);
131
+ app.post('/api/submit', guard, (_req, res) => {
119
132
  res.json({ ok: true });
120
133
  });
121
134
 
@@ -129,6 +142,14 @@ are the same representation. V1 accepts identity or absent
129
142
  uses SHA-256 of zero bytes. A nonempty body whose parser failed to capture
130
143
  it is `500 ATTESTATION_BODY_CAPTURE_REQUIRED`, not the empty digest.
131
144
 
145
+ Use the same `capture.verify` with `express.urlencoded`, `express.text`, or
146
+ `express.raw` for the body types your routes accept. For exact multipart
147
+ bytes, capture with `express.raw({ type: 'multipart/*', ... })` before your
148
+ application's multipart interpretation. Do not reconstruct a digest from
149
+ parsed JSON or trust a client-provided digest. The capture error handler
150
+ maps parser size/encoding failures to fixed 413/415 responses and forwards
151
+ other parser errors to the application's error handling.
152
+
132
153
  ### Public keyring provisioning
133
154
 
134
155
  One public keyring example. Provisioning is application-owned (cron, KMS, or
@@ -188,7 +209,7 @@ export { client, keyProvider, store };
188
209
  ```
189
210
 
190
211
  One same-slot sorted set per shared prefix holds opaque replay keys with
191
- absolute `retainUntil` scores. All clients sharing a prefix must configure
212
+ absolute `retainUntilMs` scores. All clients sharing a prefix must configure
192
213
  identical `maxEntries`. Size the ledger as unique accepted requests per second
193
214
  times maximum retention plus headroom; actual Redis memory and throughput
194
215
  require measurement. Use a dedicated `noeviction` deployment when relying on
@@ -217,10 +238,17 @@ const entry = generateAttestationKey('v1-mongo-01', {
217
238
  const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
218
239
  const client = new MongoClient('mongodb://127.0.0.1:27017/?replicaSet=rs0');
219
240
  await client.connect();
241
+ const db = client.db('app-attestation');
242
+ const proofsCollectionName = 'attestation_proofs';
243
+ const capacityCollectionName = 'attestation_replay_capacity';
244
+ // Provision before accepting traffic. No expireAfterSeconds: this is NOT a TTL index.
245
+ await db
246
+ .collection(capacityCollectionName)
247
+ .createIndex({ kind: 1, retainUntilMs: 1 }, { name: 'attestation_reservation_expiry' });
220
248
  const store = createMongoAttestationStore({
221
- db: client.db('app-attestation'),
222
- proofsCollectionName: 'attestation_proofs',
223
- capacityCollectionName: 'attestation_replay_capacity',
249
+ db,
250
+ proofsCollectionName,
251
+ capacityCollectionName,
224
252
  maxEntries: 50000,
225
253
  });
226
254
 
@@ -233,10 +261,13 @@ replay keys with absolute `retainUntilMs` deadlines. Each admission runs one
233
261
  snapshot transaction: duplicate check, bounded expiry reclaim (the requested
234
262
  expired entry plus at most 64 expired entries), capacity check, then insert.
235
263
  Duplicates return `'duplicate'` even when full and never extend expiry; no
236
- live entry is ever evicted. Do not rely on a TTL index for correctness;
237
- create a non-TTL index on `retainUntilMs` (for example
238
- `{ kind: 1, retainUntilMs: 1 }` on the capacity collection) and never add a
239
- TTL index to the accounting collection.
264
+ live entry is ever evicted. MongoDB supplies the unique `_id` indexes; the
265
+ compound non-TTL index above supports bounded expiry lookup and sorting.
266
+ The application owns index provisioning/readiness: the store constructor
267
+ performs no database I/O and does not create indexes. `retainUntilMs` is
268
+ numeric epoch milliseconds. Do not rely on TTL cleanup for correctness and
269
+ never add a TTL index to the capacity/accounting collection or independently
270
+ delete its live rows.
240
271
 
241
272
  All clients sharing one database/collections must configure identical
242
273
  `maxEntries` (default 50000, positive safe integer) and synchronized clocks
@@ -253,15 +284,41 @@ database. Client lifecycle stays caller-owned (connect/close the
253
284
  `MongoClient` in application code).
254
285
 
255
286
  When to choose a store: memory for a single instance with no shared state;
256
- Redis for high-throughput shared replay with server `TIME`; MongoDB when
287
+ Redis for shared replay with atomic Lua admission and server `TIME`; MongoDB when
257
288
  deployments already run a replica set and prefer shared replay without
258
289
  operating Redis, accepting one transaction per admission.
259
290
 
291
+ ### Dependency deadlines and ownership
292
+
293
+ The guard and asset router bound each asynchronous dependency wait with
294
+ `operationTimeoutMs` (default 5000 ms, integer range 1–60000). This is a
295
+ **waiting bound**, not cancellation of provider work, Redis commands, MongoDB
296
+ transactions/retries, or session cleanup. A late reservation may commit;
297
+ the timed-out request never resumes its handler or releases that reservation.
298
+ No store failure falls back to process-local memory.
299
+
300
+ Configure connection/server-selection, socket/command, pool-wait, and
301
+ transaction/commit deadlines at the injected driver/provider boundary, with
302
+ bounded retry and concurrency policies appropriate to the deployment.
303
+ Direct `store.reserve()` calls have no package-level wait timeout. The caller
304
+ owns client connection, error listeners, readiness, and shutdown; the Mongo
305
+ adapter owns its per-admission session but does not bound driver cleanup.
306
+ Database persistence, failover, and restore settings must meet the application's
307
+ replay-history requirements; losing live replay state can admit an old proof
308
+ again while it is still valid.
309
+
260
310
  ### Bearer-auth composition
261
311
 
262
- Tested composition. The guard never sets `req.auth`, never enrolls or
263
- validates a DPoP binding, and never rewrites authentication. Every auth retry
264
- re-signs via a fresh wrapper call.
312
+ The guard never sets `req.auth`, never enrolls or validates a DPoP binding,
313
+ and never rewrites authentication. Public HMAC material authenticates neither
314
+ a caller nor a session. The small bearer check below illustrates middleware
315
+ ordering; replace it with your application's actual token/session validation.
316
+
317
+ Putting **auth before the guard** avoids replay allocation for requests auth
318
+ rejects. Putting the guard first can reject bad proofs before expensive auth
319
+ work, but a valid public-HMAC proof reserves replay capacity even if auth later
320
+ rejects. Choose the ordering deliberately; captured body bytes must be
321
+ available before the guard in either case. This example runs auth first.
265
322
 
266
323
  ```ts
267
324
  import express from 'express';
@@ -284,7 +341,18 @@ const entry = generateAttestationKey('v1-bearer-01', {
284
341
  const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
285
342
  const store = createMemoryAttestationStore();
286
343
  const capture = createAttestationBodyCapture({ maxBodyBytes });
287
- const guard = createRequestAttestationMiddleware({ publicOrigin, replayNamespace, keyProvider, store });
344
+ const guard: express.RequestHandler = createRequestAttestationMiddleware({
345
+ publicOrigin,
346
+ replayNamespace,
347
+ keyProvider,
348
+ store,
349
+ });
350
+ const parser: express.RequestHandler = express.json({
351
+ limit: maxBodyBytes,
352
+ verify: capture.verify,
353
+ inflate: false,
354
+ });
355
+ const captureErrorHandler: express.ErrorRequestHandler = capture.errorHandler;
288
356
 
289
357
  function requireBearerAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {
290
358
  if (req.headers.authorization !== 'Bearer good-token') {
@@ -295,9 +363,9 @@ function requireBearerAuth(req: express.Request, res: express.Response, next: ex
295
363
  }
296
364
 
297
365
  const app = express();
298
- app.use(express.json({ limit: maxBodyBytes, verify: capture.verify as never, inflate: false }));
299
- app.use(capture.errorHandler as never);
300
- app.post('/api/submit', guard as never, requireBearerAuth, (_req, res) => {
366
+ app.use(parser);
367
+ app.use(captureErrorHandler);
368
+ app.post('/api/submit', requireBearerAuth, guard, (_req, res) => {
301
369
  res.json({ ok: true });
302
370
  });
303
371
 
@@ -310,6 +378,15 @@ selected API routes. DPoP composition uses existing public vault APIs through
310
378
  the same selected-route pattern. Fingerprint or recognition collection is not
311
379
  implemented by this package.
312
380
 
381
+ Every application auth retry makes a **fresh `fetchWithAttestation` call**
382
+ and obtains a fresh proof; a finite caller-owned `Request` can be reused as
383
+ described below. The wrapper does not automatically retry 401/DPoP challenges.
384
+ For business retries after a lost response or handler failure, persist an
385
+ application idempotency key bound to the authenticated actor and operation,
386
+ atomically with the business effect. A new nonce alone can execute the same
387
+ operation again. Auth, authorization, CSRF policy, and durable business
388
+ idempotency remain application responsibilities.
389
+
313
390
  ### Frontend quickstart (signer client + prepared fetch)
314
391
 
315
392
  Same-origin default. Caller JSON objects are not valid Fetch bodies: callers
@@ -350,10 +427,91 @@ replay, invalid proofs, `5xx`, auth failures, redirects, and network
350
427
  uncertainty never retry. A second stale challenge stops. Returned responses
351
428
  remain readable and retries never reuse the old envelope.
352
429
 
430
+ ### Request reuse, abort, and response ownership
431
+
432
+ With a standard same-realm `Request`, an omitted, `undefined`, or `null`
433
+ `init.body` **inherits** its body (`null` does not remove it). The wrapper
434
+ clones that body synchronously before reading, so finite POST requests support
435
+ repeated and concurrent calls and the original Request remains readable.
436
+ Start inherited-body calls before the caller uses, locks, or cancels its body;
437
+ already-used/locked inherited bodies fail with `SIGNER_PREPARATION_FAILED`.
438
+ Native cloning can replace the Request's internal stream: reuse applies to
439
+ the original Request's current body, not a previously retained raw stream
440
+ reference. Native tees may buffer an unread caller branch outside this
441
+ library's reader-owned byte limit.
442
+
443
+ A non-null `init.body`, including `''` or zero bytes, **replaces** the body.
444
+ An independent replacement leaves the original body alone even if the
445
+ original is used/locked. An explicitly supplied `ReadableStream` transfers
446
+ reading/cancellation ownership after native Request construction succeeds;
447
+ this includes an explicit alias of the original's body and is consumptive.
448
+ Construction failure leaves an unaccepted explicit stream caller-owned.
449
+ `init.mode` is sampled once before `init.body`, which is also sampled once.
450
+ Other options follow native Fetch precedence: `init.headers` replaces rather
451
+ than merges inherited headers, and a nonempty init resets inherited
452
+ `referrerPolicy` unless supplied explicitly. Body overrides inherit Content-Type
453
+ unless you replace the headers. When overriding with `FormData`, supply headers
454
+ without the old/manual Content-Type so Fetch generates the new multipart boundary.
455
+
456
+ The first stale Response stays unconsumed and unlocked until a valid retry
457
+ Request is built. Reload, signing, retry-construction/proof-retention failure,
458
+ or a repeated envelope returns that **same readable first Response**.
459
+ Only after choosing the retry is the abandoned first body cancelled, without
460
+ awaiting producer cleanup. Cancellation rejection is observed; cancellation
461
+ does not guarantee producer, native tee, or connection cleanup has finished.
462
+ Once dispatched, a retry transport failure propagates and there is no third
463
+ attempt or fallback to the abandoned response.
464
+
465
+ During body reading or initial/replacement signer waiting, caller abort
466
+ rejects with the caller's exact reason and abandons only this invocation's
467
+ owned bodies. The shared signer client remains caller-owned and usable;
468
+ the wrapper never disposes it or cancels native module/crypto execution.
469
+ Native Fetch (or the injected `fetch`) owns dispatched request readers and
470
+ must honor the forwarded signal. There is no new API transport deadline;
471
+ use the request signal and an application retry policy for that boundary.
472
+ `materializeRequestBodyBytes` is a lower-level **consuming** helper: clone its
473
+ input yourself if you need reuse.
474
+
475
+ ### Browser transport and body bounds
476
+
477
+ Effective `mode: 'no-cors'` is unsupported because it cannot carry the proof.
478
+ Omitted/undefined mode inherits the Request's mode or native default;
479
+ explicit `cors` or `same-origin` can override it. Choose a browser-writable
480
+ proof header such as `x-client-transaction-id` or `x-request-proof`.
481
+ The wrapper applies these browser constraints even with an injected transport
482
+ or under Node. Statically incompatible modes/names fail before body ownership,
483
+ signer loading, or API calls. Every constructed signed Request is also checked
484
+ for the exact proof; a stripped, normalized, replaced, or appended proof never
485
+ reaches transport on that attempt.
486
+
487
+ In addition to shared HTTP token/length/reserved-name rules, the wrapper rejects
488
+ case-insensitive `sec-*`/`proxy-*`, `Accept-Charset`, `Accept-Encoding`,
489
+ `Access-Control-Request-Headers`, `Access-Control-Request-Method`, `Connection`,
490
+ `Cookie2`, `Date`, `DNT`, `Expect`, `Keep-Alive`, `Referer`, `TE`, `Trailer`,
491
+ `Upgrade`, `Via`, and `User-Agent`. These browser-only failures and `no-cors`
492
+ use `SignerClientError` / `SIGNER_PREPARATION_FAILED`; malformed/reserved names
493
+ already rejected by shared validation use `AttestationProtocolError`.
494
+ Server HTTP header validation remains separate. Ordinary custom names and
495
+ the three method-override fields (`X-HTTP-Method`, `X-HTTP-Method-Override`,
496
+ `X-Method-Override`) accept real envelopes, subject to the final Request check.
497
+
353
498
  Body profile: finite `BodyInit` plus replayable `Request`s within
354
499
  `maxBodyBytes` (default 1 MiB, hard cap 16 MiB) and a bounded read deadline
355
- (default 5000 ms). `FormData` is serialized through a `Request` once;
356
- streaming uploads and non-identity `Content-Encoding` are rejected locally.
500
+ (wrapper option `bodyReadTimeoutMs`, default 5000 ms, integer range 1–60000).
501
+ `materializeRequestBodyBytes` calls the same deadline option `timeoutMs`.
502
+ `FormData` is serialized through a `Request` once. Finite byte streams must
503
+ finish within both bounds; unbounded/stalled uploads and non-identity
504
+ `Content-Encoding` are rejected locally.
505
+
506
+ Body and discovery readers check the cap before copying an overflowing chunk,
507
+ discard empty chunks, and coalesce tiny chunks into geometrically grown byte
508
+ storage. Configured read deadlines are checked between reads, and all readers
509
+ yield periodically to allow caller-abort/deadline tasks to run. Limit/deadline
510
+ failures are local `SignerClientError`s
511
+ (`SIGNER_PREPARATION_FAILED` for bodies); body caller-abort preserves the abort
512
+ reason. Failure signals cancellation, observes its rejection without awaiting
513
+ producer cleanup, and releases the reader lock, timers, and abort listener.
514
+ The storage bound applies to reader-owned bytes, not producer/browser queues.
357
515
 
358
516
  ## Signer assets and caching
359
517
 
@@ -365,46 +523,131 @@ application at a configured base path (default `/attestation`). Routes:
365
523
  `no-store`; it serves the current active key only and contains no
366
524
  session or user credential.
367
525
 
526
+ The application must mount the router at its configured `basePath`:
527
+
528
+ | `basePath` | `publicPathPrefix` | Application mount | External metadata path |
529
+ | ------------------------ | ------------------ | --------------------------------- | ---------------------------- |
530
+ | `/attestation` (default) | omitted | `app.use('/attestation', router)` | `/attestation/signer-meta` |
531
+ | `/` | omitted | `app.use('/', router)` | `/signer-meta` |
532
+ | `/` | `/prefix` | `app.use('/', router)` | `/prefix/signer-meta` |
533
+ | `/custom` | omitted | `app.use('/custom', router)` | `/custom/signer-meta` |
534
+ | `/custom` | `/prefix` | `app.use('/custom', router)` | `/prefix/custom/signer-meta` |
535
+
536
+ At the root mount, the sibling assets are `/signer.<hash>.mjs` and
537
+ `/runtime.<hash>.mjs`, or `/prefix/signer.<hash>.mjs` and
538
+ `/prefix/runtime.<hash>.mjs` behind that stripped prefix. The key module's
539
+ `./runtime.<hash>.mjs` import resolves beside it. `publicPathPrefix` describes
540
+ a prefix already stripped by the upstream proxy; the caller configures that
541
+ proxy and mounts only the internal base path. Published URLs come from trusted
542
+ configuration, never request headers. This asset URL join does not normalize
543
+ signed request paths: duplicate slashes, percent-escape spelling, and query
544
+ order remain significant.
545
+
546
+ Discovery validates `now()` before provider work and again after copying its
547
+ returned snapshot. Each value must be nonnegative safe-integer epoch
548
+ milliseconds. The current key must satisfy `acceptFrom <= nowMs < acceptUntil`
549
+ at the post-read clock; a retained alternate key is not substituted. Inactive
550
+ or missing current keys, provider errors, malformed snapshots, and provider
551
+ timeouts return fixed `503 ATTESTATION_KEYS_UNAVAILABLE`; invalid/throwing
552
+ clocks return `500 ATTESTATION_INTERNAL_ERROR`. Both use `no-store` and no
553
+ stale-key retry marker.
554
+
368
555
  The key-specific ESM wrapper imports `createRequestSigner` from the
369
556
  content-addressed runtime module and exports `signer`. The runtime is the
370
557
  packaged browser `signer.mjs`, so route assets and the npm browser entry
371
558
  share the codec and signer source. The 64-hex content hash identifies exact
372
559
  immutable bytes; never serve different content from the same URL. Assets are
373
560
  public, served as JavaScript with `nosniff`, cacheable with `immutable`
374
- (runtime cap 512 KiB, key module cap 8 KiB). Unknown hashes return `404`;
561
+ (runtime cap 512 KiB, key module cap 8 KiB). Malformed asset paths/hashes return `404`;
375
562
  syntactically valid but not retained hashes return `410`; neither redirects
376
563
  to latest. CORS and CSP stay application-configured.
377
564
 
378
- `fetchSignerBundle` uses bounded metadata (4096-byte cap) with noncached,
565
+ `fetchSignerBundle` reads metadata incrementally (4096-byte cap) with noncached,
379
566
  credential-omitting, redirect-rejecting defaults, validates origin, namespace,
380
567
  and version, and loads via native dynamic `import()` (default loader) for a
381
- URL on the configured API origin. `createSignerClient` owns at most one
382
- active signer and one in-flight load (single-flight, no cross-tab claim) with
383
- generation-aware `invalidate(observedKeyId)` and terminal `dispose()`. A
384
- metadata-to-module retirement race may refetch discovery once; repeated
385
- failure stops. Native import caching cannot be erased by invalidation; do not
386
- claim keys live for only milliseconds.
568
+ URL on the configured API origin. Injected fetch implementations must return
569
+ a standard `Response` with a readable byte body; `arrayBuffer()`/`text()`-only
570
+ substitutes are rejected because they cannot enforce the incremental cap.
571
+
572
+ Standalone `fetchSignerBundle` has a fixed **5000 ms response-body read
573
+ deadline**, starting when the accepted metadata response arrives and reading
574
+ begins. It excludes waiting for response headers and importing/evaluating the
575
+ module. Its `signal` propagates to native discovery fetch and body reading;
576
+ metadata read/limit/abort failures reject with `SIGNER_LOAD_FAILED`.
577
+
578
+ `createSignerClient` owns at most one active signer and one in-flight load
579
+ (single-flight, no cross-tab claim) with
580
+ generation-aware `invalidate(observedKeyId)` and terminal `dispose()`. Its
581
+ `loadTimeoutMs` (default 5000 ms, integer range 1–60000) bounds the **whole load**:
582
+ waiting for discovery headers, body reading, module import, and the one
583
+ retirement refetch. Client metadata has no additional fixed 5000 ms read limit.
584
+ Whole-load expiry rejects with `SIGNER_LOAD_TIMEOUT` and signals metadata
585
+ cancellation. Pending replacements, including explicit recovery after a failed
586
+ replacement, share challenges from **any older issued key**, even multiple
587
+ rotations behind: a late K1 challenge cannot evict the pending K3 replacement
588
+ for K2. An installed newer key survives old-key challenges; a newly installed
589
+ signer can be invalidated again even with the same key ID. Initial-load
590
+ invalidation is supported and malformed observed key IDs are ignored.
591
+
592
+ A metadata-to-module retirement race may refetch discovery once. Recovery
593
+ compares validated `(keyId, absolute module URL)` identity: a changed URL can
594
+ recover under the same key, while equivalent URLs count as unchanged. Unchanged
595
+ identity or a second module failure ends that load; a later explicit
596
+ `getSigner()` can recover. This does not add API transport retries.
597
+ Timeout stops waiting; terminal `dispose()` signals the current metadata fetch
598
+ and prevents late installation. Obsolete loads cannot alter a replacement.
599
+ Neither operation cancels native module evaluation or erases its cache.
600
+ `createRequestSigner` also shares lazy WebCrypto key import: concurrent failure
601
+ rejects with `SignerClientError`, clears that failed import, and permits a later
602
+ explicit sign to retry. Public material may remain in the native module cache.
387
603
 
388
604
  ## Time policy
389
605
 
390
606
  ```text
391
607
  reject future proof if timestampMs > nowMs + clockSkewMs
392
- timeDeadline = timestampMs + maxAgeMs + clockSkewMs
393
- proofDeadline = min(timeDeadline, acceptedKey.acceptUntil)
394
- reject when nowMs >= proofDeadline or acceptedKey is not active at nowMs
395
- retainUntil = proofDeadline + clusterClockGuardMs
608
+ timeDeadlineMs = timestampMs + maxAgeMs + clockSkewMs
609
+ proofDeadlineMs = min(timeDeadlineMs, acceptedKey.acceptUntil)
610
+ reject when nowMs >= proofDeadlineMs or acceptedKey is not active at nowMs
611
+ retainUntilMs = proofDeadlineMs + clusterClockGuardMs
396
612
  ```
397
613
 
398
614
  Defaults: `maxAgeMs 30000` (1000–120000), `clockSkewMs 5000` (0–30000),
399
615
  `clusterClockGuardMs 5000` (0–30000). The last is the declared maximum
400
616
  pairwise clock difference between verifiers and replay stores, not an extra
401
617
  freshness allowance. Maximum remaining retention at a verifier is
402
- `maxAgeMs + 2 * clockSkewMs + clusterClockGuardMs` (default at most 45000 ms);
403
- the store admission bound adds one more guard (default at most 50000 ms,
404
- global cap 240000 ms). Retain through the whole acceptance period; do not
405
- clamp, slide on duplicate, or use a different Redis formula. Repeat the
406
- time and deadline check after async provider work, immediately before
407
- reservation, and before calling the handler after reservation.
618
+ `maxAgeMs + 2 * clockSkewMs + clusterClockGuardMs` (default at most 45000 ms).
619
+ A store clock trailing that verifier by one guard can observe at most
620
+ `maxAgeMs + 2 * clockSkewMs + 2 * clusterClockGuardMs` (default 50000 ms).
621
+ Built-in stores enforce the global 240000 ms ceiling; they receive absolute
622
+ deadlines, not a policy registry. Verifiers must derive the exact policy
623
+ deadline. Retain through the whole acceptance period; do not clamp, slide on
624
+ duplicate, or use a different Redis formula. Repeat the time/deadline check
625
+ after async provider work, immediately before reservation, and before calling
626
+ the handler after reservation.
627
+
628
+ **One protection space requires one time policy.** All verifiers for the same
629
+ `(publicOrigin, replayNamespace)`, including guards on different routes, must
630
+ share replay state and identical `maxAgeMs`, `clockSkewMs`, and
631
+ `clusterClockGuardMs`. Every accepted key ID must have identical bytes,
632
+ `acceptFrom`, and `acceptUntil` across those verifiers. Keep verifier/store
633
+ clocks within the declared pairwise guard (Redis uses server `TIME`; memory
634
+ and Mongo use their configured app clock). Shared capacity settings must also
635
+ match. A short-policy verifier can otherwise discard a nonce while a
636
+ long-policy verifier still accepts its proof; shared storage alone cannot
637
+ repair heterogeneous policies. Custom stores receive the absolute
638
+ `reserve({ replayKey, retainUntilMs })` deadline, not a relative TTL.
639
+
640
+ For a live policy migration, provision a **new `replayNamespace` and fresh key
641
+ IDs**, deploy matching signer/client and guard configuration together, and
642
+ keep any draining old namespace uniformly on its old policy and shared replay
643
+ ledger. Retain old replay state through the last possible old `retainUntilMs`
644
+ (including future-skew and clock-guard allowances), then retire that protection
645
+ space. Namespace changes require new client configuration; stale-key reload
646
+ alone does not change the client's pinned namespace. Do not rolling-mix policy
647
+ values in one space, extend the acceptance interval of a live key ID, or
648
+ reinterpret existing reservations under a longer window. Duplicate admission
649
+ never extends retention, and the built-in stores offer no migration API to
650
+ retroactively recover expired replay history.
408
651
 
409
652
  Replay key: `att:v1:` plus SHA-256 of `[replayNamespace, publicOrigin,
410
653
  nonceHex]`. It carries no instance ID, path, token, session, or key ID, so
@@ -420,6 +663,14 @@ bytes, opaque replay keys 1–256 printable ASCII bytes. `publicOrigin` is
420
663
  required, static, canonical HTTP(S) (HTTPS except loopback); never derive it
421
664
  from `Host`, `Forwarded`, or `X-Forwarded-*`.
422
665
 
666
+ The guard rejects missing/empty proofs as `403 ATTESTATION_MISSING` and
667
+ duplicate, comma-joined, oversized, or noncanonical proofs as
668
+ `403 ATTESTATION_MALFORMED` before provider/replay/handler work, even if the
669
+ provider is unavailable. The 1024-byte envelope cap is checked before UTF-8
670
+ allocation. Canonical envelopes cannot start with a UTF-8 BOM. Raw duplicate
671
+ `Content-Type` fields are malformed even when identical; commas inside a
672
+ single field are preserved.
673
+
423
674
  ## Errors
424
675
 
425
676
  All verifier failures use fixed `{ code, message }` JSON with
@@ -445,7 +696,11 @@ adds `X-Attestation-Error: stale-key`.
445
696
 
446
697
  Unknown or inactive well-formed key IDs select `STALE_KEY`; a known active
447
698
  key with an elapsed timestamp selects `EXPIRED`. Provider snapshot failure
448
- selects `KEYS_UNAVAILABLE`, never an unknown-key result.
699
+ selects `KEYS_UNAVAILABLE`, never an unknown-key result. This includes provider
700
+ throws/rejections (even typed stale/expired errors), timeouts, malformed
701
+ snapshots, and throwing snapshot accessors: all use the fixed 503 message and
702
+ never carry the stale marker. Clock, body, and replay-store failures keep their
703
+ separate codes above.
449
704
 
450
705
  ## Rotation runbook
451
706
 
@@ -459,10 +714,9 @@ Finite acceptance is application-configured, never automatically
459
714
 
460
715
  Do not make one instance the authoritative random-key generator for an
461
716
  uncoordinated cluster. Before dropping assets needed by published metadata,
462
- account for the one bounded refetch-on-retirement client behavior. Do not
463
- increase lifetime, skew, or guard for already-live proofs without first
464
- retaining their replay state through the longer window or waiting for the old
465
- profile to expire.
717
+ account for the one bounded refetch-on-retirement client behavior. For policy
718
+ changes follow the protection-space migration above; extending old key
719
+ deadlines or mixing lifetime/skew/guard values is not a rotation strategy.
466
720
 
467
721
  ## Runtime requirements
468
722
 
@@ -477,17 +731,48 @@ profile to expire.
477
731
  expose `X-Attestation-Error` so the wrapper can see the retry marker; allow
478
732
  credentialed origins only when explicitly using `credentials: 'include'`.
479
733
  Native module imports require `script-src` plus CORS for the public module
480
- URLs. Same-origin uses the native default; cross-origin deployments
481
- explicitly configure CORS for metadata and module fetches (which use
482
- `cache: no-store`, `credentials: omit`, `redirect: error`).
734
+ URLs, and `connect-src` must allow metadata/API fetches. Configure actual
735
+ origins/methods and let OPTIONS preflight reach the CORS handler before the
736
+ attestation guard. Missing marker exposure leaves the first 403 visible but
737
+ prevents stale-key retry. Discovery fetch uses `cache: no-store`,
738
+ `credentials: omit`, `redirect: error`; native dynamic imports follow browser
739
+ module-fetch/cache rules, not those injected fetch options. Serve both key
740
+ and runtime modules with suitable CORS, permit their origins in CSP, and do
741
+ not redirect modules to other origins.
742
+
743
+ ## Compatibility notes
744
+
745
+ The v1 MAC tuple, named exports, two entrypoints, and absolute `retainUntilMs`
746
+ reservation API are unchanged. Consumers of earlier, more permissive inputs
747
+ should account for these tightened contracts:
748
+
749
+ - Proof framing is checked before providers; BOM envelopes and duplicate raw
750
+ `Content-Type` fields reject. Provider failures always produce sanitized
751
+ 503 responses rather than stale-key challenges.
752
+ - Metadata fetch substitutes must expose a readable byte stream. Standalone
753
+ discovery's 5-second body-read deadline differs from the configurable
754
+ whole-client-load deadline described above.
755
+ - Inherited Request bodies remain reusable; explicit non-null bodies override,
756
+ `null` inherits, and mode/body getters are sampled once in that order. Failed
757
+ retry preparation returns readable first-response data; caller abort rejects.
758
+ - `no-cors` and browser-forbidden proof fields fail locally, including under
759
+ injected/Node transports. The final Request must retain the exact proof.
760
+ - Replacement loads coalesce challenges across older key generations. A changed
761
+ validated module URL permits the single same-key retirement refetch.
762
+ - Root asset mounts publish single-slash paths. Discovery rechecks the current
763
+ key after provider waits instead of publishing a key that became inactive.
483
764
 
484
765
  ## Verification commands
485
766
 
486
- From the repository root:
767
+ Contributor checks, from the repository root after dependency installation.
768
+ Build first: declaration checks and runtime/consumer lanes read the built
769
+ output and do not rebuild it implicitly.
487
770
 
488
771
  ```sh
489
772
  pnpm --filter @web-ts-toolkit/express-request-attestation... build
490
773
  pnpm --filter @web-ts-toolkit/express-request-attestation typecheck
774
+ # Included in typecheck; this runs only the built-declaration fixtures:
775
+ pnpm --filter @web-ts-toolkit/express-request-attestation typecheck:declarations
491
776
  pnpm --filter @web-ts-toolkit/express-request-attestation test:node
492
777
  pnpm --filter @web-ts-toolkit/express-request-attestation test:redis
493
778
  pnpm --filter @web-ts-toolkit/express-request-attestation test:mongo
@@ -497,8 +782,19 @@ pnpm --filter @web-ts-toolkit/express-request-attestation exec npm pack --dry-ru
497
782
  pnpm --filter @web-ts-toolkit/express-request-attestation test
498
783
  ```
499
784
 
500
- `pnpm test` builds once, then runs Node, live Redis, live MongoDB, browser,
501
- and tarball lanes serially (AGENTS.md serial-build rule). Prerequisites:
785
+ `typecheck` runs source and browser-only source checks, then
786
+ `typecheck:declarations` runs all three configs in `test-decl-consumer/`:
787
+ `tsconfig-nodenext.json` (both entries under `.mts` import and `.cts` require),
788
+ `tsconfig-bundler.json`, and `tsconfig-browser.json` (`types: []`). All use
789
+ public package imports against built declarations with `strict: true` and
790
+ `skipLibCheck: false`; no source path mapping stands in for the package.
791
+ `test:packed-consumer` installs the release-shaped tarball in isolated backend
792
+ and browser projects, compiles actual Express handler assignments and the
793
+ installed README examples, and checks CJS/ESM and package inventories.
794
+
795
+ The filtered package `test` command builds once, then runs Node, live Redis,
796
+ live MongoDB, browser, and tarball lanes serially; never overlap builds writing
797
+ the same output directory. Prerequisites:
502
798
  Docker for isolated Redis 6.2/7.2 tests, a MongoDB replica-set binary
503
799
  download (registry/network) for the live `test:mongo` lane, installed
504
800
  Playwright Chromium, and registry access for fresh tarball consumers. A