@reclaimprotocol/client 0.1.35 → 0.1.36

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
@@ -750,16 +750,18 @@ including `hasNoPii`, verifier TEE (`teeAttestation.appSecret`), and
750
750
  attestor-TEE checks. The configured Builder JWKS URL supplies the
751
751
  purpose-bound result keys, its `x-reclaim-attestors` signer list, and its
752
752
  `x-reclaim-attestor-image-digests` Confidential Space image allowlist. When
753
- the JWKS publishes image digests, `verifyResultFull` automatically verifies
754
- every witness's attestor TEE `claimAttestation` — its attestor address and
755
- claim signature always, and its `attestation_report` when the witness carries
756
- one. The report is optional: production attestors sign claims without
757
- returning one, so a witness with only an address and a signature verifies, and
758
- `isAttestorTeeAttestationVerified` comes back `false` to say nothing was
759
- attested. If
760
- `proofValidation.attestorTeeAttestation.expectedImageDigests` is also set, the
761
- SDK uses the intersection: caller policy can narrow the published trust list,
762
- but can't broaden it. TEE settings preserve the same configuration objects;
753
+ the JWKS publishes image digests, `verifyResultFull` verifies any attestor TEE
754
+ `claimAttestation` a witness carries — its attestor address and claim
755
+ signature, and its `attestation_report` pinned to those digests when present.
756
+ Neither is required: like `@reclaimprotocol/js-sdk`, a witness is trusted by
757
+ its signer being in `x-reclaim-attestors`. A portal TEE proof without a bound
758
+ nonce has no `claimAttestation`, and production attestors sign without
759
+ returning a report. When nothing was attested,
760
+ `isAttestorTeeAttestationVerified` comes back `false`. Set
761
+ `proofValidation.attestorTeeAttestation` to require a `claimAttestation` on
762
+ every witness. If its `expectedImageDigests` is also set, the SDK uses the
763
+ intersection: caller policy can narrow the published trust list, but can't
764
+ broaden it. TEE settings preserve the same configuration objects;
763
765
  `verifyResultFull` does not accept injected verification hooks. The surrounding
764
766
  issuer, session, and context checks are stateless; persist accepted session IDs
765
767
  in your application to prevent replay.
