@zadq/x402 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,8 +30,30 @@ When the service serves its own statement about the seller's guarantee deposit
30
30
  beside the verdict, `readDepositDeclaration(verdict)` returns it — a state and
31
31
  a level band, never an amount — and `null` otherwise.
32
32
 
33
- Subpath exports: `verdict`, `extension`, `header`, `receipt`, `explorer`,
34
- `keys`, `webhook`, `holder` (Node only) — or everything from the root. The
33
+ ```ts
34
+ import { embedVerificationBadge } from '@zadq/x402/badge';
35
+
36
+ // Always renders: `human-backed · ≥ G`, `revoked` or `unknown`; never rejects.
37
+ await embedVerificationBadge(document.getElementById('badge')!, {
38
+ baseUrl: 'https://api.zadq.net',
39
+ subject: { chain: 'base', payTo: '0x…', resourceUrl: 'https://…' },
40
+ });
41
+ ```
42
+
43
+ A fetched verdict is structurally checked and must be about the subject asked;
44
+ anything else is no verdict, which every helper shows as `unknown`. The
45
+ signature check never throws (`valid`, `invalid` or `unverifiable`); keys are
46
+ selected by `keyId` from the published set, so a verdict signed before a key
47
+ rotation keeps verifying. An inline verdict on a 402 is projected to the
48
+ canonical v0 fields, so the emitted `identityPolicy` always fits MintID's closed
49
+ schema.
50
+
51
+ Subpath exports: `verdict`, `extension` (also as `payment-required`), `header`
52
+ (also as `x-payment`), `receipt`, `badge`, `explorer`, `keys`, `webhook`, `client`,
53
+ `holder` (Node only) — or everything from the root. The byte forms of the
54
+ signed verdict, the `X-PAYMENT-IDENTITY` envelope and the issuer receipt are
55
+ pinned by vectors in the test suite; a change to any of them is a major
56
+ version. The
35
57
  contracts each module honours are documented at
36
58
  <https://zadq.info/docs/sdk>; the modules are also available as copy-in files
37
59
  in the repository's `snippets/` folder for integrators who prefer to vendor.
