Verify a post against the protocol
Public posts carry a bridge into the signed DFOS protocol. This recipe follows it from an API response to the relay, fetches the post's proof log and document, and verifies the chain locally.
1. Fetch the post
curl https://api.dfos.com/v1/spaces/home/posts/post_ze2kh2d47tzerkhet8348c
On an eligible response, read post.protocol.contentId and
post.protocol.headOpCid:
{
"state": "eligible",
"post": {
"id": "post_ze2kh2d47tzerkhet8348c",
"protocol": {
"contentId": "d4743469vf6heca8t466ckknvzknha2",
"headOpCid": "bafyreiaztsa2wm76vu2t7nchqnpg3nl6jkydxolikj62ywwl3geccbbeby"
}
}
}
A space's protocol setting governs whether its members-only posts are anchored,
never its public ones. An absent protocol block usually means the chain has
not settled yet; fetch the post again shortly.
2. Discover the relay
curl https://api.dfos.com/v1/protocol
It returns the 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
curl https://relay.dfos.com/proof/v1/content/d4743469vf6heca8t466ckknvzknha2
curl https://relay.dfos.com/proof/v1/content/d4743469vf6heca8t466ckknvzknha2/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 carrying one signed protocol operation. Follow the response's
next cursor until it is null before verifying a long chain.
The API's headOpCid names 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
curl https://relay.dfos.com/content/d4743469vf6heca8t466ckknvzknha2/blob \
--output post.json
The body is the raw post document committed by the chain's current
documentCID. Hashing that document is separate from folding the signed
operation log: the chain proves which document CID is current, the blob supplies
the 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 verifies the API's point-in-time head even if the
chain has since advanced:
import { decodeMultikey, verifyContentChain, verifyIdentityChain } 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');
// `verifyContentChain` calls this with each operation's own `createdAt` as
// `basis`. Resolve the key in the signer's state as of that instant, not now.
const resolveKey = async (kid, basis) => {
const [did, keyId] = kid.split('#');
const log = await fetchAllLogEntries(`${relayUrl}/proof/v1/identities/${did}/log`);
const state = await verifyIdentityChain({
didPrefix: 'did:dfos',
log: log.map(({ jwsToken }) => jwsToken),
asOf: basis,
});
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; identity and content logs share that envelope. Resolving
against current state instead of basis misverifies any identity that has
rotated a signing key. @metalabel/dfos-client ships this resolver bound to a
relay set: resolvers(['https://relay.dfos.com']).resolveKey.
The Go dfos CLI fetches into its local relay and re-verifies the stored chain.
Register the peer with the relayUrl discovery returned:
dfos peer add dfos https://relay.dfos.com
dfos content fetch d4743469vf6heca8t466ckknvzknha2 --peer dfos
dfos content verify d4743469vf6heca8t466ckknvzknha2
Or check it in a browser with nothing installed:
explore.dfos.com takes a content id at
#/content/<contentId> and a DID at #/did/<did>, resolves from the relay, and
recomputes the whole chain fold in your own tab. It has no backend, so it is a
third implementation of this walkthrough rather than a report from us.
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. An empty profile has no profile chain, so protocol.profile
is absent.
Caveats
- New posts settle before chaining. A very recently created post may not
have a
protocolblock yet. Retry after a short delay. - Heads are snapshots.
headOpCidrecords the chain head when the API response was assembled. Verify the log prefix ending at that operation rather than requiring it to still be 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 a protocol read credential — the protocol-level artifact granting read access to a content chain, not the app credential a user issues at the Sign in with DFOS consent screen. The DFOS API only surfaces public entities; the relay serves broader protocol use cases.
See Protocol discovery for the relay templates and Relays & verification for the protocol's trust model.