@@ -1654,6 +1654,55 @@ export async function activate(api) {
16541654 return { url : stored . url , hosted : ! ! hex } ;
16551655 }
16561656
1657+ /**
1658+ * The capstone storage beat (WAC-native authored words). A WebID/Solid
1659+ * (DPoP) — or NIP-98 — proof is cryptographically bound to ONE request URI
1660+ * (DPoP `htu`, NIP-98 `u`): the forge CANNOT forward the caller's
1661+ * credential to a SECOND URL (the pod write) because the proof names the
1662+ * API URL, not the pod resource. So the BROWSER writes the body into its
1663+ * OWN pod (a fresh per-request proof, minted by xlogin.authFetch), and the
1664+ * forge is handed only a POINTER. Here the forge VALIDATES that pointer:
1665+ * - it must name a resource inside THIS author's OWN pod forge area for
1666+ * THIS repo — `${podPath}public/forge/${owner}--${name}/….jsonld`, no
1667+ * traversal (pointer-injection defense: a caller can register only its
1668+ * own pod's resources for this one repo);
1669+ * - it must really be there and PUBLICLY readable — an UNAUTHENTICATED
1670+ * loopback GET, exactly how resolveEntry re-fetches bodies for display
1671+ * (bodies live under public/forge/, so the read is request-agnostic and
1672+ * needs no forwarded proof).
1673+ * On success the pointer is stored identically to a server-written one.
1674+ * Returns { url } or { status, error }.
1675+ */
1676+ async function registerPointer ( request , agent , owner , name , resourceUrl ) {
1677+ const podPath = podPathFromAgent ( agent ) ;
1678+ if ( ! podPath ) return { status : 403 , error : 'pointer registration requires a pod agent' } ;
1679+ const p = resourcePathOf ( resourceUrl ) ;
1680+ if ( ! p ) return { status : 400 , error : 'invalid resourceUrl' } ;
1681+ const allowed = `${ podPath } public/forge/${ owner } --${ name } /` ;
1682+ if ( ! p . startsWith ( allowed ) || ! p . endsWith ( '.jsonld' ) || p . includes ( '..' ) ) {
1683+ return { status : 403 , error : 'resourceUrl must be inside your own pod forge area for this repo' } ;
1684+ }
1685+ let res ;
1686+ try {
1687+ res = await lb ( p , {
1688+ headers : { accept : 'application/ld+json' } ,
1689+ signal : AbortSignal . timeout ( THREAD_FETCH_TIMEOUT_MS ) ,
1690+ } ) ;
1691+ } catch ( err ) {
1692+ return { status : 400 , error : `pointer not found in your pod: ${ err . message } ` } ;
1693+ }
1694+ if ( ! res . ok ) {
1695+ try { await res . body ?. cancel ( ) ; } catch { /* drained */ }
1696+ return { status : 400 , error : 'pointer not found in your pod' } ;
1697+ }
1698+ let doc ;
1699+ try { doc = await res . json ( ) ; } catch { doc = null ; }
1700+ if ( ! doc || typeof doc . body !== 'string' || doc . body . length > ISSUE_BODY_CAP ) {
1701+ return { status : 400 , error : 'pointer is not a readable forge document' } ;
1702+ }
1703+ return { url : `${ publicOrigin ( ) } ${ p } ` } ;
1704+ }
1705+
16571706 /** Loopback path of a stored resource URL (absolute or path form). */
16581707 function resourcePathOf ( resourceUrl ) {
16591708 if ( typeof resourceUrl !== 'string' ) return null ;
@@ -2336,29 +2385,67 @@ async function call(path,body,method){
23362385 if(!res.ok)throw new Error((j&&j.error)||('HTTP '+res.status));
23372386 return j||{};
23382387}
2388+ // A Solid/DPoP proof is bound to ONE request URI, so the SERVER cannot
2389+ // forward it to write the pod: the browser signs a FRESH proof per request
2390+ // and writes the body into its own pod, then hands the forge a pointer.
2391+ const Sol=()=>{const x=X();return (x&&x.type==='solid')?x:null};
2392+ async function podWrite(doc,filePrefix){
2393+ const id=new URL(String(window.xlogin.id));
2394+ const segs=id.pathname.split('/').filter(Boolean);
2395+ const podPath=(segs.length>=2&&segs[0]!=='profile')?'/'+segs[0]+'/':'/';
2396+ const resourcePath=podPath+'public/forge/'+CFG.owner+'--'+CFG.name+'/'+filePrefix+'-'+crypto.randomUUID()+'.jsonld';
2397+ const res=await window.xlogin.authFetch(id.origin+resourcePath,
2398+ {method:'PUT',headers:{'content-type':'application/ld+json'},body:JSON.stringify(doc)});
2399+ if(!(res.ok||res.status===201||res.status===204))throw new Error('could not write to your pod: '+res.status);
2400+ return resourcePath;
2401+ }
23392402const si=document.getElementById('submit-issue');
23402403if(si)si.onclick=async function(){
23412404 setMsg('');
23422405 try{
2343- const r=await call('/issues',{title:document.getElementById('f-title').value,
2344- body:document.getElementById('f-body').value});
2406+ const title=document.getElementById('f-title').value;
2407+ const bodyText=document.getElementById('f-body').value;
2408+ let payload={title:title,body:bodyText};
2409+ if(Sol()){
2410+ const doc={type:'ForgeIssue',repo:CFG.owner+'/'+CFG.name,title:title,body:bodyText,
2411+ published:new Date().toISOString(),author:window.xlogin.id};
2412+ payload={title:title,resourceUrl:await podWrite(doc,'issue')};
2413+ }
2414+ const r=await call('/issues',payload);
23452415 location.href=CFG.base+'/issues/'+r.number;
23462416 }catch(e){setMsg(String(e.message||e))}
23472417};
23482418const sp=document.getElementById('submit-pull');
23492419if(sp)sp.onclick=async function(){
23502420 setMsg('');
23512421 try{
2352- const r=await call('/pulls',{title:document.getElementById('f-title').value,
2353- body:document.getElementById('f-body').value,base:CFG.prBase,head:CFG.prHead});
2422+ const title=document.getElementById('f-title').value;
2423+ const bodyText=document.getElementById('f-body').value;
2424+ let payload={title:title,body:bodyText,base:CFG.prBase,head:CFG.prHead};
2425+ if(Sol()){
2426+ const doc={type:'ForgePullRequest',repo:CFG.owner+'/'+CFG.name,title:title,body:bodyText,
2427+ base:CFG.prBase,head:CFG.prHead,published:new Date().toISOString(),author:window.xlogin.id};
2428+ payload={title:title,base:CFG.prBase,head:CFG.prHead,resourceUrl:await podWrite(doc,'pull')};
2429+ }
2430+ const r=await call('/pulls',payload);
23542431 location.href=CFG.base+'/pulls/'+r.number;
23552432 }catch(e){setMsg(String(e.message||e))}
23562433};
23572434const sc=document.getElementById('submit-comment');
23582435if(sc)sc.onclick=async function(){
23592436 setMsg('');
23602437 try{
2361- await call(CFG.thread+'/comments',{body:document.getElementById('f-body').value});
2438+ const bodyText=document.getElementById('f-body').value;
2439+ let payload={body:bodyText};
2440+ if(Sol()){
2441+ const isPull=String(CFG.thread||'').indexOf('/pulls/')===0;
2442+ const num=parseInt(String(CFG.thread||'').split('/').pop(),10);
2443+ const doc={type:'ForgeComment',repo:CFG.owner+'/'+CFG.name,
2444+ published:new Date().toISOString(),author:window.xlogin.id,body:bodyText};
2445+ if(isPull)doc.pull=num;else doc.issue=num;
2446+ payload={resourceUrl:await podWrite(doc,'comment')};
2447+ }
2448+ await call(CFG.thread+'/comments',payload);
23622449 location.reload();
23632450 }catch(e){setMsg(String(e.message||e))}
23642451};
@@ -2494,7 +2581,7 @@ ${authBox('Commenting, closing, or reopening')}
24942581<button id="submit-comment" class="btn btn-primary" type="button" disabled>Comment</button>
24952582</div></div></div>
24962583</div></main>
2497- ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , base, thread : `/issues/${ issue . number } ` , state : issue . state } ) } `;
2584+ ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , owner , name , base, thread : `/issues/${ issue . number } ` , state : issue . state } ) } `;
24982585 return sendHtml ( reply , 200 , page ( `${ issue . title } · #${ issue . number } · ${ owner } /${ name } ` , body ) ) ;
24992586 }
25002587
@@ -2513,7 +2600,7 @@ ${authBox('Opening an issue')}
25132600<button id="submit-issue" class="btn btn-primary" type="button" disabled>Submit new issue</button>
25142601</div></div></div>
25152602</div></main>
2516- ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , base, thread : null , state : null } ) } `;
2603+ ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , owner , name , base, thread : null , state : null } ) } `;
25172604 return sendHtml ( reply , 200 , page ( `New issue · ${ owner } /${ name } ` , body ) ) ;
25182605 }
25192606
@@ -2675,7 +2762,7 @@ ${pr.state !== 'merged' ? `<button id="toggle-state" class="btn" type="button" d
26752762<button id="submit-comment" class="btn btn-primary" type="button" disabled>Comment</button>
26762763</div></div></div>
26772764</div></main>
2678- ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , base, thread : `/pulls/${ pr . number } ` , state : pr . state , expectedBase : info ?. baseSha ?? null } ) } `;
2765+ ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , owner , name , base, thread : `/pulls/${ pr . number } ` , state : pr . state , expectedBase : info ?. baseSha ?? null } ) } `;
26792766 return sendHtml ( reply , 200 , page ( `${ pr . title } · #${ pr . number } · ${ owner } /${ name } ` , body ) ) ;
26802767 }
26812768
@@ -2733,7 +2820,7 @@ ${authBox('Opening a pull request')}
27332820<button id="submit-pull" class="btn btn-primary" type="button" disabled>Create pull request</button>
27342821</div></div></div>
27352822</div></main>
2736- ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , base, thread : null , state : null , prBase : baseRef , prHead : headSpec } ) } `;
2823+ ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , owner , name , base, thread : null , state : null , prBase : baseRef , prHead : headSpec } ) } `;
27372824 return sendHtml ( reply , 200 , page ( `New pull request · ${ owner } /${ name } ` , body ) ) ;
27382825 }
27392826
@@ -2856,7 +2943,7 @@ ${authBox('Enabling anchoring')}
28562943</div>
28572944</div>
28582945</div></main>
2859- ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , base, thread : null , state : null } ) } `;
2946+ ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , owner , name , base, thread : null , state : null } ) } `;
28602947 return sendHtml ( reply , 200 , page ( `Anchors · ${ owner } /${ name } ` , body ) ) ;
28612948 }
28622949
@@ -2924,7 +3011,7 @@ ${rows}
29243011</table></div>
29253012${ fundBox }
29263013</div></main>
2927- ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , base, thread : null , state : null , markIndex : firstPending ? firstPending . index : null } ) } `;
3014+ ${ issuesScript ( { api : `${ prefix } /api/repos/${ owner } /${ name } ` , owner , name , base, thread : null , state : null , markIndex : firstPending ? firstPending . index : null } ) } `;
29283015 return sendHtml ( reply , 200 , page ( `Anchors · ${ owner } /${ name } ` , body ) ) ;
29293016 }
29303017
@@ -3141,6 +3228,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
31413228 if ( ! p ) return apiErr ( reply , 400 , 'invalid JSON body' ) ;
31423229 const title = typeof p . title === 'string' ? p . title . trim ( ) : '' ;
31433230 const bodyText = typeof p . body === 'string' ? p . body : '' ;
3231+ // Capstone: a pod agent may hand a POINTER (client already wrote the body
3232+ // into its own pod via authFetch) instead of a server-written body.
3233+ const usePointer = ! ! podPath && typeof p . resourceUrl === 'string' && p . resourceUrl . length > 0 ;
31443234 if ( ! title || title . length > ISSUE_TITLE_CAP ) return apiErr ( reply , 422 , `title required (1-${ ISSUE_TITLE_CAP } chars)` ) ;
31453235 if ( bodyText . length > ISSUE_BODY_CAP ) return apiErr ( reply , 422 , 'body too large' ) ;
31463236 return withIssueLock ( owner , name , async ( ) => {
@@ -3158,7 +3248,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
31583248 // did:nostr agents have no pod: the forge hosts their words (2.5).
31593249 const stored = nostrHex
31603250 ? storeHosted ( nostrHex , { ...doc , hosted : true } )
3161- : await storeAuthored ( request , podPath , owner , name , doc , `issue-${ crypto . randomUUID ( ) } .jsonld` ) ;
3251+ : usePointer
3252+ ? await registerPointer ( request , agent , owner , name , p . resourceUrl )
3253+ : await storeAuthored ( request , podPath , owner , name , doc , `issue-${ crypto . randomUUID ( ) } .jsonld` ) ;
31623254 if ( stored . error ) return apiErr ( reply , stored . status , stored . error ) ;
31633255 const at = Math . floor ( Date . now ( ) / 1000 ) ;
31643256 idx . next = number + 1 ;
@@ -3189,7 +3281,8 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
31893281 if ( ! podPath && ! nostrHex ) return apiErr ( reply , 403 , 'no pod namespace for this agent' ) ;
31903282 if ( ! p ) return apiErr ( reply , 400 , 'invalid JSON body' ) ;
31913283 const bodyText = typeof p . body === 'string' ? p . body : '' ;
3192- if ( ! bodyText . trim ( ) ) return apiErr ( reply , 422 , 'body required' ) ;
3284+ const usePointer = ! ! podPath && typeof p . resourceUrl === 'string' && p . resourceUrl . length > 0 ;
3285+ if ( ! usePointer && ! bodyText . trim ( ) ) return apiErr ( reply , 422 , 'body required' ) ;
31933286 if ( bodyText . length > ISSUE_BODY_CAP ) return apiErr ( reply , 422 , 'body too large' ) ;
31943287 return withIssueLock ( owner , name , async ( ) => {
31953288 const idx = loadIssueIndex ( owner , name ) ;
@@ -3206,7 +3299,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
32063299 } ;
32073300 const stored = nostrHex
32083301 ? storeHosted ( nostrHex , { ...doc , hosted : true } )
3209- : await storeAuthored ( request , podPath , owner , name , doc , `comment-${ crypto . randomUUID ( ) } .jsonld` ) ;
3302+ : usePointer
3303+ ? await registerPointer ( request , agent , owner , name , p . resourceUrl )
3304+ : await storeAuthored ( request , podPath , owner , name , doc , `comment-${ crypto . randomUUID ( ) } .jsonld` ) ;
32103305 if ( stored . error ) return apiErr ( reply , stored . status , stored . error ) ;
32113306 issue . thread . push ( {
32123307 author : agent ,
@@ -3624,7 +3719,10 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
36243719 published : new Date ( ) . toISOString ( ) ,
36253720 author : agent ,
36263721 } ;
3627- const stored = await persistBody ( request , agent , owner , name , doc , 'pull' ) ;
3722+ const usePointer = ! ! podPathFromAgent ( agent ) && typeof p . resourceUrl === 'string' && p . resourceUrl . length > 0 ;
3723+ const stored = usePointer
3724+ ? await registerPointer ( request , agent , owner , name , p . resourceUrl )
3725+ : await persistBody ( request , agent , owner , name , doc , 'pull' ) ;
36283726 if ( stored . error ) return apiErr ( reply , stored . status , stored . error ) ;
36293727 const at = Math . floor ( Date . now ( ) / 1000 ) ;
36303728 idx . next = number + 1 ;
@@ -3655,7 +3753,8 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
36553753 if ( ! agent ) return reply ;
36563754 if ( ! p ) return apiErr ( reply , 400 , 'invalid JSON body' ) ;
36573755 const bodyText = typeof p . body === 'string' ? p . body : '' ;
3658- if ( ! bodyText . trim ( ) ) return apiErr ( reply , 422 , 'body required' ) ;
3756+ const usePointer = ! ! podPathFromAgent ( agent ) && typeof p . resourceUrl === 'string' && p . resourceUrl . length > 0 ;
3757+ if ( ! usePointer && ! bodyText . trim ( ) ) return apiErr ( reply , 422 , 'body required' ) ;
36593758 if ( bodyText . length > ISSUE_BODY_CAP ) return apiErr ( reply , 422 , 'body too large' ) ;
36603759 return withPullLock ( owner , name , async ( ) => {
36613760 const idx = loadPullIndex ( owner , name ) ;
@@ -3670,7 +3769,9 @@ ${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, base, thread: null
36703769 published : new Date ( ) . toISOString ( ) ,
36713770 author : agent ,
36723771 } ;
3673- const stored = await persistBody ( request , agent , owner , name , doc , 'comment' ) ;
3772+ const stored = usePointer
3773+ ? await registerPointer ( request , agent , owner , name , p . resourceUrl )
3774+ : await persistBody ( request , agent , owner , name , doc , 'comment' ) ;
36743775 if ( stored . error ) return apiErr ( reply , stored . status , stored . error ) ;
36753776 pr . thread . push ( {
36763777 author : agent ,
0 commit comments