@@ -0,0 +1,51 @@
1
+ import { type Verdict, type VerdictSubject } from './verdict.js';
2
+ /** What the badge shows. Everything uncertain is "unknown". */
3
+ export type BadgeState = 'human-backed' | 'revoked' | 'unknown';
4
+ /** The tooltip when no verification signal is available. */
5
+ export declare const NO_SIGNAL_TITLE = "No verification signal available \u2014 nothing in this flow depends on it.";
6
+ /**
7
+ * The state a verdict maps to: `human_backed` → `human-backed`, `revoked` →
8
+ * `revoked`; `stale`, `no_signal`, no verdict and anything that is not a
9
+ * structurally valid verdict → `unknown`. Pure; never throws.
10
+ */
11
+ export declare function badgeState(verdict: Verdict | undefined | null): BadgeState;
12
+ /**
13
+ * The badge's text: `human-backed · ≥ <assuranceLevel>` (U+00B7, U+2265,
14
+ * single spaces) for the positive claim with a grade; otherwise the state
15
+ * itself (`human-backed`, `revoked`, `unknown`).
16
+ */
17
+ export declare function badgeLabel(state: BadgeState, verdict?: Verdict | null): string;
18
+ /**
19
+ * The badge's tooltip: `Verified at <verifiedAt> · re-checked every <N> h`
20
+ * (the time as transported, and the cadence promise the verdict carries),
21
+ * plus ` · re-check overdue` when the verdict is older than its promise. With
22
+ * no verdict, or no `verifiedAt`, the no-signal sentence (`NO_SIGNAL_TITLE`).
23
+ */
24
+ export declare function badgeTitle(verdict: Verdict | undefined | null, now?: Date): string;
25
+ export type BadgeOptions = {
26
+ /** Base URL of the verification service (its free path is used). */
27
+ baseUrl: string;
28
+ /** The endpoint the badge speaks about. */
29
+ subject: VerdictSubject;
30
+ /**
31
+ * Optional verification public key (base64, 32 raw bytes). When given, a
32
+ * positive claim whose signature is `invalid` renders "unknown"; one the
33
+ * runtime cannot check (`unverifiable`) is shown as delivered.
34
+ */
35
+ publicKey?: string;
36
+ /** Fetch timeout in milliseconds (default 3000). */
37
+ timeoutMs?: number;
38
+ };
39
+ /**
40
+ * Fetches the endpoint's verdict and renders the badge into `container`: one
41
+ * `<span>` with the label as its text, the tooltip as its `title`,
42
+ * `data-verification-badge="<state>"` and an inline pill style, replacing the
43
+ * container's children. Resolves to the state shown. Never rejects: whatever
44
+ * the service answers — or if it does not — a badge is rendered.
45
+ */
46
+ export declare function embedVerificationBadge(container: Element, options: BadgeOptions): Promise<BadgeState>;
47
+ /**
48
+ * Renders a badge for a verdict the caller already holds (inline in a 402,
49
+ * from a webhook, from the explorer client). Same element as the embed.
50
+ */
51
+ export declare function renderBadge(container: Element, state: BadgeState, verdict?: Verdict | null): void;
package/dist/badge.js ADDED
@@ -0,0 +1,103 @@
1
+ // Badge snippet — the verification badge an explorer, a listing or a seller's
2
+ // own page shows next to an x402 endpoint.
3
+ //
4
+ // Vendor this file with verdict.ts. Zero runtime dependencies; the DOM embed
5
+ // uses only `ownerDocument.createElement` and `replaceChildren`.
6
+ //
7
+ // Graceful degradation, by design: the badge always renders. No verdict, a
8
+ // malformed answer, an unreachable service or a positive claim whose
9
+ // signature does not check all render the "unknown" badge, and the rest of
10
+ // the page is unaffected — the embed never rejects.
11
+ import { BADGE_CLIENT } from "./client.js";
12
+ import { fetchVerdict, freshness, isVerdict, verifyVerdictSignature, } from "./verdict.js";
13
+ /** The tooltip when no verification signal is available. */
14
+ export const NO_SIGNAL_TITLE = 'No verification signal available — nothing in this flow depends on it.';
15
+ /**
16
+ * The state a verdict maps to: `human_backed` → `human-backed`, `revoked` →
17
+ * `revoked`; `stale`, `no_signal`, no verdict and anything that is not a
18
+ * structurally valid verdict → `unknown`. Pure; never throws.
19
+ */
20
+ export function badgeState(verdict) {
21
+ if (!isVerdict(verdict))
22
+ return 'unknown';
23
+ if (verdict.outcome === 'human_backed')
24
+ return 'human-backed';
25
+ if (verdict.outcome === 'revoked')
26
+ return 'revoked';
27
+ return 'unknown';
28
+ }
29
+ /**
30
+ * The badge's text: `human-backed · ≥ <assuranceLevel>` (U+00B7, U+2265,
31
+ * single spaces) for the positive claim with a grade; otherwise the state
32
+ * itself (`human-backed`, `revoked`, `unknown`).
33
+ */
34
+ export function badgeLabel(state, verdict) {
35
+ const grade = isVerdict(verdict) ? verdict.assuranceLevel : undefined;
36
+ if (state === 'human-backed' && grade !== undefined) {
37
+ return `human-backed · ≥ ${grade}`;
38
+ }
39
+ return state;
40
+ }
41
+ /**
42
+ * The badge's tooltip: `Verified at <verifiedAt> · re-checked every <N> h`
43
+ * (the time as transported, and the cadence promise the verdict carries),
44
+ * plus ` · re-check overdue` when the verdict is older than its promise. With
45
+ * no verdict, or no `verifiedAt`, the no-signal sentence (`NO_SIGNAL_TITLE`).
46
+ */
47
+ export function badgeTitle(verdict, now = new Date()) {
48
+ if (!isVerdict(verdict) || verdict.verifiedAt === undefined)
49
+ return NO_SIGNAL_TITLE;
50
+ const base = `Verified at ${verdict.verifiedAt} · re-checked every ${verdict.recheckEveryHours} h`;
51
+ return freshness(verdict, now) === 'overdue' ? `${base} · re-check overdue` : base;
52
+ }
53
+ const COLOURS = {
54
+ 'human-backed': '#1a7f37',
55
+ revoked: '#b42318',
56
+ unknown: '#6b7280',
57
+ };
58
+ /**
59
+ * Fetches the endpoint's verdict and renders the badge into `container`: one
60
+ * `<span>` with the label as its text, the tooltip as its `title`,
61
+ * `data-verification-badge="<state>"` and an inline pill style, replacing the
62
+ * container's children. Resolves to the state shown. Never rejects: whatever
63
+ * the service answers — or if it does not — a badge is rendered.
64
+ */
65
+ export async function embedVerificationBadge(container, options) {
66
+ let shown;
67
+ try {
68
+ shown = await fetchVerdict(options.baseUrl, options.subject, {
69
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
70
+ client: BADGE_CLIENT,
71
+ });
72
+ if (shown?.outcome === 'human_backed' && options.publicKey !== undefined) {
73
+ if ((await verifyVerdictSignature(shown, options.publicKey)) === 'invalid') {
74
+ shown = undefined;
75
+ }
76
+ }
77
+ }
78
+ catch {
79
+ shown = undefined;
80
+ }
81
+ const state = badgeState(shown);
82
+ try {
83
+ renderBadge(container, state, shown);
84
+ }
85
+ catch {
86
+ // A container that cannot take children leaves the page as it was.
87
+ }
88
+ return state;
89
+ }
90
+ /**
91
+ * Renders a badge for a verdict the caller already holds (inline in a 402,
92
+ * from a webhook, from the explorer client). Same element as the embed.
93
+ */
94
+ export function renderBadge(container, state, verdict) {
95
+ const span = container.ownerDocument.createElement('span');
96
+ span.textContent = badgeLabel(state, verdict);
97
+ span.title = badgeTitle(verdict);
98
+ span.setAttribute('data-verification-badge', state);
99
+ span.setAttribute('style', 'display:inline-block;padding:0.125em 0.6em;border-radius:999px;' +
100
+ 'font:500 0.75rem/1.5 system-ui,sans-serif;color:#fff;white-space:nowrap;' +
101
+ `background:${COLOURS[state]}`);
102
+ container.replaceChildren(span);
103
+ }
@@ -0,0 +1,26 @@
1
+ /** This library's version, as its package manifest states it (a test keeps them equal). */
2
+ export declare const SDK_VERSION = "0.3.0";
3
+ /** The service's own clients. */
4
+ export declare const CLIENT_CLASSES: readonly ["sdk", "mcp", "badge"];
5
+ export type ClientClass = (typeof CLIENT_CLASSES)[number];
6
+ /** Which client is calling: its class and its version (`major.minor.patch`). */
7
+ export type ClientDeclaration = {
8
+ client: ClientClass;
9
+ version: string;
10
+ };
11
+ /** The library's own declaration, the default for every helper that calls the service. */
12
+ export declare const SDK_CLIENT: ClientDeclaration;
13
+ /** The library's badge (`badge.ts`): its own class, the library's version. */
14
+ export declare const BADGE_CLIENT: ClientDeclaration;
15
+ /** The header that carries the declaration. */
16
+ export declare const CLIENT_HEADER = "user-agent";
17
+ /**
18
+ * `<brand>-<class>/<version>`, e.g. the SDK's `…-sdk/0.2.0`; `undefined`
19
+ * for a class outside the enumeration or a version that is not three
20
+ * dot-separated numbers — nothing else can be carried.
21
+ */
22
+ export declare function clientUserAgent(declaration?: ClientDeclaration): string | undefined;
23
+ /** True where the runtime owns its User-Agent; false in a browser (window or worker). */
24
+ export declare function runtimeDeclaresClient(): boolean;
25
+ /** The headers a request to the service carries for the declaration: one, or none. */
26
+ export declare function clientHeaders(declaration?: ClientDeclaration): Record<string, string>;
package/dist/client.js ADDED
@@ -0,0 +1,46 @@
1
+ // The client class: the service's own clients say which one they are, so
2
+ // the free path's aggregate counters can tell the SDK, the MCP server and
3
+ // the badge apart from any other caller. The declaration is a fixed
4
+ // User-Agent, `<brand>-<class>/<version>` — the client's name and version
5
+ // and nothing else: never an identifier of the integrator, the page, the
6
+ // agent or the person, and never anything a caller could fill in (the
7
+ // class is a closed enumeration and the version is digits and dots).
8
+ //
9
+ // Browsers are left alone: there a User-Agent set by script is ignored or,
10
+ // cross-origin, turns a simple request into a CORS preflight, so the
11
+ // declaration is sent only where the runtime owns its User-Agent (Node,
12
+ // Deno, Bun, edge workers). Nothing depends on it: a request without it is
13
+ // answered exactly the same, and every helper still degrades to "unknown".
14
+ import { BRAND } from "./brand.js";
15
+ /** This library's version, as its package manifest states it (a test keeps them equal). */
16
+ export const SDK_VERSION = '0.3.0';
17
+ /** The service's own clients. */
18
+ export const CLIENT_CLASSES = ['sdk', 'mcp', 'badge'];
19
+ /** The library's own declaration, the default for every helper that calls the service. */
20
+ export const SDK_CLIENT = { client: 'sdk', version: SDK_VERSION };
21
+ /** The library's badge (`badge.ts`): its own class, the library's version. */
22
+ export const BADGE_CLIENT = { client: 'badge', version: SDK_VERSION };
23
+ /** The header that carries the declaration. */
24
+ export const CLIENT_HEADER = 'user-agent';
25
+ /**
26
+ * `<brand>-<class>/<version>`, e.g. the SDK's `…-sdk/0.2.0`; `undefined`
27
+ * for a class outside the enumeration or a version that is not three
28
+ * dot-separated numbers — nothing else can be carried.
29
+ */
30
+ export function clientUserAgent(declaration = SDK_CLIENT) {
31
+ if (!CLIENT_CLASSES.includes(declaration.client))
32
+ return undefined;
33
+ if (!/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(declaration.version))
34
+ return undefined;
35
+ return `${BRAND.toLowerCase()}-${declaration.client}/${declaration.version}`;
36
+ }
37
+ /** True where the runtime owns its User-Agent; false in a browser (window or worker). */
38
+ export function runtimeDeclaresClient() {
39
+ const ua = globalThis.navigator?.userAgent;
40
+ return !(typeof ua === 'string' && ua.startsWith('Mozilla/'));
41
+ }
42
+ /** The headers a request to the service carries for the declaration: one, or none. */
43
+ export function clientHeaders(declaration = SDK_CLIENT) {
44
+ const ua = clientUserAgent(declaration);
45
+ return ua !== undefined && runtimeDeclaresClient() ? { [CLIENT_HEADER]: ua } : {};
46
+ }
@@ -1,3 +1,5 @@
1
+ import type { ClientDeclaration } from './client.js';
2
+ import { type VerificationKey } from './keys.js';
1
3
  import { type Freshness, type Verdict, type VerdictSubject } from './verdict.js';
