Skip to main content

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 protocol block yet. Retry after a short delay.
  • Heads are snapshots. headOpCid records 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.