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
protocolblock yet. Retry after settlement. - Heads are snapshots.
headOpCidrecords 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.