@metalabel/dfos-web-relay
v0.54.0
Published
DFOS Web Relay — verifying HTTP relay for identity chains, content chains, and content blobs
Maintainers
Readme
@metalabel/dfos-web-relay
Relays verify everything they receive and serve everything they've verified. Authorship is verifiable without trusting any server, and which view of an identity you follow is a choice of relay. No hierarchy, no central authority, topology is emergent. Portable HTTP relay for the DFOS protocol.
See RELAY.md for the full relay specification.
Install
npm install @metalabel/dfos-web-relay @metalabel/dfos-protocol hono@metalabel/dfos-protocol and hono are peer dependencies. Hono is peered
rather than bundled because the relay's public API is Hono-typed — createRelay
returns { app: Hono } and serve(app) takes one — so the relay and your app
must resolve to the same Hono install. A private copy yields two structurally
incompatible Hono types and the handoff fails to compile.
Usage
Embedded (Hono app)
import { createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
const relay = await createRelay({
store: new MemoryRelayStore(),
});
// relay.app — Hono application
// relay.did — the relay's auto-generated DID
// relay.syncFromPeers() — pull operations from configured peers
// relay.projectIndex() — advance the /index/v0 projection by one budget
export default relay.app;Set signing: true to enable the optional signing mailbox; it is disabled by default.
The store must implement SigningStore or createRelay throws
signing capability requires a store implementing SigningStore.
Set authority to the host[:port] callers reach this relay at. It is what every
API-AUTH identity proof is checked against, and
it is configuration, never read from a request header — without it the authenticated
routes answer 503. ingestion (open | proof-required | closed) and an injectable
admissionPolicy set who may submit operations
(RELAY § Admission).
Advertising an OpenAPI document
Serving an OpenAPI document is a SHOULD, so it is opt-in. openapi: { document }
serves the document at /openapi.json (override with route) and advertises that
path in the well-known's openapi field; openapi: { url } advertises a document
hosted elsewhere without registering a route. Absent the option the relay serves
none and omits the field. This package's own document — the one describing the
route table below — ships as an importable JSON artifact generated from
openapi.yaml:
import document from '@metalabel/dfos-web-relay/openapi.json';
const relay = await createRelay({ store, openapi: { document } });The document is discovery, never authority: the routes, capability gates, and auth rules the spec fixes govern regardless of what an advertised document says.
Standalone (Node.js)
import { serve } from '@metalabel/dfos-web-relay/node';
serve({ port: 4444 });Routes
| Method | Path | Description |
| ------ | ------------------------------------------- | -------------------------------------------------------------------- |
| GET | /.well-known/dfos-relay | Relay metadata (DID, capabilities, profile, peers, stats) |
| GET | /openapi.json | The configured OpenAPI document (registered only when configured) |
| POST | /proof/v1/operations | Submit signed operations (identity, content, countersig) |
| GET | /proof/v1/identities/:did | Get identity chain terminal state |
| GET | /proof/v1/identities/:did/log | Paginated identity chain operation log |
| GET | /proof/v1/content/:contentId | Get content chain terminal state |
| GET | /proof/v1/content/:contentId/log | Paginated content chain operation log |
| GET | /proof/v1/log | Paginated global operation log (log capability) |
| GET | /proof/v1/operations/:cid | Get a single operation by CID |
| GET | /proof/v1/countersignatures/:cid | Paginated countersignatures for any CID (ops, artifacts) |
| GET | /1.0/identifiers/:did | Resolve a did:dfos to a W3C DID Document (DIF-compat) |
| GET | /revocations/v1/credential/:credentialCID | Revocation status for a credential (self-proving JWS) |
| GET | /revocations/v1/issuer/:did | Paginated feed of all revocations ingested for an issuer |
| POST | /signing/v0/requests | Deposit a sign request (signing capability; 501 when disabled) |
| GET | /signing/v0/requests | Poll pending sign requests (signing capability; 501 when disabled) |
| POST | /signing/v0/requests/:cid/response | Submit a sign response (signing capability; 501 when disabled) |
| GET | /signing/v0/requests/:cid/response | Poll sign response status (signing capability; 501 when disabled) |
| POST | /signing/v0/requests/:cid/decline | Decline a sign request (signing capability; 501 when disabled) |
| GET | /index/v0/operations | Browse operation metadata rows by recency (index capability) |
| GET | /index/v0/identities | Query materialized identity projections (index capability) |
| GET | /index/v0/content | Query materialized content projections (index capability) |
| GET | /index/v0/artifacts | Query standalone signed artifacts (index capability) |
| GET | /index/v0/countersignatures | Query countersignatures by witness (index capability) |
| GET | /index/v0/credentials | Query credential projections (index capability) |
| GET | /index/v0/credits | Query credits on public head documents (index capability) |
| PUT | /content/:contentId/blob/:operationCID | Upload blob (identity proof required) |
| GET | /content/:contentId/blob | Download blob at head (public grant, or identity proof + credential) |
| GET | /content/:contentId/blob/:ref | Download blob at specific operation ref |
Route Semantics
The route table above is the full surface this package serves; the semantics
behind it are the spec's to define, not this README's. DID resolution
(/1.0/identifiers/:did) follows the normative mapping in
DID-METHOD.md §4; revocation status
(/revocations/v1/*) is specified in
RELAY § Revocation status;
blob upload/download authorization is
RELAY § Access.
Peering
Relays replicate operations via three composable per-peer behaviors — gossip-out, read-through, sync-in — specified in RELAY § Peering; operator guidance for running a peered relay is at protocol.dfos.com/deploy.
import { createHttpPeerClient, createRelay, MemoryRelayStore } from '@metalabel/dfos-web-relay';
const relay = await createRelay({
store: new MemoryRelayStore(),
peerClient: createHttpPeerClient(),
peers: [{ url: 'https://other-relay.example.com' }],
});The PeerClient is injected like the store — semantic per-resource methods,
not raw HTTP. The default (createHttpPeerClient) uses HTTP; tests inject
mocks that route directly to another relay's API in-process:
interface PeerClient {
getIdentityLog(
peerUrl: string,
did: string,
params?: { after?: string; limit?: number },
): Promise<{ entries: PeerLogEntry[]; next: string | null } | 'invalid-cursor' | null>;
getContentLog(
peerUrl: string,
contentId: string,
params?: { after?: string; limit?: number },
): Promise<{ entries: PeerLogEntry[]; next: string | null } | 'invalid-cursor' | null>;
// `null` = transport/peer failure; `'invalid-cursor'` = the peer explicitly
// rejected `after` (400) — the distinguished value the sync loop's self-heal
// requires. A client that collapses the 400 into `null` leaves the puller
// retrying a dead cursor forever after a peer wipes or rebuilds its log.
getOperationLog(
peerUrl: string,
params?: { after?: string; limit?: number },
): Promise<{ entries: PeerLogEntry[]; next: string | null } | 'invalid-cursor' | null>;
submitOperations(peerUrl: string, operations: string[]): Promise<void>;
}A PeerLogEntry is { cid: string; jwsToken: string }. The
'invalid-cursor' outcome (a peer's 400 cursor rejection, distinct from
transport failure) is load-bearing for the sync loop's self-heal — see
RELAY § Peering.
Implementing a store
A store implements one required contract and, optionally, up to three more. Which ones it implements is what the relay reads its capabilities from — there is no capability flag that can promise something the store cannot do, and no member that exists only to throw.
import { createRelay } from '@metalabel/dfos-web-relay';
// a read-only relay: serves the proof plane, the content plane, the log and the
// revocation routes. `write` is false, `index` is false, and it says so.
const relay = await createRelay({ store: myReadStore, identity: myIdentity });RelayReadStore — required
Every read a route performs: operations, identity and content chains, chain state at an arbitrary CID, blobs, countersignatures, the paginated global log, stats, revocations, and held public credentials. A relay over nothing but this serves every GET the spec defines.
Reads FAIL CLOSED. A read that cannot be answered throws; it never returns
undefined to mean "the store is unwell". Absence and failure are different
answers, and ingestion classifies them differently — absence is a verdict, a
throw is retryable.
RelayWriteStore — one method
interface RelayWriteStore extends RelayReadStore {
commit(batch: CommitBatch): Promise<CommitResult>;
}CommitBatch is a typed, exhaustive description of everything ONE accepted
operation implies — the operation row, the identity or content chain's new head
and log, the global-log append, a countersignature, a revocation, a standing
credential added or (issuer-scoped) dropped — or one document blob. The store
persists all of it or none of it, and answers new or duplicate.
Atomicity is the contract. A partial commit is a corrupt relay: an operation in the operation table but not in the log is invisible to every puller forever, and a chain head advanced without its operation row breaks fork verification. If the commit throws, the store MUST have persisted nothing; the relay treats a throw as retryable and keeps the raw operation for a later pass.
A writing relay also holds writer-internal bookkeeping — the raw-operation
buffer the sequencer drains, and per-peer sync cursors (RelayWriterState). That
is this package's own state, not part of the store contract a read-only
integration has to care about.
IndexReadStore / IndexWriteStore — the index profile
IndexReadStore is the nine queries behind /index/v0, pushed down so a page
costs O(page). IndexWriteStore is the projection side: applyIndexRows plus a
persisted cursor.
They are separate because a store can implement one without the other. A store
whose index rows are maintained by an external worker implements the queries,
advertises index: true, and the relay does no projection work for it. A store
that implements both lets this package run the projection:
import { projectIndex } from '@metalabel/dfos-web-relay';
// inline (default): the relay drains the projection after each accepted batch
await createRelay({ store });
// external: nothing runs it but you
const relay = await createRelay({ store, indexProjection: 'external' });
setInterval(() => void relay.projectIndex(), 5_000);The projection walks the operation log from its cursor, maps each entry to the
rows it dirties, and applies them. It is never inside a commit, every pass is
bounded by a budget, and a full-corpus fan-out (a chain:* grant, an identity
delete or restore) is carried on the cursor as a resumable sweep rather than
drained in one pass.
external moves the LOG drain to your worker; it is not "the relay runs no
projection". A document blob arrives on its own route and nothing on the
operation log marks that moment, so the relay recomputes the rows that project
that document itself, in both modes, right after the blob commits. That pass is
bounded by a reverse lookup on the documentCID — no fan-out, no log read.
The index is a projection OF THE LOG, so log: false turns it off for a store
this relay maintains: createRelay throws on an explicit index: true and
advertises index: false otherwise, rather than serving empty pages forever. A
store that implements only IndexReadStore is maintained elsewhere and keeps
its index either way.
SigningStore — the optional mailbox
The ephemeral courier state behind /signing/v0. signing: true over a store
that does not implement it is a configuration that lies, so createRelay throws.
MemoryRelayStore implements all of them, and is the reference implementation.
Migrating a store written against the old interface
This is a breaking change to the package's store API (0.x, so a minor bump).
A store written against the single RelayStore interface needs three edits:
- Replace the ~14 write members with
commit.putOperation,putIdentityChain,putContentChain,putBlob,addCountersignature,appendToLog,addRevocation,addPublicCredentialandremovePublicCredentialare gone. Onecommitcarries what all of them carried, and the store decides how to make it atomic.removePublicCredentialis now issuer-scoped: drop the held credential only when itsissuerDIDmatches the one on the batch. - Replace the
putIndex*members withapplyIndexRows, and add the cursor.getIndexCursor/setIndexCursorpersist where the projection got to. If your index is maintained elsewhere, implement neither and keep the queries. - Return
ingestedAton log entries, and deletegetIndexOperationRow. ALogEntrynow carries the relay's receipt stamp for that operation. It is the single clock read per operation and the projection's only source for it, which is what the optionalgetIndexOperationRowused to be for. It is store state, not wire state:GET /proof/v1/logstill serves{cid, jwsToken, kind, chainId}.
Members that were optional and are now simply absent (getStats is required;
getRevocations was unused and is deleted) and the members a read-only store
used to answer by throwing can all be removed.
Two more renames a caller sees: the exported isDependencyFailure is now
isRetryableRejection (it covers a momentary store fault as well as a missing
dependency, and both mean "keep the raw operation and try again"), and
IngestionResult carries storeFault alongside dependencyMissing.
A read-only store (RelayReadStore with no commit) also needs three things
from its deployment: getStats is required rather than optional, identity
must be passed to createRelay (there is nowhere to write a JIT genesis), and
no peers may be configured (peer sync writes).
License
MIT
