{
  "openapi": "3.1.0",
  "info": {
    "title": "FreeSign — Free e-signature API",
    "version": "0.0.0",
    "summary": "Free e-signature (electronic signature) API — zero-document PDF signing.",
    "description": "FreeSign is a free e-signature (electronic signature, esignature) service. The API accepts hashes, OTP state, signatures, and receipts only. Do not upload PDF bytes. Browser/headless clients compute hashes and generate signatures locally. Output is a standard PAdES-B-T signed PDF, designed for the EU eIDAS Advanced Electronic Signature (AES, Article 26) and US ESIGN/UETA evidence models — legal admissibility is jurisdiction- and fact-specific."
  },
  "servers": [
    {
      "url": "https://free-sign.com",
      "description": "Production"
    },
    {
      "url": "http://localhost:8787",
      "description": "Local development"
    }
  ],
  "paths": {
    "/api/consent": {
      "get": {
        "summary": "Fetch current consent text and public ceremony configuration",
        "description": "Single source of truth for the consent version/text the browser signs. Also returns enabled signature variants and the public Cloudflare Turnstile site key when configured. No PDF bytes are involved.",
        "responses": {
          "200": {
            "description": "Consent text, hash, signature variants, and public Turnstile site key.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "version": { "type": "string" },
                    "text": { "type": "string" },
                    "text_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                    "signature_variants": {
                      "type": "array",
                      "items": { "type": "string", "enum": ["signer_certificate", "freesign_verified_seal"] }
                    },
                    "turnstile_site_key": { "type": "string" },
                    "intelligence": {
                      "type": "object",
                      "description": "Present only on deployments with ENABLE_DOCUMENT_INTELLIGENCE=true (the optional in-browser one-paragraph PDF summary POC). Absent when the flag is off — clients must treat the field as flag-conditional, not required.",
                      "properties": {
                        "enabled": { "type": "boolean" },
                        "free_quota_per_30d": {
                          "type": "integer",
                          "minimum": 0,
                          "description": "Per-deployment free quota label (informational only — not enforced in code today)."
                        }
                      },
                      "required": ["enabled", "free_quota_per_30d"]
                    }
                  },
                  "required": ["version", "text", "text_sha256", "signature_variants", "turnstile_site_key"]
                }
              }
            }
          }
        }
      }
    },
    "/api/envelopes": {
      "post": {
        "summary": "Create envelope from local PDF hash and bind the browser's session keypair",
        "description": "Posts the SHA-256 of the local PDF AND the public-key JWK of a non-extractable ECDSA P-256 keypair generated by the browser. The pubkey is persisted write-once on the envelope row; every subsequent protected endpoint requires a signature with the matching private key (see x-fsig-session-* parameter component).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "document_sha256": {
                    "type": "string",
                    "pattern": "^[a-f0-9]{64}$"
                  },
                  "session_pubkey_jwk": {
                    "type": "object",
                    "description": "Public JWK of the browser-generated, non-extractable ECDSA P-256 session keypair. Must contain ONLY {kty: 'EC', crv: 'P-256', x, y} — a private 'd' field is rejected.",
                    "properties": {
                      "kty": { "type": "string", "enum": ["EC"] },
                      "crv": { "type": "string", "enum": ["P-256"] },
                      "x": { "type": "string", "description": "Base64url EC x coordinate." },
                      "y": { "type": "string", "description": "Base64url EC y coordinate." }
                    },
                    "required": ["kty", "crv", "x", "y"]
                  }
                },
                "required": ["document_sha256", "session_pubkey_jwk"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Envelope created. Returns envelope_id, expires_at, and the canonical session_pubkey_sha256 + session_bound_at the server committed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "envelope_id": { "type": "string", "pattern": "^env_[a-f0-9]{32}$" },
                    "expires_at": { "type": "string", "format": "date-time" },
                    "session_pubkey_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                    "session_bound_at": { "type": "string", "format": "date-time" }
                  },
                  "required": ["envelope_id", "expires_at", "session_pubkey_sha256", "session_bound_at"]
                }
              }
            }
          },
          "400": { "description": "invalid_document_sha256 / invalid_session_pubkey_jwk." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/session-bind": {
      "post": {
        "summary": "Late-bind a session pubkey to an envelope that was created without one",
        "description": "MCP `create_signing_envelope` creates envelopes whose `session_pubkey_jwk_json` is NULL (an AI agent has no IndexedDB to keep a private key in). The first browser to open the resulting `signing_url` generates a non-extractable ECDSA P-256 keypair and POSTs the public JWK here. The handler UPDATEs the envelope row iff `session_pubkey_jwk_json IS NULL` (write-once, enforced by both the WHERE clause AND by trg_envelopes_session_update from migrations 0010 + 0011). Subsequent protected requests then carry the standard `x-fsig-session-*` headers. NOT required when the envelope was created via `POST /api/envelopes` directly — that call already binds the session.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^env_[a-f0-9]{32}$" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "session_pubkey_jwk": {
                    "type": "object",
                    "description": "ECDSA P-256 public key in JWK format. Must be kty=EC, crv=P-256, with x and y as 43-char base64url (32-byte coordinates). Must NOT contain the private `d` field."
                  }
                },
                "required": ["session_pubkey_jwk"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session bound.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "envelope_id": { "type": "string" },
                    "session_pubkey_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                    "session_bound_at": { "type": "string", "format": "date-time" }
                  },
                  "required": ["envelope_id", "session_pubkey_sha256", "session_bound_at"]
                }
              }
            }
          },
          "400": { "description": "invalid_session_pubkey_jwk." },
          "404": { "description": "not_found — envelope does not exist." },
          "409": { "description": "session_already_bound — another browser already bound this envelope." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/otp": {
      "post": {
        "summary": "Send OTP to signer email",
        "description": "Requires the four session-binding headers (see x-fsig-session-* component parameters). 401 session_signature_required when absent. When the deployment has Cloudflare Turnstile configured (TURNSTILE_SECRET_KEY set), the request must also carry a valid `turnstile_token`; the browser solves the challenge in the OTP modal before this call. A short per-email cooldown (default 30s) plus 15-minute sliding-window rate limits apply.",
        "parameters": [
          {
            "name": "envelope_id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "turnstile_token": {
                    "type": "string",
                    "description": "Cloudflare Turnstile token from the browser challenge. Required when the deployment has TURNSTILE_SECRET_KEY set; omitted otherwise. The @smoke.free-sign.com test channel is exempt."
                  }
                },
                "required": ["email"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "OTP challenge created." },
          "403": { "description": "turnstile_required (token missing) / turnstile_failed (token rejected by siteverify)." },
          "429": { "description": "otp_cooldown (a code was sent under OTP_COOLDOWN_SECONDS ago) or otp_rate_limited (15-minute bucket exceeded)." },
          "503": { "description": "turnstile_unavailable — Cloudflare siteverify unreachable; the gate fails closed." }
        }
      }
    },
    "/api/batch/otp": {
      "post": {
        "summary": "Send one OTP across a batch of envelopes (multi-document signing)",
        "description": "Multi-document signing (one OTP/passkey ceremony signs several PDFs). The browser creates one envelope per document, then calls this once: it verifies a per-envelope session proof for every item (carried in the body, since one request has only one set of session headers), enforces Turnstile + the per-email cooldown + rate limits ONCE, generates ONE code, and creates one OTP challenge per envelope with that shared code. A single email is sent. Each envelope is then verified individually via POST /api/envelopes/{envelope_id}/otp/verify with the one typed code (each challenge is salted by its envelope id, so the same code verifies all of them). Batch size is capped at BATCH_MAX_DOCS (default 10).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "turnstile_token": {
                    "type": "string",
                    "description": "Cloudflare Turnstile token; required when TURNSTILE_SECRET_KEY is set. Checked once for the whole batch."
                  },
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "description": "One entry per envelope in the batch (1..BATCH_MAX_DOCS).",
                    "items": {
                      "type": "object",
                      "properties": {
                        "envelope_id": { "type": "string" },
                        "session": {
                          "type": "object",
                          "description": "Per-envelope session proof — the same fields the x-fsig-session-* headers carry, over canonicalJson({action:'otp.request', envelope_id, nonce, timestamp}).",
                          "properties": {
                            "signature": { "type": "string", "description": "base64url ECDSA P-256 (P1363) signature" },
                            "nonce": { "type": "string", "pattern": "^[a-f0-9]{32}$" },
                            "timestamp": { "type": "string", "format": "date-time" }
                          },
                          "required": ["signature", "nonce", "timestamp"]
                        }
                      },
                      "required": ["envelope_id", "session"]
                    }
                  }
                },
                "required": ["email", "items"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "One OTP challenge created per envelope; one email sent.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "envelope_id": { "type": "string" },
                          "signer_id": { "type": "string" },
                          "email_hmac": { "type": "string" },
                          "otp_challenge_id": { "type": "string" }
                        },
                        "required": ["envelope_id", "signer_id", "email_hmac", "otp_challenge_id"]
                      }
                    },
                    "expires_at": { "type": "string", "format": "date-time" }
                  },
                  "required": ["items"]
                }
              }
            }
          },
          "400": { "description": "invalid_email / batch_items_required / batch_too_large / batch_item_invalid / batch_duplicate_envelope." },
          "401": { "description": "A per-item session proof failed (invalid signature/timestamp/action) or its nonce was replayed (session_nonce_replayed) — the whole batch is rejected." },
          "403": { "description": "turnstile_required (token missing while Turnstile is configured) / turnstile_failed (siteverify rejected the token)." },
          "404": { "description": "not_found — one of the envelopes does not exist." },
          "429": { "description": "otp_cooldown / otp_rate_limited (counted once for the batch)." },
          "502": { "description": "otp_email_failed — the upstream email provider (Mailgun) rejected the send." },
          "503": { "description": "turnstile_unavailable (Cloudflare siteverify outage — fails closed) / session_store_unavailable (the session-nonce store was unreachable; retry the same nonce) / otp_delivery_unconfigured (email delivery is not configured on a production host)." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/otp/verify": {
      "post": {
        "summary": "Verify OTP before browser signing",
        "description": "Returns otp_challenge_id and otp_verified_at so the browser can bind OTP verification into the signed canonical payload. Idempotent: a repeated call with the same signer_id + email + otp_code returns the same verified_at, even after the OTP TTL has elapsed. Idempotency still requires the correct otp_code — signer_id alone is not a bearer token. Fresh (un-verified) calls additionally enforce expires_at and max_attempts. Requires the four session-binding headers (see x-fsig-session-* component parameters).",
        "parameters": [
          {
            "name": "envelope_id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "signer_id": { "type": "string" },
                  "email": { "type": "string", "format": "email" },
                  "otp_code": { "type": "string", "pattern": "^[0-9]{6}$" }
                },
                "required": ["signer_id", "email", "otp_code"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OTP verified.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "signer_id": { "type": "string" },
                    "email_hmac": { "type": "string" },
                    "otp_challenge_id": { "type": "string" },
                    "otp_verified_at": { "type": "string", "format": "date-time" }
                  },
                  "required": ["signer_id", "email_hmac", "otp_challenge_id", "otp_verified_at"]
                }
              }
            }
          },
          "400": { "description": "otp_mismatch / otp_expired / otp_not_found." },
          "429": { "description": "otp_locked (attempts exceeded)." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/sign": {
      "post": {
        "summary": "Submit browser-generated signature receipt",
        "description": "The request must contain a browser-generated public key and signature over the canonical payload. It must not contain PDF bytes. Requires the four session-binding headers (see x-fsig-session-* component parameters).",
        "parameters": [
          {
            "name": "envelope_id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "The browser-signed receipt. canonical_payload carries EXACTLY ONE identity proof: an OTP signer includes otp_challenge_id + otp_verified_at inside canonical_payload; a passkey (WebAuthn) signer includes a `webauthn` object inside canonical_payload AND a top-level webauthn_assertion. The two are mutually exclusive — sending both is rejected 400 identity_proof_ambiguous, sending neither 400 identity_proof_required. No PDF bytes are ever accepted here.",
                "required": ["signer_id", "signer_name", "consent_accepted", "canonical_payload", "public_key_jwk", "signature_base64url"],
                "properties": {
                  "signer_id": { "type": "string", "pattern": "^sig_[a-f0-9]{32}$" },
                  "signer_name": { "type": "string", "description": "Typed legal name; becomes the leaf-certificate Subject CN." },
                  "signer_email": { "type": "string", "description": "Required for the WebAuthn path — binds the passkey credential to this signer." },
                  "consent_accepted": { "type": "boolean" },
                  "canonical_payload": { "type": "object", "description": "The object the browser ECDSA-signed. Full shape: https://free-sign.com/evidence/v2/schema.json ($defs/canonicalPayload)." },
                  "public_key_jwk": { "type": "object", "description": "Browser-generated ECDSA P-256 public key (EC / P-256 JWK)." },
                  "signature_base64url": { "type": "string", "description": "base64url ECDSA P-256 / SHA-256 signature over canonicalJson(canonical_payload)." },
                  "webauthn_assertion": {
                    "type": "object",
                    "description": "WebAuthn (passkey) path only — the navigator.credentials.get() result. Its challenge equals SHA-256(canonicalJson(canonical_payload)); the server verifies it against the registered credential's stored COSE key.",
                    "properties": {
                      "authenticator_data": { "type": "string", "description": "base64url raw authenticatorData." },
                      "client_data_json": { "type": "string", "description": "base64url raw clientDataJSON." },
                      "signature": { "type": "string", "description": "base64url DER ECDSA-Sig-Value." }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Signature receipt stored." },
          "400": { "description": "identity_proof_required / identity_proof_ambiguous / signature_mismatch / payload_* validation error." },
          "401": { "description": "webauthn_assertion_failed / webauthn_credential_unknown — passkey assertion rejected." },
          "409": { "description": "signer_already_signed — one receipt per signer." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/seal": {
      "post": {
        "summary": "Issue a per-user leaf certificate, sign the PDF ByteRange digest server-side, embed in a CMS PKCS#7 (PAdES-B-B, or PAdES-B-T when TSA_URL is configured)",
        "description": "After /sign and after the browser has APPENDED an incremental update to the locally stamped PDF (new /Sig + Widget + re-emitted Page/Catalog + new xref pointing at /Prev, with placeholder /ByteRange and /Contents <hex>), the browser POSTs the SHA-256 of the PDF's ByteRange plus the verified signer email. The Worker verifies the email matches the envelope-scoped HMAC stored at /sign time, generates an ephemeral ECDSA P-256 keypair, issues a 10-year leaf certificate under the FreeSign CA (Google Cloud HSM in production; the long validity is required because Adobe Reader re-validates the signer cert against the current wall clock when the chain is not AATL-trusted, the ephemeral signing key itself is still destroyed inside the same request) with Subject CN = the human signer's typed name and subjectAltName.rfc822Name = the verified email, signs SignedAttributes with the ephemeral key, and assembles a CMS SignedData with both the leaf and CA certs in the certs set. When env.TSA_URL is configured, the Worker additionally fetches an RFC 3161 TimeStampToken over the signature value and embeds it as the SignerInfo signatureTimeStampToken unsigned attribute (PAdES-B-T). The ephemeral private key never leaves the Worker. The browser embeds the resulting CMS bytes into the /Contents placeholder. PDF bytes are never accepted on this endpoint. The base PDF stays intact as revision 1, the signature is revision 2 — multi-signer documents stack additional revisions cleanly, each with its own per-user leaf cert. Idempotent: same byterange_sha256 on retry returns the cached CMS (and the previously-issued leaf cert); a different byterange_sha256 returns 409 already_sealed_with_different_byterange. Requires the four session-binding headers (see x-fsig-session-* component parameters). FreeSign also submits the byterange hash to OpenTimestamps for independent timestamp proof; after calendar upgrade this proof resolves to public block headers.",
        "parameters": [
          {
            "name": "envelope_id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "signer_id": { "type": "string" },
                  "signer_email": {
                    "type": "string",
                    "format": "email",
                    "description": "The signer's email — must match the envelope-scoped HMAC stored at /sign time. Used as the leaf cert's subjectAltName.rfc822Name."
                  },
                  "byterange_sha256": {
                    "type": "string",
                    "pattern": "^[a-f0-9]{64}$",
                    "description": "SHA-256 of (preparedPdf[0..ByteRange[1]) ++ preparedPdf[ByteRange[2]..ByteRange[2]+ByteRange[3])). I.e., everything except the hex chars inside /Contents <...>."
                  },
                  "evidence_json": {
                    "type": "object",
                    "description": "Optional. The signer's pre-seal evidence record (signer identity, OTP, consent, the signed canonical_payload + its signature + the browser public_key_jwk). The Worker embeds it verbatim into this signer's CMS as an unsignedAttribute (OID 1.3.6.1.4.1.65834.1.2), so every signer of a multi-signer PDF carries their own. Dropped silently if it is not a plain object, claims a different envelope_id, or stringifies past ~12 KB; the seal still succeeds."
                  }
                },
                "required": ["signer_id", "signer_email", "byterange_sha256"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-user CMS produced (or returned from cache if idempotent). Response includes the base64 CMS, the leaf cert + serial + validity, and audit hashes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "envelope_id": { "type": "string" },
                    "cms_base64": { "type": "string", "description": "Base64-encoded CMS DER. Caller writes hex(cms_base64-decoded) into the /Contents <...> placeholder, padding the rest with '0'." },
                    "seal_cms_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                    "seal_cert_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of the issued LEAF cert (i.e. the per-user cert, not the CA)." },
                    "seal_signed_attrs_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                    "seal_byterange_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                    "seal_signed_at": { "type": "string", "format": "date-time" },
                    "signer_cert_base64": { "type": ["string", "null"], "description": "Base64-encoded DER of the issued leaf cert. Carries Subject CN = signer's name and subjectAltName.rfc822Name = signer's email." },
                    "signer_cert_serial_hex": { "type": ["string", "null"], "pattern": "^[a-f0-9]{32}$", "description": "16-byte serial of the issued leaf cert (hex)." },
                    "signer_cert_not_after": { "type": ["string", "null"], "format": "date-time", "description": "ISO timestamp at which the leaf cert expires (default 10 years after issuance; the ephemeral signing key is destroyed earlier, the cert outlives it so Adobe's wall-clock revalidation stays green)." },
                    "seal_ca_mode": { "type": "string", "enum": ["local-pem", "gcp-kms"], "description": "Which Signing-CA implementation produced this signature." },
                    "seal_tst_sha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of the embedded RFC 3161 TimeStampToken bytes. Null when no TSA was configured." },
                    "seal_tst_base64": { "type": ["string", "null"], "description": "Base64 of the raw TimeStampToken (ContentInfo DER) for PDF-free re-verification." },
                    "seal_tst_signed_at": { "type": ["string", "null"], "format": "date-time", "description": "ISO timestamp at which the Worker accepted the TSA reply. Distinct from the TST's own genTime, which is signed by the TSA." },
                    "seal_profile": { "type": "string", "enum": ["PAdES-B-B", "PAdES-B-T"], "description": "CMS-level profile. The browser upgrades the final PDF to PAdES-B-LT when `dss` is non-null by appending a /DSS revision." },
                    "dss": {
                      "type": ["object", "null"],
                      "description": "PAdES-B-LT material the browser embeds into the PDF /DSS as a separate incremental update (cert chain + CRL). Null unless the deployment configured SIGNING_CA_CRL_DER_BASE64. The leaf cert is already in signer_cert_base64.",
                      "properties": {
                        "ca_cert_base64": { "type": "string", "description": "Base64 DER of the FreeSign CA cert." },
                        "crl_base64": { "type": "string", "description": "Base64 DER of the (always-empty) FreeSign CA CRL." }
                      }
                    },
                    "idempotent": { "type": "boolean", "description": "True when the response was served from the cached CMS (same byterange digest replayed)." },
                    "ots_anchor": {
                      "type": "object",
                      "description": "OpenTimestamps anchor produced inline as part of this /seal. The .ots proof is always exposed through proof_download. By default (OTS_EMBED_IN_CMS=true) embedded_in_cms=true also places the .ots proof inside the PDF as CMS unsignedAttribute OID 1.3.6.1.4.1.65834.1.1; Adobe Acrobat Reader, pyHanko, and openssl all validate that attribute identically to a plain PAdES-B-T seal. Set OTS_EMBED_IN_CMS=false to keep it out of the PDF.",
                      "properties": {
                        "id": { "type": "string", "pattern": "^ots_[a-f0-9]{32}$" },
                        "anchored_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Equal to seal_byterange_sha256 — the hash committed through OpenTimestamps for independent timestamp proof." },
                        "status": { "type": "string", "enum": ["pending", "deferred"], "description": "pending: calendar attestation in hand. deferred: inline submission exhausted its 6-second budget; a 30-min cron job retries (rows older than 30 days are abandoned). The resulting .ots is retrievable via proof_download. `confirmed` only ever appears on the /api/receipts response after the BTC-upgrade cron stage runs (typically ~1-2 h after signing) — never inline from /seal." },
                        "embedded_in_cms": { "type": "boolean" },
                        "calendar_urls": { "type": "array", "items": { "type": "string" } },
                        "pending_submitted_at": { "type": ["string", "null"], "format": "date-time" },
                        "proof_download": { "type": "string", "description": "Path to GET the .ots file. Available regardless of status (404 until the proof actually lands)." }
                      }
                    }
                  },
                  "required": ["envelope_id", "cms_base64", "seal_cms_sha256", "seal_cert_sha256", "seal_signed_at", "seal_profile"]
                }
              }
            }
          },
          "400": { "description": "signer_email mismatch or invalid byterange_sha256." },
          "409": { "description": "Envelope already sealed (with a different byterange digest) or envelope/signer not in 'signed' state." },
          "502": { "description": "TSA_URL was configured but the TSA round-trip failed (network error or non-granted PKIStatus). The seal is not committed; the client may retry." },
          "503": { "description": "Signing CA is not configured (no SIGNING_CA_CERT_DER_BASE64 or no CA signer wired up)." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/platform-seal": {
      "post": {
        "summary": "Sign a second PDF variant with the configured FreeSign organization seal certificate",
        "description": "Same zero-document contract as /seal: the browser prepares a separate PDF signature placeholder locally and POSTs only the SHA-256 of that PDF's ByteRange. The Worker signs the digest with the configured platform seal signer (SEAL_SIGNER / SEAL_*), returns CMS DER as base64, and records an OpenTimestamps anchor row. This endpoint powers the freesign_verified_seal variant; while the configured seal cert is a bootstrap cert Adobe may still show yellow, and replacing it with an Adobe-trusted organization seal cert makes this variant the green-counterparty version without changing the browser flow. Requires x-fsig-session-action=platform-seal.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "signer_id": { "type": "string" },
                  "signer_email": { "type": "string", "format": "email" },
                  "byterange_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                  "evidence_json": { "type": "object", "description": "Optional pre-seal evidence record, embedded into this CMS as unsignedAttribute OID 1.3.6.1.4.1.65834.1.2 (see /seal)." }
                },
                "required": ["signer_id", "signer_email", "byterange_sha256"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Platform-seal CMS produced. Response shape mirrors /seal and adds variant=freesign_verified_seal plus seal_cert_subject / seal_signer_mode." },
          "400": { "description": "signer_email mismatch or invalid byterange_sha256." },
          "409": { "description": "Variant disabled or envelope/signer not in signed state." },
          "502": { "description": "TSA_URL was configured but the TSA round-trip failed." },
          "503": { "description": "Platform seal signer is not configured." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/finalize": {
      "post": {
        "summary": "Store final stamped PDF hash and signer's final signature",
        "description": "Browser stamps the PDF locally, hashes the stamped PDF, then signs a canonical final payload with the SAME ECDSA key used for /sign. The server verifies that signature with the public key already on file. Idempotent: once final_pdf_sha256 is set, the endpoint returns 409 already_finalized. Requires the four session-binding headers (see x-fsig-session-* component parameters).",
        "parameters": [
          {
            "name": "envelope_id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "signer_id": { "type": "string" },
                  "final_pdf_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                  "final_payload": {
                    "type": "object",
                    "additionalProperties": false,
                    "description": "Final payload v2 — canonical-JSON object signed by the browser with the same ECDSA P-256 key as /sign. Strict key set: the server rejects unknown keys with 400 and requires app === \"free-sign.com\". audit_chain_head_hash is the event_hash of the latest audit event at finalize time; the server re-derives the current chain head and rejects on mismatch, freezing the audit chain in signed evidence (security audit G-01).",
                    "properties": {
                      "app": { "type": "string", "enum": ["free-sign.com"] },
                      "envelope_id": { "type": "string" },
                      "document_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                      "final_pdf_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                      "payload_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                      "audit_chain_head_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                      "finalized_at": { "type": "string", "format": "date-time" }
                    },
                    "required": ["app", "envelope_id", "document_sha256", "final_pdf_sha256", "payload_hash", "audit_chain_head_hash", "finalized_at"]
                  },
                  "final_signature_base64url": { "type": "string" }
                },
                "required": ["signer_id", "final_pdf_sha256", "final_payload", "final_signature_base64url"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Final PDF hash and signature stored." },
          "409": { "description": "Envelope already finalized." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/webauthn/register/options": {
      "post": {
        "summary": "Begin passkey enrolment (browser-only, Stage 6E)",
        "description": "BROWSER-ONLY — not an MCP/agent tool: an AI agent has no WebAuthn authenticator. Issues PublicKeyCredentialCreationOptions for navigator.credentials.create(). Offered only after the signer has completed a signature (signer.status must be 'signed' — the OTP that produced it vouches for the email; trust-on-first-use). Requires the four session-binding headers with action 'webauthn.register'.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "signer_email": { "type": "string", "format": "email" } },
                "required": ["signer_email"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "challenge_id + PublicKeyCredentialCreationOptions." },
          "409": { "description": "signer_not_signed — enrolment is offered only post-signature." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/webauthn/register/verify": {
      "post": {
        "summary": "Finish passkey enrolment (browser-only, Stage 6E)",
        "description": "BROWSER-ONLY — not an MCP/agent tool. Verifies the navigator.credentials.create() attestation (challenge / origin / rpId / UP+UV flags, attestation 'none') and stores the credential. Idempotent for re-enrolment of the same authenticator. Requires the four session-binding headers with action 'webauthn.register'.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "responses": {
          "200": { "description": "{ enrolled: true } — passkey stored." },
          "400": { "description": "webauthn_registration_failed / webauthn_challenge_* — attestation rejected." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/webauthn/auth/options": {
      "post": {
        "summary": "Check for a passkey before signing (browser-only, Stage 6E)",
        "description": "BROWSER-ONLY — not an MCP/agent tool. Reports whether the signer's email already has a passkey on file and returns the credential allow-list for navigator.credentials.get(). The authenticate challenge is the deterministic payload hash, so no challenge is issued here. Also creates the signer row (the passkey path has no /otp call to do it). Requires the four session-binding headers with action 'webauthn.authenticate'.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/SessionSignature" },
          { "$ref": "#/components/parameters/SessionNonce" },
          { "$ref": "#/components/parameters/SessionTimestamp" },
          { "$ref": "#/components/parameters/SessionAction" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "signer_email": { "type": "string", "format": "email" } },
                "required": ["signer_email"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ signer_id, email_hmac, has_passkey, allow_credentials, rp_id }.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "signer_id": { "type": "string" },
                    "email_hmac": { "type": "string" },
                    "has_passkey": { "type": "boolean" },
                    "allow_credentials": { "type": "array", "items": { "type": "object" } },
                    "rp_id": { "type": "string" }
                  },
                  "required": ["signer_id", "has_passkey", "allow_credentials", "rp_id"]
                }
              }
            }
          }
        }
      }
    },
    "/api/verify": {
      "get": {
        "summary": "Find receipts by local PDF hash",
        "parameters": [
          {
            "name": "document_sha256",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching envelopes (up to 20, newest first).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "matches": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "string", "pattern": "^env_[a-f0-9]{32}$" },
                          "status": { "type": "string", "enum": ["draft", "otp_sent", "signed", "finalized", "expired"] },
                          "document_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                          "final_pdf_sha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" },
                          "created_at": { "type": "string", "format": "date-time" },
                          "updated_at": { "type": "string", "format": "date-time" }
                        }
                      }
                    }
                  },
                  "required": ["matches"]
                }
              }
            }
          }
        }
      }
    },
    "/api/receipts/{envelope_id}": {
      "get": {
        "summary": "Fetch evidence: envelope + per-signer receipts",
        "description": "Returns the envelope record (including final_pdf_sha256, final_signature_base64url, final_payload_json once /finalize completes), per-signer signing receipts, and OpenTimestamps anchors. This endpoint does not return the request fingerprint: the plaintext IP / user-agent fingerprint captured at /sign time lives in the audit chain — see `GET /api/envelopes/{envelope_id}/audit`, inside each event's `event_data_json` — and is also rendered onto the in-PDF Certificate of Signatures page that ships to every recipient. Capturing it is intentional and mirrors DocuSign / Adobe Sign Certificate-of-Completion industry practice (eIDAS Art. 26, ESIGN/UETA §7); FreeSign's privacy invariant is 'no PDF bytes server-side', not 'no IPs'.",
        "parameters": [
          {
            "name": "envelope_id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Envelope and per-signer receipts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "envelope": {
                      "type": "object",
                      "nullable": true,
                      "properties": {
                        "id": { "type": "string" },
                        "document_sha256": { "type": "string" },
                        "final_pdf_sha256": { "type": "string", "nullable": true },
                        "final_signature_base64url": { "type": "string", "nullable": true },
                        "final_payload_json": { "type": "string", "nullable": true },
                        "audit_chain_head_hash": { "type": "string", "nullable": true, "description": "The audit-chain head (event_hash) the signer attested in the v2 final payload at finalize time. NULL until finalized (security audit G-01)." },
                        "status": { "type": "string", "enum": ["draft", "otp_sent", "signed", "finalized", "expired"] },
                        "created_at": { "type": "string", "format": "date-time" },
                        "updated_at": { "type": "string", "format": "date-time" }
                      }
                    },
                    "receipts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "string" },
                          "envelope_id": { "type": "string" },
                          "signer_id": { "type": "string" },
                          "payload_hash": { "type": "string" },
                          "canonical_payload_json": { "type": "string" },
                          "public_key_jwk_json": { "type": "string" },
                          "signature_base64url": { "type": "string" },
                          "consent_version": { "type": "string" },
                          "created_at": { "type": "string", "format": "date-time" },
                          "email_hmac": { "type": "string" }
                        }
                      }
                    },
                    "ots_anchors": {
                      "type": "array",
                      "description": "OpenTimestamps anchors for this envelope. One per /seal call (so multi-signer envelopes get N anchors).",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "string", "pattern": "^ots_[a-f0-9]{32}$" },
                          "signer_id": { "type": "string" },
                          "seal_cms_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                          "anchored_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                          "status": { "type": "string", "enum": ["pending", "confirmed", "deferred"] },
                          "calendar_urls": { "type": "array", "items": { "type": "string" } },
                          "pending_submitted_at": { "type": ["string", "null"], "format": "date-time" },
                          "btc_block_height": { "type": ["integer", "null"] },
                          "btc_block_hash": { "type": ["string", "null"] },
                          "btc_anchored_at": { "type": ["string", "null"], "format": "date-time" },
                          "proof_download": { "type": ["string", "null"] }
                        }
                      }
                    }
                  },
                  "required": ["envelope", "receipts", "ots_anchors"]
                }
              }
            }
          }
        }
      }
    },
    "/api/envelopes/{envelope_id}/audit": {
      "get": {
        "summary": "Validate the envelope's append-only audit hash chain",
        "description": "Returns the envelope's audit events (the append-only, per-envelope hash chain) together with a server-computed integrity verdict. Every `event_hash` is recomputed from its canonical material, and the `prev_event_hash` linkage plus `seq` contiguity are checked. No authentication — the 32-hex envelope id is the bearer capability, exactly like /api/receipts. The `events` array is returned verbatim (including each row's full `event_data_json`, which carries the plaintext `request_fingerprint`) so a client can independently re-derive the verdict by recomputing the hashes itself; the in-browser /verify page does exactly this and does not trust `chain.valid`. The audit chain lives only in FreeSign's database — unlike the CMS / RFC 3161 / OpenTimestamps evidence inside the PDF, validating it requires the FreeSign service to be online.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^env_[a-f0-9]{32}$" } }
        ],
        "responses": {
          "200": {
            "description": "Audit chain and integrity verdict.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "envelope_id": { "type": "string", "pattern": "^env_[a-f0-9]{32}$" },
                    "attested_audit_chain_head_hash": {
                      "type": ["string", "null"],
                      "pattern": "^[a-f0-9]{64}$",
                      "description": "The audit-chain head the signer froze in the v2 final payload at finalize time (security audit G-01), derived from the signed final_payload_json — not the denormalised audit_chain_head_hash column. NULL until the envelope is finalized. It is fed into the verdict's head cross-check ONLY when attested_head_signature_verified is true; an unverified head is reported here but not cross-checked."
                    },
                    "attested_head_signature_verified": {
                      "type": "boolean",
                      "description": "True when the final-payload signature carrying the attested head was re-verified against the signer's on-file public key. False when the envelope is not finalized or that signature did not verify — in the latter case the attested head's provenance is unconfirmed, the head is NOT cross-checked (head_checked is false), and the audit trail should be treated as unverified."
                    },
                    "chain": {
                      "type": "object",
                      "description": "Server-computed integrity verdict. Re-derivable from `events`.",
                      "properties": {
                        "valid": { "type": "boolean" },
                        "event_count": { "type": "integer" },
                        "broken_at": { "type": ["integer", "null"], "description": "seq of the first broken event, or null when the chain is intact." },
                        "reason": {
                          "type": ["string", "null"],
                          "enum": ["hash_mismatch", "broken_link", "seq_bad_start", "seq_gap", "seq_duplicate", "head_mismatch", null],
                          "description": "Bounded reason enum for the first break, or null. head_mismatch means the recomputed chain head does not match the head frozen in the signer-attested final payload — a re-forged chain (G-01)."
                        },
                        "head_checked": { "type": "boolean", "description": "True when a signature-verified attested head was supplied and cross-checked against the recomputed chain. False when the envelope is not finalized OR the attested head's final-payload signature did not verify (see attested_head_signature_verified)." },
                        "head_match": { "type": ["boolean", "null"], "description": "Result of the head cross-check, or null when no signature-verified attested head was available." },
                        "events": {
                          "type": "array",
                          "description": "Per-event check flags, parallel to the raw `events` array.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "seq": { "type": ["integer", "null"] },
                              "id": { "type": ["string", "null"] },
                              "event_type": { "type": ["string", "null"] },
                              "created_at": { "type": ["string", "null"], "format": "date-time" },
                              "hash_ok": { "type": "boolean" },
                              "link_ok": { "type": "boolean" },
                              "seq_ok": { "type": "boolean" }
                            }
                          }
                        }
                      },
                      "required": ["valid", "event_count", "broken_at", "reason", "head_checked", "head_match", "events"]
                    },
                    "events": {
                      "type": "array",
                      "description": "Raw audit_events rows ordered by seq ASC — every field that goes into the event_hash, so the verdict is independently recomputable.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "string", "pattern": "^evt_[a-f0-9]{32}$" },
                          "envelope_id": { "type": "string" },
                          "signer_id": { "type": ["string", "null"] },
                          "event_type": { "type": "string" },
                          "event_data_json": { "type": "string" },
                          "prev_event_hash": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" },
                          "event_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
                          "seq": { "type": "integer" },
                          "ip_hash": { "type": ["string", "null"] },
                          "user_agent_hash": { "type": ["string", "null"] },
                          "cf_country": { "type": ["string", "null"] },
                          "created_at": { "type": "string", "format": "date-time" }
                        }
                      }
                    }
                  },
                  "required": ["envelope_id", "attested_audit_chain_head_hash", "attested_head_signature_verified", "chain", "events"]
                }
              }
            }
          },
          "400": { "description": "Malformed envelope_id." },
          "404": { "description": "envelope_not_found." }
        }
      }
    },
    "/api/envelopes/{envelope_id}/anchors/{anchor_id}/proof.ots": {
      "get": {
        "summary": "Download the .ots OpenTimestamps proof for a sealed envelope",
        "description": "Returns the complete .ots file (binary). When inline calendar submission succeeded and OTS_EMBED_IN_CMS was enabled for that seal, the same proof is also embedded inside the seal CMS as an unsignedAttribute (OID 1.3.6.1.4.1.65834.1.1). This endpoint is also used for envelopes whose inline submission was deferred to the cron retry path. No authentication — OTS anchors are public by design.",
        "parameters": [
          { "name": "envelope_id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^env_[a-f0-9]{32}$" } },
          { "name": "anchor_id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^ots_[a-f0-9]{32}$" } }
        ],
        "responses": {
          "200": {
            "description": ".ots file bytes.",
            "content": {
              "application/vnd.opentimestamps.ots": {
                "schema": { "type": "string", "format": "binary" }
              }
            }
          },
          "400": { "description": "Malformed envelope_id or anchor_id." },
          "404": { "description": "Anchor not found, or proof not yet submitted (deferred state)." }
        }
      }
    },
    "/verify": {
      "get": {
        "summary": "Browser-only PDF verifier (HTML page, not an API)",
        "description": "Returns the FreeSign Verifier page (public/verify.html + verify.js). Listed here so AI agents scanning AI-facing surfaces don't conflate /verify (client-side, drag-and-drop, never transmits the PDF) with /api/verify (server-side lookup by document SHA-256). The page accepts any PAdES-B-T signed PDF from any vendor — RSA PKCS#1 v1.5 / RSA-PSS / ECDSA P-256/P-384/P-521 / Ed25519 / Ed448 — and runs the verification entirely in the browser (CMS sig, leaf-CA cert-chain crypto verify, RFC 3161 TST inner-CMS verify, OpenTimestamps Bitcoin attestation). The FreeSign Worker never sees the dropped PDF.",
        "responses": {
          "200": {
            "description": "HTML page.",
            "content": {
              "text/html": { "schema": { "type": "string" } }
            }
          }
        }
      }
    },
    "/freesign-trust.fdf": {
      "get": {
        "summary": "Adobe FDF trust setup — adds the FreeSign CA to local Adobe Reader/Acrobat trust",
        "description": "Returns an Adobe Forms Data Format (FDF) file that, when opened in Adobe Reader or Acrobat, prompts the user to import the FreeSign CA certificate into their local trusted-roots list with usages 'sign documents' and 'certify documents'. This is NOT AATL inclusion and NOT QES — it is a per-user, per-device trust grant. After one-time import, all FreeSign-signed PDFs verify with a green checkmark in that user's Adobe install. The FDF embeds the CA DER and the SHA-256 fingerprint; the response also carries the fingerprint in the `x-freesign-ca-sha256` response header for paranoid pre-import checks. Public, GET-only, no authentication; cached for 1 hour.",
        "responses": {
          "200": {
            "description": "FDF file ready for Adobe Reader/Acrobat to ingest.",
            "headers": {
              "x-freesign-ca-sha256": {
                "description": "Hex SHA-256 of the embedded CA DER (cross-check against /.well-known/free-sign-signing-ca.sha256.txt).",
                "schema": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
              }
            },
            "content": {
              "application/vnd.fdf": {
                "schema": { "type": "string", "format": "binary" }
              }
            }
          },
          "404": { "description": "Signing CA cert is not configured on this deployment." }
        }
      }
    },
    "/.well-known/free-sign-signing-ca.sha256.txt": {
      "get": {
        "summary": "SHA-256 fingerprint of the FreeSign CA certificate",
        "description": "Plain-text lowercase hex SHA-256 of the FreeSign CA certificate DER, followed by a trailing newline. Pair with /.well-known/free-sign-signing-ca.pem and /freesign-trust.fdf — these three surfaces together let any verifier or recipient pin / install the CA out of band, with no Worker code on their side.",
        "responses": {
          "200": {
            "description": "64-character lowercase hex SHA-256 + newline.",
            "content": {
              "text/plain": { "schema": { "type": "string", "pattern": "^[0-9a-f]{64}\\n$" } }
            }
          },
          "404": { "description": "Signing CA cert is not configured on this deployment." }
        }
      }
    },
    "/mcp": {
      "get": {
        "summary": "MCP discovery document",
        "responses": {
          "200": { "description": "MCP discovery." }
        }
      },
      "post": {
        "summary": "MCP JSON-RPC endpoint",
        "responses": {
          "200": { "description": "MCP response." }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "SessionSignature": {
        "name": "x-fsig-session-signature",
        "in": "header",
        "required": true,
        "description": "Base64url ECDSA P-256 signature over canonicalJson({action, envelope_id, nonce, timestamp}). Produced by the non-extractable session keypair bound to this envelope at creation. See invariant #11 in CLAUDE.md.",
        "schema": { "type": "string" }
      },
      "SessionNonce": {
        "name": "x-fsig-session-nonce",
        "in": "header",
        "required": true,
        "description": "Random 32-hex-char nonce, fresh per request.",
        "schema": { "type": "string", "pattern": "^[a-f0-9]{32}$" }
      },
      "SessionTimestamp": {
        "name": "x-fsig-session-timestamp",
        "in": "header",
        "required": true,
        "description": "ISO-8601 UTC timestamp of the request. Accepted within +/-5 minutes of server clock.",
        "schema": { "type": "string", "format": "date-time" }
      },
      "SessionAction": {
        "name": "x-fsig-session-action",
        "in": "header",
        "required": true,
        "description": "Action name matching the route. Must equal the action field in the signed canonical payload. The webauthn.* actions gate the browser-only passkey endpoints.",
        "schema": { "type": "string", "enum": ["otp.request", "otp.verify", "sign", "seal", "platform-seal", "finalize", "webauthn.register", "webauthn.authenticate"] }
      }
    }
  }
}
