@openvtc/trust-tasks 0.12.17 → 0.13.1

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.
Files changed (37) hide show
  1. package/README.md +27 -3
  2. package/dist/_runtime/canonical.d.ts +40 -0
  3. package/dist/_runtime/canonical.d.ts.map +1 -0
  4. package/dist/_runtime/canonical.js +147 -0
  5. package/dist/_runtime/canonical.js.map +1 -0
  6. package/dist/_runtime/consume.d.ts +108 -2
  7. package/dist/_runtime/consume.d.ts.map +1 -1
  8. package/dist/_runtime/consume.js +174 -8
  9. package/dist/_runtime/consume.js.map +1 -1
  10. package/dist/_runtime/document.d.ts +17 -4
  11. package/dist/_runtime/document.d.ts.map +1 -1
  12. package/dist/_runtime/document.js +17 -4
  13. package/dist/_runtime/document.js.map +1 -1
  14. package/dist/_runtime/freshness.d.ts +105 -0
  15. package/dist/_runtime/freshness.d.ts.map +1 -0
  16. package/dist/_runtime/freshness.js +142 -0
  17. package/dist/_runtime/freshness.js.map +1 -0
  18. package/dist/_runtime/index.d.ts +4 -1
  19. package/dist/_runtime/index.d.ts.map +1 -1
  20. package/dist/_runtime/index.js +4 -1
  21. package/dist/_runtime/index.js.map +1 -1
  22. package/dist/_runtime/replay.d.ts +176 -0
  23. package/dist/_runtime/replay.d.ts.map +1 -0
  24. package/dist/_runtime/replay.js +147 -0
  25. package/dist/_runtime/replay.js.map +1 -0
  26. package/dist/keys/create/0.1/payload.d.ts +14 -0
  27. package/dist/keys/create/0.1/payload.d.ts.map +1 -1
  28. package/dist/keys/create/0.1/payload.js +5 -0
  29. package/dist/keys/create/0.1/payload.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/_runtime/canonical.ts +159 -0
  32. package/src/_runtime/consume.ts +267 -10
  33. package/src/_runtime/document.ts +17 -4
  34. package/src/_runtime/freshness.ts +183 -0
  35. package/src/_runtime/index.ts +30 -0
  36. package/src/_runtime/replay.ts +250 -0
  37. package/src/keys/create/0.1/payload.ts +9 -0
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Inbound-document orchestration for SPEC.md §7.2 item 2 and items 4–8.
2
+ * Inbound-document orchestration for SPEC.md §7.2 item 2, items 4–8, and the
3
+ * two stateful checks — the freshness bound of item 4 and the
4
+ * duplicate-execution record of item 11.
3
5
  *
4
6
  * Hand-written. Mirrors `consume.rs` in trust-tasks-rs, deliberately closely:
5
7
  * a TypeScript consumer and a Rust one must reach the same verdict on the same
6
8
  * document, or the two reference implementations disagree about what conforms.
7
9
  *
8
10
  * ```ts
9
- * import { consumeInbound, StaticTransport, AclGrant_v0_1 } from "@openvtc/trust-tasks";
11
+ * import {
12
+ * consequentialChecks, consumeInbound, InMemoryReplayGuard,
13
+ * StaticTransport, AclGrant_v0_1,
14
+ * } from "@openvtc/trust-tasks";
15
+ *
16
+ * // One guard per consumer, not one per document: it *is* the record.
17
+ * const guard = new InMemoryReplayGuard();
10
18
  *
11
19
  * const outcome = await consumeInbound({
12
20
  * transport,
@@ -14,6 +22,8 @@
14
22
  * proofPolicy: { kind: "verify", verify: myVerifier },
15
23
  * // ajv, or whatever validator you already run — see PayloadPolicy.
16
24
  * payloadPolicy: { kind: "validate", validate: myValidator },
25
+ * // acl/grant is consequential: a replayed envelope must not grant twice.
26
+ * checks: consequentialChecks(guard),
17
27
  * doc,
18
28
  * myVid: "did:web:maintainer.example",
19
29
  * now: Date.now(),
@@ -27,6 +37,11 @@
27
37
  * case "rejected": emit(outcome.error); break;
28
38
  * case "suppressed": logSuppressed(); break;
29
39
  * case "accepted": break; // fire-and-forget: nothing to emit
40
+ * // §7.2 item 11: already executed. Not an error — return the prior result
41
+ * // if there is one, otherwise emit nothing.
42
+ * case "duplicate":
43
+ * if (outcome.priorResponse !== undefined) emit(outcome.priorResponse);
44
+ * break;
30
45
  * }
31
46
  * ```
