@@ -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
162162const HASH_RE = / ^ s h a 2 5 6 : [ 0 - 9 a - f ] { 64 } $ / ;
163163const 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+
165183export 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) ----------------------------
0 commit comments