2
4
  /** Display signal for one endpoint row. Everything uncertain is "unknown". */
3
5
  export type VerificationSignal = {
@@ -23,10 +25,19 @@ export type VerificationClientOptions = {
23
25
  * as delivered — the listing keeps working either way.
24
26
  */
25
27
  publicKey?: string;
28
+ /**
29
+ * Optional published key set (`fetchVerificationKeys`), used instead of
30
+ * `publicKey`: each positive claim is checked with the key its `keyId`
31
+ * names, `current` or `previous`. A positive claim naming no published key
32
+ * is demoted to "unknown".
33
+ */
34
+ keys?: readonly VerificationKey[];
26
35
  /** Fetch timeout in milliseconds (default 3000). */
27
36
  timeoutMs?: number;
28
37
  /** In-memory cache TTL in milliseconds (default 5 minutes; 0 disables). */
29
38
  cacheTtlMs?: number;
39
+ /** Which of the service's clients is calling (default: this library). See client.ts. */
40
+ client?: ClientDeclaration;
30
41
  };
31
42
  export type VerificationClient = {
32
43
  /** Resolves the display signal for one endpoint. Never rejects. */
@@ -40,8 +51,10 @@ export type VerificationClient = {
40
51
  export declare function createVerificationClient(options: VerificationClientOptions): VerificationClient;
41
52
  /**
42
53
  * Maps a verdict (or its absence) to the display signal, optionally checking
43
- * the signature of positive claims locally. Exported for callers that already
44
- * hold a verdict — e.g. one delivered inline in a `PaymentRequired` extension
45
- * (extension.ts) or by a webhook.
54
+ * the signature of positive claims locally — against one public key (base64)
55
+ * or against a published key set, selected by the verdict's `keyId`. Exported
56
+ * for callers that already hold a verdict — e.g. one delivered inline in a
57
+ * `PaymentRequired` extension (extension.ts) or by a webhook. Anything that is
58
+ * not a structurally valid verdict maps to "unknown"; never rejects.
46
59
  */
47
- export declare function toSignal(verdict: Verdict | undefined, publicKey?: string): Promise<VerificationSignal>;
60
+ export declare function toSignal(verdict: Verdict | undefined | null, key?: string | readonly VerificationKey[]): Promise<VerificationSignal>;
package/dist/explorer.js CHANGED
@@ -5,7 +5,8 @@
5
5
  // for. Graceful degradation, by design: a listing keeps rendering exactly as
6
6
  // before when no verification response is available — the signal degrades to
7
7
  // "unknown", it never becomes an error state, a spinner or a blocked row.
8
- import { canonicalPayTo, fetchVerdict, freshness, verifyVerdictSignature, } from "./verdict.js";
8
+ import { keyFor } from "./keys.js";
9
+ import { canonicalPayTo, fetchVerdict, freshness, isVerdict, verifyVerdictSignature, } from "./verdict.js";
9
10
  /** The signal rendered when no verdict is available. Rendering never blocks on it. */
10
11
  export const UNKNOWN_SIGNAL = { state: 'unknown', freshness: 'unknown' };
11
12
  /**
@@ -17,8 +18,11 @@ export function createVerificationClient(options) {
17
18
  const ttl = options.cacheTtlMs ?? 300_000;
18
19
  const cache = new Map();
19
20
  async function resolve(subject) {
20
- const verdict = await fetchVerdict(options.baseUrl, subject, options.timeoutMs === undefined ? undefined : { timeoutMs: options.timeoutMs });
21
- return toSignal(verdict, options.publicKey);
21
+ const verdict = await fetchVerdict(options.baseUrl, subject, {
22
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
23
+ ...(options.client === undefined ? {} : { client: options.client }),
24
+ });
25
+ return toSignal(verdict, options.keys ?? options.publicKey);
22
26
  }
23
27
  return {
24
28
  async signalFor(subject) {
@@ -43,16 +47,24 @@ export function createVerificationClient(options) {
43
47
  }
44
48
  /**
45
49
  * Maps a verdict (or its absence) to the display signal, optionally checking
46
- * the signature of positive claims locally. Exported for callers that already
47
- * hold a verdict — e.g. one delivered inline in a `PaymentRequired` extension
48
- * (extension.ts) or by a webhook.
50
+ * the signature of positive claims locally — against one public key (base64)
51
+ * or against a published key set, selected by the verdict's `keyId`. Exported
52
+ * for callers that already hold a verdict — e.g. one delivered inline in a
53
+ * `PaymentRequired` extension (extension.ts) or by a webhook. Anything that is
54
+ * not a structurally valid verdict maps to "unknown"; never rejects.
49
55
  */
50
- export async function toSignal(verdict, publicKey) {
51
- if (verdict === undefined) {
56
+ export async function toSignal(verdict, key) {
57
+ if (!isVerdict(verdict)) {
52
58
  return UNKNOWN_SIGNAL;
53
59
  }
54
- if (verdict.outcome === 'human_backed' && publicKey !== undefined) {
55
- const check = await verifyVerdictSignature(verdict, publicKey);
60
+ if (verdict.outcome === 'human_backed' && key !== undefined) {
61
+ const publicKey = typeof key === 'string' ? key : keyFor(verdict, key);
62
+ // A signed positive claim naming no published key cannot match the set.
63
+ const check = publicKey !== undefined
64
+ ? await verifyVerdictSignature(verdict, publicKey)
65
+ : verdict.signature === undefined
66
+ ? 'unverifiable'
67
+ : 'invalid';
56
68
  if (check === 'invalid') {
57
69
  // An unverified positive claim is not displayed; the row still renders.
58
70
  return UNKNOWN_SIGNAL;
@@ -1,4 +1,4 @@
1
- import type { Verdict } from './verdict.js';
1
+ import { type Verdict } from './verdict.js';
2
2
  /**
3
3
  * The identity policy a seller advertises on its 402 response, following the
4
4
  * bIP-0002 profile — sub-profile `bip-0002/0-seller`, the canonical v0 seller
@@ -59,17 +59,31 @@ export declare const IDENTITY_POLICY_EXTENSION = "identityPolicy";
59
59
  export declare const PERIMETER_EXTENSION = "trustPerimeter";
60
60
  /**
61
61
  * Seller side: returns a copy of the 402 body with the identity policy
62
- * attached under `extensions.identityPolicy`. Existing extensions are
63
- * preserved. Only the canonical fields are emitted, so the object always
64
- * validates against the closed v0 seller schema whatever else the caller's
65
- * policy value carried.
62
+ * attached under `extensions.identityPolicy`. The input is not mutated;
63
+ * existing extensions are preserved and `x402Version`, `error` and `accepts`
64
+ * are never touched. Only the canonical fields are emitted — the inline
65
+ * verdict projected by `canonicalInlineVerdict` — so the object validates
66
+ * against the closed v0 seller schema whatever else the caller's policy value
67
+ * carried, a live verdict's `extensions` included.
66
68
  */
67
69
  export declare function withIdentityPolicy<T extends PaymentRequiredBody>(body: T, policy: IdentityPolicy): T;
70
+ /**
71
+ * The inline verdict reduced to the fields of the canonical v0 seller object:
72
+ * `outcome`, `reason` (never beside `human_backed`), `subject` {chain, payTo,
73
+ * resourceUrl}, `assuranceLevel`, `verifiedAt`, `recheckEveryHours`,
74
+ * `signature` and `keyId`. Everything else — the verifying party's own
75
+ * `extensions` beside the verdict included — is dropped, so a verdict taken
76
+ * straight from the free path keeps the closed schema valid; the signed bytes
77
+ * are all kept, so it verifies exactly as the fetched one does. Anything that
78
+ * is not a structurally valid verdict gives `undefined` (no inline verdict).
79
+ */
80
+ export declare function canonicalInlineVerdict(verdict: unknown): Verdict | undefined;
68
81
  /**
69
82
  * Counterparty side: reads the identity policy from a 402 body, if present
70
83
  * and well-formed. Returns `undefined` otherwise — an absent or malformed
71
84
  * policy is a normal state, and the payment flow proceeds unchanged. Fields
72
- * outside the canonical object are dropped, never fatal.
85
+ * outside the canonical object are dropped, never fatal; an inline verdict
86
+ * that is not structurally valid is dropped and the policy kept.
73
87
  */
74
88
  export declare function readIdentityPolicy(body: PaymentRequiredBody): IdentityPolicy | undefined;
75
89
  /**
package/dist/extension.js CHANGED
@@ -10,16 +10,19 @@
10
10
  // and with no round-trip, or later via the free verification path. Absence of
11
11
  // the extension, of a verdict, or of any verification response changes
12
12
  // nothing in either party's flow.
13
+ import { isVerdict } from "./verdict.js";
13
14
  /** Extension key defined by the bIP-0002 profile for the identity policy. */
14
15
  export const IDENTITY_POLICY_EXTENSION = 'identityPolicy';
15
16
  /** The vendor-owned extension key the trust-perimeter invitation rides under. */
16
17
  export const PERIMETER_EXTENSION = 'trustPerimeter';
17
18
  /**
18
19
  * Seller side: returns a copy of the 402 body with the identity policy
19
- * attached under `extensions.identityPolicy`. Existing extensions are
20
- * preserved. Only the canonical fields are emitted, so the object always
21
- * validates against the closed v0 seller schema whatever else the caller's
22
- * policy value carried.
20
+ * attached under `extensions.identityPolicy`. The input is not mutated;
21
+ * existing extensions are preserved and `x402Version`, `error` and `accepts`
22
+ * are never touched. Only the canonical fields are emitted — the inline
23
+ * verdict projected by `canonicalInlineVerdict` — so the object validates
24
+ * against the closed v0 seller schema whatever else the caller's policy value
25
+ * carried, a live verdict's `extensions` included.
23
26
  */
24
27
  export function withIdentityPolicy(body, policy) {
25
28
  const canonical = {
@@ -27,20 +30,59 @@ export function withIdentityPolicy(body, policy) {
27
30
  assuranceAtLeast: policy.assuranceAtLeast,
28
31
  verifyUrl: policy.verifyUrl,
29
32
  };
30
- if (policy.verdict !== undefined)
31
- canonical.verdict = policy.verdict;
33
+ const verdict = canonicalInlineVerdict(policy.verdict);
34
+ if (verdict !== undefined)
35
+ canonical.verdict = verdict;
32
36
  return {
33
37
  ...body,
34
38
  extensions: { ...body.extensions, [IDENTITY_POLICY_EXTENSION]: canonical },
35
39
  };
36
40
  }
41
+ /**
42
+ * The inline verdict reduced to the fields of the canonical v0 seller object:
43
+ * `outcome`, `reason` (never beside `human_backed`), `subject` {chain, payTo,
44
+ * resourceUrl}, `assuranceLevel`, `verifiedAt`, `recheckEveryHours`,
45
+ * `signature` and `keyId`. Everything else — the verifying party's own
46
+ * `extensions` beside the verdict included — is dropped, so a verdict taken
47
+ * straight from the free path keeps the closed schema valid; the signed bytes
48
+ * are all kept, so it verifies exactly as the fetched one does. Anything that
49
+ * is not a structurally valid verdict gives `undefined` (no inline verdict).
50
+ */
51
+ export function canonicalInlineVerdict(verdict) {
52
+ if (!isVerdict(verdict))
53
+ return undefined;
54
+ const out = {
55
+ outcome: verdict.outcome,
56
+ subject: {
57
+ chain: verdict.subject.chain,
58
+ payTo: verdict.subject.payTo,
59
+ resourceUrl: verdict.subject.resourceUrl,
60
+ },
61
+ recheckEveryHours: verdict.recheckEveryHours,
62
+ };
63
+ if (verdict.reason !== undefined && verdict.outcome !== 'human_backed') {
64
+ out.reason = verdict.reason;
65
+ }
66
+ if (verdict.assuranceLevel !== undefined)
67
+ out.assuranceLevel = verdict.assuranceLevel;
68
+ if (verdict.verifiedAt !== undefined)
69
+ out.verifiedAt = verdict.verifiedAt;
70
+ if (verdict.signature !== undefined)
71
+ out.signature = verdict.signature;
72
+ if (verdict.keyId !== undefined)
73
+ out.keyId = verdict.keyId;
74
+ return out;
75
+ }
37
76
  /**
38
77
  * Counterparty side: reads the identity policy from a 402 body, if present
39
78
  * and well-formed. Returns `undefined` otherwise — an absent or malformed
40
79
  * policy is a normal state, and the payment flow proceeds unchanged. Fields
41
- * outside the canonical object are dropped, never fatal.
80
+ * outside the canonical object are dropped, never fatal; an inline verdict
81
+ * that is not structurally valid is dropped and the policy kept.
42
82
  */
43
83
  export function readIdentityPolicy(body) {
84
+ if (typeof body !== 'object' || body === null)
85
+ return undefined;
44
86
  const raw = body.extensions?.[IDENTITY_POLICY_EXTENSION];
45
87
  if (typeof raw !== 'object' || raw === null) {
46
88
  return undefined;
@@ -56,10 +98,11 @@ export function readIdentityPolicy(body) {
56
98
  assuranceAtLeast: policy['assuranceAtLeast'],
57
99
  verifyUrl: policy['verifyUrl'],
58
100
  };
59
- const verdict = policy['verdict'];
60
- if (typeof verdict === 'object' && verdict !== null) {
101
+ // An inline verdict that is not structurally valid is dropped, the policy
102
+ // kept; a valid one is read back in its canonical fields only.
103
+ const verdict = canonicalInlineVerdict(policy['verdict']);
104
+ if (verdict !== undefined)
61
105
  read.verdict = verdict;
62
- }
63
106
  return read;
64
107
  }
65
108
  /**
@@ -83,6 +126,8 @@ export function withPerimeterInvitation(body, invitation) {
83
126
  * exactly as before.
84
127
  */
85
128
  export function readPerimeterInvitation(body) {
129
+ if (typeof body !== 'object' || body === null)
130
+ return undefined;
86
131
  const raw = body.extensions?.[PERIMETER_EXTENSION];
87
132
  if (typeof raw !== 'object' || raw === null)
88
133
  return undefined;
package/dist/header.d.ts CHANGED
@@ -17,14 +17,24 @@ export type IdentityProof = {
17
17
  };
18
18
  type HeaderRecord = Record<string, string>;
19
19
  /**
20
- * Sender side: attaches the identity proof next to `X-PAYMENT`. Returns the
21
- * same headers object/record for chaining.
20
+ * The exact header value for a proof: base64url without padding (RFC 4648
21
+ * §5) of the compact UTF-8 JSON `{"version":1,"format":…,"payload":…}`, keys
22
+ * in that order and nothing else. Any change to these bytes is a major
23
+ * version of the package.
24
+ */
25
+ export declare function identityProofHeaderValue(proof: IdentityProof): string;
26
+ /**
27
+ * Sender side: attaches the identity proof next to `X-PAYMENT` (which is
28
+ * never read or modified). Mutates and returns the same headers
29
+ * object/record for chaining.
22
30
  */
23
31
  export declare function attachIdentityProof<T extends Headers | HeaderRecord>(headers: T, proof: IdentityProof): T;
24
32
  /**
25
- * Receiver side: reads the identity proof, if present and well-formed.
26
- * Returns `undefined` otherwise — an absent or malformed proof is a normal
27
- * state; handle the request exactly as you would without the header.
33
+ * Receiver side: reads the identity proof, if present and well-formed — the
34
+ * header name case-insensitively, the value with or without padding. Returns
35
+ * `undefined` otherwise — an absent or malformed proof is a normal state;
36
+ * handle the request exactly as you would without the header. Only the three
37
+ * envelope fields are returned.
28
38
  */
29
39
  export declare function readIdentityProof(headers: Headers | HeaderRecord): IdentityProof | undefined;
30
40
  export {};
package/dist/header.js CHANGED
@@ -16,11 +16,21 @@ export const X_PAYMENT_HEADER = 'X-PAYMENT';
16
16
  /** Companion header defined by the bIP-0002 profile for the identity proof. */
17
17
  export const X_PAYMENT_IDENTITY_HEADER = 'X-PAYMENT-IDENTITY';
18
18
  /**
19
- * Sender side: attaches the identity proof next to `X-PAYMENT`. Returns the
20
- * same headers object/record for chaining.
19
+ * The exact header value for a proof: base64url without padding (RFC 4648
20
+ * §5) of the compact UTF-8 JSON `{"version":1,"format":…,"payload":…}`, keys
21
+ * in that order and nothing else. Any change to these bytes is a major
22
+ * version of the package.
23
+ */
24
+ export function identityProofHeaderValue(proof) {
25
+ return base64UrlEncode(JSON.stringify({ version: proof.version, format: proof.format, payload: proof.payload }));
26
+ }
27
+ /**
28
+ * Sender side: attaches the identity proof next to `X-PAYMENT` (which is
29
+ * never read or modified). Mutates and returns the same headers
30
+ * object/record for chaining.
21
31
  */
22
32
  export function attachIdentityProof(headers, proof) {
23
- const value = base64UrlEncode(JSON.stringify(proof));
33
+ const value = identityProofHeaderValue(proof);
24
34
  if (headers instanceof Headers) {
25
35
  headers.set(X_PAYMENT_IDENTITY_HEADER, value);
26
36
  }
@@ -30,25 +40,28 @@ export function attachIdentityProof(headers, proof) {
30
40
  return headers;
31
41
  }
32
42
  /**
33
- * Receiver side: reads the identity proof, if present and well-formed.
34
- * Returns `undefined` otherwise — an absent or malformed proof is a normal
35
- * state; handle the request exactly as you would without the header.
43
+ * Receiver side: reads the identity proof, if present and well-formed — the
44
+ * header name case-insensitively, the value with or without padding. Returns
45
+ * `undefined` otherwise — an absent or malformed proof is a normal state;
46
+ * handle the request exactly as you would without the header. Only the three
47
+ * envelope fields are returned.
36
48
  */
37
49
  export function readIdentityProof(headers) {
38
- const value = headers instanceof Headers
39
- ? headers.get(X_PAYMENT_IDENTITY_HEADER)
40
- : findHeader(headers, X_PAYMENT_IDENTITY_HEADER);
41
- if (value === null || value === undefined || value === '') {
42
- return undefined;
43
- }
44
50
  try {
51
+ const value = headers instanceof Headers
52
+ ? headers.get(X_PAYMENT_IDENTITY_HEADER)
53
+ : findHeader(headers, X_PAYMENT_IDENTITY_HEADER);
54
+ if (typeof value !== 'string' || value === '') {
55
+ return undefined;
56
+ }
45
57
  const parsed = JSON.parse(base64UrlDecode(value));
46
- if (parsed['version'] !== 1 ||
47
- typeof parsed['format'] !== 'string' ||
48
- typeof parsed['payload'] !== 'string') {
58
+ if (typeof parsed !== 'object' || parsed === null)
59
+ return undefined;
60
+ const p = parsed;
61
+ if (p['version'] !== 1 || typeof p['format'] !== 'string' || typeof p['payload'] !== 'string') {
49
62
  return undefined;
50
63
  }
51
- return parsed;
64
+ return { version: 1, format: p['format'], payload: p['payload'] };
52
65
  }
53
66
  catch {
54
67
  return undefined;
@@ -1,5 +1,7 @@
1
1
  /** The chain query path of the issuer-scoped read. */
2
2
  export declare const RECORDED_NULLIFIERS_QUERY = "/identity.v1.selfrevoke.Query/RecordedNullifiers";
3
+ /** After how long the chain prunes a record (`retention_seconds`, both networks). */
4
+ export declare const RECORD_RETENTION_SECONDS = 3600;
3
5
  /** A recorded self-revocation's state on the chain, by revocation value (hex). */
4
6
  export type RecordedState = 'pending' | 'folded';
5
7
  export type ChainReadOptions = {
@@ -12,7 +14,10 @@ export declare function bytesField(field: number, value: Uint8Array): Uint8Array
12
14
  export declare function varintField(field: number, value: number): Uint8Array;
13
15
  export declare function hexToBytes(hex: string): Uint8Array;
14
16
  export declare function bytesToHex(bytes: Uint8Array): string;
15
- /** `QueryRecordedNullifiersRequest{issuer_id = 1}` — names no revocation value. */
17
+ /**
18
+ * `QueryRecordedNullifiersRequest{issuer_id = 1}` — `0a20‖issuer_id`; names
19
+ * no revocation value. The issuer id is exactly 32 bytes.
20
+ */
16
21
  export declare function encodeIssuerQuery(issuerIdHex: string): Uint8Array;
17
22
  /**
18
23
  * `QueryRecordedNullifiersResponse{repeated RecordedNullifier records = 1}`