{
  "openapi": "3.1.0",
  "info": {
    "title": "FreeSign API",
    "version": "0.0.0",
    "summary": "Zero-document PDF signing API.",
    "description": "FreeSign APIs accept hashes, OTP state, signatures, and receipts only. Do not upload PDF bytes. Browser/headless clients compute hashes and generate signatures locally."
  },
  "servers": [
    {
      "url": "https://free-sign.com",
      "description": "Production"
    },
    {
      "url": "http://localhost:8787",
      "description": "Local development"
    }
  ],
  "paths": {
    "/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/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",
                    "description": "Final payload v2 — canonical-JSON object signed by the browser with the same ECDSA P-256 key as /sign. Strict key set: unknown keys are rejected with 400. 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" },
                      "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": ["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." }
        }
      }
    },
    "/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. Each receipt's underlying audit-event row also carries a plaintext `request_fingerprint` (cf-connecting-ip, x-forwarded-for chain, x-real-ip, true-client-ip, user-agent, accept-language, sec-ch-ua, plus Cloudflare's `cf` geo / ASN / TLS metadata) captured at /sign time — intentional, 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'. The same fingerprint is also rendered onto the in-PDF Certificate of Completion page that ships to every recipient — there is no opt-out at this layer.",
        "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" }
                        }
                      }
                    },
                    "request_fingerprint": {
                      "type": "object",
                      "nullable": true,
                      "description": "Plaintext request fingerprint captured at /sign time. Stored verbatim in the per-event audit row (`event_data_json`) and re-surfaced here. The same object is also rendered onto the in-PDF Certificate of Completion page. INTENTIONAL surface — mirrors DocuSign / Adobe Sign industry baseline; eIDAS Art. 26 / ESIGN §7 / UETA §7 expect raw IP and UA in the audit trail. FreeSign's privacy invariant is 'no PDF bytes server-side', not 'no IPs'.",
                      "properties": {
                        "cf_connecting_ip": { "type": ["string", "null"] },
                        "x_forwarded_for": { "type": ["array", "null"], "items": { "type": "string" } },
                        "x_real_ip": { "type": ["string", "null"] },
                        "true_client_ip": { "type": ["string", "null"] },
                        "user_agent": { "type": ["string", "null"] },
                        "accept_language": { "type": ["string", "null"] },
                        "referer": { "type": ["string", "null"] },
                        "sec_ch_ua": { "type": ["string", "null"] },
                        "sec_ch_ua_platform": { "type": ["string", "null"] },
                        "sec_ch_ua_mobile": { "type": ["string", "null"] },
                        "cf": {
                          "type": ["object", "null"],
                          "description": "Cloudflare `request.cf` metadata — geo, ASN, TLS fingerprint, colo. Null in dev / outside a CF Worker.",
                          "properties": {
                            "country": { "type": ["string", "null"] },
                            "city": { "type": ["string", "null"] },
                            "region": { "type": ["string", "null"] },
                            "postal_code": { "type": ["string", "null"] },
                            "latitude": { "type": ["string", "null"] },
                            "longitude": { "type": ["string", "null"] },
                            "timezone": { "type": ["string", "null"] },
                            "asn": { "type": ["integer", "null"] },
                            "as_organization": { "type": ["string", "null"] },
                            "colo": { "type": ["string", "null"] },
                            "http_protocol": { "type": ["string", "null"] },
                            "tls_version": { "type": ["string", "null"] },
                            "tls_cipher": { "type": ["string", "null"] }
                          }
                        },
                        "captured_at": { "type": ["string", "null"], "format": "date-time" }
                      }
                    },
                    "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). NULL until the envelope is finalized. When present it is fed into the verdict's head cross-check."
                    },
                    "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 an attested chain head was supplied and cross-checked against the recomputed chain." },
                        "head_match": { "type": ["boolean", "null"], "description": "Result of the head cross-check, or null when no 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", "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). The proof is also embedded inside the seal CMS as an unsignedAttribute (OID 1.3.6.1.4.1.65834.1.1), but this endpoint is convenient for direct verification with `ots-cli` and 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"] }
      }
    }
  }
}
