Skip to content

Commit 5a0b2d7

Browse files
recordweb: deliver a content-honest pod copy on finalize
On finalize, best-effort loopback-PUT a read-only copy of the sealed snapshot (metadata + payload + DID document) into the owner's own pod under records/<uuid>/, forwarding the owner's Authorization so the host's WAC — not the plugin — authorizes the write. This is RWC's "citizen-controlled copy": pluginDir stays the system of record (immutability the plugin owns), the pod holds a distribution copy the content hash keeps honest — a verifier re-hashes the pod copy to the same snapshotHash. Needed no new api (getAgent + loopback + serverInfo). Best-effort (a pod refusal never fails the seal; podCopy in the response reports it), opt-out via config.deliverToPod:false; skipped for did:nostr owners (no pod path). +2 tests (pod copy re-hashes; non-owner WAC-denied); full suite 583 green.
1 parent 8115ad0 commit 5a0b2d7

5 files changed

Lines changed: 122 additions & 23 deletions

File tree

compose.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('composition: every plugin on one server', () => {
9797
// (origin via api.serverInfo, container defaults to the caller's pod).
9898
{ module: at('gallery/plugin.js'), prefix: '/gallery' },
9999
{ module: at('forge/plugin.js'), prefix: '/forge' },
100-
{ module: at('recordweb/plugin.js'), prefix: '/recordweb' },
100+
{ module: at('recordweb/plugin.js'), prefix: '/recordweb', config: { baseUrl: base, loopbackUrl: base } },
101101
{
102102
module: at('remotestorage/plugin.js'),
103103
prefix: '/remotestorage',

recordweb/README.md

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ plugins: [{
2020
config: {
2121
namespace: 'bern.ch', // optional — RWP namespaces are DNS domains; default = host
2222
baseUrl: 'https://pod.example', // optional reverse-proxy override; default = api.serverInfo()
23+
loopbackUrl: 'http://localhost:3000', // optional — pod delivery target; default = own origin
24+
deliverToPod: true, // optional — write a pod copy on finalize (default true)
2325
},
2426
}]
2527
```
@@ -93,18 +95,23 @@ Resolution and verification are public, as a DID resolver must be.
9395
landed seams (0.0.218/0.0.219) close the gap for a *from-scratch* protocol,
9496
not just for ports of bundled features.
9597

96-
- **`pluginDir` vs. the pod — the immutability/ownership split.** RWC frames
97-
Solid pods as *"citizen-controlled copies"*, which suggests storing records
98-
**in** the pod over loopback (the `micropub/`/`gallery/` pattern). But RWP's
99-
core invariant is that a finalized snapshot is immutable, and a pod resource
100-
is mutable by its owner. So this MVP keeps the authoritative,
101-
content-addressed snapshots server-private in `pluginDir` (immutability the
102-
plugin fully controls) and treats pod delivery as the *distribution* layer,
103-
not the *system of record*. **The natural next wave**: on finalize, PUT a
104-
read-only copy of the snapshot + DID doc into the owner's pod over loopback
105-
with forwarded auth — the citizen-controlled copy RWC describes — while the
106-
content hash keeps the pod copy honest. That needs no new api; it is the
107-
`micropub/` write pattern pointed at the owner's pod.
98+
- **`pluginDir` vs. the pod — the immutability/ownership split, resolved by
99+
keeping both.** RWC frames Solid pods as *"citizen-controlled copies"*. But
100+
RWP's core invariant is that a finalized snapshot is immutable, and a pod
101+
resource is mutable by its owner. So the authoritative, content-addressed
102+
snapshots stay server-private in `pluginDir` (immutability the plugin fully
103+
controls, the *system of record*), and **on finalize the plugin also delivers
104+
a read-only copy into the owner's own pod** over loopback — `records/<uuid>/`
105+
gets the snapshot metadata, the payload, and the DID document — forwarding the
106+
owner's `Authorization` so the host's **WAC** authorizes the write, not the
107+
plugin (the `micropub/`/`gallery/` pattern). The content hash keeps the two
108+
copies reconciled: the owner (or anyone they grant) can fetch the pod copy and
109+
re-hash it to the same `snapshotHash`. This is the RWC citizen-controlled copy,
110+
and it needed **no new api**`getAgent` + loopback + `serverInfo`. Delivery
111+
is best-effort (a pod-write refusal never fails the seal; the response's
112+
`podCopy` reports what happened) and opt-out via `config.deliverToPod: false`.
113+
A `did:nostr` owner has no pod path, so delivery is skipped for it — the one
114+
identity shape this wave doesn't cover, and the finding it surfaces.
108115

109116
- **`did:rwp` is not `did:web`, so no well-known collision — but also no free
110117
WAC exemption.** `didweb/` leans on core blanket-exempting `/.well-known/*`.
@@ -135,9 +142,8 @@ Resolution and verification are public, as a DID resolver must be.
135142
but not yet wired here; the 404/410 split is the resolver-visible part and
136143
the hook for the rest.
137144

138-
## Not yet (scoped out of the MVP, no api gap — just work)
145+
## Not yet (scoped out, no api gap — just work)
139146

140-
- **Pod delivery of finalized copies** (the loopback wave above).
141147
- **`SchemaRecord`-driven validation** — RWP lets a record type carry a JSON
142148
Schema + allowed `stateTransitions` + per-state `payloadFormats`; finalize
143149
would validate the payload against it. The engine is here; the schema
@@ -157,7 +163,9 @@ Resolution and verification are public, as a DID resolver must be.
157163
node --test --test-concurrency=1 recordweb/test.js
158164
```
159165

160-
17 tests: the pure crypto units (JCS, Merkle), the full create→append→finalize
161-
lifecycle with owner/anon/non-owner gating, the one-way finalize, DID
162-
resolution (native + universal shim + foreign-namespace 404), snapshot + Case
163-
verification by independent recompute, and the well-known resolver doc.
166+
19 tests: the pure crypto units (JCS, Merkle), the full create→append→finalize
167+
lifecycle with owner/anon/non-owner gating, the one-way finalize, **pod
168+
delivery** (the sealed copy re-hashes from the owner's pod; a non-owner is
169+
WAC-denied), DID resolution (native + universal shim + foreign-namespace 404),
170+
snapshot + Case verification by independent recompute, and the well-known
171+
resolver doc.

recordweb/plugin.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,24 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f
162162
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
163163
const now = () => new Date().toISOString();
164164

165+
/**
166+
* Owner's pod root path from a WebID (the podFromWebid mapping gallery/micropub
167+
* use). Only http(s) WebIDs map to a pod — a did:nostr owner has no pod path,
168+
* so pod delivery is skipped for it (returns null).
169+
* http://host/alice/profile/card#me → /alice/ http://host/profile/card#me → /
170+
*/
171+
function podFromWebid(webid) {
172+
try {
173+
const u = new URL(webid);
174+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
175+
const segs = u.pathname.split('/').filter(Boolean);
176+
if (segs.length >= 2 && segs[0] !== 'profile') return `/${segs[0]}/`;
177+
return '/';
178+
} catch { return null; }
179+
}
180+
/** A `sha256:<hex>` → a filename-safe segment (`sha256_<hex>`). */
181+
const hashFile = (h) => h.replace(':', '_');
182+
165183
export async function activate(api) {
166184
const prefix = api.prefix || '/recordweb';
167185
const store = api.storage.pluginDir();
@@ -182,6 +200,53 @@ export async function activate(api) {
182200
: new URL(resolveBaseUrl()).host);
183201
const didFor = (uuid) => `did:rwp:${resolveNamespace()}:${uuid}`;
184202

203+
// Loopback origin for pod delivery. config.loopbackUrl overrides; else the
204+
// server's own listening origin (serverInfo, #601). Delivery is on by default
205+
// and can be turned off with config.deliverToPod === false.
206+
const loopbackOrigin = () => {
207+
if (api.config.loopbackUrl) return String(api.config.loopbackUrl).replace(/\/$/, '');
208+
const { protocol, host, port } = api.serverInfo();
209+
const h = host.includes(':') ? `[${host}]` : host;
210+
return `${protocol}://${h}:${port}`;
211+
};
212+
const deliverEnabled = api.config.deliverToPod !== false;
213+
214+
/**
215+
* Best-effort: on finalize, write a read-only copy of the sealed snapshot
216+
* (metadata + payload + the record's DID document) into the OWNER's own pod
217+
* over loopback, forwarding the owner's Authorization so the host's WAC —
218+
* not this plugin — authorizes the write. The content hash keeps the pod copy
219+
* honest: a verifier can re-hash it and get the same snapshotHash. This is
220+
* RWC's "citizen-controlled copy". Never fails the finalize — pluginDir stays
221+
* the system of record; the pod is distribution. Returns a status object.
222+
*/
223+
async function deliverToPod(owner, uuid, meta, payloadBytes, didDoc, auth) {
224+
if (!deliverEnabled) return { delivered: false, reason: 'disabled' };
225+
if (!auth) return { delivered: false, reason: 'no-authorization-to-forward' };
226+
const pod = podFromWebid(owner);
227+
if (!pod) return { delivered: false, reason: 'owner has no pod path (non-WebID)' };
228+
const origin = loopbackOrigin();
229+
const base = `${pod}records/${uuid}/`;
230+
const put = (p, body, type) => fetch(origin + p, {
231+
method: 'PUT', redirect: 'manual',
232+
headers: { authorization: auth, 'content-type': type }, body,
233+
});
234+
try {
235+
const hf = hashFile(meta.snapshotHash);
236+
const r1 = await put(`${base}${hf}.json`, JSON.stringify(meta, null, 2), 'application/json');
237+
const r2 = await put(`${base}${hf}.bin`, Buffer.from(payloadBytes), meta.payloadFormat || 'application/octet-stream');
238+
const r3 = await put(`${base}did.json`, JSON.stringify(didDoc, null, 2), 'application/did+json');
239+
const codes = [r1.status, r2.status, r3.status];
240+
const ok = codes.every((c) => c >= 200 && c < 300);
241+
if (!ok) return { delivered: false, reason: `pod write refused (${codes.join('/')})`, base: `${resolveBaseUrl()}${base}` };
242+
const podBase = `${resolveBaseUrl()}${base}`;
243+
return { delivered: true, base: podBase,
244+
snapshot: `${podBase}${hf}.json`, payload: `${podBase}${hf}.bin`, didDocument: `${podBase}did.json` };
245+
} catch (err) {
246+
return { delivered: false, reason: `loopback failed: ${err.message}` };
247+
}
248+
}
249+
185250
// ---- owner signing keys (mint + persist per agent) -----------------------
186251
const keyFile = (agent) => path.join(keysDir, sha256hex(Buffer.from(agent, 'utf8')) + '.json');
187252
function ownerKey(agent) {
@@ -401,8 +466,13 @@ export async function activate(api) {
401466
index.state = 'finalized';
402467
index.updated = frozenCore.finalized;
403468
await fsp.writeFile(indexPath(uuid), JSON.stringify(index, null, 2));
404-
api.log.info(`recordweb: finalized ${index.did}${frozenHash}`);
405-
return json(reply, 200, { snapshot: meta, didDocument: buildDidDoc(index) });
469+
// Deliver a read-only, content-honest copy into the owner's pod (RWC's
470+
// "citizen-controlled copy"), forwarding the owner's own Authorization.
471+
const didDoc = buildDidDoc(index);
472+
const podCopy = await deliverToPod(agent, uuid, meta, payloadBytes, didDoc, request.headers.authorization);
473+
api.log.info(`recordweb: finalized ${index.did}${frozenHash}`
474+
+ (podCopy.delivered ? ` (pod copy at ${podCopy.base})` : ` (pod copy skipped: ${podCopy.reason})`));
475+
return json(reply, 200, { snapshot: meta, didDocument: didDoc, podCopy });
406476
});
407477

408478
// ---- read a Record (metadata + version graph) ----------------------------

recordweb/test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('recordweb (RWP) plugin', () => {
7777
});
7878

7979
// ---- lifecycle -----------------------------------------------------------
80-
let did; let uuid; let rootHash; let draftHash; let finalHash;
80+
let did; let uuid; let rootHash; let draftHash; let finalHash; let podCopy;
8181

8282
it('refuses an anonymous create (writes need a pod agent)', async () => {
8383
const res = await postJson(`${base}/recordweb/records`, null, { payload: { hello: 'world' } });
@@ -125,6 +125,27 @@ describe('recordweb (RWP) plugin', () => {
125125
assert.ok(body.snapshot.signature?.startsWith('z'), 'multibase signature present');
126126
assert.notStrictEqual(finalHash, draftHash, 'the state change gives a new content hash');
127127
assert.strictEqual(body.didDocument.currentVersion, finalHash, 'DID doc points at the finalized snapshot');
128+
podCopy = body.podCopy;
129+
assert.strictEqual(podCopy.delivered, true, `pod delivery: ${JSON.stringify(podCopy)}`);
130+
assert.ok(podCopy.snapshot.includes(`/alice/records/${uuid}/`), 'copy lands in the owner\'s pod');
131+
});
132+
133+
it('the pod copy is content-honest — it re-hashes to the same snapshotHash', async () => {
134+
// The owner fetches their citizen-controlled copy from their own pod and
135+
// recomputes the hash; it must equal the sealed snapshotHash. WAC governs
136+
// the read (alice's bearer), proving the write went through the host, not
137+
// a plugin backdoor.
138+
const metaRes = await fetch(podCopy.snapshot, { headers: { authorization: `Bearer ${alice}` } });
139+
assert.strictEqual(metaRes.status, 200, 'owner reads the pod copy');
140+
const meta = await metaRes.json();
141+
const payload = Buffer.from(await (await fetch(podCopy.payload, { headers: { authorization: `Bearer ${alice}` } })).arrayBuffer());
142+
assert.strictEqual(computeSnapshotHash(meta, payload), finalHash, 'pod copy re-hashes to the sealed hash');
143+
assert.strictEqual(meta.state, 'finalized');
144+
});
145+
146+
it('a non-owner cannot read another agent\'s pod copy (WAC still governs)', async () => {
147+
const res = await fetch(podCopy.snapshot, { headers: { authorization: `Bearer ${bob}` } });
148+
assert.ok([401, 403].includes(res.status), `bob is denied alice's pod copy (got ${res.status})`);
128149
});
129150

130151
it('refuses a second finalize — finalization is one-way (409)', async () => {

serve.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ const fastify = createServer({
105105
// container defaults to the caller's own pod).
106106
{ module: at('gallery/plugin.js'), prefix: '/gallery' },
107107
{ module: at('forge/plugin.js'), prefix: '/forge' },
108-
{ module: at('recordweb/plugin.js'), prefix: '/recordweb', config: { baseUrl: PUBLIC_URL } },
108+
{ module: at('recordweb/plugin.js'), prefix: '/recordweb', config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://localhost:${PORT}` } },
109109
// webfinger/ above owns /.well-known/webfinger — the witnessed collision
110110
// (remotestorage/README.md) — so remotestorage stands down here.
111111
{ module: at('remotestorage/plugin.js'), prefix: '/remotestorage', config: { baseUrl: PUBLIC_URL, loopbackUrl: `http://localhost:${PORT}`, claimWellKnown: false } },

0 commit comments

Comments
 (0)