Example app (C++)#

A C++ application exposes itself to CCF by implementing:

/** To be implemented by the application. Creates a collection of endpoints
 * which will be exposed to callers under /app.
 *
 * @param context Access to node and host services
 *
 * @return Unique pointer to the endpoint registry instance
 */
std::unique_ptr<ccf::endpoints::EndpointRegistry> make_user_endpoints(
  ccfapp::AbstractNodeContext& context);

/** To be implemented by the application.
 *
 * @return Vector of JavaScript FFI plugins
 */
std::vector<ccf::js::FFIPlugin> get_js_plugins();

The Logging example application simply has:

std::unique_ptr<ccf::endpoints::EndpointRegistry> make_user_endpoints(
  ccfapp::AbstractNodeContext& context)
{
  return std::make_unique<loggingapp::LoggerHandlers>(context);
}

Note

kv::Map tables are the only interface between CCF and the replicated application, and the sole mechanism for it to have distributed state.

The Logging application keeps its state in a pair of tables, one containing private encrypted logs and the other containing public unencrypted logs. Their type is defined as:

using RecordsMap = kv::Map<size_t, string>;

These tables are then accessed by type and name:

auto records_handle =
  ctx.tx.template rw<RecordsMap>(public_records(ctx));
auto records_handle =
  ctx.tx.template rw<RecordsMap>(private_records(ctx));

Application Endpoints#

The implementation of ccfapp::make_user_endpoints() should return a subclass of ccf::endpoints::EndpointRegistry, containing the endpoints that constitute the app.

class LoggerHandlers : public ccf::UserEndpointRegistry

The logging app defines ccfapp::LoggerHandlers, which creates and installs handler functions or lambdas for several different HTTP endpoints. Each of these functions takes as input the details of the current request (such as the URI which was called, the query string, the request body), interacts with the KV tables using the given kv::Tx object, and returns a result:

auto record = [this](auto& ctx, nlohmann::json&& params) {
  // SNIPPET_START: macro_validation_record
  const auto in = params.get<LoggingRecord::In>();
  // SNIPPET_END: macro_validation_record

  if (in.msg.empty())
  {
    return ccf::make_error(
      HTTP_STATUS_BAD_REQUEST,
      ccf::errors::InvalidInput,
      "Cannot record an empty log message.");
  }

  // SNIPPET: private_table_access
  auto records_handle =
    ctx.tx.template rw<RecordsMap>(private_records(ctx));
  // SNIPPET_END: private_table_access
  records_handle->put(in.id, in.msg);
  return ccf::make_success(true);
};

This example uses the json_adapter wrapper function, which handles parsing of a JSON params object from the HTTP request body.

Each function is installed as the handler for a specific HTTP resource, defined by a verb and URI:

make_endpoint(
  "/log/private", HTTP_POST, ccf::json_adapter(record), auth_policies)
  .set_auto_schema<LoggingRecord::In, bool>()
  .install();

This example installs at "/app/log/private", HTTP_POST, so will be invoked for HTTP requests beginning POST /app/log/private.

The return value from make_endpoint is an Endpoint& object which can be used to alter how the handler is executed. For example, the handler for POST /app/log/private shown above sets a schema declaring the types of its request and response bodies. These will be used in calls to the GET /app/api endpoint to populate the relevant parts of the OpenAPI document. That OpenAPI document in turn is used to generate the entries in this documentation describing POST /app/log/private.

There are other endpoints installed for the URI path /app/log/private with different verbs, to handle GET and DELETE requests. Requests with those verbs will be executed by the appropriate handler. Any other verbs, without an installed endpoint, will not be accepted - the framework will return a 405 Method Not Allowed response.

To process the raw body directly, a handler should use the general lambda signature which takes a single EndpointContext& parameter. Examples of this are also included in the logging sample app. For instance the log_record_text handler takes a raw string as the request body:

auto log_record_text = [this](auto& ctx) {
  const auto expected = http::headervalues::contenttype::TEXT;
  const auto actual =
    ctx.rpc_ctx->get_request_header(http::headers::CONTENT_TYPE)
      .value_or("");
  if (expected != actual)
  {
    ctx.rpc_ctx->set_error(
      HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
      ccf::errors::InvalidHeaderValue,
      fmt::format(
        "Expected content-type '{}'. Got '{}'.", expected, actual));
    return;
  }

  const auto& path_params = ctx.rpc_ctx->get_request_path_params();
  const auto id_it = path_params.find("id");
  if (id_it == path_params.end())
  {
    ctx.rpc_ctx->set_error(
      HTTP_STATUS_BAD_REQUEST,
      ccf::errors::InvalidInput,
      "Missing ID component in request path");
    return;
  }

  const auto id = strtoul(id_it->second.c_str(), nullptr, 10);

  const std::vector<uint8_t>& content = ctx.rpc_ctx->get_request_body();
  const std::string log_line(content.begin(), content.end());

  auto records_handle =
    ctx.tx.template rw<RecordsMap>(private_records(ctx));
  records_handle->put(id, log_line);

  ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
};
make_endpoint(
  "/log/private/raw_text/{id}", HTTP_POST, log_record_text, auth_policies)
  .install();

Rather than parsing the request body as JSON and extracting the message from it, in this case the entire body is the message to be logged, and the ID to associate it with is passed as a request header. This requires some additional code in the handler, but provides complete control of the request and response formats.

This general signature also allows a handler to see additional caller context. An example of this is the log_record_prefix_cert handler:

auto log_record_prefix_cert = [this](auto& ctx) {
  const auto& caller_ident =
    ctx.template get_caller<ccf::UserCertAuthnIdentity>();

  const nlohmann::json body_j =
    nlohmann::json::parse(ctx.rpc_ctx->get_request_body());

  const auto in = body_j.get<LoggingRecord::In>();
  if (in.msg.empty())
  {
    ctx.rpc_ctx->set_error(
      HTTP_STATUS_BAD_REQUEST,
      ccf::errors::InvalidInput,
      "Cannot record an empty log message");
    return;
  }

  const auto log_line =
    fmt::format("{}: {}", caller_ident.user_id.value(), in.msg);
  auto records_handle =
    ctx.tx.template rw<RecordsMap>(private_records(ctx));
  records_handle->put(in.id, log_line);

  ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
  ctx.rpc_ctx->set_response_header(
    http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON);
  ctx.rpc_ctx->set_response_body(nlohmann::json(true).dump());
};
make_endpoint(
  "/log/private/prefix_cert",
  HTTP_POST,
  log_record_prefix_cert,
  {ccf::user_cert_auth_policy})
  .set_auto_schema<LoggingRecord::In, bool>()
  .install();

This parses the caller’s TLS certificate, and prefixes the logged message with the Subject field extracted from this certificate.

If a handler makes no writes to the KV, it may be installed as read-only:

make_read_only_endpoint(
  "/log/private",
  HTTP_GET,
  ccf::json_read_only_adapter(get),
  auth_policies)
  .set_auto_schema<void, LoggingGet::Out>()
  .add_query_parameter<size_t>("id")
  .install();

This offers some additional type safety (accidental puts or removes will be caught at compile-time) and also enables performance scaling since read-only operations can be executed on any receiving node, whereas writes must always be executed on the primary node.

API Schema#

Instead of taking and returning nlohmann::json objects directly, the endpoint handlers use a macro-generated schema and parser converting compliant requests into a PoD C++ object:

struct LoggingRecord
{
  struct In
  {
    size_t id;
    std::string msg;
    bool record_claim = false;
  };
};

struct LoggingGet
{
  struct Out
  {
    std::string msg;
  };
};

struct LoggingRemove
{
  using Out = bool;
};

struct LoggingPut
{
  struct Out
  {
    bool success;
    std::string tx_id;
  };
};

struct LoggingGetReceipt
{
  struct In
  {
    size_t id;
  };

  struct Out
  {
    std::string msg;
    nlohmann::json receipt;
  };
};

DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(LoggingRecord::In);
DECLARE_JSON_REQUIRED_FIELDS(LoggingRecord::In, id, msg);
DECLARE_JSON_OPTIONAL_FIELDS(LoggingRecord::In, record_claim);