@@ -808,6 +810,51 @@ try {
808
810
  A non-terminal `event` delivery is not a result and never throws. It returns
809
811
  `{ kind: 'event', … }`.
810
812
 
813
+ #### Respond to a callback delivery
814
+
815
+ Builder retries a delivery until your callback route answers 2xx, and a
816
+ delivery that never succeeds holds back that session's later events. If
817
+ retrying the same body can't help, answer 2xx. If the failure is temporary,
818
+ answer 5xx.
819
+
820
+ `ResultVerificationError.retryable` tells you which case you're in. It's
821
+ `true` only when a temporary condition caused the failure:
822
+ Builder's JWKS or provider recipe couldn't be fetched because of a network
823
+ error, a timeout, an HTTP 5xx, or rate limiting (`jwks-fetch-failed`,
824
+ `session-resolution-failed`, or an `invalid-proof` whose proofs all failed
825
+ this way). It's `false` for everything about the result itself, including
826
+ `bad-signature`, `issuer-not-trusted`, `aud-mismatch`,
827
+ `reclaimSessionId-mismatch`, `expired`, and an `invalid-proof` with any
828
+ genuinely invalid proof.
829
+
830
+ ```ts
831
+ app.post('/reclaim/callback', async (req, res) => {
832
+ try {
833
+ const { result } = await reclaim.results.receive(req.body, {
834
+ orgEthPrivateKey,
835
+ reclaimSessionId,
836
+ })
837
+ await markVerificationComplete(result)
838
+ res.sendStatus(200)
839
+ } catch (err) {
840
+ if (err instanceof ResultVerificationError) {
841
+ await storeFailure(err.reason, err.outcome)
842
+ // Retrying a bad result can't help, so only ask for a retry when the
843
+ // failure is temporary.
844
+ res.sendStatus(err.retryable ? 503 : 200)
845
+ return
846
+ }
847
+ throw err
848
+ }
849
+ })
850
+ ```
851
+
852
+ `results.receive` can also throw an ordinary `Error` instead of
853
+ `ResultVerificationError`, for example when `orgEthPrivateKey` is missing or
854
+ doesn't match the key the result was encrypted to. Treat it as a
855
+ configuration problem: log it and answer 5xx, so the delivery succeeds once
856
+ you fix the key. Builder holds an undelivered result for 7 days.
857
+
811
858
  ### `reclaim.api` — escape hatch
812
859
 
813
860
  ```ts
package/lib/errors.d.ts CHANGED
@@ -22,6 +22,21 @@ export type ProblemError = Error & {
22
22
  };
23
23
  /** Returns whether a thrown value is a Builder {@link ProblemError}. */
24
24
  export declare function isProblemError(error: unknown): error is ProblemError;
25
+ /**
26
+ * Whether retrying the same request later can succeed: a network failure, a
27
+ * timeout, or an HTTP 5xx, 408, or 429 from Builder (a {@link ProblemError}
28
+ * or a plain error carrying the HTTP `status`). Everything else, such as a
29
+ * malformed response, won't change on retry.
30
+ */
31
+ export declare function isTransientError(error: unknown): boolean;
32
+ /**
33
+ * Runs one network step, such as a JWKS fetch or a session or provider
34
+ * lookup, and marks a transient failure so {@link isTransientFailure} can
35
+ * tell it apart from a failed content check.
36
+ */
37
+ export declare function networkStep<T>(step: () => Promise<T> | T): Promise<T>;
38
+ /** Whether `error` came from a {@link networkStep} and a retry can succeed. */
39
+ export declare function isTransientFailure(error: unknown): boolean;
25
40
  /** Returns whether a value follows the RFC 9457 problem-details schema. */
26
41
  export declare function isProblemDetails(value: unknown): value is ProblemDetails;
27
42
  /** Reads an RFC 9457 response, with a status-only fallback for other bodies. */
package/lib/errors.js CHANGED
@@ -14,6 +14,50 @@ export function isProblemError(error) {
14
14
  return isProblemDetails(error.data)
15
15
  && isProblemErrorOutput(error.output);
16
16
  }
17
+ /**
18
+ * Whether retrying the same request later can succeed: a network failure, a
19
+ * timeout, or an HTTP 5xx, 408, or 429 from Builder (a {@link ProblemError}
20
+ * or a plain error carrying the HTTP `status`). Everything else, such as a
21
+ * malformed response, won't change on retry.
22
+ */
23
+ export function isTransientError(error) {
24
+ // `fetch` rejects with a TypeError when the network fails.
25
+ if (error instanceof TypeError) {
26
+ return true;
27
+ }
28
+ if (error instanceof Error
29
+ && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
30
+ return true;
31
+ }
32
+ const status = isProblemError(error)
33
+ ? error.output.statusCode
34
+ : isRecord(error) ? error.status : undefined;
35
+ return typeof status === 'number'
36
+ && (status >= 500 || status === 408 || status === 429);
37
+ }
38
+ // Errors thrown by a network step and judged transient. Tracked by identity
39
+ // so a content check that throws, say, a TypeError is never mistaken for one.
40
+ const TRANSIENT_ERRORS = new WeakSet();
41
+ /**
42
+ * Runs one network step, such as a JWKS fetch or a session or provider
43
+ * lookup, and marks a transient failure so {@link isTransientFailure} can
44
+ * tell it apart from a failed content check.
45
+ */
46
+ export async function networkStep(step) {
47
+ try {
48
+ return await step();
49
+ }
50
+ catch (error) {
51
+ if (error instanceof Error && isTransientError(error)) {
52
+ TRANSIENT_ERRORS.add(error);
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+ /** Whether `error` came from a {@link networkStep} and a retry can succeed. */
58
+ export function isTransientFailure(error) {
59
+ return error instanceof Error && TRANSIENT_ERRORS.has(error);
60
+ }
17
61
  /** Returns whether a value follows the RFC 9457 problem-details schema. */
18
62
  export function isProblemDetails(value) {
19
63
  if (!isRecord(value)) {
package/lib/jwks.js CHANGED
@@ -47,7 +47,8 @@ export async function fetchReclaimJwks(url, fetchImpl = fetch) {
47
47
  assertSecureJwksUrl(url);
48
48
  const res = await fetchImpl(url);
49
49
  if (!res.ok) {
50
- throw new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`);
50
+ // `status` lets callers tell a temporary Builder outage from a bad URL.
51
+ throw Object.assign(new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`), { status: res.status });
51
52
  }
52
53
  const body = (await res.json());
53
54
  return {
@@ -25,9 +25,15 @@ export interface VerifyRequestProofOptions {
25
25
  /** Read `providerHash` from context, while still verifying signatures. */
26
26
  hasNoPii?: true;
27
27
  teeAttestation?: TeeAttestationConfig;
28
+ /** Require every witness to carry a `claimAttestation`, and verify any
29
+ * `attestation_report` against `expectedImageDigests`. */
28
30
  attestorTeeAttestation?: AttestorTeeAttestationConfig;
29
31
  /** Explicit trusted attestor ETH addresses. */
30
32
  attestorAddresses?: string[];
33
+ /** Trusted attestor image digests, like `x-reclaim-attestor-image-digests`.
34
+ * Pin any `attestation_report` a witness carries without requiring one,
35
+ * matching `@reclaimprotocol/js-sdk`'s default. */
36
+ attestorImageDigests?: string[];
31
37
  /** Trusted Builder JWKS registry containing attestor-purpose keys. */
32
38
  attestorJwksUrl?: string;
33
39
  fetchImpl?: typeof fetch;
@@ -43,6 +49,10 @@ export interface VerifyRequestProofOptions {
43
49
  export interface ReclaimRequestProofOutcome {
44
50
  valid: boolean;
45
51
  reason?: string;
52
+ /** On failure: true when a temporary condition caused it, such as a
53
+ * network error while resolving the session or provider. A retry can
54
+ * succeed. */
55
+ transient?: boolean;
46
56
  proofHashes?: string[];
47
57
  isTeeAttestationVerified?: boolean;
48
58
  /** True when at least one witness's `attestation_report` verified; false
@@ -1,5 +1,6 @@
1
1
  import { createSignDataForClaim } from '@reclaimprotocol/attestor-core';
2
2
  import { recoverEip191Address } from "./eip191.js";
3
+ import { isTransientFailure, networkStep } from "./errors.js";
3
4
  import { fetchReclaimJwks, trustedAttestorAddresses } from "./jwks.js";
4
5
  import { assertValidProofHashes, getHashFromProof } from "./proof-hash.js";
5
6
  import { runAttestorTeeVerification, } from "./verify-attestor-tee.js";
@@ -31,11 +32,13 @@ export async function verifyRequestProof(proofOrProofs, config) {
31
32
  let isAttestorTeeAttestationVerified;
32
33
  const attestorTeeAttestation = resolveAttestorTeeAttestation(effective.attestorTeeAttestation, trust.imageDigests);
33
34
  if (attestorTeeAttestation) {
35
+ // Only a caller-configured check requires an envelope on every
36
+ // witness; registry digests alone pin the reports that exist.
34
37
  // `verified` counts witnesses whose attestation report was checked.
35
38
  // A proof whose witnesses carry no report still passes the address
36
39
  // and signature bindings, but nothing was attested — say so rather
37
40
  // than reporting a TEE attestation that never happened.
38
- const summary = await runAttestorTeeVerification(proofs, attestorTeeAttestation);
41
+ const summary = await runAttestorTeeVerification(proofs, attestorTeeAttestation, { requireClaimAttestation: !!effective.attestorTeeAttestation });
39
42
  isAttestorTeeAttestationVerified = summary.verified > 0;
40
43
  }
41
44
  return {
@@ -51,27 +54,30 @@ export async function verifyRequestProof(proofOrProofs, config) {
51
54
  return {
52
55
  valid: false,
53
56
  reason: failureReason(error),
57
+ transient: isTransientFailure(error),
54
58
  };
55
59
  }
56
60
  }
57
61
  async function resolveValidation(config, proofs) {
58
62
  let effective = config;
59
- if (config.sessionId && !hasContentValidation(config)) {
60
- if (!config.resolveSession) {
63
+ const { sessionId, resolveSession } = config;
64
+ if (sessionId && !hasContentValidation(config)) {
65
+ if (!resolveSession) {
61
66
  throw new Error('session-resolver-required');
62
67
  }
63
68
  effective = {
64
69
  ...config,
65
- ...await config.resolveSession(config.sessionId, proofs),
70
+ ...await networkStep(() => resolveSession(sessionId, proofs)),
66
71
  };
67
72
  }
68
73
  return effective;
69
74
  }
70
75
  async function resolveAttestorTrust(config) {
71
76
  const addresses = config.attestorAddresses?.map(normalized) ?? [];
72
- const imageDigests = [];
73
- if (config.attestorJwksUrl) {
74
- const registry = await fetchReclaimJwks(config.attestorJwksUrl, config.fetchImpl);
77
+ const imageDigests = config.attestorImageDigests?.map(normalized) ?? [];
78
+ const { attestorJwksUrl, fetchImpl } = config;
79
+ if (attestorJwksUrl) {
80
+ const registry = await networkStep(() => (fetchReclaimJwks(attestorJwksUrl, fetchImpl)));
75
81
  addresses.push(...(registry['x-reclaim-attestors'] ?? []), ...trustedAttestorAddresses(registry.keys));
76
82
  imageDigests.push(...(registry['x-reclaim-attestor-image-digests'] ?? []));
77
83
  }
@@ -131,11 +137,12 @@ async function assertContent(proofs, config) {
131
137
  assertValidProofHashes(proofs, config.hashes, config.hasNoPii);
132
138
  return;
133
139
  }
134
- if (config.providerId) {
135
- if (!config.resolveProvider) {
140
+ const { providerId, providerVersion, allowedTags, resolveProvider } = config;
141
+ if (providerId) {
142
+ if (!resolveProvider) {
136
143
  throw new Error('provider-resolver-required');
137
144
  }
138
- const candidates = await config.resolveProvider(config.providerId, config.providerVersion, config.allowedTags, proofs);
145
+ const candidates = await networkStep(() => (resolveProvider(providerId, providerVersion, allowedTags, proofs)));
139
146
  if (!candidates.length) {
140
147
  throw new Error('provider-not-found');
141
148
  }
package/lib/result.d.ts CHANGED
@@ -132,6 +132,9 @@ export interface ResultProofOutcome {
132
132
  proof?: ReclaimRequestProof;
133
133
  proofValid: boolean;
134
134
  reason?: string;
135
+ /** On failure: true when a temporary condition caused it, such as a
136
+ * network error while resolving the provider. */
137
+ transient?: boolean;
135
138
  /** @deprecated Use `attestorAddresses`; retained for compatibility. */
136
139
  attestorAddress?: string;
137
140
  /** Every attestor address declared by the proof. */
@@ -157,11 +160,17 @@ export interface VerifyResultFullOutcome extends VerifyResultOutcome {
157
160
  }
158
161
  export interface ResultVerificationFailure extends VerifyResultFullOutcome {
159
162
  reason: string;
163
+ /** True when a temporary condition caused the failure. Set by the
164
+ * verifier; read it as {@link ResultVerificationError.retryable}. */
165
+ retryable?: boolean;
160
166
  }
161
167
  /**
162
168
  * Thrown by {@link verifyResult} / {@link verifyResultFull} (and therefore
163
169
  * `reclaim.results.verify`/`receive`) when a result fails verification. Carries
164
- * the full invalid outcome so callers can inspect it in a `catch`:
170
+ * the full invalid outcome so callers can inspect it in a `catch`.
171
+ *
172
+ * In a callback handler, answer 2xx when `retryable` is `false` (Builder
173
+ * retrying the same delivery can't help) and 5xx when it's `true`:
165
174
  *
166
175
  * try {
167
176
  * const { result } = await reclaim.results.receive(body, {
@@ -172,6 +181,7 @@ export interface ResultVerificationFailure extends VerifyResultFullOutcome {
172
181
  * if (err instanceof ResultVerificationError) {
173
182
  * // for example, 'expired', { payload, … }
174
183
  * console.error(err.reason, err.outcome)
184
+ * res.sendStatus(err.retryable ? 503 : 200)
175
185
  * }
176
186
  * }
177
187
  */
@@ -182,6 +192,15 @@ export declare class ResultVerificationError extends Error {
182
192
  /** The full (invalid) outcome: `payload`, `reclaimSessionId`, and per-proof
183
193
  * detail (`proofs`) when verification got that far. */
184
194
  readonly outcome: ResultVerificationFailure;
195
+ /**
196
+ * Whether a retry can succeed. `true` only for a temporary condition on
197
+ * the verifier's side: Builder's JWKS or recipe couldn't be fetched
198
+ * (network error, timeout, 5xx, 429). `false` when the result itself is
199
+ * wrong (signature, issuer, audience, session, expiry, invalid proof), so
200
+ * redelivering the same body fails the same way. Respond 5xx to a
201
+ * callback only when `true`.
202
+ */
203
+ readonly retryable: boolean;
185
204
  constructor(outcome: ResultVerificationFailure);
186
205
  }
187
206
  /**
package/lib/result.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { sha256 } from '@noble/hashes/sha2.js';
14
14
  import { decrypt as eciesDecrypt, encrypt as eciesEncrypt } from 'eciesjs';
15
15
  import { b64url, base64urlToBytes, hexToBytes, stripTrailingSlashes, } from "./encoding.js";
16
+ import { isTransientError } from "./errors.js";
16
17
  import { fetchReclaimJwks, jwkToUncompressedPublicKey, selectVerificationResultKey, } from "./jwks.js";
17
18
  import { getHashFromProof } from "./proof-hash.js";
18
19
  import { proofHashesFromBuilderRecipe, } from "./proof-recipe.js";
@@ -145,7 +146,10 @@ export function verifyJwsSignature(jws, signerPublicKey) {
145
146
  /**
146
147
  * Thrown by {@link verifyResult} / {@link verifyResultFull} (and therefore
147
148
  * `reclaim.results.verify`/`receive`) when a result fails verification. Carries
148
- * the full invalid outcome so callers can inspect it in a `catch`:
149
+ * the full invalid outcome so callers can inspect it in a `catch`.
150
+ *
151
+ * In a callback handler, answer 2xx when `retryable` is `false` (Builder
152
+ * retrying the same delivery can't help) and 5xx when it's `true`:
149
153
  *
150
154
  * try {
151
155
  * const { result } = await reclaim.results.receive(body, {
@@ -156,6 +160,7 @@ export function verifyJwsSignature(jws, signerPublicKey) {
156
160
  * if (err instanceof ResultVerificationError) {
157
161
  * // for example, 'expired', { payload, … }
158
162
  * console.error(err.reason, err.outcome)
163
+ * res.sendStatus(err.retryable ? 503 : 200)
159
164
  * }
160
165
  * }
161
166
  */
@@ -165,6 +170,7 @@ export class ResultVerificationError extends Error {
165
170
  this.name = 'ResultVerificationError';
166
171
  this.reason = outcome.reason;
167
172
  this.outcome = outcome;
173
+ this.retryable = outcome.retryable ?? false;
168
174
  }
169
175
  }
170
176
  /**
@@ -230,8 +236,10 @@ export async function verifyResultFull(jws, opts = {}) {
230
236
  jwksAttestorImageDigests =
231
237
  registry['x-reclaim-attestor-image-digests'] ?? [];
232
238
  }
233
- catch {
234
- return fail('jwks-fetch-failed');
239
+ catch (error) {
240
+ return fail('jwks-fetch-failed', {
241
+ retryable: isTransientError(error),
242
+ });
235
243
  }
236
244
  const jwk = selectVerificationResultKey(keys, String(header.kid ?? ''));
237
245
  if (!jwk) {
@@ -348,7 +356,12 @@ export async function verifyResultFull(jws, opts = {}) {
348
356
  else if (configuredImageDigests?.length) {
349
357
  expectedImageDigests = configuredImageDigests;
350
358
  }
351
- const attestorTeeAttestation = expectedImageDigests.length
359
+ // A caller-configured check requires a `claimAttestation` on every
360
+ // witness. Registry digests alone only pin the attestor reports that
361
+ // are present, as `@reclaimprotocol/js-sdk` does by default — a portal
362
+ // TEE proof without a bound nonce carries no envelope.
363
+ const attestorTeeAttestation = configuredAttestorTee
364
+ && expectedImageDigests.length
352
365
  ? { ...configuredAttestorTee, expectedImageDigests }
353
366
  : configuredAttestorTee;
354
367
  const commonValidation = {
@@ -360,6 +373,9 @@ export async function verifyResultFull(jws, opts = {}) {
360
373
  ...(attestorTeeAttestation
361
374
  ? { attestorTeeAttestation }
362
375
  : {}),
376
+ ...(expectedImageDigests.length
377
+ ? { attestorImageDigests: expectedImageDigests }
378
+ : {}),
363
379
  ...(expectedSessionId
364
380
  ? {
365
381
  expectedSessionId,
@@ -375,7 +391,12 @@ export async function verifyResultFull(jws, opts = {}) {
375
391
  }
376
392
  }
377
393
  if (!proofs.every((p) => p.proofValid)) {
378
- return fail('invalid-proof', { proofs });
394
+ // Retry only when every failure is transient; one genuinely invalid
395
+ // proof makes redelivery pointless.
396
+ return fail('invalid-proof', {
397
+ proofs,
398
+ retryable: proofs.every((p) => p.proofValid || p.transient),
399
+ });
379
400
  }
380
401
  return { payload, reclaimSessionId, proofs };
381
402
  async function verifyRequestProofGroup(group, validation) {
@@ -403,6 +424,7 @@ export async function verifyResultFull(jws, opts = {}) {
403
424
  for (const { outcomeIndex } of group) {
404
425
  proofs[outcomeIndex].proofValid = false;
405
426
  proofs[outcomeIndex].reason = requestProofOutcome.reason;
427
+ proofs[outcomeIndex].transient = requestProofOutcome.transient;
406
428
  }
407
429
  }
408
430
  async function verifyByResolvedRecipes(common, expectedSessionId) {
@@ -412,8 +434,11 @@ export async function verifyResultFull(jws, opts = {}) {
412
434
  const resolved = await opts.resolveSession(expectedSessionId);
413
435
  recipes = resolved.recipes;
414
436
  }
415
- catch {
416
- return fail('session-resolution-failed', { proofs });
437
+ catch (error) {
438
+ return fail('session-resolution-failed', {
439
+ proofs,
440
+ retryable: isTransientError(error),
441
+ });
417
442
  }
418
443
  }
419
444
  const groups = Map.groupBy(proofGroups, proofGroupKey);
package/lib/routes.gen.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:de7373741e35).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -3104,6 +3104,7 @@ export interface paths {
3104
3104
  /**
3105
3105
  * Submit exact proofs as a managed Builder result
3106
3106
  * @description Accepts exact `Proof` objects from a claimant-facing `api=2` client. Builder atomically records the first terminal outcome, checks it against the submitted status, signs the outer Builder JWS with its managed key, optionally encrypts it for matching callback subscriptions, and enqueues delivery. Retrying the same terminal outcome is idempotent. This is the only result-submission endpoint; Verification Clients never receive signing or encryption keys.
3107
+ * Before it accepts a `success` result, Builder verifies every proof with the same trust a default `@reclaimprotocol/client` consumer uses: Builder's attestor addresses and image digests, the session recipe's provider hashes, and the session binding. If a proof fails, Builder records the session as a terminal `error` with a `problem` whose `reasonCode` is `invalid-proof`, enqueues the `verification_error` delivery, and returns `422`. Don't retry a `422`; the session is already terminal.
3107
3108
  */
3108
3109
  post: operations["SubmitVerificationClientResult"];
3109
3110
  delete?: never;
@@ -6380,7 +6381,18 @@ export interface components {
6380
6381
  /** @enum {string} */
6381
6382
  status: "success" | "rejected" | "error" | "cancelled";
6382
6383
  results: components["schemas"]["VerificationClientProviderResult"][];
6383
- problem?: components["schemas"]["Problem"] | null;
6384
+ problem?: ({
6385
+ /** @description Stable machine-readable failure code. Builder records `invalid-proof` when a submitted proof fails its verification. */
6386
+ reasonCode?: string;
6387
+ retryable?: boolean;
6388
+ /** @description Builder-recorded proof failures, one per provider. Present with `reasonCode: invalid-proof`. */
6389
+ proofs?: {
6390
+ providerId?: string;
6391
+ resolvedVersion?: string;
6392
+ requestIds?: string[];
6393
+ reason?: string;
6394
+ }[];
6395
+ } & components["schemas"]["Problem"]) | null;
6384
6396
  };
6385
6397
  VerificationClientProviderResult: {
6386
6398
  providerId: components["schemas"]["ProviderID"];
@@ -11563,6 +11575,15 @@ export interface operations {
11563
11575
  };
11564
11576
  400: components["responses"]["ProblemResponse"];
11565
11577
  409: components["responses"]["ProblemResponse"];
11578
+ /** @description A submitted proof failed Builder verification. Builder recorded the session as a terminal `error` and enqueued `verification_error`. */
11579
+ 422: {
11580
+ headers: {
11581
+ [name: string]: unknown;
11582
+ };
11583
+ content: {
11584
+ "application/problem+json": components["schemas"]["Problem"];
11585
+ };
11586
+ };
11566
11587
  default: components["responses"]["ProblemResponse"];
11567
11588
  };
11568
11589
  };
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/openapi-typegen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:de7373741e35).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -51,6 +51,18 @@ export type AttestorTeeAttestationConfig = {
51
51
  */
52
52
  expectedImageDigests?: string[];
53
53
  };
54
+ /** How strictly `runAttestorTeeVerification` treats witnesses without a
55
+ * `claimAttestation`. */
56
+ export interface AttestorTeeVerificationOptions {
57
+ /**
58
+ * Default `true`: every witness must carry a `claimAttestation`. `false`
59
+ * counts a witness without one as unattested, which is how
60
+ * `@reclaimprotocol/js-sdk` verifies by default — its claim signature is
61
+ * still checked against the trusted attestors by the caller. Portal TEE
62
+ * proofs without a bound nonce have no envelope.
63
+ */
64
+ requireClaimAttestation?: boolean;
65
+ }
54
66
  /** A witness's attestation kind. Required: every client sets it explicitly. */
55
67
  export declare function claimAttestationType(att: AttestorClaimAttestation): ClaimAttestationType;
56
68
  /** What `runAttestorTeeVerification` actually checked, per witness. */
@@ -58,8 +70,9 @@ export interface AttestorTeeVerificationSummary {
58
70
  /** Witnesses whose `attestation_report` was verified (and pinned, when
59
71
  * `expectedImageDigests` was set). */
60
72
  verified: number;
61
- /** Witnesses that carried no `attestation_report`. Their attestor address
62
- * and claim signature were still verified. */
73
+ /** Witnesses that carried no `attestation_report`, or no
74
+ * `claimAttestation` when it isn't required. Their attestor address and
75
+ * claim signature were still verified. */
63
76
  unattested: number;
64
77
  }
65
78
  /**
@@ -67,7 +80,8 @@ export interface AttestorTeeVerificationSummary {
67
80
  * provided proof. Throws `AttestorTeeVerificationError` on the first
68
81
  * failure.
69
82
  *
70
- * Each witness must carry a `claimAttestation`. For each one, this:
83
+ * Each witness must carry a `claimAttestation` unless
84
+ * `options.requireClaimAttestation` is `false`. For each one, this:
71
85
  * 1. Asserts `attestor_address` matches `witness.id`.
72
86
  * 2. Asserts `claim_signature` is present in `proof.signatures`.
73
87
  * 3. Recovers the signer of `claim_signature` from the claim data and
@@ -89,5 +103,6 @@ export interface AttestorTeeVerificationSummary {
89
103
  *
90
104
  * @param proofs - The proofs to verify.
91
105
  * @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
106
+ * @param options - See {@link AttestorTeeVerificationOptions}.
92
107
  */
93
- export declare function runAttestorTeeVerification(proofs: Proof[], config?: AttestorTeeAttestationConfig): Promise<AttestorTeeVerificationSummary>;
108
+ export declare function runAttestorTeeVerification(proofs: Proof[], config?: AttestorTeeAttestationConfig, { requireClaimAttestation }?: AttestorTeeVerificationOptions): Promise<AttestorTeeVerificationSummary>;
@@ -165,7 +165,7 @@ function normalizeAttestorAddress(address) {
165
165
  function normalizeSignature(sig) {
166
166
  return sig.trim().toLowerCase();
167
167
  }
168
- async function verifyAttestorTeeForProof(proof, config, summary) {
168
+ async function verifyAttestorTeeForProof(proof, config, requireClaimAttestation, summary) {
169
169
  if (!proof.witnesses || proof.witnesses.length === 0) {
170
170
  throw new AttestorTeeVerificationError('Proof has no witnesses');
171
171
  }
@@ -174,6 +174,10 @@ async function verifyAttestorTeeForProof(proof, config, summary) {
174
174
  const claimSignData = createSignDataForClaim(proof.claimData);
175
175
  for (const witness of proof.witnesses) {
176
176
  const att = witness.claimAttestation;
177
+ if (!att && !requireClaimAttestation) {
178
+ summary.unattested++;
179
+ continue;
180
+ }
177
181
  if (!att) {
178
182
  throw new AttestorTeeVerificationError(`Witness ${witness.id} is missing claimAttestation`);
179
183
  }
@@ -233,7 +237,8 @@ async function verifyAttestorTeeForProof(proof, config, summary) {
233
237
  * provided proof. Throws `AttestorTeeVerificationError` on the first
234
238
  * failure.
235
239
  *
236
- * Each witness must carry a `claimAttestation`. For each one, this:
240
+ * Each witness must carry a `claimAttestation` unless
241
+ * `options.requireClaimAttestation` is `false`. For each one, this:
237
242
  * 1. Asserts `attestor_address` matches `witness.id`.
238
243
  * 2. Asserts `claim_signature` is present in `proof.signatures`.
239
244
  * 3. Recovers the signer of `claim_signature` from the claim data and
@@ -255,8 +260,9 @@ async function verifyAttestorTeeForProof(proof, config, summary) {
255
260
  *
256
261
  * @param proofs - The proofs to verify.
257
262
  * @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
263
+ * @param options - See {@link AttestorTeeVerificationOptions}.
258
264
  */
259
- export async function runAttestorTeeVerification(proofs, config = {}) {
265
+ export async function runAttestorTeeVerification(proofs, config = {}, { requireClaimAttestation = true } = {}) {
260
266
  if (!proofs || proofs.length === 0) {
261
267
  throw new AttestorTeeVerificationError('No proofs provided for attestor TEE verification');
262
268
  }
@@ -266,7 +272,7 @@ export async function runAttestorTeeVerification(proofs, config = {}) {
266
272
  };
267
273
  try {
268
274
  for (const proof of proofs) {
269
- await verifyAttestorTeeForProof(proof, config, summary);
275
+ await verifyAttestorTeeForProof(proof, config, requireClaimAttestation, summary);
270
276
  }
271
277
  return summary;
272
278
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/client",
3
3
  "title": "Reclaim Client",
4
- "version": "0.1.35",
4
+ "version": "0.1.36",
5
5
  "description": "Typed SDK/HTTP client SDK for the Reclaim Protocol — the programmatic way to use Reclaim Protocol to verify **data points** about your users (a follower count, an account balance, an order). Can be directly integrated by apps to request verification for their users using Reclaim Protocol and verify the results.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
package/src/errors.ts CHANGED
@@ -44,6 +44,58 @@ export function isProblemError(error: unknown): error is ProblemError {
44
44
  && isProblemErrorOutput(error.output)
45
45
  }
46
46
 
47
+ /**
48
+ * Whether retrying the same request later can succeed: a network failure, a
49
+ * timeout, or an HTTP 5xx, 408, or 429 from Builder (a {@link ProblemError}
50
+ * or a plain error carrying the HTTP `status`). Everything else, such as a
51
+ * malformed response, won't change on retry.
52
+ */
53
+ export function isTransientError(error: unknown): boolean {
54
+ // `fetch` rejects with a TypeError when the network fails.
55
+ if(error instanceof TypeError) {
56
+ return true
57
+ }
58
+
59
+ if(
60
+ error instanceof Error
61
+ && (error.name === 'TimeoutError' || error.name === 'AbortError')
62
+ ) {
63
+ return true
64
+ }
65
+
66
+ const status = isProblemError(error)
67
+ ? error.output.statusCode
68
+ : isRecord(error) ? error.status : undefined
69
+ return typeof status === 'number'
70
+ && (status >= 500 || status === 408 || status === 429)
71
+ }
72
+
73
+ // Errors thrown by a network step and judged transient. Tracked by identity
74
+ // so a content check that throws, say, a TypeError is never mistaken for one.
75
+ const TRANSIENT_ERRORS = new WeakSet<object>()
76
+
77
+ /**
78
+ * Runs one network step, such as a JWKS fetch or a session or provider
79
+ * lookup, and marks a transient failure so {@link isTransientFailure} can
80
+ * tell it apart from a failed content check.
81
+ */
82
+ export async function networkStep<T>(step: () => Promise<T> | T): Promise<T> {
83
+ try {
84
+ return await step()
85
+ } catch(error) {
86
+ if(error instanceof Error && isTransientError(error)) {
87
+ TRANSIENT_ERRORS.add(error)
88
+ }
89
+
90
+ throw error
91
+ }
92
+ }
93
+
94
+ /** Whether `error` came from a {@link networkStep} and a retry can succeed. */
95
+ export function isTransientFailure(error: unknown): boolean {
96
+ return error instanceof Error && TRANSIENT_ERRORS.has(error)
97
+ }
98
+
47
99
  /** Returns whether a value follows the RFC 9457 problem-details schema. */
48
100
  export function isProblemDetails(value: unknown): value is ProblemDetails {
49
101
  if(!isRecord(value)) {
package/src/jwks.ts CHANGED
@@ -95,7 +95,11 @@ export async function fetchReclaimJwks(
95
95
  assertSecureJwksUrl(url)
96
96
  const res = await fetchImpl(url)
97
97
  if(!res.ok) {
98
- throw new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`)
98
+ // `status` lets callers tell a temporary Builder outage from a bad URL.
99
+ throw Object.assign(
100
+ new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`),
101
+ { status: res.status },
102
+ )
99
103
  }
100
104
 
101
105
  const body = (await res.json()) as Partial<ReclaimJwks>
@@ -1,5 +1,6 @@
1
1
  import { createSignDataForClaim } from '@reclaimprotocol/attestor-core'
2
2
  import { recoverEip191Address } from './eip191.ts'
3
+ import { isTransientFailure, networkStep } from './errors.ts'
3
4
  import { fetchReclaimJwks, trustedAttestorAddresses } from './jwks.ts'
4
5
  import { assertValidProofHashes, getHashFromProof } from './proof-hash.ts'
5
6
  import type {
@@ -41,9 +42,15 @@ export interface VerifyRequestProofOptions {
41
42
  /** Read `providerHash` from context, while still verifying signatures. */
42
43
  hasNoPii?: true
43
44
  teeAttestation?: TeeAttestationConfig
45
+ /** Require every witness to carry a `claimAttestation`, and verify any
46
+ * `attestation_report` against `expectedImageDigests`. */
44
47
  attestorTeeAttestation?: AttestorTeeAttestationConfig
45
48
  /** Explicit trusted attestor ETH addresses. */
46
49
  attestorAddresses?: string[]
50
+ /** Trusted attestor image digests, like `x-reclaim-attestor-image-digests`.
51
+ * Pin any `attestation_report` a witness carries without requiring one,
52
+ * matching `@reclaimprotocol/js-sdk`'s default. */
53
+ attestorImageDigests?: string[]
47
54
  /** Trusted Builder JWKS registry containing attestor-purpose keys. */
48
55
  attestorJwksUrl?: string
49
56
  fetchImpl?: typeof fetch
@@ -70,6 +77,10 @@ export interface VerifyRequestProofOptions {
70
77
  export interface ReclaimRequestProofOutcome {
71
78
  valid: boolean
72
79
  reason?: string
80
+ /** On failure: true when a temporary condition caused it, such as a
81
+ * network error while resolving the session or provider. A retry can
82
+ * succeed. */
83
+ transient?: boolean
73
84
  proofHashes?: string[]
74
85
  isTeeAttestationVerified?: boolean
75
86
  /** True when at least one witness's `attestation_report` verified; false
@@ -116,6 +127,8 @@ export async function verifyRequestProof(
116
127
  trust.imageDigests,
117
128
  )
118
129
  if(attestorTeeAttestation) {
130
+ // Only a caller-configured check requires an envelope on every
131
+ // witness; registry digests alone pin the reports that exist.
119
132
  // `verified` counts witnesses whose attestation report was checked.
120
133
  // A proof whose witnesses carry no report still passes the address
121
134
  // and signature bindings, but nothing was attested — say so rather
@@ -123,6 +136,7 @@ export async function verifyRequestProof(
123
136
  const summary = await runAttestorTeeVerification(
124
137
  proofs,
125
138
  attestorTeeAttestation,
139
+ { requireClaimAttestation: !!effective.attestorTeeAttestation },
126
140
  )
127
141
  isAttestorTeeAttestationVerified = summary.verified > 0
128
142
  }
@@ -139,6 +153,7 @@ export async function verifyRequestProof(
139
153
  return {
140
154
  valid: false,
141
155
  reason: failureReason(error),
156
+ transient: isTransientFailure(error),
142
157
  }
143
158
  }
144
159
  }
@@ -148,14 +163,15 @@ async function resolveValidation(
148
163
  proofs: ReclaimRequestProof[],
149
164
  ) {
150
165
  let effective = config
151
- if(config.sessionId && !hasContentValidation(config)) {
152
- if(!config.resolveSession) {
166
+ const { sessionId, resolveSession } = config
167
+ if(sessionId && !hasContentValidation(config)) {
168
+ if(!resolveSession) {
153
169
  throw new Error('session-resolver-required')
154
170
  }
155
171
 
156
172
  effective = {
157
173
  ...config,
158
- ...await config.resolveSession(config.sessionId, proofs),
174
+ ...await networkStep(() => resolveSession(sessionId, proofs)),
159
175
  }
160
176
  }
161
177
 
@@ -164,12 +180,12 @@ async function resolveValidation(
164
180
 
165
181
  async function resolveAttestorTrust(config: VerifyRequestProofOptions) {
166
182
  const addresses = config.attestorAddresses?.map(normalized) ?? []
167
- const imageDigests: string[] = []
168
- if(config.attestorJwksUrl) {
169
- const registry = await fetchReclaimJwks(
170
- config.attestorJwksUrl,
171
- config.fetchImpl,
172
- )
183
+ const imageDigests = config.attestorImageDigests?.map(normalized) ?? []
184
+ const { attestorJwksUrl, fetchImpl } = config
185
+ if(attestorJwksUrl) {
186
+ const registry = await networkStep(() => (
187
+ fetchReclaimJwks(attestorJwksUrl, fetchImpl)
188
+ ))
173
189
  addresses.push(
174
190
  ...(registry['x-reclaim-attestors'] ?? []),
175
191
  ...trustedAttestorAddresses(registry.keys),
@@ -258,17 +274,15 @@ async function assertContent(
258
274
  return
259
275
  }
260
276
 
261
- if(config.providerId) {
262
- if(!config.resolveProvider) {
277
+ const { providerId, providerVersion, allowedTags, resolveProvider } = config
278
+ if(providerId) {
279
+ if(!resolveProvider) {
263
280
  throw new Error('provider-resolver-required')
264
281
  }
265
282
 
266
- const candidates = await config.resolveProvider(
267
- config.providerId,
268
- config.providerVersion,
269
- config.allowedTags,
270
- proofs,
271
- )
283
+ const candidates = await networkStep(() => (
284
+ resolveProvider(providerId, providerVersion, allowedTags, proofs)
285
+ ))
272
286
  if(!candidates.length) {
273
287
  throw new Error('provider-not-found')
274
288
  }
package/src/result.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  hexToBytes,
21
21
  stripTrailingSlashes,
22
22
  } from './encoding.ts'
23
+ import { isTransientError } from './errors.ts'
23
24
  import {
24
25
  fetchReclaimJwks,
25
26
  type Jwk,
@@ -303,6 +304,9 @@ export interface ResultProofOutcome {
303
304
  proof?: ReclaimRequestProof
304
305
  proofValid: boolean
305
306
  reason?: string
307
+ /** On failure: true when a temporary condition caused it, such as a
308
+ * network error while resolving the provider. */
309
+ transient?: boolean
306
310
  /** @deprecated Use `attestorAddresses`; retained for compatibility. */
307
311
  attestorAddress?: string
308
312
  /** Every attestor address declared by the proof. */
@@ -330,12 +334,18 @@ export interface VerifyResultFullOutcome extends VerifyResultOutcome {
330
334
 
331
335
  export interface ResultVerificationFailure extends VerifyResultFullOutcome {
332
336
  reason: string
337
+ /** True when a temporary condition caused the failure. Set by the
338
+ * verifier; read it as {@link ResultVerificationError.retryable}. */
339
+ retryable?: boolean
333
340
  }
334
341
 
335
342
  /**
336
343
  * Thrown by {@link verifyResult} / {@link verifyResultFull} (and therefore
337
344
  * `reclaim.results.verify`/`receive`) when a result fails verification. Carries
338
- * the full invalid outcome so callers can inspect it in a `catch`:
345
+ * the full invalid outcome so callers can inspect it in a `catch`.
346
+ *
347
+ * In a callback handler, answer 2xx when `retryable` is `false` (Builder
348
+ * retrying the same delivery can't help) and 5xx when it's `true`:
339
349
  *
340
350
  * try {
341
351
  * const { result } = await reclaim.results.receive(body, {
@@ -346,6 +356,7 @@ export interface ResultVerificationFailure extends VerifyResultFullOutcome {
346
356
  * if (err instanceof ResultVerificationError) {
347
357
  * // for example, 'expired', { payload, … }
348
358
  * console.error(err.reason, err.outcome)
359
+ * res.sendStatus(err.retryable ? 503 : 200)
349
360
  * }
350
361
  * }
351
362
  */
@@ -356,12 +367,22 @@ export class ResultVerificationError extends Error {
356
367
  /** The full (invalid) outcome: `payload`, `reclaimSessionId`, and per-proof
357
368
  * detail (`proofs`) when verification got that far. */
358
369
  readonly outcome: ResultVerificationFailure
370
+ /**
371
+ * Whether a retry can succeed. `true` only for a temporary condition on
372
+ * the verifier's side: Builder's JWKS or recipe couldn't be fetched
373
+ * (network error, timeout, 5xx, 429). `false` when the result itself is
374
+ * wrong (signature, issuer, audience, session, expiry, invalid proof), so
375
+ * redelivering the same body fails the same way. Respond 5xx to a
376
+ * callback only when `true`.
377
+ */
378
+ readonly retryable: boolean
359
379
 
360
380
  constructor(outcome: ResultVerificationFailure) {
361
381
  super(`result verification failed: ${outcome.reason}`)
362
382
  this.name = 'ResultVerificationError'
363
383
  this.reason = outcome.reason
364
384
  this.outcome = outcome
385
+ this.retryable = outcome.retryable ?? false
365
386
  }
366
387
  }
367
388
 
@@ -446,8 +467,10 @@ export async function verifyResultFull(
446
467
  jwksAttestorAddresses = registry['x-reclaim-attestors'] ?? []
447
468
  jwksAttestorImageDigests =
448
469
  registry['x-reclaim-attestor-image-digests'] ?? []
449
- } catch{
450
- return fail('jwks-fetch-failed')
470
+ } catch(error) {
471
+ return fail('jwks-fetch-failed', {
472
+ retryable: isTransientError(error),
473
+ })
451
474
  }
452
475
 
453
476
  const jwk = selectVerificationResultKey(
@@ -594,7 +617,12 @@ export async function verifyResultFull(
594
617
  expectedImageDigests = configuredImageDigests
595
618
  }
596
619
 
597
- const attestorTeeAttestation = expectedImageDigests.length
620
+ // A caller-configured check requires a `claimAttestation` on every
621
+ // witness. Registry digests alone only pin the attestor reports that
622
+ // are present, as `@reclaimprotocol/js-sdk` does by default — a portal
623
+ // TEE proof without a bound nonce carries no envelope.
624
+ const attestorTeeAttestation = configuredAttestorTee
625
+ && expectedImageDigests.length
598
626
  ? { ...configuredAttestorTee, expectedImageDigests }
599
627
  : configuredAttestorTee
600
628
  const commonValidation = {
@@ -606,6 +634,9 @@ export async function verifyResultFull(
606
634
  ...(attestorTeeAttestation
607
635
  ? { attestorTeeAttestation }
608
636
  : {}),
637
+ ...(expectedImageDigests.length
638
+ ? { attestorImageDigests: expectedImageDigests }
639
+ : {}),
609
640
  ...(expectedSessionId
610
641
  ? {
611
642
  expectedSessionId,
@@ -622,7 +653,12 @@ export async function verifyResultFull(
622
653
  }
623
654
 
624
655
  if(!proofs.every((p) => p.proofValid)) {
625
- return fail('invalid-proof', { proofs })
656
+ // Retry only when every failure is transient; one genuinely invalid
657
+ // proof makes redelivery pointless.
658
+ return fail('invalid-proof', {
659
+ proofs,
660
+ retryable: proofs.every((p) => p.proofValid || p.transient),
661
+ })
626
662
  }
627
663
 
628
664
  return { payload, reclaimSessionId, proofs }
@@ -666,6 +702,7 @@ export async function verifyResultFull(
666
702
  for(const { outcomeIndex } of group) {
667
703
  proofs[outcomeIndex].proofValid = false
668
704
  proofs[outcomeIndex].reason = requestProofOutcome.reason
705
+ proofs[outcomeIndex].transient = requestProofOutcome.transient
669
706
  }
670
707
  }
671
708
 
@@ -678,8 +715,11 @@ export async function verifyResultFull(
678
715
  try {
679
716
  const resolved = await opts.resolveSession(expectedSessionId)
680
717
  recipes = resolved.recipes
681
- } catch{
682
- return fail('session-resolution-failed', { proofs })
718
+ } catch(error) {
719
+ return fail('session-resolution-failed', {
720
+ proofs,
721
+ retryable: isTransientError(error),
722
+ })
683
723
  }
684
724
  }
685
725
 
package/src/routes.gen.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:de7373741e35).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/openapi-typegen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:de7373741e35).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -3110,6 +3110,7 @@ export interface paths {
3110
3110
  /**
3111
3111
  * Submit exact proofs as a managed Builder result
3112
3112
  * @description Accepts exact `Proof` objects from a claimant-facing `api=2` client. Builder atomically records the first terminal outcome, checks it against the submitted status, signs the outer Builder JWS with its managed key, optionally encrypts it for matching callback subscriptions, and enqueues delivery. Retrying the same terminal outcome is idempotent. This is the only result-submission endpoint; Verification Clients never receive signing or encryption keys.
3113
+ * Before it accepts a `success` result, Builder verifies every proof with the same trust a default `@reclaimprotocol/client` consumer uses: Builder's attestor addresses and image digests, the session recipe's provider hashes, and the session binding. If a proof fails, Builder records the session as a terminal `error` with a `problem` whose `reasonCode` is `invalid-proof`, enqueues the `verification_error` delivery, and returns `422`. Don't retry a `422`; the session is already terminal.
3113
3114
  */
3114
3115
  post: operations["SubmitVerificationClientResult"];
3115
3116
  delete?: never;
@@ -6386,7 +6387,18 @@ export interface components {
6386
6387
  /** @enum {string} */
6387
6388
  status: "success" | "rejected" | "error" | "cancelled";
6388
6389
  results: components["schemas"]["VerificationClientProviderResult"][];
6389
- problem?: components["schemas"]["Problem"] | null;
6390
+ problem?: ({
6391
+ /** @description Stable machine-readable failure code. Builder records `invalid-proof` when a submitted proof fails its verification. */
6392
+ reasonCode?: string;
6393
+ retryable?: boolean;
6394
+ /** @description Builder-recorded proof failures, one per provider. Present with `reasonCode: invalid-proof`. */
6395
+ proofs?: {
6396
+ providerId?: string;
6397
+ resolvedVersion?: string;
6398
+ requestIds?: string[];
6399
+ reason?: string;
6400
+ }[];
6401
+ } & components["schemas"]["Problem"]) | null;
6390
6402
  };
6391
6403
  VerificationClientProviderResult: {
6392
6404
  providerId: components["schemas"]["ProviderID"];
@@ -11569,6 +11581,15 @@ export interface operations {
11569
11581
  };
11570
11582
  400: components["responses"]["ProblemResponse"];
11571
11583
  409: components["responses"]["ProblemResponse"];
11584
+ /** @description A submitted proof failed Builder verification. Builder recorded the session as a terminal `error` and enqueued `verification_error`. */
11585
+ 422: {
11586
+ headers: {
11587
+ [name: string]: unknown;
11588
+ };
11589
+ content: {
11590
+ "application/problem+json": components["schemas"]["Problem"];
11591
+ };
11592
+ };
11572
11593
  default: components["responses"]["ProblemResponse"];
11573
11594
  };
11574
11595
  };
@@ -239,6 +239,19 @@ export type AttestorTeeAttestationConfig = {
239
239
  expectedImageDigests?: string[]
240
240
  }
241
241
 
242
+ /** How strictly `runAttestorTeeVerification` treats witnesses without a
243
+ * `claimAttestation`. */
244
+ export interface AttestorTeeVerificationOptions {
245
+ /**
246
+ * Default `true`: every witness must carry a `claimAttestation`. `false`
247
+ * counts a witness without one as unattested, which is how
248
+ * `@reclaimprotocol/js-sdk` verifies by default — its claim signature is
249
+ * still checked against the trusted attestors by the caller. Portal TEE
250
+ * proofs without a bound nonce have no envelope.
251
+ */
252
+ requireClaimAttestation?: boolean
253
+ }
254
+
242
255
  /** A witness's attestation kind. Required: every client sets it explicitly. */
243
256
  export function claimAttestationType(
244
257
  att: AttestorClaimAttestation,
@@ -251,8 +264,9 @@ export interface AttestorTeeVerificationSummary {
251
264
  /** Witnesses whose `attestation_report` was verified (and pinned, when
252
265
  * `expectedImageDigests` was set). */
253
266
  verified: number
254
- /** Witnesses that carried no `attestation_report`. Their attestor address
255
- * and claim signature were still verified. */
267
+ /** Witnesses that carried no `attestation_report`, or no
268
+ * `claimAttestation` when it isn't required. Their attestor address and
269
+ * claim signature were still verified. */
256
270
  unattested: number
257
271
  }
258
272
 
@@ -267,6 +281,7 @@ function normalizeSignature(sig: string): string {
267
281
  async function verifyAttestorTeeForProof(
268
282
  proof: Proof,
269
283
  config: AttestorTeeAttestationConfig,
284
+ requireClaimAttestation: boolean,
270
285
  summary: AttestorTeeVerificationSummary,
271
286
  ): Promise<void> {
272
287
  if(!proof.witnesses || proof.witnesses.length === 0) {
@@ -282,6 +297,11 @@ async function verifyAttestorTeeForProof(
282
297
 
283
298
  for(const witness of proof.witnesses) {
284
299
  const att: AttestorClaimAttestation | undefined = witness.claimAttestation
300
+ if(!att && !requireClaimAttestation) {
301
+ summary.unattested++
302
+ continue
303
+ }
304
+
285
305
  if(!att) {
286
306
  throw new AttestorTeeVerificationError(
287
307
  `Witness ${witness.id} is missing claimAttestation`
@@ -368,7 +388,8 @@ async function verifyAttestorTeeForProof(
368
388
  * provided proof. Throws `AttestorTeeVerificationError` on the first
369
389
  * failure.
370
390
  *
371
- * Each witness must carry a `claimAttestation`. For each one, this:
391
+ * Each witness must carry a `claimAttestation` unless
392
+ * `options.requireClaimAttestation` is `false`. For each one, this:
372
393
  * 1. Asserts `attestor_address` matches `witness.id`.
373
394
  * 2. Asserts `claim_signature` is present in `proof.signatures`.
374
395
  * 3. Recovers the signer of `claim_signature` from the claim data and
@@ -390,10 +411,12 @@ async function verifyAttestorTeeForProof(
390
411
  *
391
412
  * @param proofs - The proofs to verify.
392
413
  * @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
414
+ * @param options - See {@link AttestorTeeVerificationOptions}.
393
415
  */
394
416
  export async function runAttestorTeeVerification(
395
417
  proofs: Proof[],
396
- config: AttestorTeeAttestationConfig = {}
418
+ config: AttestorTeeAttestationConfig = {},
419
+ { requireClaimAttestation = true }: AttestorTeeVerificationOptions = {},
397
420
  ): Promise<AttestorTeeVerificationSummary> {
398
421
  if(!proofs || proofs.length === 0) {
399
422
  throw new AttestorTeeVerificationError('No proofs provided for attestor TEE verification')
@@ -406,7 +429,12 @@ export async function runAttestorTeeVerification(
406
429
 
407
430
  try {
408
431
  for(const proof of proofs) {
409
- await verifyAttestorTeeForProof(proof, config, summary)
432
+ await verifyAttestorTeeForProof(
433
+ proof,
434
+ config,
435
+ requireClaimAttestation,
436
+ summary,
437
+ )
410
438
  }
411
439
 
412
440
  return summary