@web-ts-toolkit/express-request-attestation 0.48.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 ADDED
@@ -0,0 +1,505 @@
1
+ # `@web-ts-toolkit/express-request-attestation`
2
+
3
+ Versioned per-request HMAC signing and replay detection for Express APIs,
4
+ with a browser-safe signer entry. One installable package, two public
5
+ entries: `.` for Express, `./signer` for the browser.
6
+
7
+ ## Guarantee and limitation
8
+
9
+ A verifier checks that a request's signed fields match a currently accepted
10
+ key, that its timestamp is within policy, and that its nonce has not already
11
+ been admitted in the same protection space.
12
+
13
+ **A key delivered in public JavaScript is accessible to every caller.**
14
+ Encoding, minification, and rotation do not make it a confidential
15
+ authentication credential: any script can fetch the current material and
16
+ generate fresh valid signatures without a browser. This package therefore
17
+ provides request-format consistency and detection of repeated signed
18
+ submissions while replay state survives. It does **not** establish
19
+ browser/device authenticity, a human user, session ownership, application
20
+ authorization, or reliable bot prevention. OIDC/session/DPoP validation
21
+ continues to establish its own authentication boundary, and the public HMAC
22
+ key must never be reused for JWT signing, OIDC client secrets, cookies,
23
+ CSRF secrets, DPoP challenges, or other confidential server operations.
24
+
25
+ Rotation limits how long old material is accepted; it does not stop a caller
26
+ from downloading the next version. Nonce admission does not prevent abuse
27
+ using newly generated nonces. Replay admission is not business-operation
28
+ idempotency or exactly-once delivery; durable idempotency is
29
+ application-owned. Never release a successful reservation after handler
30
+ failure or a lost response; a fresh proof is required on a new attempt.
31
+
32
+ ## Install
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.
38
+
39
+ ```sh
40
+ # Backend (Express >= 5 required at runtime)
41
+ pnpm add @web-ts-toolkit/express-request-attestation express
42
+ # Browser-only (no Express needed)
43
+ pnpm add @web-ts-toolkit/express-request-attestation
44
+ # With shared Redis replay state (backend only)
45
+ pnpm add redis
46
+ # With shared MongoDB replay state (backend only, replica set required)
47
+ pnpm add mongodb
48
+ ```
49
+
50
+ ## Entry imports
51
+
52
+ ```ts
53
+ // Server: middleware, body capture, key providers, stores, asset router.
54
+ import {
55
+ createAttestationBodyCapture,
56
+ createMemoryAttestationStore,
57
+ createMongoAttestationStore,
58
+ createRedisAttestationStore,
59
+ createRequestAttestationMiddleware,
60
+ createSignerBundleRouter,
61
+ createStaticKeyProvider,
62
+ generateAttestationKey,
63
+ } from '@web-ts-toolkit/express-request-attestation';
64
+ ```
65
+
66
+ ```ts
67
+ // Browser: WebCrypto signer, module client, prepared fetch. Never imports
68
+ // Express, Redis, MongoDB, Buffer, or node:*.
69
+ import {
70
+ createRequestSigner,
71
+ createSignerClient,
72
+ fetchWithAttestation,
73
+ } from '@web-ts-toolkit/express-request-attestation/signer';
74
+ ```
75
+
76
+ Only `.` and `./signer` are public. No default exports and no deep
77
+ `src/*` or `dist/*` imports.
78
+
79
+ ### Backend quickstart (guard + capture + assets)
80
+
81
+ Complete parser/capture/guard/asset wiring. Mount the guard on selected API
82
+ routes only; asset, preflight, OIDC navigation, callback, and backchannel
83
+ routes stay reachable under their own policies.
84
+
85
+ ```ts
86
+ import express from 'express';
87
+ import {
88
+ createAttestationBodyCapture,
89
+ createMemoryAttestationStore,
90
+ createRequestAttestationMiddleware,
91
+ createSignerBundleRouter,
92
+ createStaticKeyProvider,
93
+ generateAttestationKey,
94
+ } from '@web-ts-toolkit/express-request-attestation';
95
+
96
+ const publicOrigin = 'https://api.example.com';
97
+ const replayNamespace = 'my-app-prod';
98
+ const maxBodyBytes = 1024 * 1024;
99
+ const now = Date.now();
100
+ const entry = generateAttestationKey('v1-20261002-01', {
101
+ acceptFrom: now - 60_000,
102
+ acceptUntil: now + 3_600_000,
103
+ });
104
+ const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
105
+ const store = createMemoryAttestationStore();
106
+ const capture = createAttestationBodyCapture({ maxBodyBytes });
107
+ const guard = createRequestAttestationMiddleware({
108
+ publicOrigin,
109
+ replayNamespace,
110
+ keyProvider,
111
+ store,
112
+ });
113
+
114
+ const app = express();
115
+ 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) => {
119
+ res.json({ ok: true });
120
+ });
121
+
122
+ export { app };
123
+ ```
124
+
125
+ Parser and wrapper limits must match (`maxBodyBytes`, default 1 MiB, hard
126
+ cap 16 MiB). Always use `inflate: false` so signed bytes and captured bytes
127
+ are the same representation. V1 accepts identity or absent
128
+ `Content-Encoding` only; compression is rejected. An actually empty request
129
+ uses SHA-256 of zero bytes. A nonempty body whose parser failed to capture
130
+ it is `500 ATTESTATION_BODY_CAPTURE_REQUIRED`, not the empty digest.
131
+
132
+ ### Public keyring provisioning
133
+
134
+ One public keyring example. Provisioning is application-owned (cron, KMS, or
135
+ config); the library performs explicit atomic replacement with no polling
136
+ timer and no hardcoded grace policy.
137
+
138
+ ```ts
139
+ import { createRotatingKeyProvider, generateAttestationKey } from '@web-ts-toolkit/express-request-attestation';
140
+
141
+ const now = Date.now();
142
+ const oldEntry = generateAttestationKey('v1-20261002-01', {
143
+ acceptFrom: now - 60_000,
144
+ acceptUntil: now + 3_600_000,
145
+ });
146
+ const provider = createRotatingKeyProvider({ currentKeyId: oldEntry.keyId, keys: [oldEntry] });
147
+
148
+ const nextEntry = generateAttestationKey('v1-20261003-01', {
149
+ acceptFrom: now,
150
+ acceptUntil: now + 3_600_000,
151
+ });
152
+ provider.replace({ currentKeyId: nextEntry.keyId, keys: [oldEntry, nextEntry] });
153
+
154
+ export { provider };
155
+ ```
156
+
157
+ Snapshots are owned and copy-isolated (max 16 keys). `currentKeyId` must
158
+ name a present entry. Key IDs are unique across provisioning history:
159
+ providers reject remapping a retained ID to different bytes or a different
160
+ acceptance interval. Generate unique version/random IDs and never reintroduce
161
+ a retired ID with different material. Rejected replacements leave the previous
162
+ valid snapshot intact.
163
+
164
+ ### Redis store wiring
165
+
166
+ Structural injection only. The library never creates, connects, or closes
167
+ clients and never imports a Redis driver.
168
+
169
+ ```ts
170
+ import { createClient } from 'redis';
171
+ import {
172
+ createRedisAttestationStore,
173
+ createStaticKeyProvider,
174
+ generateAttestationKey,
175
+ } from '@web-ts-toolkit/express-request-attestation';
176
+
177
+ const now = Date.now();
178
+ const entry = generateAttestationKey('v1-redis-01', {
179
+ acceptFrom: now - 60_000,
180
+ acceptUntil: now + 3_600_000,
181
+ });
182
+ const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
183
+ const client = createClient({ url: 'redis://127.0.0.1:6379' });
184
+ await client.connect();
185
+ const store = createRedisAttestationStore({ client, keyPrefix: 'wtt-attestation', maxEntries: 50000 });
186
+
187
+ export { client, keyProvider, store };
188
+ ```
189
+
190
+ One same-slot sorted set per shared prefix holds opaque replay keys with
191
+ absolute `retainUntil` scores. All clients sharing a prefix must configure
192
+ identical `maxEntries`. Size the ledger as unique accepted requests per second
193
+ times maximum retention plus headroom; actual Redis memory and throughput
194
+ require measurement. Use a dedicated `noeviction` deployment when relying on
195
+ window-wide admission history: eviction, restore, state loss, or failover can
196
+ erase replay reservations. Client lifecycle stays caller-owned.
197
+
198
+ ### Mongo store wiring
199
+
200
+ Structural injection only. The library never creates, connects, or closes
201
+ clients and never imports a MongoDB driver. Production deployments require
202
+ a replica set (transactions).
203
+
204
+ ```ts
205
+ import { MongoClient } from 'mongodb';
206
+ import {
207
+ createMongoAttestationStore,
208
+ createStaticKeyProvider,
209
+ generateAttestationKey,
210
+ } from '@web-ts-toolkit/express-request-attestation';
211
+
212
+ const now = Date.now();
213
+ const entry = generateAttestationKey('v1-mongo-01', {
214
+ acceptFrom: now - 60_000,
215
+ acceptUntil: now + 3_600_000,
216
+ });
217
+ const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
218
+ const client = new MongoClient('mongodb://127.0.0.1:27017/?replicaSet=rs0');
219
+ await client.connect();
220
+ const store = createMongoAttestationStore({
221
+ db: client.db('app-attestation'),
222
+ proofsCollectionName: 'attestation_proofs',
223
+ capacityCollectionName: 'attestation_replay_capacity',
224
+ maxEntries: 50000,
225
+ });
226
+
227
+ export { client, keyProvider, store };
228
+ ```
229
+
230
+ One proof collection with unique `_id`s plus a separate non-TTL
231
+ capacity/accounting collection with one shared capacity row holds opaque
232
+ replay keys with absolute `retainUntilMs` deadlines. Each admission runs one
233
+ snapshot transaction: duplicate check, bounded expiry reclaim (the requested
234
+ expired entry plus at most 64 expired entries), capacity check, then insert.
235
+ 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.
240
+
241
+ All clients sharing one database/collections must configure identical
242
+ `maxEntries` (default 50000, positive safe integer) and synchronized clocks
243
+ within the configured `clusterClockGuardMs`; an invalid clock fails closed
244
+ without touching the database. Global retention is capped at 240000 ms
245
+ without truncation. Size the ledger as unique accepted requests per second
246
+ times maximum retention plus headroom; each admission costs one snapshot
247
+ transaction and actual MongoDB memory and throughput require measurement on
248
+ your deployment. Capacity throws the shared `AttestationCapacityError`;
249
+ transport, topology, transaction, and malformed-row failures throw the narrow
250
+ `AttestationMongoStoreError` mapped to `ATTESTATION_REPLAY_UNAVAILABLE`;
251
+ malformed inputs throw `AttestationProtocolError` without touching the
252
+ database. Client lifecycle stays caller-owned (connect/close the
253
+ `MongoClient` in application code).
254
+
255
+ 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
257
+ deployments already run a replica set and prefer shared replay without
258
+ operating Redis, accepting one transaction per admission.
259
+
260
+ ### Bearer-auth composition
261
+
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.
265
+
266
+ ```ts
267
+ import express from 'express';
268
+ import {
269
+ createAttestationBodyCapture,
270
+ createMemoryAttestationStore,
271
+ createRequestAttestationMiddleware,
272
+ createStaticKeyProvider,
273
+ generateAttestationKey,
274
+ } from '@web-ts-toolkit/express-request-attestation';
275
+
276
+ const publicOrigin = 'https://api.example.com';
277
+ const replayNamespace = 'my-app-prod';
278
+ const maxBodyBytes = 1024 * 1024;
279
+ const now = Date.now();
280
+ const entry = generateAttestationKey('v1-bearer-01', {
281
+ acceptFrom: now - 60_000,
282
+ acceptUntil: now + 3_600_000,
283
+ });
284
+ const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
285
+ const store = createMemoryAttestationStore();
286
+ const capture = createAttestationBodyCapture({ maxBodyBytes });
287
+ const guard = createRequestAttestationMiddleware({ publicOrigin, replayNamespace, keyProvider, store });
288
+
289
+ function requireBearerAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {
290
+ if (req.headers.authorization !== 'Bearer good-token') {
291
+ res.status(401).json({ code: 'UNAUTHORIZED' });
292
+ return;
293
+ }
294
+ next();
295
+ }
296
+
297
+ 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) => {
301
+ res.json({ ok: true });
302
+ });
303
+
304
+ export { app, requireBearerAuth };
305
+ ```
306
+
307
+ Mount an OIDC vault router once at its own base path (for example
308
+ `basePath: '/auth/oidc'`); never double-prefix. The guard applies only to
309
+ selected API routes. DPoP composition uses existing public vault APIs through
310
+ the same selected-route pattern. Fingerprint or recognition collection is not
311
+ implemented by this package.
312
+
313
+ ### Frontend quickstart (signer client + prepared fetch)
314
+
315
+ Same-origin default. Caller JSON objects are not valid Fetch bodies: callers
316
+ stringify first. The wrapper materializes those exact bytes once, signs them,
317
+ and reuses them verbatim for the single stale-key retry (multipart
318
+ boundaries preserved).
319
+
320
+ ```ts
321
+ import { createSignerClient, fetchWithAttestation } from '@web-ts-toolkit/express-request-attestation/signer';
322
+
323
+ const apiOrigin = 'https://api.example.com';
324
+ const replayNamespace = 'my-app-prod';
325
+ const signerClient = createSignerClient({
326
+ metadataUrl: `${apiOrigin}/attestation/signer-meta`,
327
+ apiOrigin,
328
+ replayNamespace,
329
+ });
330
+
331
+ const response = await fetchWithAttestation(
332
+ `${apiOrigin}/api/submit`,
333
+ {
334
+ method: 'POST',
335
+ headers: { 'content-type': 'application/json' },
336
+ body: JSON.stringify({ hello: 'world' }),
337
+ },
338
+ { signerClient, apiOrigin, replayNamespace },
339
+ );
340
+
341
+ export { response, signerClient };
342
+ ```
343
+
344
+ Only the configured `apiOrigin` is signed; external URLs fail locally without
345
+ sending `Authorization` or a signature. Explicit `credentials` are preserved
346
+ (native default `same-origin`, never defaulted to `include`). Signed attempts
347
+ use `redirect: 'manual'` and never forward a signature. Exactly one automatic
348
+ retry, only on `403` plus `X-Attestation-Error: stale-key`; expired, future,
349
+ replay, invalid proofs, `5xx`, auth failures, redirects, and network
350
+ uncertainty never retry. A second stale challenge stops. Returned responses
351
+ remain readable and retries never reuse the old envelope.
352
+
353
+ Body profile: finite `BodyInit` plus replayable `Request`s within
354
+ `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.
357
+
358
+ ## Signer assets and caching
359
+
360
+ `createSignerBundleRouter` returns a relative router mounted by the
361
+ application at a configured base path (default `/attestation`). Routes:
362
+ `GET /signer-meta`, `GET /signer.<contenthash>.mjs`, and
363
+ `GET /runtime.<contenthash>.mjs`. Metadata is bounded JSON
364
+ (`{ version: 1, keyId, signerUrl, publicOrigin, replayNamespace }`) with
365
+ `no-store`; it serves the current active key only and contains no
366
+ session or user credential.
367
+
368
+ The key-specific ESM wrapper imports `createRequestSigner` from the
369
+ content-addressed runtime module and exports `signer`. The runtime is the
370
+ packaged browser `signer.mjs`, so route assets and the npm browser entry
371
+ share the codec and signer source. The 64-hex content hash identifies exact
372
+ immutable bytes; never serve different content from the same URL. Assets are
373
+ public, served as JavaScript with `nosniff`, cacheable with `immutable`
374
+ (runtime cap 512 KiB, key module cap 8 KiB). Unknown hashes return `404`;
375
+ syntactically valid but not retained hashes return `410`; neither redirects
376
+ to latest. CORS and CSP stay application-configured.
377
+
378
+ `fetchSignerBundle` uses bounded metadata (4096-byte cap) with noncached,
379
+ credential-omitting, redirect-rejecting defaults, validates origin, namespace,
380
+ 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.
387
+
388
+ ## Time policy
389
+
390
+ ```text
391
+ 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
396
+ ```
397
+
398
+ Defaults: `maxAgeMs 30000` (1000–120000), `clockSkewMs 5000` (0–30000),
399
+ `clusterClockGuardMs 5000` (0–30000). The last is the declared maximum
400
+ pairwise clock difference between verifiers and replay stores, not an extra
401
+ 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.
408
+
409
+ Replay key: `att:v1:` plus SHA-256 of `[replayNamespace, publicOrigin,
410
+ nonceHex]`. It carries no instance ID, path, token, session, or key ID, so
411
+ nonce single-use spans accepted rotations in one protection space. Distinct
412
+ applications use distinct namespaces or origins.
413
+
414
+ Default header: `x-client-transaction-id` (valid HTTP token, no auth, cookie,
415
+ host, origin, body-metadata, or response-marker collision). One incoming
416
+ signature field only; duplicates and comma-joined presentations are rejected.
417
+ Limits: encoded ID 1024 bytes, canonical request target 8192 bytes, content
418
+ type 256 bytes, namespace `[A-Za-z0-9_-]{1,128}`, canonical public origin 512
419
+ bytes, opaque replay keys 1–256 printable ASCII bytes. `publicOrigin` is
420
+ required, static, canonical HTTP(S) (HTTPS except loopback); never derive it
421
+ from `Host`, `Forwarded`, or `X-Forwarded-*`.
422
+
423
+ ## Errors
424
+
425
+ All verifier failures use fixed `{ code, message }` JSON with
426
+ `Cache-Control: no-store`. No raw key, nonce, signature, body, provider
427
+ error, or requested key ID is echoed. Only a pre-handler stale-key rejection
428
+ adds `X-Attestation-Error: stale-key`.
429
+
430
+ | HTTP | Code | Fixed message |
431
+ | ---- | ----------------------------------- | ----------------------------------------------------------------- |
432
+ | 403 | `ATTESTATION_MISSING` | Request signature is required. |
433
+ | 403 | `ATTESTATION_MALFORMED` | Request signature is invalid. |
434
+ | 403 | `ATTESTATION_STALE_KEY` | Request signing key is no longer accepted. |
435
+ | 403 | `ATTESTATION_EXPIRED` | Request signature has expired. |
436
+ | 403 | `ATTESTATION_FUTURE` | Request signature timestamp is ahead of the allowed clock window. |
437
+ | 403 | `ATTESTATION_INVALID_SIGNATURE` | Request signature does not match. |
438
+ | 403 | `ATTESTATION_REPLAY` | Request signature was already used. |
439
+ | 413 | `ATTESTATION_BODY_TOO_LARGE` | Request body exceeds the configured byte limit. |
440
+ | 415 | `ATTESTATION_UNSUPPORTED_ENCODING` | Request body encoding is unsupported. |
441
+ | 500 | `ATTESTATION_BODY_CAPTURE_REQUIRED` | Request body capture is not configured. |
442
+ | 500 | `ATTESTATION_INTERNAL_ERROR` | Request signature verification failed. |
443
+ | 503 | `ATTESTATION_KEYS_UNAVAILABLE` | Request signing keys are unavailable. |
444
+ | 503 | `ATTESTATION_REPLAY_UNAVAILABLE` | Request replay protection is unavailable. |
445
+
446
+ Unknown or inactive well-formed key IDs select `STALE_KEY`; a known active
447
+ key with an elapsed timestamp selects `EXPIRED`. Provider snapshot failure
448
+ selects `KEYS_UNAVAILABLE`, never an unknown-key result.
449
+
450
+ ## Rotation runbook
451
+
452
+ Finite acceptance is application-configured, never automatically
453
+ `2 × rotation interval`:
454
+
455
+ 1. Prepare new material on **all** verifiers.
456
+ 2. Publish current metadata and module.
457
+ 3. Retain old material to its explicit deadline for overlap.
458
+ 4. Retire only after overlap requirements are met.
459
+
460
+ Do not make one instance the authoritative random-key generator for an
461
+ 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.
466
+
467
+ ## Runtime requirements
468
+
469
+ - **Node** `>= 22` (see `engines`).
470
+ - **Browser**: a secure context with `TextEncoder`/`TextDecoder`,
471
+ `crypto.subtle` (HMAC-SHA-256, non-extractable keys), and
472
+ `crypto.getRandomValues`, feature-detected on use. Real Chromium is
473
+ exercised in `test:browser`; jsdom alone is not evidence. No specific
474
+ historical browser versions are claimed.
475
+ - **CORS/CSP** (application-configured): allow the configured signature field
476
+ plus actual `Content-Type`, `Authorization`, or DPoP headers as needed;
477
+ expose `X-Attestation-Error` so the wrapper can see the retry marker; allow
478
+ credentialed origins only when explicitly using `credentials: 'include'`.
479
+ 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`).
483
+
484
+ ## Verification commands
485
+
486
+ From the repository root:
487
+
488
+ ```sh
489
+ pnpm --filter @web-ts-toolkit/express-request-attestation... build
490
+ pnpm --filter @web-ts-toolkit/express-request-attestation typecheck
491
+ pnpm --filter @web-ts-toolkit/express-request-attestation test:node
492
+ pnpm --filter @web-ts-toolkit/express-request-attestation test:redis
493
+ pnpm --filter @web-ts-toolkit/express-request-attestation test:mongo
494
+ pnpm --filter @web-ts-toolkit/express-request-attestation test:browser
495
+ pnpm --filter @web-ts-toolkit/express-request-attestation test:packed-consumer
496
+ pnpm --filter @web-ts-toolkit/express-request-attestation exec npm pack --dry-run --json
497
+ pnpm --filter @web-ts-toolkit/express-request-attestation test
498
+ ```
499
+
500
+ `pnpm test` builds once, then runs Node, live Redis, live MongoDB, browser,
501
+ and tarball lanes serially (AGENTS.md serial-build rule). Prerequisites:
502
+ Docker for isolated Redis 6.2/7.2 tests, a MongoDB replica-set binary
503
+ download (registry/network) for the live `test:mongo` lane, installed
504
+ Playwright Chromium, and registry access for fresh tarball consumers. A
505
+ missing prerequisite blocks that lane instead of silently passing it.