DECLARE_JSON_TYPE(LoggingGet::Out);
DECLARE_JSON_REQUIRED_FIELDS(LoggingGet::Out, msg);

DECLARE_JSON_TYPE(LoggingPut::Out);
DECLARE_JSON_REQUIRED_FIELDS(LoggingPut::Out, success, tx_id);

DECLARE_JSON_TYPE(LoggingGetReceipt::In);
DECLARE_JSON_REQUIRED_FIELDS(LoggingGetReceipt::In, id);
DECLARE_JSON_TYPE(LoggingGetReceipt::Out);
DECLARE_JSON_REQUIRED_FIELDS(LoggingGetReceipt::Out, msg, receipt);
const auto in = params.get<LoggingRecord::In>();

This produces validation error messages with a low performance overhead, and ensures the schema and parsing logic stay in sync, but is only suitable for simple schema - an object with some required and some optional fields, each of a supported type.

Authentication#

Each endpoint must provide a list of associated authentication policies in the call to make_endpoint. Inside the handler, the caller identity that was constructed by the accepting policy check can be retrieved with get_caller or try_get_caller - the latter should be used when multiple policies are present, to detect which policy accepted the request.

For example in the /log/private endpoint above there is a single policy stating that requests must come from a known user cert, over mutually authenticated TLS. This is one of several built-in policies provided by CCF. These built-in policies will check that the caller’s TLS cert is a known user or member identity, or that the request is HTTP signed by a known user or member identity, or that the request contains a JWT signed by a known issuer. Additionally, there is an empty policy which accepts all requests, which should be used as the final policy to declare that the endpoint is optionally authenticated (either an earlier-listed policy passes providing a real caller identity, or the empty policy passes and the endpoint is invoked with no caller identity). To declare that an endpoint has no authentication requirements and should be accessible by any caller, use the special value no_auth_required.

Applications can extend this system by writing their own authentication policies. There is an example of this in the C++ logging app. First it defines a type describing the identity details it aims to find in an acceptable request:

struct CustomIdentity : public ccf::AuthnIdentity
{
  std::string name;
  size_t age;
};

Next it defines the policy itself. The core functionality is the implementation of the authenticate() method, which looks at each request and returns either a valid new identity if it accepts the request, or nullptr if it does not. In this demo case it is looking for a pair of headers and doing some validation of their values:

class CustomAuthPolicy : public ccf::AuthnPolicy
{
public:
  std::unique_ptr<ccf::AuthnIdentity> authenticate(
    kv::ReadOnlyTx&,
    const std::shared_ptr<ccf::RpcContext>& ctx,
    std::string& error_reason) override
  {
    const auto& headers = ctx->get_request_headers();

    {
      // If a specific header is present, throw an exception to simulate a
      // dangerously implemented auth policy
      constexpr auto explode_header_key = "x-custom-auth-explode";
      const auto explode_header_it = headers.find(explode_header_key);
      if (explode_header_it != headers.end())
      {
        throw std::logic_error(explode_header_it->second);
      }
    }

    constexpr auto name_header_key = "x-custom-auth-name";
    const auto name_header_it = headers.find(name_header_key);
    if (name_header_it == headers.end())
    {
      error_reason =
        fmt::format("Missing required header {}", name_header_key);
      return nullptr;
    }

    const auto& name = name_header_it->second;
    if (name.empty())
    {
      error_reason = "Name must not be empty";
      return nullptr;
    }

    constexpr auto age_header_key = "x-custom-auth-age";
    const auto age_header_it = headers.find(age_header_key);
    if (age_header_it == headers.end())
    {
      error_reason =
        fmt::format("Missing required header {}", age_header_key);
      return nullptr;
    }

    const auto& age_s = age_header_it->second;
    size_t age;
    const auto [p, ec] =
      std::from_chars(age_s.data(), age_s.data() + age_s.size(), age);
    if (ec != std::errc())
    {
      error_reason =
        fmt::format("Unable to parse age header as a number: {}", age_s);
      return nullptr;
    }

    constexpr auto min_age = 16;
    if (age < min_age)
    {
      error_reason = fmt::format("Caller age must be at least {}", min_age);
      return nullptr;
    }

    auto ident = std::make_unique<CustomIdentity>();
    ident->name = name;
    ident->age = age;
    return ident;
  }

