Ports the RecordWeb Protocol (RWP —
spec,
concept) onto the #206 plugin loader.
RecordWeb is a W3C Community Group draft (Nik Jenzer / TRIEBWERKSTATT) for
institutional records: give every record a global DID, keep its whole
version history as an immutable, content-addressed DAG, prove finalized
versions cryptographically, and group related records into Merkle-rooted
Cases. This plugin turns a JSS server into an RWP node — and RWC itself
names two of this estate's own pieces as extensions: "deliver Records to
Solid Pods (citizen-controlled copies)" (a JSS pod is that) and "anchor
Merkle roots to an external timestamp proof" (which forge/
already does against Bitcoin).
plugins: [{
module: 'recordweb/plugin.js',
prefix: '/recordweb',
config: {
namespace: 'bern.ch', // optional — RWP namespaces are DNS domains; default = host
baseUrl: 'https://pod.example', // optional reverse-proxy override; default = api.serverInfo()
loopbackUrl: 'http://localhost:3000', // optional — pod delivery target; default = own origin
deliverToPod: true, // optional — write a pod copy on finalize (default true)
},
}]| RWP construct | here |
|---|---|
Record did:rwp:<ns>:<uuid> |
one directory under pluginDir; a version DAG of snapshots |
| Snapshot | content-addressed: snapshotHash = SHA-256( JCS(meta \ {snapshotHash,signature}) ‖ payload ) (RFC 8785) |
state draft→finalized |
draft is mutable-by-replacement; finalize signs (Ed25519) + freezes + makes it linkable, one-way |
| Version graph (PROV-O DAG) | parents[] edges, cycle-guarded on append (RWP MUST) |
| Case | payload hard-links finalized snapshots (by hash) + soft-links Records (by DID); merkleRoot over the sorted hard links |
| DID resolution | GET …/resolve/:uuid + a DIF-universal-resolver shim; 200 / 404 / 410 |
POST /recordweb/records create a Record (root draft) [owner]
POST /recordweb/records/:uuid/snapshots append a draft child [owner]
POST /recordweb/records/:uuid/snapshots/:hash/finalize sign + freeze [owner]
GET /recordweb/records/:uuid metadata + version graph
GET /recordweb/records/:uuid/snapshots/:hash one snapshot (verbatim; payload via Link header)
GET /recordweb/records/:uuid/payload/:hash raw payload bytes
POST /recordweb/cases create a Merkle-rooted Case [owner]
GET /recordweb/cases/:uuid Case doc + merkleRoot
GET /recordweb/resolve/:uuid the Record's DID document
GET /recordweb/1.0/identifiers/<did:rwp:…> DIF universal-resolver shim
GET /recordweb/verify/records/:uuid/:hash recompute hash + check signature
GET /recordweb/verify/cases/:uuid recompute the Merkle root
GET /.well-known/rwp-resolver.json HTTP mirror of the DNS-TXT resolver record
:hash is written with the sha256: colon replaced by _ in the URL (path
segments and fastify's param matcher stay clean); it is restored server-side.
Writes require an authenticated pod agent (api.auth.getAgent) who becomes
the Record owner/controller — only the owner may append or finalize.
Resolution and verification are public, as a DID resolver must be.
- Content-addressing + immutability land naturally on
pluginDir: a finalized snapshot is named by its own hash, so it cannot change without changing its DID reference, and the plugin refuses to overwrite one. This is a better footing than a pod for the immutable half of the model — a pod is WAC-governed but its owner can still mutate a resource; a content-addressed server-private blob cannot silently drift. - JCS (RFC 8785) + SHA-256 + Ed25519 are all
node:crypto+ a ~30-line canonicalizer — zero new dependencies (not even@noble;didweb/needed it for secp256k1, RWP signs with Ed25519 whichnode:cryptodoes natively).canonicalize,computeSnapshotHash, andmerkleRootare exported so a client re-derives every hash independently — the test asserts a client-side recompute equals the server's. - The DID document is the
didweb/shape almost verbatim (Multikey verificationMethod,authentication/assertionMethod), plus RWP'srecordEndpoint+currentVersionpointers. Origin viaapi.serverInfo()(#601), resolved at request time. api.reservePath(#602) claims/.well-known/rwp-resolver.jsonthe same deliberate, collision-guarded waydidweb/claims/.well-known/did.json.
-
The plugin api is sufficient for a whole new protocol with zero seam gaps. recordweb needs none of the still-open seams: no
api.events(records are explicit POSTs, not reactions to pod writes), noapi.authorize(ownership is the requester's own identity viagetAgent, never a third party's authority), noapi.mountApp(payloads are bounded JSON/base64, not streams).serverInfo+reservePath+getAgent+pluginDircover it end to end. That is itself the finding: the four landed seams (0.0.218/0.0.219) close the gap for a from-scratch protocol, not just for ports of bundled features. -
pluginDirvs. the pod — the immutability/ownership split, resolved by keeping both. RWC frames Solid pods as "citizen-controlled copies". But RWP's core invariant is that a finalized snapshot is immutable, and a pod resource is mutable by its owner. So the authoritative, content-addressed snapshots stay server-private inpluginDir(immutability the plugin fully controls, the system of record), and on finalize the plugin also delivers a read-only copy into the owner's own pod over loopback —records/<uuid>/gets the snapshot metadata, the payload, and the DID document — forwarding the owner'sAuthorizationso the host's WAC authorizes the write, not the plugin (themicropub//gallery/pattern). The content hash keeps the two copies reconciled: the owner (or anyone they grant) can fetch the pod copy and re-hash it to the samesnapshotHash. This is the RWC citizen-controlled copy, and it needed no new api —getAgent+ loopback +serverInfo. Delivery is best-effort (a pod-write refusal never fails the seal; the response'spodCopyreports what happened) and opt-out viaconfig.deliverToPod: false. Adid:nostrowner has no pod path, so delivery is skipped for it — the one identity shape this wave doesn't cover, and the finding it surfaces. -
did:rwpis notdid:web, so no well-known collision — but also no free WAC exemption.didweb/leans on core blanket-exempting/.well-known/*.did:rwpresolution is namespace-scoped (discovered via a DNS TXT record, which we also mirror at/.well-known/rwp-resolver.json), so resolution lives under the plugin prefix where the loader already exempts routes — no reservation needed for the resolver itself, only for the well-known mirror. -
maxParamLength(100) bites a full DID in a named param. Adid:rwp:<ns>:<uuid>easily exceeds fastify's default 100-char param cap, so the DIF-universal shim uses a wildcard route (/1.0/identifiers/*) and parses the DID itself — the same footguncapability/documented, same wildcard escape. The native…/resolve/:uuidroute takes only the 36-char uuid and stays a clean named param. -
Finalization is a state transition, not a new child. The finalized snapshot keeps the draft's
parents(it is the same logical node at a new state), so its content hash changes but its lineage doesn't — the finalized node and its former draft both descend from the same parent. Modeling it as a child instead would double every node's ancestry and corrupt the Merkle provenance. The draft node is retained in the graph (history never disappears — RWC's thesis), just no longer thecurrentVersion. -
Deletion is a tombstone, honestly partial.
resolvereturns 410 for a Record marked deleted (adeletedmarker file) vs 404 for one never known — the RWP resolver contract. The fullDeletionRecordregimes (payload-only "empty shell", full-delete, exempt) are modeled in the spec but not yet wired here; the 404/410 split is the resolver-visible part and the hook for the rest.
SchemaRecord-driven validation — RWP lets a record type carry a JSON Schema + allowedstateTransitions+ per-statepayloadFormats; finalize would validate the payload against it. The engine is here; the schema registry + validation pass is additive.- Cross-namespace federation (RWP Level 2) — resolve a foreign
did:rwp:<other-ns>:…by reading its DNS TXT resolver endpoint and fetching over HTTP, then verifying the returned snapshot hash. Purefetch+ thecorsproxy/SSRF-gate discipline; no new api. - Bitcoin anchoring of Case Merkle roots — the RWC "external timestamp
proof" extension, which
forge/already implements against Bitcoin (Blocktrails). A Case'smerkleRootis exactly the thing to anchor; the obvious cross-plugin synergy in this estate.
node --test --test-concurrency=1 recordweb/test.js19 tests: the pure crypto units (JCS, Merkle), the full create→append→finalize lifecycle with owner/anon/non-owner gating, the one-way finalize, pod delivery (the sealed copy re-hashes from the owner's pod; a non-owner is WAC-denied), DID resolution (native + universal shim + foreign-namespace 404), snapshot + Case verification by independent recompute, and the well-known resolver doc.