32
47
  *
@@ -55,6 +70,19 @@ import {
55
70
  type SpecPolicy,
56
71
  type TrustTaskDocument,
57
72
  } from "./document.js";
73
+ import {
74
+ CONSEQUENTIAL_FRESHNESS,
75
+ DEFAULT_FRESHNESS,
76
+ STALE_WIRE_MESSAGE,
77
+ recordExpiry,
78
+ validateFreshness,
79
+ type FreshnessPolicy,
80
+ } from "./freshness.js";
81
+ import {
82
+ documentDigest,
83
+ type ReplayGuard,
84
+ type ReplayPolicy,
85
+ } from "./replay.js";
58
86
  import {
59
87
  identityMismatchReason,
60
88
  reject,
@@ -63,6 +91,53 @@ import {
63
91
  type TransportHandler,
64
92
  } from "./transport.js";
65
93
 
94
+ /**
95
+ * The two stateful §7.2 checks: the freshness bound over `issuedAt` /
96
+ * `expiresAt`, and the duplicate-execution record of item 11.
97
+ *
98
+ * They travel together because the spec ties them together. §7.2 (*Bounding
99
+ * the record*) makes the acceptance window and the record's retention **the
100
+ * same bound**. Passing them as one option is what stops a deployment
101
+ * configuring a five-minute record and an unbounded acceptance window, which
102
+ * reads as a working replay defence and is not one.
103
+ *
104
+ * Like {@link PayloadPolicy}, this is a *required* option. Whether the task a
105
+ * consumer implements is *consequential* (§2) is a decision only that consumer
106
+ * can make, and the failure mode of getting it wrong silently — an ACL grant
107
+ * applied twice by a mediator retry — is not one to discover after the fact.
108
+ */
109
+ export interface ConsumeChecks {
110
+ /** Bounds the document in time (SPEC §4.2, §7.2 item 4). */
111
+ readonly freshness: FreshnessPolicy;
112
+ /** Applies (or knowingly disapplies) SPEC §7.2 item 11. */
113
+ readonly replay: ReplayPolicy;
114
+ }
115
+
116
+ /**
117
+ * The posture for a *consequential Trust Task* (§2): item 11 enforced against
118
+ * `guard`, and the bounded acceptance window that makes the record droppable.
119
+ *
120
+ * The right choice for any task whose execution grants access, moves value,
121
+ * discloses a secret, or otherwise cannot be undone by ignoring the next
122
+ * document.
123
+ */
124
+ export function consequentialChecks(guard: ReplayGuard): ConsumeChecks {
125
+ return { freshness: CONSEQUENTIAL_FRESHNESS, replay: { kind: "guard", guard } };
126
+ }
127
+
128
+ /**
129
+ * The posture for a task that is **not** consequential, or whose specification
130
+ * "explicitly declares repeated execution safe and intended" — the narrow
131
+ * disapplication item 11 permits.
132
+ *
133
+ * Keeps no record. Still applies the default freshness policy, because a
134
+ * future-dated document and one with an empty validity interval are malformed
135
+ * whatever the task does.
136
+ */
137
+ export function notConsequentialChecks(): ConsumeChecks {
138
+ return { freshness: DEFAULT_FRESHNESS, replay: { kind: "notConsequential" } };
139
+ }
140
+
66
141
  /** Verifies a document's Data Integrity proof (SPEC §4.7). */
67
142
  export interface ProofVerifier {
68
143
  /** Resolve to `true` when the proof verifies against the in-band `issuer`. */
@@ -173,7 +248,32 @@ export type ConsumeOutcome<R> =
173
248
  * return type demanded a document or an error, and returning neither threw a
174
249
  * bare `TypeError` out of the pipeline. Emit nothing.
175
250
  */
176
- | { kind: "accepted" };
251
+ | { kind: "accepted" }
252
+ /**
253
+ * SPEC §7.2 item 11: a document with this `id` and this content was already
254
+ * accepted for execution. **The handler was not called**, and the
255
+ * consequential effect did not happen a second time. This is the §8.4 retry
256
+ * being absorbed, which is what makes retrying safe.
257
+ *
258
+ * **Not an error.** §7.2 (*Disposition of a duplicate*): "In no case is a
259
+ * duplicate reported as `taskFailed`; the task did not fail, it already
260
+ * happened." Folding this into `rejected` would report a failure that did
261
+ * not occur.
262
+ *
263
+ * What to emit:
264
+ *
265
+ * - `priorResponse` present — emit it. It is the response document the first
266
+ * execution produced (a success response, or a non-retryable error
267
+ * response; tell them apart by its `type`), which §7.2 says the consumer
268
+ * SHOULD return.
269
+ * - `priorResponse` absent, `inFlight === false` — the specification defines
270
+ * no success response (§4.4.1 fire-and-forget), or the guard retains none.
271
+ * Emit nothing: that silence is the correct disposition, not an error.
272
+ * - `inFlight === true` — the first execution has not finished. §7.2: the
273
+ * consumer SHOULD "return or expose the existing execution state rather
274
+ * than begin another".
275
+ */
276
+ | { kind: "duplicate"; priorResponse?: unknown; inFlight: boolean };
177
277
 
178
278
  /** Wire-safe message for the `rejectIfPresent` path. */
179
279
  export const PROOF_NOT_ACCEPTED_BY_POLICY =
@@ -186,6 +286,12 @@ export interface ConsumeOptions<P, R> {
186
286
  proofPolicy: ProofPolicy;
187
287
  /** §7.2 item 2. Required — see {@link PayloadPolicy} for why. */
188
288
  payloadPolicy: PayloadPolicy;
289
+ /**
290
+ * §7.2 item 4 (freshness) and item 11 (duplicate execution). Required — see
291
+ * {@link ConsumeChecks}. Use {@link consequentialChecks} or
292
+ * {@link notConsequentialChecks}.
293
+ */
294
+ checks: ConsumeChecks;
189
295
  doc: TrustTaskDocument<P>;
190
296
  /** This consumer's own VID, for the §7.2 item 5 recipient check. */
191
297
  myVid: string;
@@ -225,8 +331,19 @@ export interface ConsumeOptions<P, R> {
225
331
  export async function consumeInbound<P, R>(
226
332
  opts: ConsumeOptions<P, R>,
227
333
  ): Promise<ConsumeOutcome<R>> {
228
- const { transport, spec, proofPolicy, payloadPolicy, doc, myVid, now, newErrorId, handler, clock } =
229
- opts;
334
+ const {
335
+ transport,
336
+ spec,
337
+ proofPolicy,
338
+ payloadPolicy,
339
+ checks,
340
+ doc,
341
+ myVid,
342
+ now,
343
+ newErrorId,
344
+ handler,
345
+ clock,
346
+ } = opts;
230
347
 
231
348
  const route = (reason: RejectReason): ConsumeOutcome<R> => {
232
349
  const error = reject(transport, doc, newErrorId(), reason, clock);
@@ -246,6 +363,19 @@ export async function consumeInbound<P, R>(
246
363
  );
247
364
  }
248
365
 
366
+ // Same reasoning, for the check that decides whether a replayed envelope
367
+ // executes a second time. A JavaScript caller gets no compile-time check,
368
+ // and defaulting to "keep no record" would silently reproduce the defect
369
+ // this option exists to remove.
370
+ if (checks === undefined) {
371
+ throw new TypeError(
372
+ "consumeInbound: `checks` is required as of 0.12.18 (SPEC §7.2 items 4 and 11). " +
373
+ "Pass consequentialChecks(guard) for a task whose execution grants access, " +
374
+ "moves value or is otherwise irreversible, or notConsequentialChecks() to " +
375
+ "state that repeated execution of this task is safe and intended.",
376
+ );
377
+ }
378
+
249
379
  // §7.2 item 2 — payload schema. Runs first, in the spec's own order, and
250
380
  // before anything that reasons about what the payload means: a payload that
251
381
  // is not the shape the specification declares should be refused as malformed
@@ -278,6 +408,15 @@ export async function consumeInbound<P, R>(
278
408
  const basic = validateBasic(doc, now, myVid);
279
409
  if (basic !== null) return route(basic);
280
410
 
411
+ // §7.2 item 4, the other half — the freshness bound over `issuedAt`.
412
+ // `validateBasic` honours `expiresAt`, which is optional and which a
413
+ // producer sets for its own reasons; on its own it leaves a document stamped
414
+ // years ago, or years hence, indefinitely acceptable. It is also what bounds
415
+ // the replay record below: §7.2 makes the acceptance window and the record's
416
+ // retention one bound.
417
+ const fresh = validateFreshness(doc, now, checks.freshness);
418
+ if (fresh !== null) return route(fresh);
419
+
281
420
  // §7.2 item 6 — in-band vs transport-derived identity cross-check.
282
421
  const resolved = resolveParties(transport, doc);
283
422
  if ("error" in resolved) return route(identityMismatchReason(resolved.error));
@@ -293,9 +432,15 @@ export async function consumeInbound<P, R>(
293
432
  ok = false;
294
433
  }
295
434
  if (!ok) {
435
+ // A constant, never the verifier's own error text. SPEC §10.4
436
+ // extends the §8.1 identity rule to every code, and a verifier's
437
+ // vocabulary names DIDs it tried to resolve, whether a resolver
438
+ // answered, and what a fetched DID document contained — a
439
+ // resolver-reachability oracle for a sender who is, by construction,
440
+ // unauthenticated. Log the detail; do not send it.
296
441
  return route({
297
442
  code: "proofInvalid",
298
- message: "proof verification failed",
443
+ message: PROOF_INVALID_WIRE_MESSAGE,
299
444
  retryable: false,
300
445
  });
301
446
  }
@@ -317,13 +462,125 @@ export async function consumeInbound<P, R>(
317
462
  const policy = enforceSpecPolicy(doc, spec);
318
463
  if (policy !== null) return route(policy);
319
464
 
465
+ // §7.2 item 11 — the duplicate-execution record. Deliberately **last**:
466
+ // claiming the `id` marks the document as accepted for execution, and a
467
+ // document some earlier check refuses was never accepted. Claiming first
468
+ // would burn the `id` on every malformed or unauthorised arrival, so a
469
+ // corrected resend under the same `id` would come back `idConflict` forever
470
+ // — and an attacker could pre-burn an `id` it had merely observed.
471
+ let claim: { guard: ReplayGuard; digest: string } | undefined;
472
+ if (checks.replay.kind === "guard") {
473
+ const { guard } = checks.replay;
474
+ const digest = documentDigest(doc);
475
+
476
+ // §7.2 (*Bounding the record*): "A consumer that can establish neither an
477
+ // `expiresAt` nor an age for a document has no window in which to place
478
+ // it, and MUST NOT execute a consequential Trust Task on it." A guard
479
+ // asked to retain a record forever is not a guard, so refuse rather than
480
+ // pretend.
481
+ const retainUntil = recordExpiry(doc, checks.freshness, now);
482
+ if (retainUntil === undefined) {
483
+ return route({ code: "expired", message: STALE_WIRE_MESSAGE, retryable: false });
484
+ }
485
+
486
+ let verdict;
487
+ try {
488
+ verdict = await guard.claim(doc.id, digest, retainUntil, now);
489
+ } catch {
490
+ // Fail closed. A consumer that cannot consult its record has not
491
+ // satisfied item 11, and executing anyway is exactly the double
492
+ // execution the rule forbids. `unavailable` is retryable, which is the
493
+ // honest answer: the producer's bit-for-bit resend will be absorbed
494
+ // correctly once the store is back. The thrown detail — a hostname, a
495
+ // connection string — stays out of the message, per §10.4.
496
+ return route({
497
+ code: "unavailable",
498
+ message: REPLAY_RECORD_UNAVAILABLE,
499
+ retryable: true,
500
+ });
501
+ }
502
+
503
+ switch (verdict.kind) {
504
+ case "duplicate":
505
+ return verdict.priorResponse === undefined
506
+ ? { kind: "duplicate", inFlight: verdict.inFlight }
507
+ : { kind: "duplicate", priorResponse: verdict.priorResponse, inFlight: verdict.inFlight };
508
+ case "conflict":
509
+ return route({
510
+ code: "idConflict",
511
+ message: ID_CONFLICT_WIRE_MESSAGE,
512
+ retryable: false,
513
+ });
514
+ case "fresh":
515
+ claim = { guard, digest };
516
+ break;
517
+ }
518
+ }
519
+
320
520
  const result = await handler(doc, resolved.parties);
321
- if (result === undefined || result === null) return { kind: "accepted" };
322
- return isErrorResponse(result)
323
- ? { kind: "rejected", error: result }
324
- : { kind: "handled", response: result as TrustTaskDocument<R> };
521
+
522
+ if (result === undefined || result === null) {
523
+ // Fire-and-forget: nothing to cache, but the claim stands — the effect
524
+ // happened, and item 11 is about the effect, not about the response.
525
+ await settle(claim, doc.id, undefined);
526
+ return { kind: "accepted" };
527
+ }
528
+ if (isErrorResponse(result)) {
529
+ // §8.4: a retryable refusal has just invited the producer to re-send this
530
+ // document bit-for-bit. Holding the claim would answer that invited retry
531
+ // with the cached failure forever. A non-retryable refusal is final, so
532
+ // the record stands and a replay is answered with the same determination.
533
+ if (result.payload.retryable === true) {
534
+ await claim?.guard.release?.(doc.id, claim.digest);
535
+ } else {
536
+ await settle(claim, doc.id, result);
537
+ }
538
+ return { kind: "rejected", error: result };
539
+ }
540
+ await settle(claim, doc.id, result);
541
+ return { kind: "handled", response: result as TrustTaskDocument<R> };
542
+ }
543
+
544
+ /**
545
+ * Record the response for a completed execution, best-effort.
546
+ *
547
+ * The effect has already happened, so a guard that cannot cache the response
548
+ * cannot un-happen it. The record of the *claim* is what item 11 needs and it
549
+ * is already written; all that is lost is the ability to hand the same
550
+ * response back, and the duplicate is still absorbed. Guards should log their
551
+ * own failures — there is nowhere to report this from here.
552
+ */
553
+ async function settle(
554
+ claim: { guard: ReplayGuard; digest: string } | undefined,
555
+ id: string,
556
+ response: unknown,
557
+ ): Promise<void> {
558
+ if (claim === undefined) return;
559
+ try {
560
+ await claim.guard.recordResponse?.(id, response);
561
+ } catch {
562
+ /* see above */
563
+ }
325
564
  }
326
565
 
566
+ /**
567
+ * Wire message for `proofInvalid`.
568
+ *
569
+ * Constant by design — see the §10.4 note at the rejection site. The Rust
570
+ * counterpart is `PROOF_INVALID_WIRE_MESSAGE` in `trust-tasks-rs`, kept equal.
571
+ */
572
+ export const PROOF_INVALID_WIRE_MESSAGE = "proof verification failed";
573
+
574
+ /** Wire message for `idConflict` (SPEC §7.2 item 11, §8.3). */
575
+ export const ID_CONFLICT_WIRE_MESSAGE =
576
+ "a different document has already been accepted under this id (SPEC §7.2 item 11)";
577
+
578
+ /**
579
+ * Wire message for a replay-record outage. A constant, so a store's hostname
580
+ * or connection string never reaches the wire (SPEC §10.4).
581
+ */
582
+ export const REPLAY_RECORD_UNAVAILABLE = "temporarily unavailable";
583
+
327
584
  /**
328
585
  * Whether a handler returned an error response rather than a success response.
329
586
  *
@@ -195,10 +195,23 @@ export interface RejectReason {
195
195
  /**
196
196
  * The Type URI a consumer emits error responses under.
197
197
  *
198
- * `0.3`, because this runtime populates the `inResponseTo` member of §8.2 and
199
- * `0.2`'s payload schema is `additionalProperties: false`a document carrying
200
- * it would not validate as `0.2`. Per §5.2 forward-minor compatibility a `0.2`
201
- * consumer SHOULD accept it.
198
+ * **The single source of truth for the emitted `trust-task-error` version on
199
+ * this side.** Three different answers were in circulation this runtime and
200
+ * `trust-tasks-rs` emitted `0.5`, the HTTPS server emitted `0.2`, and the
201
+ * READMEs described `0.1` — so a producer could not tell from the
202
+ * documentation which schema an error response would validate against. Build
203
+ * error responses with `rejectWith` / `rejectWithRecipient`, or read the URI
204
+ * from here; do not spell it out again.
205
+ *
206
+ * The Rust counterpart is `trust_task_error_type_uri()` in `trust-tasks-rs`,
207
+ * kept equal to this value.
208
+ *
209
+ * `0.5` because this runtime populates the `inResponseTo` member of §8.2 and
210
+ * can emit `idConflict` (§8.3), which is absent from `0.3`'s code enum and does
211
+ * not match its extended-code pattern — a document carrying it would not
212
+ * validate as `0.3`. Per §5.2 forward-minor compatibility a `0.3` consumer
213
+ * SHOULD accept it. (This comment said `0.3` while the value said `0.5`: the
214
+ * value moved and the prose did not.)
202
215
  */
203
216
  export const TRUST_TASK_ERROR_TYPE_URI = "https://trusttasks.org/spec/trust-task-error/0.5";
204
217
 
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Freshness bounds over `issuedAt` / `expiresAt` (SPEC §4.2, §7.2).
3
+ *
4
+ * Mirrors `freshness.rs` in trust-tasks-rs, deliberately closely: a TypeScript
5
+ * consumer and a Rust one must reach the same verdict on the same document.
6
+ *
7
+ * Until this module, the only temporal check either runtime made was §7.2
8
+ * item 4 — `expiresAt`, and only where the producer chose to set it.
9
+ * `issuedAt` was carried on the type and looked at by nobody. That accepted a
10
+ * document stamped a year in the future (for the whole of that year), and one
11
+ * whose `expiresAt` sat at or before its own `issuedAt` — a validity interval
12
+ * that never contained a valid instant.
13
+ *
14
+ * It is also what makes {@link ReplayGuard} implementable. SPEC §7.2
15
+ * (*Bounding the record*) ties the duplicate-execution record to the
16
+ * acceptance window and says the two bounds are the same bound: a consumer
17
+ * "**MUST NOT** accept for execution a document older than the window over
18
+ * which it retains records". Without {@link FreshnessPolicy.maxAgeMs} there is
19
+ * no window, so a record for a document carrying no `expiresAt` would have to
20
+ * be kept forever.
21
+ */
22
+
23
+ import type { RejectReason, TrustTaskDocument } from "./document.js";
24
+
25
+ /** The clock-skew tolerance SPEC §4.2 sanctions ("typically ≤ 60s"), in ms. */
26
+ export const DEFAULT_SKEW_MS = 60_000;
27
+
28
+ /**
29
+ * The acceptance window {@link consequentialFreshness} applies, in ms.
30
+ *
31
+ * Five minutes survives a mediator queue, a retry with backoff and a modest
32
+ * clock disagreement, and keeps the record it bounds small. A deployment whose
33
+ * transport buffers for longer must widen it *and* widen its guard's retention
34
+ * to match — §7.2 makes them one bound.
35
+ */
36
+ export const DEFAULT_MAX_AGE_MS = 5 * 60_000;
37
+
38
+ /** How a consumer bounds a document in time before acting on it. */
39
+ export interface FreshnessPolicy {
40
+ /**
41
+ * Tolerance applied to the document's timestamps against this consumer's
42
+ * clock, per SPEC §4.2. Applied to the future-dating check and to
43
+ * {@link maxAgeMs}; **not** to `expiresAt`, which `validateBasic` compares
44
+ * against the raw `now` it is given.
45
+ */
46
+ readonly skewMs: number;
47
+ /**
48
+ * Oldest `issuedAt` this consumer accepts, measured back from `now`.
49
+ * `undefined` means unbounded — the pre-existing behaviour, and the only
50
+ * setting under which a document carrying neither timestamp is acceptable.
51
+ */
52
+ readonly maxAgeMs?: number;
53
+ /** Reject a document carrying no `issuedAt`, with `malformedRequest`. */
54
+ readonly requireIssuedAt?: boolean;
55
+ }
56
+
57
+ /**
58
+ * The minimum every consumer should apply: reject a future-dated document and
59
+ * one whose stated validity interval is empty. No conforming producer emits
60
+ * either, so this costs a correct deployment nothing.
61
+ *
62
+ * Deliberately sets no `maxAgeMs` — an acceptance window depends on how long
63
+ * the transport may hold a message, which is a deployment fact, and a library
64
+ * that guessed one would start refusing documents that had arrived for years.
65
+ */
66
+ export const DEFAULT_FRESHNESS: FreshnessPolicy = { skewMs: DEFAULT_SKEW_MS };
67
+
68
+ /**
69
+ * The posture SPEC §7.2 (*Bounding the record*) describes for a *consequential
70
+ * Trust Task*: `issuedAt` REQUIRED and a bounded acceptance window, so every
71
+ * accepted document sits inside a window a {@link ReplayGuard} can retain a
72
+ * record for.
73
+ */
74
+ export const CONSEQUENTIAL_FRESHNESS: FreshnessPolicy = {
75
+ skewMs: DEFAULT_SKEW_MS,
76
+ maxAgeMs: DEFAULT_MAX_AGE_MS,
77
+ requireIssuedAt: true,
78
+ };
79
+
80
+ /** Wire-safe reason for a `malformedRequest` from a future-dated `issuedAt`. */
81
+ export const FUTURE_ISSUED_AT =
82
+ "issuedAt is in the future beyond the consumer's skew tolerance (SPEC §4.2)";
83
+
84
+ /** Wire-safe reason for a `malformedRequest` from `expiresAt <= issuedAt`. */
85
+ export const EXPIRY_NOT_AFTER_ISSUANCE =
86
+ "expiresAt is not after issuedAt: the document states an empty validity interval (SPEC §4.2)";
87
+
88
+ /** Wire-safe reason for a `malformedRequest` from a missing `issuedAt`. */
89
+ export const ISSUED_AT_REQUIRED =
90
+ "issuedAt is required by consumer policy (SPEC §7.2, bounding the duplicate-execution record)";
91
+
92
+ /**
93
+ * Wire message for a document outside the consumer's acceptance window.
94
+ *
95
+ * A constant, not a rendering of the window or the consumer's clock: §10.4
96
+ * keeps consumer-side state off the wire, and echoing the delta would turn
97
+ * every rejection into a remote `ntpdate` — and a probe for the window's exact
98
+ * boundary — for an unauthenticated sender.
99
+ */
100
+ export const STALE_WIRE_MESSAGE = "document is outside the consumer's acceptance window (SPEC §7.2)";
101
+
102
+ /**
103
+ * Apply `policy` to this document's `issuedAt` / `expiresAt`. Returns `null`
104
+ * when the document is acceptable.
105
+ *
106
+ * This is the freshness half of SPEC §7.2 item 4 that `validateBasic` does not
107
+ * cover. {@link consumeInbound} calls it for you.
108
+ *
109
+ * @param now Milliseconds since the epoch, as from `Date.now()`.
110
+ */
111
+ export function validateFreshness<P>(
112
+ doc: TrustTaskDocument<P>,
113
+ now: number,
114
+ policy: FreshnessPolicy,
115
+ ): RejectReason | null {
116
+ const malformed = (message: string): RejectReason => ({
117
+ code: "malformedRequest",
118
+ message,
119
+ retryable: false,
120
+ });
121
+ const stale = (): RejectReason => ({
122
+ code: "expired",
123
+ message: STALE_WIRE_MESSAGE,
124
+ retryable: false,
125
+ });
126
+
127
+ if (doc.issuedAt !== undefined) {
128
+ const issuedAt = Date.parse(doc.issuedAt);
129
+ if (Number.isNaN(issuedAt)) {
130
+ return malformed("issuedAt is not a valid RFC 3339 timestamp");
131
+ }
132
+ if (issuedAt > now + policy.skewMs) return malformed(FUTURE_ISSUED_AT);
133
+
134
+ if (doc.expiresAt !== undefined) {
135
+ const expiresAt = Date.parse(doc.expiresAt);
136
+ // A malformed `expiresAt` is `validateBasic`'s to report; skip it here
137
+ // rather than raise a second, differently-worded rejection for it.
138
+ if (!Number.isNaN(expiresAt) && expiresAt <= issuedAt) {
139
+ return malformed(EXPIRY_NOT_AFTER_ISSUANCE);
140
+ }
141
+ }
142
+
143
+ if (policy.maxAgeMs !== undefined && issuedAt + policy.maxAgeMs + policy.skewMs < now) {
144
+ return stale();
145
+ }
146
+ return null;
147
+ }
148
+
149
+ if (policy.requireIssuedAt === true) return malformed(ISSUED_AT_REQUIRED);
150
+
151
+ // No `issuedAt`. A policy with a window cannot place the document in it
152
+ // unless the producer supplied an `expiresAt` instead (SPEC §7.2, *Bounding
153
+ // the record*).
154
+ if (policy.maxAgeMs !== undefined && doc.expiresAt === undefined) return stale();
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * The instant past which a replay record for `doc` may be dropped — the end of
160
+ * this consumer's willingness to execute it, which SPEC §7.2 makes the same
161
+ * instant as the end of the record's required retention.
162
+ *
163
+ * `expiresAt` fixes it where present; otherwise `issuedAt + maxAgeMs`.
164
+ * `undefined` means this policy places no bound on the document, in which case
165
+ * a consumer **MUST NOT** execute a consequential task on it — there is no
166
+ * window in which to keep the record.
167
+ */
168
+ export function recordExpiry<P>(
169
+ doc: TrustTaskDocument<P>,
170
+ policy: FreshnessPolicy,
171
+ now: number,
172
+ ): number | undefined {
173
+ if (doc.expiresAt !== undefined) {
174
+ const expiresAt = Date.parse(doc.expiresAt);
175
+ if (!Number.isNaN(expiresAt)) return expiresAt;
176
+ }
177
+ if (policy.maxAgeMs === undefined) return undefined;
178
+ const issuedAt = doc.issuedAt === undefined ? NaN : Date.parse(doc.issuedAt);
179
+ // Fall back to `now` when the producer stamped no usable `issuedAt`: the
180
+ // record then lives a full window from first sight, which is the longest the
181
+ // document could still be arriving from a queue.
182
+ return (Number.isNaN(issuedAt) ? now : issuedAt) + policy.maxAgeMs;
183
+ }
@@ -51,9 +51,15 @@ export {
51
51
  } from "./transport.js";
52
52
 
53
53
  export {
54
+ ID_CONFLICT_WIRE_MESSAGE,
55
+ PROOF_INVALID_WIRE_MESSAGE,
54
56
  PROOF_NOT_ACCEPTED_BY_POLICY,
57
+ REPLAY_RECORD_UNAVAILABLE,
58
+ consequentialChecks,
55
59
  consumeInbound,
60
+ notConsequentialChecks,
56
61
  refuse,
62
+ type ConsumeChecks,
57
63
  type ConsumeOptions,
58
64
  type ConsumeOutcome,
59
65
  type PayloadPolicy,
@@ -61,3 +67,27 @@ export {
61
67
  type ProofPolicy,
62
68
  type ProofVerifier,
63
69
  } from "./consume.js";
70
+
71
+ export { canonicalJson, sha256Hex } from "./canonical.js";
72
+
73
+ export {
74
+ CONSEQUENTIAL_FRESHNESS,
75
+ DEFAULT_FRESHNESS,
76
+ DEFAULT_MAX_AGE_MS,
77
+ DEFAULT_SKEW_MS,
78
+ EXPIRY_NOT_AFTER_ISSUANCE,
79
+ FUTURE_ISSUED_AT,
80
+ ISSUED_AT_REQUIRED,
81
+ STALE_WIRE_MESSAGE,
82
+ recordExpiry,
83
+ validateFreshness,
84
+ type FreshnessPolicy,
85
+ } from "./freshness.js";
86
+
87
+ export {
88
+ InMemoryReplayGuard,
89
+ documentDigest,
90
+ type ReplayGuard,
91
+ type ReplayPolicy,
92
+ type ReplayVerdict,
93
+ } from "./replay.js";