  std::optional<ccf::OpenAPISecuritySchema> get_openapi_security_schema()
    const override
  {
    // There is no OpenAPI-compliant way to describe this auth scheme, so we
    // return nullopt
    return std::nullopt;
  }

  std::string get_security_scheme_name() override
  {
    return "CustomAuthPolicy";
  }
};

Note that authenticate() is also passed a ReadOnlyTx object, so more complex authentication decisions can depend on the current state of the KV. For instance the built-in TLS cert auth policies are looking up the currently known user/member certs stored in the KV, which will change over the life of the service.

The final piece is the definition of the endpoint itself, which uses an instance of this new policy when it is constructed and then retrieves the custom identity inside the handler:

auto custom_auth = [](auto& ctx) {
  const auto& caller_identity = ctx.template get_caller<CustomIdentity>();
  nlohmann::json response;
  response["name"] = caller_identity.name;
  response["age"] = caller_identity.age;
  response["description"] = fmt::format(
    "Your name is {} and you are {}",
    caller_identity.name,
    caller_identity.age);
  ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
  ctx.rpc_ctx->set_response_body(response.dump(2));
};
auto custom_policy = std::make_shared<CustomAuthPolicy>();
make_endpoint("/custom_auth", HTTP_GET, custom_auth, {custom_policy})
  .set_auto_schema<void, nlohmann::json>()
  // To test that custom auth works on both the receiving node and a
  // forwardee, we always forward it
  .set_forwarding_required(ccf::endpoints::ForwardingRequired::Always)
  .install();

Default Endpoints#

The logging app sample exposes several built-in endpoints which are provided by the framework for convenience, such as GET /app/tx, GET /app/commit, and GET /app/receipt. It is also possible to write an app which does not expose these endpoints, either to build a minimal user-facing API or to re-wrap this common functionality in your own format or authentication. A sample of this is provided in samples/apps/nobuiltins. Whereas the logging app declares a registry inheriting from ccf::CommonEndpointRegistry, this app inherits from ccf::BaseEndpointRegistry which does not install any default endpoints:

class NoBuiltinsRegistry : public ccf::BaseEndpointRegistry

This app can then define its own endpoints from a blank slate. If it wants to provide similar functionality to the default endpoints, it does so using the APIs provided by ccf::BaseEndpointRegistry. For instance to retrieve the hardware quote of the executing node:

ccf::QuoteInfo quote_info;
result = get_quote_for_this_node_v1(ctx.tx, quote_info);
if (result != ccf::ApiResult::OK)
{
  ctx.rpc_ctx->set_error(
    HTTP_STATUS_INTERNAL_SERVER_ERROR,
    ccf::errors::InternalError,
    fmt::format(
      "Failed to get quote: {}", ccf::api_result_to_str(result)));
  return;
}

summary.quote_format = quote_info.format;
summary.quote = quote_info.quote;
summary.endorsements = quote_info.endorsements;

Historical Queries#

This sample demonstrates how to define a historical query endpoint with the help of ccf::historical::adapter_v3(). Most endpoints operate over the current state of the KV, but these historical queries operate over old state, specifically over the writes made by a previous transaction. The adapter handles extracting the target Transaction ID from the user’s request, and interacting with the Historical Queries API to asynchronously fetch this entry from the ledger. The deserialised and verified transaction is then presented to the handler code below, which performs reads and constructs a response like any other handler.

The handler passed to the adapter is very similar to a read-only endpoint definition, but receives a read-only ccf::historical::State rather than a transaction.

