j:jurat

Rendered evidence bundle

A paid render of one public URL: full-page PNG, PDF, raw HTML, and markdown, each hashed, with an ed25519 signature over the flat manifest.

What you get

POST /v1/evidence-bundle ($0.75) renders one public URL and returns the page as a full-page PNG, a PDF, the raw HTML, and markdown. The bytes travel in the JSON response as base64. A short-lived download URL would point at the render worker, and that worker is reachable only from LS1 on the LAN, so the buyer could not fetch it.

curl -X POST https://jurat.dev/v1/evidence-bundle \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com"}'

No payment when the renderer cannot run

Jurat asks the render worker whether it can take a job before it answers 402. If the worker is full, not configured, or not reachable, the response is 503 with a Retry-After header and a JSON error. There is no payment challenge on that response. A render that fails after payment is a non-200, and settlement is cancelled, so a failed capture is not charged.

An unpaid GET returns the same 402 challenge as an unpaid POST when the worker can take the job, so a directory probe can read it. A GET that carries a payment header returns 405 with Allow: POST and is not verified or settled. An empty body or {} on POST still returns 402 in that same case. The body is checked after a valid payment: bad JSON, a missing url, an unknown field, or a private address returns 400, and that payment is not settled.

The signed manifest is flat

The signature uses the same recipe as an attestation. It covers the signed object only, one level deep: JSON.stringify of the top-level keys sorted lexicographically, no whitespace, UTF-8. Nested objects are not re-sorted. Links and response headers sit outside signed. They are bound by links_sha256 and headers_sha256.

// Node.js. Same key prefix as an attestation.
import crypto from 'node:crypto';
const bundle = /* the JSON response */;
const wk = await (await fetch('https://jurat.dev/.well-known/jurat.json')).json();
const raw = Buffer.from(wk.pubkeys.find(k => k.pubkey_id === bundle.pubkey_id).public_key_b64, 'base64');
const key = crypto.createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100','hex'), raw]), format: 'der', type: 'spki' });
const canonical = JSON.stringify(Object.fromEntries(Object.entries(bundle.signed).sort(([a],[b]) => a < b ? -1 : 1)));
crypto.verify(null, Buffer.from(canonical), key, Buffer.from(bundle.signature, 'base64')); // true
const png = Buffer.from(bundle.artifacts.screenshot_png_b64, 'base64');
crypto.createHash('sha256').update(png).digest('hex') === bundle.signed.screenshot_sha256; // true

Time

captured_at is the worker clock. signed_at is jurat's clock. time_source is worker_clock. When FreeTSA answers, timestamp.token_b64 is an RFC 3161 token over those same canonical signed bytes, from https://freetsa.org/tsr. Their policy allows commercial use (freetsa.org/freetsa.html, section 3). The token is outside the signature. If the timestamp service does not answer, the bundle is still returned and timestamp.source is jurat_signed_timestamp.

What the renderer will not fetch

The worker refuses non-http(s) URLs, and it refuses private, loopback, link-local, and carrier-grade NAT addresses after DNS and again on every redirect, including images and other subrequests inside the browser. HTML over 2 MB is refused. The render itself is capped at about 60 seconds.