Skip to main content

Verify a post against the protocol

The DFOS API gives public posts a bridge into the signed DFOS protocol. This recipe follows that bridge from an API response to the relay, fetches the post's proof log and document, and verifies the chain locally.

1. Fetch the post

Fetch a public post by space and post id:

curl https://api.dfos.com/v1/spaces/field-notes/posts/post_4k8m2q7v

On an eligible response, read post.protocol.contentId and post.protocol.headOpCid:

{
"state": "eligible",
"post": {
"id": "post_4k8m2q7v",
"protocol": {
"contentId": "cnnnft9f8a2rn938d6nkz38r847v2kr",
"headOpCid": "bafyrei..."
}
}
}

If protocol is absent, the post does not have a settled content chain yet. See the caveats below.

2. Discover the relay

Fetch the protocol discovery document:

curl https://api.dfos.com/v1/protocol

It returns the stage-correct relayUrl and URL templates for the relay's proof, content, and index planes. Substitute the post's contentId into the content, contentLog, and blob templates instead of hardcoding relay paths. See Protocol discovery for every field.

3. Fetch the chain and its proof log

Fetch the chain's terminal state and ordered operation log:

curl https://relay.dfos.com/proof/v1/content/cnnnft9f8a2rn938d6nkz38r847v2kr
curl https://relay.dfos.com/proof/v1/content/cnnnft9f8a2rn938d6nkz38r847v2kr/log

The first response identifies the genesis operation, current head, creator, current document CID, and other terminal state derived by folding the chain. The log response contains chain-ordered operations as { cid, jwsToken } entries. Each compact JWS token carries the signed protocol operation. Follow the response's next cursor until it is null before verifying a long chain.

The API's headOpCid should name an entry in that log. It is the head observed when the API response was produced; the relay's current head may already be newer.

4. Fetch the document blob

Fetch the document at the chain's current head:

curl https://relay.dfos.com/content/cnnnft9f8a2rn938d6nkz38r847v2kr/blob \
--output post.json

The response body is the raw post document committed by the chain's current documentCID. Hashing and comparing that document is separate from folding the signed operation log: the chain proves which document CID is current, while the blob supplies the document bytes.

5. Verify locally

With @metalabel/dfos-protocol, collect every log page, resolve each signing key from its did:dfos identity, and pass the JWS tokens through verifyContentChain. This example verifies the API's point-in-time head even if the chain has since advanced:

import { decodeMultikey, verifyContentChain } from '@metalabel/dfos-protocol';

const entries = await fetchAllLogEntries(contentLogUrl);
const headIndex = entries.findIndex(({ cid }) => cid === headOpCid);
if (headIndex === -1) throw new Error('API head is not in the relay log');

const resolveKey = async (kid) => {
const [did, keyId] = kid.split('#');
const { state } = await fetch(`${relayUrl}/proof/v1/identities/${did}`).then((r) => r.json());
const keys = [...state.authKeys, ...state.assertKeys, ...state.controllerKeys];
const key = keys.find(({ id }) => id === keyId);
if (!key) throw new Error(`Cannot resolve signing key ${kid}`);
return decodeMultikey(key.publicKeyMultibase).keyBytes;
};

const verified = await verifyContentChain({
log: entries.slice(0, headIndex + 1).map(({ jwsToken }) => jwsToken),
resolveKey,
});

if (verified.contentId !== contentId || verified.headCID !== headOpCid) {
throw new Error('Protocol handles do not match the verified chain');
}

fetchAllLogEntries should follow the log response's next cursor and return its combined entries. For identities that have rotated signing keys, resolve the key against the verified identity operation history rather than assuming it remains in the identity's current state.

Or use the Go dfos CLI, which fetches into its local relay and re-verifies the stored chain:

dfos peer add dfos https://relay.dfos.com
dfos content fetch cnnnft9f8a2rn938d6nkz38r847v2kr --peer dfos
dfos content verify cnnnft9f8a2rn938d6nkz38r847v2kr

Use the relayUrl returned by discovery when registering the peer.

6. Verify identities and profiles the same way

User and space detail responses expose the identity's protocol did plus protocol.headOpCid. Substitute that did into the discovery document's identity and identityLog templates to fetch and verify the identity chain.

When protocol.profile is present, its contentId and headOpCid point to a profile content chain. Fetch it through the same content, contentLog, and blob templates used for the post. An empty profile has no profile chain, so protocol.profile is absent.

Caveats

  • New posts settle before chaining. A post created less than roughly 30 seconds ago has no protocol block yet. Retry after settlement.
  • Heads are snapshots. headOpCid records the chain head when the API response was assembled. The chain may advance before you fetch it; verify the log prefix ending at that operation rather than requiring it to remain the relay's current head.
  • Blob access follows content visibility. Public post and profile documents are readable anonymously. Blob reads for non-public content require credentials; the DFOS API only surfaces public entities, but the relay serves broader protocol use cases.

See Protocol discovery for the relay templates and Relays & verification for the protocol's trust model.