auto get_historical = [this](
                        ccf::endpoints::ReadOnlyEndpointContext& ctx,
                        ccf::historical::StatePtr historical_state) {
  const auto pack = ccf::jsonhandler::detect_json_pack(ctx.rpc_ctx);

  // Parse id from query
  const auto parsed_query =
    http::parse_query(ctx.rpc_ctx->get_request_query());

  std::string error_reason;
  size_t id;
  if (!http::get_query_value(parsed_query, "id", id, error_reason))
  {
    ctx.rpc_ctx->set_error(
      HTTP_STATUS_BAD_REQUEST,
      ccf::errors::InvalidQueryParameterValue,
      std::move(error_reason));
    return;
  }

  auto historical_tx = historical_state->store->create_read_only_tx();
  auto records_handle =
    historical_tx.template ro<RecordsMap>(private_records(ctx));
  const auto v = records_handle->get(id);

  if (v.has_value())
  {
    LoggingGetHistorical::Out out;
    out.msg = v.value();
    nlohmann::json j = out;
    ccf::jsonhandler::set_response(std::move(j), ctx.rpc_ctx, pack);
  }
  else
  {
    ctx.rpc_ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
  }
};

auto is_tx_committed =
  [this](ccf::View view, ccf::SeqNo seqno, std::string& error_reason) {
    return ccf::historical::is_tx_committed_v2(
      consensus, view, seqno, error_reason);
  };
make_read_only_endpoint(
  "/log/private/historical",
  HTTP_GET,
  ccf::historical::read_only_adapter_v3(
    get_historical, context, is_tx_committed),
  auth_policies)
  .set_auto_schema<void, LoggingGetHistorical::Out>()
  .add_query_parameter<size_t>("id")
  .set_forwarding_required(ccf::endpoints::ForwardingRequired::Never)
  .install();

Indexing#

The historical endpoint described above must process each target transaction on a specific node, asynchronously, before the result can be served. For some use cases, in particular where the response is repeated often rather than dynamically constructed, this may be extremely inefficient. Instead, we would prefer to pre-process all committed transactions and construct an efficient index of their contents, geared towards responding to a known pattern of user queries.

For instance, if we want to list every value written to a specific key but know that writes are relatively rare, we could build an index of such writes. When this historical query comes in, rather than fetching every transaction - to extract useful writes from a small fraction - the historical query endpoint can first ask the index which transactions should be processed and fetch only those. If the response format is known, the index could even pre-construct the response itself.

In CCF, this is achieved by implementing an indexing ccf::indexing::Strategy. This is constructed on each node, in-enclave, by processing every committed transaction in-order in the implementation of ccf::indexing::Strategy::handle_committed_transaction(). The strategy can then return its aggregated results to the calling endpoint in whatever format is appropriate. A ccf::indexing::Strategy may offload partial results to disk to avoid infinite memory growth, via the automatically encrypted LFS (Large File Storage) system. Since the indexing system and all the strategies it manages exist entirely within the enclave, this has the same trust guarantees as any other in-enclave code - users can trust that the results are accurate and complete, and the query may process private data.

An example ccf::indexing::Strategy is included in the logging app, to accelerate historical range queries. This strategy stores the list of seqnos where every key is written to, offloading completed ranges to disk to cap the total memory useage. In the endpoint handler, rather than requesting every transaction in the requested range, the node relies on its index to fetch only the interesting transactions; those which write to the target key:

const auto interesting_seqnos =
  index_per_public_key->get_write_txs_in_range(
    id, range_begin, range_end);

See the sample app for full details of how this strategy is installed and used.

Receipts#

Historical state always contains a receipt. Users wishing to implement a receipt endpoint may return it directly, or include it along with other historical state in the response.

auto get_historical_with_receipt =
  [this](
    ccf::endpoints::ReadOnlyEndpointContext& ctx,
    ccf::historical::StatePtr historical_state) {
    const auto pack = ccf::jsonhandler::detect_json_pack(ctx.rpc_ctx);

    // Parse id from query
    const auto parsed_query =
      http::parse_query(ctx.rpc_ctx->get_request_query());

    std::string error_reason;
    size_t id;
    if (!http::get_query_value(parsed_query, "id", id, error_reason))
    {
      ctx.rpc_ctx->set_error(
        HTTP_STATUS_BAD_REQUEST,
        ccf::errors::InvalidQueryParameterValue,
        std::move(error_reason));
      return;
    }

    auto historical_tx = historical_state->store->create_read_only_tx();
    auto records_handle =
      historical_tx.template ro<RecordsMap>(private_records(ctx));
    const auto v = records_handle->get(id);

    if (v.has_value())
    {
      LoggingGetReceipt::Out out;
      out.msg = v.value();
      assert(historical_state->receipt);
      out.receipt = ccf::describe_receipt_v1(*historical_state->receipt);
      ccf::jsonhandler::set_response(std::move(out), ctx.rpc_ctx, pack);
    }
    else
    {
      ctx.rpc_ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
    }
  };
make_read_only_endpoint(
  "/log/private/historical_receipt",
  HTTP_GET,
  ccf::historical::read_only_adapter_v3(
    get_historical_with_receipt, context, is_tx_committed),
  auth_policies)
  .set_auto_schema<void, LoggingGetReceipt::Out>()
  .add_query_parameter<size_t>("id")
  .set_forwarding_required(ccf::endpoints::ForwardingRequired::Never)
  .install();

User-Defined Claims in Receipts#

A user wanting to tie transaction-specific values to a receipt can do so by attaching a claims digest to their transaction. This is conceptually equivalent to getting a signature from the service for claims made by the application logic.

if (in.record_claim)
{
  ctx.rpc_ctx->set_claims_digest(ccf::ClaimsDigest::Digest(in.msg));
}

CCF will record this transaction as a leaf in the Merkle tree constructed from the combined digest of the write set, this claims_digest, and the Commit Evidence.

This claims_digest will be exposed in receipts under leaf_components. It can then be revealed externally, or by the endpoint directly if it has been stored in the ledger. The receipt object deliberately makes the claims_digest optional, to allow the endpoint to remove it when the claims themselves are revealed.

Receipt verification can then only succeed if the revealed claims are digested and their digest combined into a leaf that correctly combines with the proof to form the root that the signature covers. Receipt verification therefore establishes the authenticity of the claims.

// Claims are expanded as out.msg, so the claims digest is removed
// from the receipt to force verification to re-compute it.
auto full_receipt =
  ccf::describe_receipt_v1(*historical_state->receipt);
out.receipt = full_receipt;
out.receipt["leaf_components"].erase("claims_digest");

A client consuming the output of this endpoint must digest the claims themselves, combine the digest with the other leaf components (write_set_digest and hash(commit_evidence)) to obtain the equivalent leaf. See Receipt Verification for the full set of steps.

As an example, a logging application may register the contents being logged as a claim:

auto record_public = [this](auto& ctx, nlohmann::json&& params) {
  const auto in = params.get<LoggingRecord::In>();

  if (in.msg.empty())
  {
    return ccf::make_error(
      HTTP_STATUS_BAD_REQUEST,
      ccf::errors::InvalidInput,
      "Cannot record an empty log message.");
  }

  // SNIPPET: public_table_access
  auto records_handle =
    ctx.tx.template rw<RecordsMap>(public_records(ctx));
  // SNIPPET_END: public_table_access
  const auto id = params["id"].get<size_t>();
  records_handle->put(id, in.msg);
  // SNIPPET_START: set_claims_digest
  if (in.record_claim)
  {
    ctx.rpc_ctx->set_claims_digest(ccf::ClaimsDigest::Digest(in.msg));
  }
  // SNIPPET_END: set_claims_digest
  CCF_APP_INFO("Storing {} = {}", id, in.msg);
  return ccf::make_success(true);
};

And expose an endpoint returning receipts, with that claim expanded:

auto get_historical_with_receipt =
  [this](
    ccf::endpoints::ReadOnlyEndpointContext& ctx,
    ccf::historical::StatePtr historical_state) {
    const auto pack = ccf::jsonhandler::detect_json_pack(ctx.rpc_ctx);

    // Parse id from query
    const auto parsed_query =
      http::parse_query(ctx.rpc_ctx->get_request_query());

    std::string error_reason;
    size_t id;
    if (!http::get_query_value(parsed_query, "id", id, error_reason))
    {
      ctx.rpc_ctx->set_error(
        HTTP_STATUS_BAD_REQUEST,
        ccf::errors::InvalidQueryParameterValue,
        std::move(error_reason));
      return;
    }

    auto historical_tx = historical_state->store->create_read_only_tx();
    auto records_handle =
      historical_tx.template ro<RecordsMap>(private_records(ctx));
    const auto v = records_handle->get(id);

    if (v.has_value())
    {
      LoggingGetReceipt::Out out;
      out.msg = v.value();
      assert(historical_state->receipt);
      out.receipt = ccf::describe_receipt_v1(*historical_state->receipt);
      ccf::jsonhandler::set_response(std::move(out), ctx.rpc_ctx, pack);
    }
    else
    {
      ctx.rpc_ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
    }
  };
make_read_only_endpoint(
  "/log/private/historical_receipt",
  HTTP_GET,
  ccf::historical::read_only_adapter_v3(
    get_historical_with_receipt, context, is_tx_committed),
  auth_policies)
  .set_auto_schema<void, LoggingGetReceipt::Out>()
  .add_query_parameter<size_t>("id")
  .set_forwarding_required(ccf::endpoints::ForwardingRequired::Never)
  .install();

Receipts from this endpoint will then look like:

{'msg': 'Public message at idx 5 [0]',
 'receipt': {'cert': '-----BEGIN CERTIFICATE-----\n'
                     'MIIBzzCCAVWgAwIBAgIRANKoegKBViucMxSPzftnDB4wCgYIKoZIzj0EAwMwFjEU\n'
                     'MBIGA1UEAwwLQ0NGIE5ldHdvcmswHhcNMjIwMzE1MjExODIwWhcNMjIwMzE2MjEx\n'
                     'ODE5WjATMREwDwYDVQQDDAhDQ0YgTm9kZTB2MBAGByqGSM49AgEGBSuBBAAiA2IA\n'
                     'BG+RJ5qNPOga8shCF3w64yija/ShW46JxrE0n9kDybyRf+L3810GjCvjxSpzTQhX\n'
                     '5WEF2dou1dG2ppI/KSNQsSfk081lbaB50NADWw+jDCtrq/fKuZ+w9wQSaoSvE5+0\n'
                     '1qNqMGgwCQYDVR0TBAIwADAdBgNVHQ4EFgQU7tFQR91U1EDhup1XPS3u0w5+R2Yw\n'
                     'HwYDVR0jBBgwFoAU3aI0vfJMBdWckvv9dKK2UzNCLU0wGwYDVR0RBBQwEocEfwAA\n'
                     'AYcEfxoNCocEfwAAAjAKBggqhkjOPQQDAwNoADBlAjAiOmvGpatg4Uq8phQkwj/p\n'
                     'Wj33fih6SUtRHOpdsIKvbV8TDNHRdSo1RKPArDd1w1wCMQDnw9zziS5G8qwvucP3\n'
                     'gn3htz+2ZPBJRr98AqmRNmgflhgqLQp+jAVPrJaWtD3fDpw=\n'
                     '-----END CERTIFICATE-----\n',
             'leaf_components': {'commit_evidence': 'ce:2.25:54571ec6d0540b364d8343b74dff055932981fd72a24c1399c39ca9c74d2f713',
                                 'write_set_digest': '08b044fc5b0e9cd03c68d77c949bb815e3d70bd24ad339519df48758430ac0f7'},
             'node_id': '95baf92969b4c9e52b4f8fcde830dea9fa0286a8c3a92cda4cffcf8251c06b39',
             'proof': [{'left': '50a1a35a50bd2c5a4725907e77f3b1f96f1f9f37482aa18f8e7292e0542d9d23'},
                       {'left': 'e2184154ac72b304639b923b3c7a0bc04cecbd305de4f103a174a90210cae0dc'},
                       {'left': 'abc9bcbeff670930c34ebdab0f2d57b56e9d393e4dccdccf2db59b5e34507422'}],
             'signature': 'MGUCMHYBgZ3gySdkJ+STUL13EURVBd8354ULC11l/kjx20IwpXrg/aDYLWYf7tsGwqUxPwIxAMH2wJDd9wpwbQrULpaAx5XEifpUfOriKtYo7XiFr05J+BV10U39xa9GBS49OK47QA=='}}

Note that the claims_digest is deliberately omitted from leaf_components, and must be re-computed by digesting the msg.